mirror of
https://github.com/modrinth/code.git
synced 2026-08-01 05:36:39 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3d37ab268 | ||
|
|
eadaf5504d |
@@ -56,17 +56,6 @@ 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
|
||||
@@ -106,9 +95,6 @@ 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:
|
||||
|
||||
@@ -4,14 +4,11 @@ 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:
|
||||
|
||||
@@ -59,18 +56,6 @@ 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
|
||||
@@ -94,9 +79,3 @@ 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 }}
|
||||
|
||||
@@ -204,5 +204,4 @@ jobs:
|
||||
- name: Verify intl:extract has been run
|
||||
run: |
|
||||
pnpm turbo run intl:extract --force
|
||||
pnpm scripts i18n-icu-contract prune-local --check
|
||||
git diff --exit-code --color */*/src/locales/*/index.json
|
||||
git diff --exit-code --color */*/src/locales/en-US/index.json
|
||||
|
||||
@@ -64,7 +64,7 @@ The website and app `prepr` commands
|
||||
|
||||
Each project may have its own `CLAUDE.md` with detailed instructions:
|
||||
|
||||
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
|
||||
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
|
||||
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
Generated
-1
@@ -10514,7 +10514,6 @@ dependencies = [
|
||||
"flate2",
|
||||
"fs4",
|
||||
"futures",
|
||||
"futures-lite 2.6.1",
|
||||
"heck 0.5.0",
|
||||
"hickory-resolver 0.25.2",
|
||||
"indicatif",
|
||||
|
||||
@@ -85,7 +85,6 @@ eyre = "0.6.12"
|
||||
flate2 = "1.1.4"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures-lite = "2.6.1"
|
||||
futures-util = "0.3.31"
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"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": {
|
||||
|
||||
@@ -84,7 +84,6 @@ import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
@@ -102,7 +101,6 @@ import { list } from '@/helpers/profile.js'
|
||||
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { get_opening_command, initialize_state } from '@/helpers/state'
|
||||
import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
|
||||
import {
|
||||
areUpdatesEnabled,
|
||||
enqueueUpdateForInstallation,
|
||||
@@ -136,7 +134,6 @@ const route = useRoute()
|
||||
const APP_LEFT_NAV_WIDTH = '4rem'
|
||||
const APP_SIDEBAR_WIDTH = 300
|
||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
|
||||
const credentials = ref()
|
||||
const sidebarToggled = ref(true)
|
||||
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
||||
@@ -147,9 +144,6 @@ const forceSidebar = computed(
|
||||
)
|
||||
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
|
||||
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
|
||||
const prideFundraiserEnabled = computed(
|
||||
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
|
||||
)
|
||||
const hostingIntercomIdentityKey = computed(() => {
|
||||
const rawServerId = route.params.id
|
||||
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
|
||||
@@ -198,12 +192,6 @@ const tauriApiClient = new TauriModrinthClient({
|
||||
],
|
||||
})
|
||||
provideModrinthClient(tauriApiClient)
|
||||
const { data: authenticatedModrinthUser } = useQuery({
|
||||
queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]),
|
||||
queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(),
|
||||
enabled: () => !!credentials.value?.session,
|
||||
retry: false,
|
||||
})
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(true),
|
||||
showAds: ref(false),
|
||||
@@ -342,7 +330,6 @@ async function setupApp() {
|
||||
locale,
|
||||
telemetry,
|
||||
collapsed_navigation,
|
||||
hide_nametag_skins_page,
|
||||
advanced_rendering,
|
||||
onboarded,
|
||||
default_page,
|
||||
@@ -373,7 +360,6 @@ 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
|
||||
@@ -688,11 +674,12 @@ async function logOut() {
|
||||
await fetchCredentials()
|
||||
}
|
||||
|
||||
const MIDAS_BITFLAG = 1 << 0
|
||||
const hasPlus = computed(
|
||||
() =>
|
||||
!!credentials.value?.user &&
|
||||
(hasMidasBadge(credentials.value.user) ||
|
||||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
||||
credentials.value &&
|
||||
credentials.value.user &&
|
||||
(credentials.value.user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG,
|
||||
)
|
||||
|
||||
const showAd = computed(
|
||||
@@ -1240,7 +1227,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<CompassIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
|
||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
@@ -1490,10 +1477,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||
</suspense>
|
||||
</div>
|
||||
<PrideFundraiserBanner
|
||||
v-if="prideFundraiserEnabled"
|
||||
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
/>
|
||||
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
|
||||
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
|
||||
<div class="space-y-4 flex flex-col items-center w-full">
|
||||
|
||||
@@ -150,10 +150,7 @@ async function refreshValues() {
|
||||
if (equippedSkin.value) {
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
|
||||
headUrlCache.value = new Map(headUrlCache.value).set(
|
||||
equippedSkin.value.texture_key,
|
||||
headUrl,
|
||||
)
|
||||
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
@@ -163,24 +160,12 @@ 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,
|
||||
})
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarIcon, UsersIcon, XIcon } from '@modrinth/assets'
|
||||
import { injectModrinthClient, ProgressBar } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const DISMISSED_STORAGE_KEY = 'pride-fundraiser-2026-dismissed'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const dismissed = ref(localStorage.getItem(DISMISSED_STORAGE_KEY) === 'true')
|
||||
|
||||
const { data: campaignInfo } = useQuery({
|
||||
queryKey: ['campaign', 'pride-26'],
|
||||
queryFn: () => client.labrinth.campaign_internal.getPride26(),
|
||||
enabled: () => !dismissed.value,
|
||||
staleTime: 15 * 60 * 1000,
|
||||
refetchInterval: 15 * 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
const shouldShowBanner = computed(
|
||||
() => !dismissed.value && Number(campaignInfo.value?.target_usd) > 0,
|
||||
)
|
||||
|
||||
async function openPrideFundraiser() {
|
||||
await openUrl('https://modrinth.com/pride?from=app')
|
||||
}
|
||||
|
||||
function dismissBanner() {
|
||||
dismissed.value = true
|
||||
localStorage.setItem(DISMISSED_STORAGE_KEY, 'true')
|
||||
}
|
||||
|
||||
function formatUsd(amount: string | number) {
|
||||
return Number(amount).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function daysLeft() {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.ceil((new Date('2026-07-01T00:00:00Z').getTime() - Date.now()) / (24 * 60 * 60 * 1000)),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shouldShowBanner && campaignInfo">
|
||||
<section
|
||||
role="link"
|
||||
tabindex="0"
|
||||
class="flex w-full cursor-pointer flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-button-bg p-3 text-primary transition-[border-color,filter] hover:border-surface-6 hover:brightness-125 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
aria-label="Open Pride fundraiser"
|
||||
@click="openPrideFundraiser"
|
||||
@keydown.enter="openPrideFundraiser"
|
||||
@keydown.space.prevent="openPrideFundraiser"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<h2 class="m-0 min-w-0 truncate text-base font-semibold text-contrast">
|
||||
Pride Fundraiser 2026
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="m-0 flex size-5 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-primary transition-colors hover:text-contrast focus-visible:text-contrast"
|
||||
aria-label="Dismiss Pride fundraiser"
|
||||
@click.stop="dismissBanner"
|
||||
@keydown.stop
|
||||
>
|
||||
<XIcon aria-hidden="true" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-px w-full bg-surface-5" />
|
||||
<div class="flex w-full flex-col gap-2.5">
|
||||
<div class="flex items-end gap-1 whitespace-nowrap">
|
||||
<span class="text-base font-semibold leading-5 text-contrast">
|
||||
{{ formatUsd(campaignInfo.total_donations_usd) }}
|
||||
</span>
|
||||
<span class="text-xs font-medium leading-4">
|
||||
of {{ formatUsd(campaignInfo.target_usd) }}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
class="pride-fundraiser-banner__progress"
|
||||
:progress="Number(campaignInfo.total_donations_usd)"
|
||||
:max="Number(campaignInfo.target_usd)"
|
||||
color="purple"
|
||||
full-width
|
||||
:gradient-border="false"
|
||||
:aria-label="`${formatUsd(campaignInfo.total_donations_usd)} of ${formatUsd(
|
||||
campaignInfo.target_usd,
|
||||
)} raised`"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs font-medium leading-4">
|
||||
<span class="flex items-center gap-1">
|
||||
<UsersIcon aria-hidden="true" class="size-4 shrink-0" />
|
||||
{{ campaignInfo.num_donators.toLocaleString('en-US') }}
|
||||
{{ campaignInfo.num_donators === 1 ? 'supporter' : 'supporters' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<CalendarIcon aria-hidden="true" class="size-4 shrink-0" />
|
||||
{{ daysLeft() }} {{ daysLeft() === 1 ? 'day left' : 'days left' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pride-fundraiser-banner__progress :deep(.progress-bar) {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-red) 0%,
|
||||
var(--color-orange) 20%,
|
||||
var(--color-green) 50%,
|
||||
var(--color-blue) 75%,
|
||||
var(--color-purple) 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -393,6 +393,7 @@ const messages = defineMessages({
|
||||
<FriendsSection
|
||||
v-if="pendingFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="pendingFriends"
|
||||
:heading="formatMessage(messages.pending)"
|
||||
:remove-friend="removeFriend"
|
||||
|
||||
@@ -10,9 +10,8 @@ import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
|
||||
const themeStore = useTheming()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
|
||||
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
|
||||
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
|
||||
const worldsInHomeFeatureFlag = 'worlds_in_home' as FeatureFlag
|
||||
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
|
||||
|
||||
const messages = defineMessages({
|
||||
colorThemeTitle: {
|
||||
@@ -101,14 +100,6 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
"If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it.",
|
||||
},
|
||||
showPlayTimeTitle: {
|
||||
id: 'app.appearance-settings.show-play-time.title',
|
||||
defaultMessage: 'Show play time',
|
||||
},
|
||||
showPlayTimeDescription: {
|
||||
id: 'app.appearance-settings.show-play-time.description',
|
||||
defaultMessage: `Displays how much time you've spent playing an instance.`,
|
||||
},
|
||||
})
|
||||
|
||||
const os = ref(await getOS())
|
||||
@@ -162,6 +153,16 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.hideNametagTitle) }}
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
@@ -182,44 +183,6 @@ watch(
|
||||
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.showPlayTimeTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
|
||||
themeStore.featureFlags[showPlayTimeFlag] = newValue
|
||||
settings.feature_flags[showPlayTimeFlag] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.hideNametagTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
|
||||
</div>
|
||||
<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 class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
@@ -254,12 +217,12 @@ watch(
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
|
||||
:model-value="themeStore.getFeatureFlag(worldsInHomeFeatureFlag)"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
|
||||
themeStore.featureFlags[worldsInHomeFlag] = newValue
|
||||
settings.feature_flags[worldsInHomeFlag] = newValue
|
||||
const newValue = !themeStore.getFeatureFlag(worldsInHomeFeatureFlag)
|
||||
themeStore.featureFlags[worldsInHomeFeatureFlag] = newValue
|
||||
settings.feature_flags[worldsInHomeFeatureFlag] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -273,13 +236,13 @@ watch(
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.unknownPackWarningDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
|
||||
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
const warnBeforeUnknownPackInstall = !!e
|
||||
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
|
||||
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
|
||||
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
|
||||
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = skipUnknownPackWarning
|
||||
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = skipUnknownPackWarning
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -1,242 +1,149 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :on-hide="handleModalHide">
|
||||
<UploadSkinModal ref="uploadModal" />
|
||||
<ModalWrapper ref="modal" @on-hide="resetState">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(mode === 'edit' ? messages.editSkinTitle : messages.addSkinTitle) }}
|
||||
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<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 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>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
|
||||
<section v-if="mode === 'edit' && canEditTextureAndModel">
|
||||
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Texture</h2>
|
||||
<ButtonStyled>
|
||||
<button class="!shadow-none" @click="openTextureFileBrowser">
|
||||
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
|
||||
</button>
|
||||
<button @click="openUploadSkinModal"><UploadIcon /> Replace texture</button>
|
||||
</ButtonStyled>
|
||||
<input
|
||||
ref="textureFileInput"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="onTextureFileInputChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="canEditTextureAndModel">
|
||||
<h2 class="text-base font-semibold mb-2">
|
||||
{{ formatMessage(messages.armStyleSection) }}
|
||||
</h2>
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Arm style</h2>
|
||||
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
|
||||
<template #default="{ item }">
|
||||
{{
|
||||
formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
|
||||
}}
|
||||
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<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"
|
||||
<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)"
|
||||
>
|
||||
<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>
|
||||
<span>Use default cape</span>
|
||||
</CapeButton>
|
||||
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
|
||||
<span>Use default cape</span>
|
||||
</CapeLikeTextButton>
|
||||
|
||||
<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"
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<template #icon><ChevronRightIcon /></template>
|
||||
<span>More</span>
|
||||
</CapeLikeTextButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
RadioButtons,
|
||||
SkinPreviewRenderer,
|
||||
useScrollIndicator,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
import { computed, 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,
|
||||
normalize_skin_texture,
|
||||
save_custom_skin,
|
||||
remove_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 textureFileInput = useTemplateRef<HTMLInputElement>('textureFileInput')
|
||||
const capeListRef = ref<HTMLElement | null>(null)
|
||||
const capeListMaxHeight = ref(`${CAPE_LIST_MAX_HEIGHT}px`)
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
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)
|
||||
@@ -244,49 +151,10 @@ const previewSkin = ref<string>('')
|
||||
|
||||
const variant = ref<SkinModel>('CLASSIC')
|
||||
const selectedCape = ref<Cape | undefined>(undefined)
|
||||
const props = defineProps<{ capes?: Cape[] }>()
|
||||
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
|
||||
|
||||
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
|
||||
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 visibleCapeList = ref<Cape[]>([])
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...(props.capes || [])].sort((a, b) => {
|
||||
@@ -296,6 +164,32 @@ 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
|
||||
@@ -327,13 +221,9 @@ const disableSave = computed(
|
||||
)
|
||||
|
||||
const saveTooltip = computed(() => {
|
||||
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)
|
||||
}
|
||||
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!'
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -344,13 +234,11 @@ 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
|
||||
@@ -361,11 +249,12 @@ 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) {
|
||||
@@ -374,54 +263,98 @@ 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 setUploadedTexture(skinTextureUrl: SkinTextureUrl) {
|
||||
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
await loadPreviewSkin()
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
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 openTextureFileBrowser() {
|
||||
textureFileInput.value?.click()
|
||||
function handleCapeSelected(cape: Cape | undefined) {
|
||||
selectCape(cape)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 = ''
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,32 +370,17 @@ async function save() {
|
||||
textureUrl = currentSkin.value!.texture
|
||||
}
|
||||
|
||||
await unequip_skin()
|
||||
|
||||
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
||||
|
||||
if (mode.value === 'new') {
|
||||
const addedSkin = await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved', {
|
||||
applied: true,
|
||||
skin: addedSkin,
|
||||
})
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved')
|
||||
} else {
|
||||
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!,
|
||||
})
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
await remove_custom_skin(currentSkin.value!)
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
hide()
|
||||
@@ -475,53 +393,28 @@ async function save() {
|
||||
|
||||
watch([uploadedTextureUrl, currentSkin], async () => {
|
||||
await loadPreviewSkin()
|
||||
refreshCapeListLayout()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.capes,
|
||||
() => {
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
initVisibleCapeList()
|
||||
},
|
||||
{ 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', options: { applied: boolean; skin?: Skin; previousSkin?: Skin }): void
|
||||
(event: 'saved'): void
|
||||
(event: 'deleted', skin: Skin): void
|
||||
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
showNew,
|
||||
restoreWithNewTexture,
|
||||
hide,
|
||||
shouldRestoreModal,
|
||||
restoreModal,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<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>
|
||||
@@ -0,0 +1,141 @@
|
||||
<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>
|
||||
@@ -1,432 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
SkinButton,
|
||||
SkinLikeTextButton,
|
||||
useScrollViewport,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
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
|
||||
infoTooltip?: string
|
||||
skins: Skin[]
|
||||
}
|
||||
|
||||
interface SkinSection {
|
||||
key: string
|
||||
title: string
|
||||
kind: SkinSectionKind
|
||||
infoTooltip?: string
|
||||
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,
|
||||
infoTooltip: section.infoTooltip,
|
||||
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>
|
||||
<Tooltip
|
||||
v-if="section.infoTooltip"
|
||||
theme="dismissable-prompt"
|
||||
placement="top"
|
||||
:triggers="['hover', 'focus']"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-6 shrink-0 items-center justify-center text-secondary transition-colors group-hover:text-primary"
|
||||
@click.stop
|
||||
>
|
||||
<UnknownIcon class="size-5" />
|
||||
</span>
|
||||
<template #popper>
|
||||
<p class="m-0 max-w-96 text-wrap text-sm font-medium leading-tight">
|
||||
{{ section.infoTooltip }}
|
||||
</p>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</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 flex-nowrap whitespace-nowrap"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold"
|
||||
>
|
||||
<UserIcon
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -3,8 +3,9 @@ import {
|
||||
applyCapeTexture,
|
||||
createTransparentTexture,
|
||||
disposeCaches,
|
||||
loadTexture,
|
||||
setupSkinModel,
|
||||
} from '@modrinth/ui'
|
||||
} from '@modrinth/utils'
|
||||
import * as THREE from 'three'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
@@ -28,7 +29,6 @@ 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,7 +52,6 @@ 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)
|
||||
@@ -63,7 +62,7 @@ class BatchSkinRenderer {
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = false
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
@@ -113,19 +112,9 @@ class BatchSkinRenderer {
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
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,
|
||||
)
|
||||
})
|
||||
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
|
||||
const response = await fetch(dataUrl)
|
||||
return await response.blob()
|
||||
}
|
||||
|
||||
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
|
||||
@@ -133,10 +122,14 @@ class BatchSkinRenderer {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl)
|
||||
|
||||
if (!capeUrl) {
|
||||
applyCapeTexture(model, null, this.getTransparentTexture())
|
||||
if (capeUrl) {
|
||||
const capeTexture = await loadTexture(capeUrl)
|
||||
applyCapeTexture(model, capeTexture)
|
||||
} else {
|
||||
const transparentTexture = createTransparentTexture()
|
||||
applyCapeTexture(model, null, transparentTexture)
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
@@ -148,38 +141,39 @@ class BatchSkinRenderer {
|
||||
this.currentModel = group
|
||||
}
|
||||
|
||||
private getTransparentTexture(): THREE.Texture {
|
||||
if (!this.transparentTexture) {
|
||||
this.transparentTexture = createTransparentTexture()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.transparentTexture
|
||||
}
|
||||
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)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -200,9 +194,6 @@ 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()
|
||||
@@ -365,27 +356,7 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
|
||||
return await generateHeadRender(skin)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
try {
|
||||
const skinKeys = skins.map(
|
||||
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
|
||||
@@ -397,8 +368,6 @@ async function generateSkinPreviewsForGeneration(
|
||||
headStorage.batchRetrieve(headKeys),
|
||||
])
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
for (let i = 0; i < skins.length; i++) {
|
||||
const skinKey = skinKeys[i]
|
||||
const headKey = headKeys[i]
|
||||
@@ -419,8 +388,6 @@ async function generateSkinPreviewsForGeneration(
|
||||
}
|
||||
|
||||
for (const skin of skins) {
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
|
||||
if (skinBlobUrlMap.has(key)) {
|
||||
@@ -452,8 +419,6 @@ async function generateSkinPreviewsForGeneration(
|
||||
cape?.texture,
|
||||
)
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const renderResult: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawRenderResult.forwards),
|
||||
backwards: URL.createObjectURL(rawRenderResult.backwards),
|
||||
@@ -474,12 +439,9 @@ async function generateSkinPreviewsForGeneration(
|
||||
}
|
||||
} finally {
|
||||
disposeSharedRenderer()
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
if (isCurrentGeneration()) {
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
}
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Cape {
|
||||
id: string
|
||||
name: string
|
||||
texture: string
|
||||
is_default: boolean
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
@@ -14,7 +15,6 @@ 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,11 +121,17 @@ export async function get_available_skins(): Promise<Skin[]> {
|
||||
export async function add_and_equip_custom_skin(
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
cape?: Cape,
|
||||
): Promise<Skin> {
|
||||
return await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
|
||||
capeOverride?: Cape,
|
||||
): Promise<void> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -142,22 +148,6 @@ 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)
|
||||
@@ -172,16 +162,6 @@ 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)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
const MIDAS_BITFLAG = 1 << 0
|
||||
const PRIDE_26_MIDAS_DURATION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
type Pride26Campaign = Labrinth.Users.v3.Pride26CampaignDonation | null | undefined
|
||||
|
||||
export function hasMidasBadge(user?: { badges?: number } | null) {
|
||||
return !!user?.badges && (user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG
|
||||
}
|
||||
|
||||
export function hasPride26Badge(campaign: Pride26Campaign) {
|
||||
return campaign?.has_badge === true
|
||||
}
|
||||
|
||||
export function hasActivePride26Midas(campaign: Pride26Campaign, now = Date.now()) {
|
||||
if (campaign?.has_midas !== true) {
|
||||
return false
|
||||
}
|
||||
|
||||
const donatedAt = new Date(campaign.last_donated_at).getTime()
|
||||
return Number.isFinite(donatedAt) && donatedAt + PRIDE_26_MIDAS_DURATION_MS > now
|
||||
}
|
||||
@@ -5,36 +5,9 @@
|
||||
"app.action-bar.downloads": {
|
||||
"message": "التنزيلات"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "إخفاء المزيد من الحالات قيد التشغيل"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "إنشاء نسخة أساسية"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "لا توجد نسخ قيد التشغيل"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "غير متصل"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "النسخة ألرئيسية"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "عرض المزيد من النسخ قيد التشغيل"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "إيقاف النسخة"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "عرض التحميلات الفعالة"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "عرض النسخة"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "عرض التسجيل"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "صفحة الرئيسية"
|
||||
},
|
||||
@@ -608,9 +581,6 @@
|
||||
"minecraft-account.select-account": {
|
||||
"message": "اختيار الحساب"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "سجل دخولك لـ ماينكرافت"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "مقدَّم من النسخة"
|
||||
},
|
||||
@@ -634,8 +604,5 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "يتم توفير المحمّل من قبل الخادم"
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "لا تعرض هذا التحذير مجددا"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,9 @@
|
||||
"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?"
|
||||
},
|
||||
@@ -245,6 +248,9 @@
|
||||
"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."
|
||||
},
|
||||
@@ -437,6 +443,9 @@
|
||||
"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..."
|
||||
},
|
||||
|
||||
@@ -35,9 +35,6 @@
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Vis logs"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Aktiverer avanceret gengivelse som sløringseffekter, der kan forårsage ydelsesproblemer uden hardwareaccelereret gengivelse."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Avanceret rendering"
|
||||
},
|
||||
@@ -80,12 +77,6 @@
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Vælg en mulighed"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Aktivere muligheden for at slå sidepanelet til og fra."
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.title": {
|
||||
"message": "Slå sidepanel til og fra"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Hvis du prøver at installere en Modrinth Pack fil (.mrpack) som ikke er hosted på Modrinth, så skal vi nok sikre os du forstår riskoen før du installere den."
|
||||
},
|
||||
@@ -98,15 +89,6 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Kan ikke nå autentificeringsservere"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Tilføjet server til instance"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Tilføj til en instance"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Tilføj til instance"
|
||||
},
|
||||
"app.browse.add-to-instance-name": {
|
||||
"message": "Tilføjet til {instanceName}"
|
||||
},
|
||||
@@ -116,33 +98,18 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Allerede tilføjet"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Tilbage til instance"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Opdag indhold"
|
||||
},
|
||||
"app.browse.discover-servers": {
|
||||
"message": "Opdag servere"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Gem allerede tilføjet til servere"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Ved at tilføje indhold kan der opstå kompatibilitetsproblemer, når du tilslutter dig serveren. Alt tilføjet indhold går desuden tabt, når du opdaterer indholdet på din server instance."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Installere"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.title": {
|
||||
"message": "Installere modpack..."
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Indtast modpack beskrivelse..."
|
||||
},
|
||||
@@ -152,18 +119,12 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Eksporter modpack"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "Inkludere \"{file}\"?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Modpack Navn"
|
||||
},
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "Modpack navn"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Konfigurer hvilke filer inkludere i denne eksport"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Versionsnummer"
|
||||
},
|
||||
@@ -188,12 +149,6 @@
|
||||
"app.instance.modpack-already-installed.create": {
|
||||
"message": "Opret"
|
||||
},
|
||||
"app.instance.modpack-already-installed.header": {
|
||||
"message": "Modpack allerede installeret"
|
||||
},
|
||||
"app.instance.modpack-already-installed.instance": {
|
||||
"message": "Instance"
|
||||
},
|
||||
"app.instance.mods.content-type-project": {
|
||||
"message": "projekt"
|
||||
},
|
||||
@@ -212,42 +167,6 @@
|
||||
"app.instance.mods.successfully-uploaded": {
|
||||
"message": "Uploadet"
|
||||
},
|
||||
"app.instance.worlds.add-server": {
|
||||
"message": "Tilføj server"
|
||||
},
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Søg servere"
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Er du sikker på din vil slette denne verden permanent?"
|
||||
},
|
||||
"app.instance.worlds.filter-modded": {
|
||||
"message": "Moddet"
|
||||
},
|
||||
"app.instance.worlds.filter-offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.instance.worlds.filter-online": {
|
||||
"message": "Online"
|
||||
},
|
||||
"app.instance.worlds.filter-vanilla": {
|
||||
"message": "Vanilla"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-description": {
|
||||
"message": "Tilføj en server, eller søg efter en for at komme i gang"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Ingen servere eller verdener tilføjet"
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Er du sikker på din vil fjerne {name}?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Søg {count} verdener..."
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "denne server"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "Indhold krævet"
|
||||
},
|
||||
@@ -284,15 +203,6 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "En opdatering er krævet for at spille {name}. Venligst opdater til den seneste version for at køre spillet."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Dette projekt er allerede installeret"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Tilbage til udforsk"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Installer indhold til instance"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Udvikler-tilstand aktiveret."
|
||||
},
|
||||
@@ -317,48 +227,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Ressourcestyring"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Tilføj skin"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Træk og slip"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Brug"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Slet skin"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Dette vil permanent slette det valgte skin. Dette vil ikke kunne blive fortydes."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Er du sikker på, at du vil slette dette skin?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Fejl ved læsning af den afleverede fil."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Fejl under behandling af fil"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Rediger skin"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Tilføj skin"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Tilføjer et skin"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Slank"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Bred"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Kappe"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} er klar til at blive installeret! Genindlæs for at opdatere nu, eller automatisk når du lukker Modrinth App."
|
||||
},
|
||||
@@ -701,9 +569,6 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "Verden er i brug"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Fjern konto"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Givet af instanceen"
|
||||
},
|
||||
|
||||
@@ -224,6 +224,9 @@
|
||||
"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?"
|
||||
},
|
||||
@@ -245,6 +248,12 @@
|
||||
"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?"
|
||||
},
|
||||
@@ -294,7 +303,7 @@
|
||||
"message": "Dieses Projekt ist bereits installiert"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Zurück zu Entdecken"
|
||||
"message": "Zurück zum Entdecken"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Inhalt in Instanz installieren"
|
||||
@@ -323,147 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Ressourcenmanagement"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Skin hinzufügen"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Drag-and-Drop"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Anwenden"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Skin löschen"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Dies wird den ausgewählten Skin unwiderruflich löschen. Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Bist du dir sicher, dass du diesen Skin löschen willst?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Die abgelegte Datei konnte nicht gelesen werden."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Fehler bei der Verarbeitung der Datei"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Skin hinzufügen"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Skin wird hinzugefügt"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Arm Stil"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Schlank"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Breit"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Umhang"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Umhang"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Nimm zuerst eine Änderung am Skin vor!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Kein Umhang"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Keinen"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Textur ersetzen"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Skin speichern"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Wird gespeichert..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Textur"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Lade zuerst ein Skin hoch!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Vorschau"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Du wechselst dein Skin zu oft. Die Server von Mojang haben weitere Anfragen vorübergehend blockiert. Bitte warte einen Moment, bevor du es erneut versuchst."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Langsamer!"
|
||||
},
|
||||
"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": "Standard-Skins"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Du hast diese Skins erhalten, da du während des Pride-Monats für eine Modrinth-Pride-Spendenaktion gespendet hast."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Gespeicherte 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": "Anmelden"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Bitte melde dich bei deinem Minecraft-Konto an, um die Funktionen zur Skin-Verwaltung der Modrinth-App nutzen zu können."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Begeisterter Modrinth-Bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Bitte melde dich an"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Skin-Auswahl"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} ist bereit zur Installation! Lade die App neu um jetzt zu aktualisieren, oder automatisch nach dem schliessen der Modrinth App."
|
||||
},
|
||||
@@ -743,24 +611,6 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java und Arbeitsspeicher"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Benutzerdefinierte Umgebungsvariablen"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Benutzerdefinierte Java-Argumente"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Benutzerdefinierte Java-Installation"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Benutzerdefinierte Speicherzuweisung"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Umgebungsvariablen eingeben..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Java-Argumente eingeben..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Umgebungsvariablen"
|
||||
},
|
||||
@@ -776,9 +626,6 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Zugewiesener Arbeitsspeicher"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/pfad/zu/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Spielfenster"
|
||||
},
|
||||
|
||||
@@ -224,6 +224,9 @@
|
||||
"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?"
|
||||
},
|
||||
@@ -245,6 +248,12 @@
|
||||
"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?"
|
||||
},
|
||||
@@ -323,147 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Ressourcenmanagement"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Skin hinzufügen"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Ziehen und Ablegen"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Anwenden"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Skin löschen"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Dadurch wird der ausgewählte Skin permanent gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Möchtest du diesen Skin wirklich löschen?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Die abgelegte Datei konnte nicht gelesen werden."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Fehler bei der Verarbeitung der Datei"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Skin hinzufügen"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Einen Skin hinzufügen"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Arm-Stil"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Schlank"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Breit"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Umhang"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Umhang"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Nimm zuerst eine Änderung am Skin vor!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Kein Umhang"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Keinen"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Textur ersetzen"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Skin speichern"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Wird gespeichert..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Textur"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Lade zuerst ein Skin hoch!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Vorschau"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Du wechselst dein Skin zu oft. Die Server von Mojang haben weitere Anfragen vorübergehend blockiert. Bitte warte einen Moment, bevor du es erneut versuchst."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Langsamer!"
|
||||
},
|
||||
"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": "Standard-Skins"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Du hast diese Skins erhalten, da du während des Pride-Monats für eine Modrinth-Pride-Spendenaktion gespendet hast."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Gespeicherte 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": "Anmelden"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Bitte melde dich bei deinem Minecraft-Konto an, um die Funktionen zur Skin-Verwaltung der Modrinth-App nutzen zu können."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Begeisterter Modrinth-Bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Bitte melde dich an"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Skin-Auswahl"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} ist bereit zur Installation! Neu laden, um jetzt zu aktualisieren, oder automatisch, wenn du die Modrinth App schließt."
|
||||
},
|
||||
@@ -743,24 +611,6 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java und Arbeitsspeicher"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Benutzerdefinierte Umgebungsvariablen"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Benutzerdefinierte Java-Argumente"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Benutzerdefinierte Java-Installation"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Benutzerdefinierte Speicherzuweisung"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Umgebungsvariablen eingeben..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Java-Argumente eingeben..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Umgebungsvariablen"
|
||||
},
|
||||
@@ -776,9 +626,6 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Zugewiesener Arbeitsspeicher"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/pfad/zu/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Fenster"
|
||||
},
|
||||
|
||||
@@ -86,12 +86,6 @@
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Select an option"
|
||||
},
|
||||
"app.appearance-settings.show-play-time.description": {
|
||||
"message": "Displays how much time you've spent playing an instance."
|
||||
},
|
||||
"app.appearance-settings.show-play-time.title": {
|
||||
"message": "Show play time"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Enables the ability to toggle the sidebar."
|
||||
},
|
||||
@@ -338,147 +332,6 @@
|
||||
"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.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "You received these skins for donating to a Modrinth Pride fundraiser during Pride Month."
|
||||
},
|
||||
"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,6 +224,9 @@
|
||||
"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?"
|
||||
},
|
||||
@@ -245,6 +248,12 @@
|
||||
"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}?"
|
||||
},
|
||||
@@ -291,10 +300,10 @@
|
||||
"message": "Se requiere una actualización para jugar {name}. Por favor, actualiza a la versión más reciente para iniciar el juego."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Este proyecto ya está instalado"
|
||||
"message": "Este proyecto está actualmente instalado"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Volver a descubrir contenido"
|
||||
"message": "Volver al explorador"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Instalar contenido a la instancia"
|
||||
@@ -323,138 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Gestión de recursos"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Añadir skin"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Arrastrar y soltar"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Aplicar"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Eliminar skin"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Esto eliminará permanentemente el skin seleccionado. Esta acción no se puede deshacer."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "¿Estás seguro de que quieres eliminar este skin?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Error al leer el archivo arrastrado."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Error al procesar el archivo"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Editar skin"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Añadir skin"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Añadiendo un skin"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Estilo de brazo"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Delgado"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Ancho"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Capa"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Capa"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Editando skin"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "¡Primero edita el skin!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Sin capa"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Ninguna"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Remplazar textura"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Guardar skin"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Guardando..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Textura"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "¡Primero carga un skin!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Editar skin"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Previsualizando"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Estás cambiando tu skin muy frecuentemente. Los servidores de Mojang han bloqueado temporalmente las solicitdues futuras. Por favor, espera un momento antes de volver a intentarlo."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "¡Más despacio!"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Constructores y Biomas"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "Persigue los Cielos"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Skins predeterminadas"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Monturas del Caos"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Skins guardados"
|
||||
},
|
||||
"app.skins.section.striding-hero": {
|
||||
"message": "Striding Hero"
|
||||
},
|
||||
"app.skins.section.the-copper-age": {
|
||||
"message": "La Era del Cobre"
|
||||
},
|
||||
"app.skins.section.the-garden-awakens": {
|
||||
"message": "El Jardín Despierta"
|
||||
},
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"message": "Bebés al Poder"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "Iniciar sesión"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Por favor, inicia sesión en tu cuenta de Minecraft para poder utilizar las funciones de gestión de skins de la Modrinth App."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Modrinth Bot emocionado"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Por favor, inicia sesión"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Selector de skin"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "¡Modrinth App v{version} está lista para instalarse! Actualiza ahora o automáticamente al cerrar la Modrinth App."
|
||||
},
|
||||
@@ -734,24 +611,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -767,9 +626,6 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Memoria asignada"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/ruta/de/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Ventana"
|
||||
},
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
"message": "Ocultar servidores ya añadidos"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Paquetes de Mods"
|
||||
"message": "Modpacks"
|
||||
},
|
||||
"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."
|
||||
@@ -332,15 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Gestión de recursos"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Añadir skin"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Arrastrar y soltar"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Aplicar"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "¡La versión v{version} de Modrinth está lista para instalarse! Actualiza ahora o automáticamente al cerrar la aplicación."
|
||||
},
|
||||
@@ -620,24 +611,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -653,9 +626,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -104,12 +104,6 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Hindi maabot ang mga authentication server"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Idinaragdag ang server sa instansiya"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Idagdag sa instansiya"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Idagdag sa instansiya"
|
||||
},
|
||||
@@ -122,9 +116,6 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Nadagdag na"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Bumalik sa instansiya"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Tumuklas ng kontento"
|
||||
},
|
||||
@@ -137,9 +128,6 @@
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Maaaring makasisira sa pagkakatugma sa pagsasali ng server ang pagdaragdag ng kontento. Ang anumang kontento ay mawawala sa pag-update ng kontento ng instansiyang pang-server."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Ini-install"
|
||||
},
|
||||
@@ -299,12 +287,6 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "Kailangang mag-update upang malaro ang {name}. Mangyaring mag-update sa pinakabagong bersiyon upang ma-launch ang laro."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Naka-install na ang proyektong ito"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Bumalik sa pagtutuklas"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Binuksan ang moda ng nagdi-develop."
|
||||
},
|
||||
@@ -329,36 +311,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Pamamahala ng paglalaan"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Ilapat"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Payat"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Malapad"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Kapa"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Kapa"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Walang kapa"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Wala"
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Texture"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Ang Modrinth App v{version} ay handa nang ma-install. Mag-reload upang ma-update ngayon, o awtomatiko sa pagsara ng Modrinth App."
|
||||
},
|
||||
|
||||
@@ -224,6 +224,9 @@
|
||||
"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 ?"
|
||||
},
|
||||
@@ -245,6 +248,12 @@
|
||||
"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} ?"
|
||||
},
|
||||
@@ -323,147 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Gestion des ressources"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Ajouter une skin"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Glisser et déposer"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Appliquer"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Supprimer la skin"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Cela va supprimer de manière permanente cette skin. Cette action est irréversible."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Êtes-vous sûr de vouloir supprimer cette skin ?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Impossible de lire le fichier déposé."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Erreur lors du traitement du fichier"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Modifier la skin"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Ajouter une skin"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Ajouter une skin"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Bras"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Mince"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Large"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Cape"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Cape"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Modification de la skin"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Faites d'abord une modification à la skin !"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Aucune cape"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Aucune"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Remplacer la texture"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Sauvegarder la skin"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Sauvegarde..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Texture"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Importez d'abord une skin !"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Modifier la skin"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Aperçu"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Vous changez de skin trop vite. Les serveurs de Mojang ont temporairement bloqué certaines requêtes. Veuillez patienter un moment puis réessayez."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Du calme !"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Builders & Biomes"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "Chassez les cieux"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Skins par défaut"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Vous avez reçu ces skins pour avoir fait un don à une collecte de fonds Modrinth Pride pendant le mois de la fierté."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Montures en pagaille"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Skins sauvegardées"
|
||||
},
|
||||
"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": "L'éveil du jardin"
|
||||
},
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"message": "Horde miniature"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "Se connecter"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Veuillez vous connecter à votre compte Minecraft pour utiliser les fonctionnalités de gestion de skins de Modrinth App."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Bot Modrinth enthousiaste"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Veuillez vous connecter"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Sélecteur de skin"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} est prête à être installée ! Relancez l'application pour faire la mise à jour maintenant, ou automatiquement à la fermeture de Modrinth App."
|
||||
},
|
||||
@@ -521,6 +389,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -740,24 +611,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -773,9 +626,6 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Mémoire allouée"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/chemin/vers/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Fenêtre"
|
||||
},
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
{
|
||||
"app.action-bar.downloads": {
|
||||
"message": "הורדות"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "לא מקוון"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": ""
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "מאפשר עיבוד מתקדם כגון אפקטים טשטוש העלולים לגרום לבעיות ביצועים ללא עיבוד מואץ לחומרה."
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "ייתכן ששרתי האימות של Minecraft מושבתים כרגע. יש לבדוק את חיבור האינטרנט שלך ולנסות שוב מאוחר יותר."
|
||||
},
|
||||
@@ -266,6 +254,9 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "הוספת חבר"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count, plural, one {בקשת חברות אחת} other {# בקשות חברות}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "שליחת בקשת חברות"
|
||||
},
|
||||
|
||||
@@ -6,31 +6,31 @@
|
||||
"message": "Letöltések"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "További futó példányok elrejtése"
|
||||
"message": "További futó profilok elrejtése"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Beállítás elsődleges példányként"
|
||||
"message": "Beállítás elsődleges profilként"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Nincsenek futó példányok"
|
||||
"message": "Nincsenek futó profilok"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Elsődleges példány"
|
||||
"message": "Elsődleges profil"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "További futó pédányok megjelenítése"
|
||||
"message": "További futó profilok megjelenítése"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Példány leállítása"
|
||||
"message": "Profil megállítása"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Aktív letöltések megtekintése"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Példányok megtekintése"
|
||||
"message": "Profil megtekintése"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Naplók megtekintése"
|
||||
@@ -60,7 +60,7 @@
|
||||
"message": "Alapértelmezett nyitóoldal"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "Letiltja a játékosod felett megjelenő névcímkét a Kinézetek oldalon."
|
||||
"message": "Letiltja a játékosod felett megjelenő névcímkét a Skinek oldalon."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Névcímke eltüntetése"
|
||||
@@ -105,13 +105,13 @@
|
||||
"message": "Nem lehet elérni a hitelesítési kiszolgálókat"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Szerver hozzáadása az példányhoz"
|
||||
"message": "Szerver hozzáadása a profilhoz"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Hozzáadás egy példányhoz"
|
||||
"message": "Hozzáadás egy profilhoz"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Példányhoz adás"
|
||||
"message": "Hozzáadás a profilhoz"
|
||||
},
|
||||
"app.browse.add-to-instance-name": {
|
||||
"message": "Hozzáadás ehhez: {instanceName}"
|
||||
@@ -123,7 +123,7 @@
|
||||
"message": "Már hozzá van adva"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Vissza a példányhoz"
|
||||
"message": "Vissza a profilhoz"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Tartalom böngészése"
|
||||
@@ -198,7 +198,7 @@
|
||||
"message": "A modcsomag már telepítve van"
|
||||
},
|
||||
"app.instance.modpack-already-installed.instance": {
|
||||
"message": "Példány"
|
||||
"message": "Profil"
|
||||
},
|
||||
"app.instance.mods.content-type-project": {
|
||||
"message": "projekt"
|
||||
@@ -272,6 +272,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -329,138 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Erőforráskezelés"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Kinézet hozzáadása"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Húzd ide a fájlt"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Alkalmazás"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Kinézet törlése"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Ezzel a kiválasztott kinézet véglegesen törlődik. Ez a művelet visszafordíthatatlan."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Biztos, hogy le akarod törölni ezt a kinézetet?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Nem sikerült beolvasni a feltöltött fájlt."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Hiba a fájl feldolgozása közben"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Kinézet szerkesztése"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Kinézet hozzáadása"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Kinézet hozzáadása"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Karstílus"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Vékony"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Széles"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Köpeny"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Köpeny"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Kinézet szerkesztése"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Előbb módosíts valamit!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Nincs köpeny"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Nincs"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Textúra cseréje"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Kinézet mentése"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Mentés..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Textúra"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Először tölts fel egy kinézetet!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Kinézet szerkesztése"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Előnézet"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Túl gyakran cseréled a kinézetedet. A Mojang szerverei ideiglenesen letiltották a további cseréket. Kérlek várj egy pillanatot, majd próbáld meg újra."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Lassíts!"
|
||||
},
|
||||
"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": "Alap kinézetek"
|
||||
},
|
||||
"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": "Mentett kinézetek"
|
||||
},
|
||||
"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": "Bejelentkezés"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Kérlek jelentkezz be a Minecraft-fiókodba, hogy használni tudd a Modrinth App kinézetkezelési funkcióit."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Izgatott Modrinth-bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Kérlek jelentkezz be"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Kinézetváltó"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "A Modrinth App v{version} telepítésre kész! Frissítéshez válaszd ki a Frissítés opciót, vagy az alkalmazás a bezárásakor automatikusan frissül."
|
||||
},
|
||||
@@ -524,6 +395,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -737,24 +611,6 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java és memória"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Egyéni környezeti változók"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Egyéni Java argumentumok"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Egyedi Java telepítés"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Egyéni memóriakiosztás"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Írja be a környezeti változókat..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Java argumentumok megadása..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Környezeti változók"
|
||||
},
|
||||
@@ -770,9 +626,6 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Hozzárendelt memória"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Ablak"
|
||||
},
|
||||
|
||||
@@ -56,45 +56,21 @@
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "Pustaka"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "Halaman arahan bawaan"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "Mematikan label nama di atas wujud pemain Anda pada halaman rupa."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Sembunyikan label nama"
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.description": {
|
||||
"message": "Menyertakan dunia terkini dalam bagian \"Masuk kembali\" pada Beranda."
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.title": {
|
||||
"message": "Masuk kembali ke dunia"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.description": {
|
||||
"message": "Kecilkan pluncur ketika proses Minecraft berjalan."
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Kecilkan peluncur"
|
||||
},
|
||||
"app.appearance-settings.native-decorations.description": {
|
||||
"message": "Gunakan bingkai jendela sistem (wajib menjalankan ulang aplikasi)."
|
||||
},
|
||||
"app.appearance-settings.native-decorations.title": {
|
||||
"message": "Hiasan asli"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Pilih opsi"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Menghidupkan kemampuan untuk menghidupkan dan mematikan bilah sisi."
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.title": {
|
||||
"message": "Hidup/matikan bilah sisi"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Bila Anda mencoba memasangkan berkas Modrinth Pack (.mrpack) yang tidak dihoskan pada Modrinth, kami akan memastikan Anda untuk memahami risikonya sebelum memasangkannya."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Peringatkan saya sebelum memasang paket mod tidak dikenal"
|
||||
},
|
||||
@@ -137,9 +113,6 @@
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Paket Mod"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Menambah konten dapat merusak kompatibilitas saat bergabung ke server. Konten tambahan apa pun akan hilang ketika Anda memperbarui konten instans server."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Memasang"
|
||||
},
|
||||
@@ -272,6 +245,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -296,12 +272,6 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "{name} perlu diperbarui sebelum dimainkan. Mohon perbarui ke versi terkini untuk meluncurkan permainan."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Proyek ini sudah terpasang"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Kembali ke halaman pencarian"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Pasang konten ke instans"
|
||||
},
|
||||
@@ -329,147 +299,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Manajemen sumber"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Tambah rupa"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Seret dan lepas"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Terapkan"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Hapus rupa"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Ini akan menghapus rupa terpilih secara permanen. Tindakan ini tidak dapat dibatalkan."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Apakah Anda yakin ingin menghapus rupa ini?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Gagal membaca berkas yang diletakkan."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Kesalahan dalam memroses berkas"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Sunting rupa"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Tambah rupa"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Menambah rupa"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Gaya lengan"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Ramping"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Lebar"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Jubah"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Jubah"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Menyunting rupa"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Sunting rupa terlebih dahulu!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Tanpa jubah"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Tidak ada"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Ganti tekstur"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Simpan rupa"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Menyimpan..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Tekstur"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Unggah rupa terlebih dahulu!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Sunting rupa"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Mempratinjau"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Anda terlalu sering mengganti rupa Anda. Server Mojang telah memblokir permintaan lebih lanjut untuk sementara. Mohon tunggu sebentar sebelum mencoba lagi."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Pelan-pelan!"
|
||||
},
|
||||
"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": "Rupa bawaan"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Anda mendapat rupa ini karena telah berdonasi ke penggalangan dana Modrinth Pride saat Pride Month."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Rupa tersimpan"
|
||||
},
|
||||
"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": "Masuk"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Mohon masuk ke akun Minecraft Anda untuk menggunakan fitur pengelolaan rupa dari Modrinth App."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Bot Modrinth penuh antusias"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Silakan masuk"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Pemilih rupa"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} siap dipasang! Muat ulang untuk memperbarui sekarang, atau secara otomatis saat Anda menutup Modrinth App."
|
||||
},
|
||||
@@ -533,6 +362,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -746,24 +578,6 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java dan memori"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Variabel lingkungan tersuai"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Argumen Java tersuai"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Pemasangan Java tersuai"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Alokasi memori tersuai"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Masukkan variabel lingkungan..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Masukkan argumen Java..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Variabel lingkungan"
|
||||
},
|
||||
@@ -779,9 +593,6 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Memori yang teralokasikan"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/jalur/ke/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Jendela"
|
||||
},
|
||||
@@ -869,9 +680,6 @@
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Pilih akun"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Masuk ke Minecraft"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Disediakan oleh instans"
|
||||
},
|
||||
@@ -896,9 +704,6 @@
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Pemuat disediakan oleh server"
|
||||
},
|
||||
"unknown-pack-warning-modal.body": {
|
||||
"message": "Berkas hanya akan ditinjau bila ia diunggah ke Modrinth, tak peduli format berkasnya (termasuk .mrpack)."
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Jangan tampilkan peringatan ini lagi"
|
||||
},
|
||||
@@ -908,9 +713,6 @@
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Tetap pasang"
|
||||
},
|
||||
"unknown-pack-warning-modal.malware-statement": {
|
||||
"message": "Program jahat (malware) dibagikan melalui berkas paket mod dengan membagikannya melalui platform seperti Discord."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.body": {
|
||||
"message": "Kami tidak dapat menemukan berkas ini di Modrinth. Kami sangat menyarankan Anda untuk hanya memasang berkas dari sumber-sumber terpercaya."
|
||||
},
|
||||
|
||||
@@ -224,6 +224,9 @@
|
||||
"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?"
|
||||
},
|
||||
@@ -245,6 +248,12 @@
|
||||
"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}?"
|
||||
},
|
||||
@@ -263,6 +272,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -320,147 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Gestione delle risorse"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Nuova skin"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Trascina e rilascia"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Applica"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Elimina"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Questa skin sarà rimossa per sempre. Quest'azione non può essere annullata."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Vuoi davvero eliminare questa skin?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Non è stato possibile leggere il file rilasciato."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Impossibile processare il file"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Modifica"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Salva"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Aggiungi una nuova skin"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Modello del giocatore"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Snello"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Largo"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Mantello"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Mantello"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Modifica la skin"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Modifica la skin prima!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Nessun mantello"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Nessuno"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Sfoglia"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Salva"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Salvataggio..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "File della skin"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Carica una skin prima!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Modifica"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Anteprima"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Stai modificando la skin troppo spesso. I server di Mojang hanno limitato le nostre richieste. Aspetta un momento prima di riprovare."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Rallenta!"
|
||||
},
|
||||
"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": "Skin predefinite"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Pride Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Hai ottenuto queste skin per aver donato alla raccolta fondi per il Pride durante il Pride Month."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Raccolta"
|
||||
},
|
||||
"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": "Accedi"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Accedi col tuo account Minecraft per usare la gestione delle skin."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Modrinth Bot emozionato"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Accedi"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Seleziona una skin"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} è pronta per essere installata! Ricarica per aggiornare ora, o avverrà in automatico alla chiusura dell'app."
|
||||
},
|
||||
|
||||
@@ -254,6 +254,9 @@
|
||||
"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パック"
|
||||
},
|
||||
@@ -365,6 +368,9 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "フレンドを追加"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count}件の友達リクエスト"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "フレンド申請を送信"
|
||||
},
|
||||
|
||||
@@ -332,135 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "리소스 관리"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "스킨 추가"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "드레그 엔 드롭"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "적용"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "스킨 지우기"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "선택한 스킨이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "이 스킨을 정말 삭제하시겠습니까?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "삭제된 파일을 읽는 데 실패했습니다."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "파일 처리 중 오류가 발생했습니다."
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "스킨 편집"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "스킨 추가"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "스킨 추가"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "팔 형테"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Slim"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Wide"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "망토"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "망토"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "스킨 수정"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "먼저 스킨을 수정하세요"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "망토 없음"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "망토 없음"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "찾아보기"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "스킨 저장하기"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "저장중..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "스킨 파일"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "먼저 스킨을 업로드하세요"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "스킨 수정"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "미리보기"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "스킨을 너무 자주 변경하고 있습니다. Mojang 서버에서 일시적으로 추가 요청을 차단했습니다. 잠시 후 다시 시도해 주세요."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "잠시 기다려주세요."
|
||||
},
|
||||
"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": "기본 스킨"
|
||||
},
|
||||
"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": "저장된 스킨"
|
||||
},
|
||||
"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": "로그인"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Modrinth 앱의 스킨 관리 기능을 사용하려면 Minecraft 계정에 로그인하세요."
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "로그인하세요"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "스킨 선택"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version}을 설치할 준비가 완료되었습니다! 새로고침하거나 Modrinth App을 종료하면 자동으로 업데이트됩니다."
|
||||
},
|
||||
@@ -524,6 +395,9 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "친구 추가"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count}건의 친구 요청"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "친구 요청 보내기"
|
||||
},
|
||||
|
||||
@@ -272,6 +272,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -299,9 +302,6 @@
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Projek ini sudah dipasang"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Kembali menemukan"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Pasang kandungan ke dalam pemasangan"
|
||||
},
|
||||
@@ -392,6 +392,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -605,21 +608,6 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java dan ingatan"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Pembolehubah persekitaran tersuai"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Argumen Java tersuai"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Pemasangan Java tersuai"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Peruntukan ingatan tersuai"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Masukkan argumen Java..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Pembolehubah persekitaran"
|
||||
},
|
||||
@@ -635,9 +623,6 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Ingatan yang diperuntukkan"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/laluan/ke/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Tetingkap"
|
||||
},
|
||||
|
||||
@@ -224,6 +224,9 @@
|
||||
"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?"
|
||||
},
|
||||
@@ -254,6 +257,9 @@
|
||||
"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,6 +206,9 @@
|
||||
"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!"
|
||||
},
|
||||
@@ -269,6 +272,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -326,69 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Zarządzanie zasobami"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Dodaj skina"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Zastosuj"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Usuń skina"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Edytuj skina"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Dodaj skina"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Dodawanie skina"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Peleryna"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Pelerynki"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Edytuj skina"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Brak peleryny"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Nic"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Zapisz skina"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Zapisywanie..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Tekstura"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Edytuj skina"
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Zwolnij!"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Domyślne skiny"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "Zaloguj Się"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Proszę zaloguj się"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Wybierz skina"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Wersja Modrinth App v{version} jest gotowa do zainstalowania! Załaduj ponownie, żeby zaktualizować teraz, albo automatycznie, gdy zamkniesz Modrinth App."
|
||||
},
|
||||
@@ -452,6 +395,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -665,24 +611,6 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java i pamięć"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Zmienne środowiskowe"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Niestandardowe argumenty Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Niestandardowa instalacja Javy"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Niestandardowa alokacja pamięci"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Zmienne środowiskowe..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Argumenty Javy..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Zmienne środowiskowe"
|
||||
},
|
||||
@@ -698,9 +626,6 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Przypisana pamięć"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Okno"
|
||||
},
|
||||
|
||||
@@ -272,6 +272,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -329,147 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Gerenciamento de recursos"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Adicionar skin"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Arraste e solte"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Aplicar"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Excluir skin"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Isto permanentemente deletará a skin selecionada. Esta ação não pode ser desfeita."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Tem certeza de que deseja excluir esta skin?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Falha ao ler o arquivo solto."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Erro ao processar o arquivo"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Editar skin"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Adicionar skin"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Adicionando skin"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Estilo do braço"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Fino"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Largo"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Capa"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Capa"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Editando skin"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Primeiro, faça uma edição na skin!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Sem capa"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Nenhum"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Substituir textura"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Salvar skin"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Salvando..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Textura"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Primeiro, faça o envio de uma skin!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Editar skin"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Pré-visualizando"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Você está trocando de skin com muita frequência. Os servidores da Mojang bloquearam temporariamente novas solicitações. Aguarde um momento antes de tentar novamente."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Vá mais devagar!"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Construtores & Biomas"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "Chase the Skies"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Skins padrões"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Orgulho LGBTQIA+ do Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Você recebeu estas skins por doar para uma campanha de arrecadação de fundos do Orgulho LGBTQIA+ do Modrinth durante o Mês do Orgulho."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Skins salvas"
|
||||
},
|
||||
"app.skins.section.striding-hero": {
|
||||
"message": "Striding Hero"
|
||||
},
|
||||
"app.skins.section.the-copper-age": {
|
||||
"message": "A Era do Cobre"
|
||||
},
|
||||
"app.skins.section.the-garden-awakens": {
|
||||
"message": "The Garden Awakens"
|
||||
},
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"message": "Tiny Takeover"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "Fazer login"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Faça login na sua conta do Minecraft para usar os recursos de gerenciamento de skins do Modrinth app."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Modrinth Bot animado"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Por favor, faça login"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Seletor de skin"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "O Modrinth App v{version} está pronto para ser instalado! Você pode recarregar para atualizar agora ou a atualização será feita automaticamente ao fechar o Modrinth App."
|
||||
},
|
||||
@@ -533,6 +395,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -780,7 +645,7 @@
|
||||
"message": "Memória alocada"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/caminho/ao/java"
|
||||
"message": "/caminho/para/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Janela"
|
||||
|
||||
@@ -149,6 +149,9 @@
|
||||
"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?"
|
||||
},
|
||||
@@ -170,6 +173,12 @@
|
||||
"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}?"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "Скачивание Java {version}"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Загрузки"
|
||||
"message": "Скачивание"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Скрыть другие активные сборки"
|
||||
@@ -27,7 +27,7 @@
|
||||
"message": "Остановить сборку"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Посмотреть текущие загрузки"
|
||||
"message": "Посмотреть текущие скачивания"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Посмотреть сборку"
|
||||
@@ -138,7 +138,7 @@
|
||||
"message": "Сборки"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Добавление контента может сломать совместимость при подключении к серверу. Весь добавленный контент также будет удалён при обновлении сервера."
|
||||
"message": "Добавление контента может нарушить совместимость при подключении к серверу. Также весь добавленный контент будет удалён при обновлении содержимого серверной сборки."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Установка"
|
||||
@@ -206,6 +206,9 @@
|
||||
"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": "Что в моей сборке:"
|
||||
},
|
||||
@@ -221,6 +224,9 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Найти серверы"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "«{name}» будет **удалён навсегда**. Его невозможно восстановить."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Вы действительно хотите удалить этот мир?"
|
||||
},
|
||||
@@ -242,9 +248,18 @@
|
||||
"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": "этот сервер"
|
||||
},
|
||||
@@ -257,6 +272,9 @@
|
||||
"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": "Необходимая сборка"
|
||||
},
|
||||
@@ -314,141 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Управление ресурсами"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Добавить скин"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Перетащите сюда"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Применить"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Удалить скин"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Выбранный скин будет удалён навсегда. Это действие необратимо."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Вы действительно хотите удалить этот скин?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Не удалось прочитать перетащенный файл."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Ошибка обработки файла"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Изменить скин"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Добавить скин"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Добавление скина"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Руки"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Тонкие"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Широкие"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Плащ"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Плащ"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Настройка скина"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Сначала внесите изменения в скин!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Без плаща"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Нет"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Заменить текстуру"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Сохранить скин"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Сохранение..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Текстура"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Сначала загрузите скин!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Изменить"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Предпросмотр"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Слишком частая смена скина. Серверы Mojang временно заблокировали дальнейшие запросы. Подождите немного, прежде чем попробовать снова."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Не так быстро!"
|
||||
},
|
||||
"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": "Стандартные скины"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Сохранённые скины"
|
||||
},
|
||||
"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": "Войти"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Чтобы использовать функции управления скинами Modrinth App, войдите в свою учётную запись Minecraft."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Восторженный Modrinth Bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Войдите"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Выбор скина"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Версия Modrinth App {version} готова к установке! Перезапустите приложение, чтобы обновить его, или оно обновится автоматически после закрытия."
|
||||
},
|
||||
@@ -512,6 +395,9 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Добавить в друзья"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} {count, plural, one {запрос} few {запроса} other {запросов}} дружбы"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Отправить запрос дружбы"
|
||||
},
|
||||
@@ -605,6 +491,9 @@
|
||||
"instance.edit-world.title": {
|
||||
"message": "Настройка мира"
|
||||
},
|
||||
"instance.files.adding-files": {
|
||||
"message": "Добавление файлов ({completed, number}/{total, number})"
|
||||
},
|
||||
"instance.files.save-as": {
|
||||
"message": "Сохранить как..."
|
||||
},
|
||||
@@ -723,19 +612,19 @@
|
||||
"message": "Java и память"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Переопределение переменных среды"
|
||||
"message": "Пользовательские настройки запуска"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Переопределение аргументов Java"
|
||||
"message": "Пользовательские аргументы Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Переопределение расположения Java"
|
||||
"message": "Пользовательская установка Java"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Переопределение выделенной памяти"
|
||||
"message": "Пользовательский объём памяти"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Введите переменные среды..."
|
||||
"message": "Введите дополнительные параметры системы..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Введите аргументы Java..."
|
||||
|
||||
@@ -56,18 +56,6 @@
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Dölj namnskylt"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Minimera programstartare"
|
||||
},
|
||||
"app.appearance-settings.native-decorations.description": {
|
||||
"message": "Använd systemets fönsterram (omstart av app krävs)."
|
||||
},
|
||||
"app.appearance-settings.native-decorations.title": {
|
||||
"message": "Inbyggda fönsterdekorationer"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Välj ett alternativ"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Minecrafts autentiseringsservrar kan vara nere just nu. Kontrollera din internetanslutning och försök igen senare."
|
||||
},
|
||||
@@ -98,9 +86,6 @@
|
||||
"app.browse.server.installing": {
|
||||
"message": "Installerar"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Ange modpaketets beskrivning..."
|
||||
},
|
||||
@@ -110,9 +95,6 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Exportera modpaket"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "Inkludera \"{file}\"?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Modpaketets namn"
|
||||
},
|
||||
@@ -272,60 +254,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Resurshantering"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Lägg till utseende"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Ta bort utseende"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Redigera utseende"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Lägg till utseende"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Lägga till ett utseende"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Arm stil"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Smal"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Bred"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Mantel"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Mantel"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Redigerar utseende"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Gör en ändring på utseendet först!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Ingen mantel"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Ingen"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Byt ut textur"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Spara utseende"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Sparar..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Textur"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} är redo att laddas ner! Ladda om för att uppdatera nu, eller automatiskt när du stänger Modrinth App."
|
||||
},
|
||||
@@ -692,24 +620,6 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "Värld används"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "Lägg till konto"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Minecraft konto"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Inte inloggad"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Ta bort konto"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Välj konto"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Logga in till Minecraft"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Tillhandahållet av instansen"
|
||||
},
|
||||
@@ -733,8 +643,5 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Loader tillhandahålls av servern"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Installera ändå"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,9 @@
|
||||
"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": "มอดแพ็กที่จำเป็น"
|
||||
},
|
||||
@@ -371,6 +374,9 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "เพิ่มเพื่อน"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "คำขอเป็นเพื่อน {count} {count, plural, other {คน}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "ส่งคำขอเป็นเพื่อน"
|
||||
},
|
||||
|
||||
@@ -104,12 +104,6 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Doğrulama sunucularına erişilemedi"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Sunucuyu kuruluma ekle"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Bir kuruluma ekle"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Kuruluma ekle"
|
||||
},
|
||||
@@ -122,9 +116,6 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Zaten ekli"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Kuruluma dön"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "İçerik keşfet"
|
||||
},
|
||||
@@ -137,9 +128,6 @@
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Mod paketleri"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Yeni bir içerik eklemek sunucuya girerkenki uyumluluğu bozabilir. Herhangi eklenmiş içerik ise sunucu kurulumunu güncellemeye çalıştığınızda kaybolacaktır."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Yükleniyor"
|
||||
},
|
||||
@@ -158,18 +146,12 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Mod paketini dışa aktar"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "\"{file}\" dahil mi?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Mod Paketi Adı"
|
||||
},
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "Modpaketi adı"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Bu dışa aktarmaya hangi dosyaların dahil edileceğini yapılandırma"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Sürüm numarası"
|
||||
},
|
||||
@@ -299,15 +281,6 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "{name} oyununu oynamak için güncelleme gereklidir. Oyunu başlatmak için lütfen en son sürüme güncelleyin."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Bu proje zaten kurulu"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Keşfete geri dön"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "İçeriği kuruluma yükle"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Geliştirici modu açıldı."
|
||||
},
|
||||
@@ -332,138 +305,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Kaynak yönetimi"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Skin ekle"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Sürükle bırak"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Uygula"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Skin'i sil"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Bu seçilmiş skin'i kalıcı olarak silecektir. Bu işlem geri alınamaz."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Bu skin'i silmek istediğinize gerçekten emin misiniz?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Bıraktığınız dosya okunamadı."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Dosya işlenirken hata oluştu"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Skin'i düzenle"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Skin ekle"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Skin ekleme"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Kol stili"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "İnce"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Klasik"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Pelerin"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Pelerin"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Skin düzenleme"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Önce skin'e bir düzenleme yapın!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Pelerin yok"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Hiçbiri"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Dokuyu değiştir"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Skin'i kaydet"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Kaydediliyor..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Doku"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "İlk başta skin yükleyin!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Skin'i düzenle"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Önizleme"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Skin'inizi çok hızlı değiştiriyorsunuz. Mojang'ın sunucuları geçici olarak diğer istekleri engelledi. Lütfen tekrar denemeden önce biraz bekleyin."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Yavaşla!"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "İnşaatçılar & Biyomlar"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "Chase the Skies"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Varsayılan skinler"
|
||||
},
|
||||
"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": "Kaydedilen Skinler"
|
||||
},
|
||||
"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": "Giriş Yap"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Modrinth appdeki skin yönetim özelliklerini kullanmak için lütfen Minecraft hesabınıza giriş yapın."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Heyecanlı Modrinth Bot'u"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Lütfen giriş yapın"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Skin seçici"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} güncellemesi indirilmeye hazır! Hemen güncellemek için yeniden başlatın veya Modrinth App’i kapattığınızda otomatik olarak güncellenecek."
|
||||
},
|
||||
@@ -527,6 +368,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -740,24 +584,6 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java ve bellek"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Özel ortam değişkenleri"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Özel Java argümanları"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Özel Java kurulumu"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Özel bellek tahsisi"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Ortam değişkenlerini girin..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Java argümanlarını girin..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Ortam değişkenleri"
|
||||
},
|
||||
@@ -773,9 +599,6 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Ayrılan bellek"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/java/kurulum/yolu"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Pencere"
|
||||
},
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
"message": "Якщо ви спробуєте встановити файл пакета Modrinth (.mrpack), який не розміщено на Modrinth, ми переконаємося, що ви розумієте ризики, перш ніж установлювати його."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Попередження перед установленням невідомих збірок"
|
||||
"message": "Попереджати мене перед установленням невідомих збірок"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Сервери автентифікації Minecraft можуть зараз не працювати. Перевірте з’єднання з інтернетом та спробуйте пізніше."
|
||||
@@ -224,6 +224,9 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Переглянути сервери"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "«{name}» буде **назавжди видалено** й у вас не буде можливости відновлення."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Ви впевнені, що хочете назавжди видалити цей світ?"
|
||||
},
|
||||
@@ -245,6 +248,12 @@
|
||||
"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}»?"
|
||||
},
|
||||
@@ -263,6 +272,9 @@
|
||||
"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": "Потрібна збірка"
|
||||
},
|
||||
@@ -320,138 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Керування ресурсами"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Додати скін"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Перетягніть і вставте"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Застосувати"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Видалити скін"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Це видалить вибраний скін назавжди. Ця дія не може бути скасована."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Чи ви впевнені що хочете видалити цей скін?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Неможливо відкрити вибраний файл."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Помилка обробки файлу"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Редагувати скін"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Додати скін"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Додавання скіна"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Стиль рук"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Тонкі"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Широкі"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Плащ"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Плащ"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Редагування скіна"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Відредагуйте скін для початку!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Без плаща"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Без"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Замінити текстуру"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Зберегти вигляд"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Збереження…"
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Текстура"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Завантажте скін для початку!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Редагувати скін"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Попередній перегляд"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Ви змінюєте свій скін дуже часто. Сервери Mojang тимчасово заблокували подальші запити. Зачекайте трохи перш ніж спробувати знову."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Шановний!"
|
||||
},
|
||||
"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": "Усталені скіни"
|
||||
},
|
||||
"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": "Збережені скіни"
|
||||
},
|
||||
"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": "Увійдіть"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Будь ласка, увійдіть у свій обліковий запис Minecraft, щоб використовувати особливості керування скінами в Modrinth App."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Схвильований бот Modrinth"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Увійдіть"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Вибір скіну"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} готовий до встановлення! Перезапустіть, щоб оновити зараз. Або, оновлення буде здійснено автоматично, коли закриєте застосунок Modrinth."
|
||||
},
|
||||
@@ -515,6 +395,9 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Додати друга"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} запит{count, plural, one { у} few {и в} many {ів у} other {у в}} друзі"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Надіслати запит у друзі"
|
||||
},
|
||||
@@ -840,10 +723,10 @@
|
||||
"message": "Додати обліковий запис"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Обліковий запис Minecraft"
|
||||
"message": "Майнкрафт аккаунт"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Не авторизовано"
|
||||
"message": "Не ввійдено в систему"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Видалити обліковий запис"
|
||||
@@ -852,7 +735,7 @@
|
||||
"message": "Обрати обліковий запис"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Увійти в Minecraft"
|
||||
"message": "Увійти в Майнкрафт"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Надано профілем"
|
||||
@@ -885,10 +768,10 @@
|
||||
"message": "Не показувати більше це попередження"
|
||||
},
|
||||
"unknown-pack-warning-modal.header": {
|
||||
"message": "Підтвердити встановлення"
|
||||
"message": "Підтвердити інсталяцію"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Усе одно встановити"
|
||||
"message": "Усе одно інсталювати"
|
||||
},
|
||||
"unknown-pack-warning-modal.malware-statement": {
|
||||
"message": "Шкідливе програмне забезпечення часто поширюють через файли збірок, які публікуються на таких платформах, як Discord."
|
||||
|
||||
@@ -332,147 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Quản lý tài nguyên"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Skin tự tạo"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Kéo và thả"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Áp dụng"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Xóa skin"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Hành động này sẽ xóa skin trên vĩnh viễn. Hành động này không thể khôi phục lại."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Bạn hoàn toàn muốn xóa skin này chứ?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Không đọc được file thả."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Có lỗi khi đọc file"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Sửa skin"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Tạo skin"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Tạo skin"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Độ rộng cánh tay"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Gầy"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Béo"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Cape"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Cape"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Sửa skin"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Sửa skin trước đã!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Không cape"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Không"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Đổi skin"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Lưu skin"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Đang lưu..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Skin"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Nhập skin vào trước đã!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Sửa skin"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Xem trước"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Bạn đang chỉnh skin quá thường xuyên. Mãy chủ của Mojang đã tạm thời chặn lại các yêu cầu hiện thời. Đợi một lúc trước khi thử lại lần nữa."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Bình tĩnh lại!"
|
||||
},
|
||||
"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": "Skin mặc định"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Bạn đã được tặng những skin này sau khi quyên góp cho Modrinth trong tháng Lẩu Gà Bình Thuận thế giới."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Skin đã lưu"
|
||||
},
|
||||
"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": "Đăng nhập"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Đặng nhập vào mội tài khoản Minecraft để sử dụng trình sửa Skin trong ứng dụng này."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Bot phấn khích"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Đăng nhập đã"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Trình sửa skin"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Phiên bản v{version} của Modrinth đã được chuẩn bị để cài đặt! Khởi động lại ứng dụng để cập nhật ngay bây giờ, hoặc cập nhật tự động khi bạn đóng Modrinth."
|
||||
},
|
||||
|
||||
@@ -224,6 +224,9 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "浏览服务器"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "“{name}”将被**永久删除**,并且无法恢复。"
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "你确定要永久删除这个世界吗?"
|
||||
},
|
||||
@@ -245,6 +248,12 @@
|
||||
"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} 吗?"
|
||||
},
|
||||
@@ -263,6 +272,9 @@
|
||||
"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": "需求整合包"
|
||||
},
|
||||
@@ -320,141 +332,6 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "资源管理"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "添加皮肤"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "拖放"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "应用"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "删除皮肤"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "这会永久删除所选皮肤。这操作无法撤销。"
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "你确定要删除这个皮肤吗?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "无法读取拖放的文件。"
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "处理文件时出错"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "编辑皮肤"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "添加皮肤"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "添加皮肤中"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "手臂样式"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "细"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "粗"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "披风"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "披风"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "编辑皮肤中"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "请先对皮肤进行编辑!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "无披风"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "无"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "替换纹理"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "保存皮肤"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "保存中……"
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "纹理"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "请先上传皮肤!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "编辑皮肤"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "预览"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "你更改皮肤过于频繁。Mojang服务器已暂时阻止了后续请求。请稍等片刻后再试。"
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "慢点!"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "建造者与生物群系"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "追逐苍穹"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "默认皮肤"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "暴乱坐骑"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "已保存皮肤"
|
||||
},
|
||||
"app.skins.section.striding-hero": {
|
||||
"message": "阔步英雄"
|
||||
},
|
||||
"app.skins.section.the-copper-age": {
|
||||
"message": "铜器时代"
|
||||
},
|
||||
"app.skins.section.the-garden-awakens": {
|
||||
"message": "花园苏醒"
|
||||
},
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"message": "微小占领"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "登录"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "请登录您的 Minecraft 账号,以便使用 Modrinth 应用的皮肤管理功能。"
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "兴奋的 Modrinth Bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "请登录"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "皮肤选择器"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} 更新已就绪!立即重启更新,或退出时自动安装。"
|
||||
},
|
||||
@@ -518,6 +395,9 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "添加好友"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} 个好友请求"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "发送好友请求"
|
||||
},
|
||||
@@ -731,24 +611,6 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java 及内存"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "自定义环境变量"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "自定义Java参数"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "自定义Java安装"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "自定义内存分配"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "输入环境变量……"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "输入 Java 参数……"
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "环境变量"
|
||||
},
|
||||
@@ -764,9 +626,6 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "内存分配"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "游戏窗口"
|
||||
},
|
||||
|
||||
@@ -224,6 +224,9 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "瀏覽伺服器"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "「{name}」將**永久刪除**,且無法還原。"
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "確定要永久刪除這個世界嗎?"
|
||||
},
|
||||
@@ -245,6 +248,12 @@
|
||||
"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}」嗎?"
|
||||
},
|
||||
@@ -263,6 +272,9 @@
|
||||
"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": "所需模組包"
|
||||
},
|
||||
@@ -383,6 +395,9 @@
|
||||
"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
@@ -32,16 +32,14 @@
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</div>
|
||||
|
||||
<template v-if="showInstancePlayTime">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<template v-if="timePlayed > 0">
|
||||
{{ timePlayedHumanized }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<template v-if="timePlayed > 0">
|
||||
{{ timePlayedHumanized }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -328,7 +326,7 @@ import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { useBreadcrumbs, useTheming } from '@/store/state'
|
||||
import { useBreadcrumbs } from '@/store/state'
|
||||
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(relativeTime)
|
||||
@@ -340,8 +338,6 @@ const route = useRoute()
|
||||
|
||||
const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
|
||||
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
|
||||
@@ -297,58 +297,6 @@ function isActiveUpdateRequest(requestId: number) {
|
||||
return activeUpdateRequestId.value === requestId
|
||||
}
|
||||
|
||||
function sortVersionsByPublishedDate(versions: Labrinth.Versions.v2.Version[]) {
|
||||
return [...versions].sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
}
|
||||
|
||||
function mergeVersionIntoList(
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
version: Labrinth.Versions.v2.Version,
|
||||
) {
|
||||
const existingIndex = versions.findIndex((v) => v.id === version.id)
|
||||
if (existingIndex === -1) {
|
||||
return sortVersionsByPublishedDate([version, ...versions])
|
||||
}
|
||||
|
||||
const mergedVersions = [...versions]
|
||||
mergedVersions[existingIndex] = version
|
||||
return sortVersionsByPublishedDate(mergedVersions)
|
||||
}
|
||||
|
||||
async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: string) {
|
||||
let fetchError: unknown = null
|
||||
let versions = (await get_project_versions(projectId, 'bypass').catch((err) => {
|
||||
fetchError = err
|
||||
return null
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!versions) {
|
||||
versions = (await get_project_versions(projectId).catch(() => null)) as
|
||||
| Labrinth.Versions.v2.Version[]
|
||||
| null
|
||||
}
|
||||
|
||||
if (!versions && fetchError) {
|
||||
handleError(fetchError as Error)
|
||||
}
|
||||
|
||||
let mergedVersions = sortVersionsByPublishedDate(versions ?? [])
|
||||
|
||||
if (pinnedVersionId && !mergedVersions.some((version) => version.id === pinnedVersionId)) {
|
||||
const pinnedVersion = (await get_version(pinnedVersionId, 'bypass').catch(
|
||||
() => null,
|
||||
)) as Labrinth.Versions.v2.Version | null
|
||||
|
||||
if (pinnedVersion) {
|
||||
mergedVersions = mergeVersionIntoList(mergedVersions, pinnedVersion)
|
||||
}
|
||||
}
|
||||
|
||||
return mergedVersions
|
||||
}
|
||||
|
||||
async function handleBrowseContent() {
|
||||
if (!props.instance) return
|
||||
await router.push({
|
||||
@@ -595,14 +543,16 @@ async function handleUpdate(id: string) {
|
||||
})
|
||||
contentUpdaterModal.value?.show(initialVersionId)
|
||||
|
||||
const versions = await getUpdaterProjectVersions(item.project.id, initialVersionId)
|
||||
const versions = (await get_project_versions(item.project.id).catch((e) => {
|
||||
return handleError(e)
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
|
||||
return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
if (versions.length === 0) {
|
||||
if (!versions) {
|
||||
debug('handleUpdate: no versions returned', { projectId: item.project.id })
|
||||
return
|
||||
}
|
||||
@@ -621,6 +571,9 @@ async function handleUpdate(id: string) {
|
||||
updateVersionInList: versions.some((v) => v.id === item.update_version_id),
|
||||
})
|
||||
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
const preselectedVersion =
|
||||
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
|
||||
debug('handleUpdate: resolved content updater preselection', {
|
||||
@@ -658,16 +611,23 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
|
||||
await nextTick()
|
||||
|
||||
const initialVersionId = item.version.id
|
||||
contentUpdaterModal.value?.show(initialVersionId, { switchMode: true })
|
||||
contentUpdaterModal.value?.show(item.version.id, { switchMode: true })
|
||||
|
||||
const versions = await getUpdaterProjectVersions(item.project.id, initialVersionId)
|
||||
const versions = (await get_project_versions(item.project.id).catch((e) => {
|
||||
return handleError(e)
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
|
||||
return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
if (!versions) return
|
||||
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
|
||||
updatingProjectVersions.value = versions
|
||||
}
|
||||
|
||||
@@ -753,17 +713,19 @@ async function handleModpackUpdate() {
|
||||
})
|
||||
contentUpdaterModal.value?.show(initialVersionId)
|
||||
|
||||
const versions = await getUpdaterProjectVersions(
|
||||
props.instance.linked_data.project_id,
|
||||
initialVersionId,
|
||||
)
|
||||
const versions = (await get_project_versions(props.instance.linked_data.project_id).catch(
|
||||
handleError,
|
||||
)) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
if (versions.length === 0) return
|
||||
if (!versions) return
|
||||
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
const preselectedVersion =
|
||||
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
|
||||
debug('handleModpackUpdate: resolved modpack updater preselection', {
|
||||
@@ -798,7 +760,12 @@ async function fetchAndSpliceVersion(
|
||||
)) as Labrinth.Versions.v2.Version | null
|
||||
if (!isActiveUpdateRequest(requestId)) return
|
||||
if (!fullVersion) return
|
||||
updatingProjectVersions.value = mergeVersionIntoList(updatingProjectVersions.value, fullVersion)
|
||||
const index = updatingProjectVersions.value.findIndex((v) => v.id === versionId)
|
||||
if (index !== -1) {
|
||||
const newVersions = [...updatingProjectVersions.value]
|
||||
newVersions[index] = fullVersion
|
||||
updatingProjectVersions.value = newVersions
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
|
||||
import { type AuthProvider, provideAuth } from '@modrinth/ui'
|
||||
import { computed, type Ref, ref, watchEffect } from 'vue'
|
||||
|
||||
type AppCredentials = {
|
||||
@@ -12,7 +12,7 @@ export function setupAuthProvider(
|
||||
requestSignIn: (redirectPath: string) => void | Promise<void>,
|
||||
) {
|
||||
const sessionToken = ref<string | null>(null)
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const user = ref<Labrinth.Users.v2.User | null>(null)
|
||||
const isReady = computed(() => credentials.value !== undefined)
|
||||
|
||||
const authProvider: AuthProvider = {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function setupFilePickerProvider() {
|
||||
const file = await createFileFromPath(path, 'icon')
|
||||
return { file, path, previewUrl: convertFileSrc(path) }
|
||||
},
|
||||
async pickModpackFile(options) {
|
||||
async pickModpackFile() {
|
||||
const result = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: 'Modpack', extensions: ['mrpack'] }],
|
||||
@@ -34,19 +34,12 @@ export function setupFilePickerProvider() {
|
||||
if (!result) return null
|
||||
const path = result.path ?? result
|
||||
if (!path) return null
|
||||
if (options?.readFile === false) {
|
||||
// Instance imports stream from the native path, keeping large packs out of JS memory.
|
||||
return { path, previewUrl: '' }
|
||||
}
|
||||
return {
|
||||
file: await createFileFromPath(
|
||||
path,
|
||||
'modpack.mrpack',
|
||||
'application/x-modrinth-modpack+zip',
|
||||
),
|
||||
const file = await createFileFromPath(
|
||||
path,
|
||||
previewUrl: '',
|
||||
}
|
||||
'modpack.mrpack',
|
||||
'application/x-modrinth-modpack+zip',
|
||||
)
|
||||
return { file, path, previewUrl: '' }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,10 +86,10 @@ export default new createRouter({
|
||||
},
|
||||
{
|
||||
path: '/skins',
|
||||
name: 'Skin selector',
|
||||
name: 'Skins',
|
||||
component: Pages.Skins,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Skin selector' }],
|
||||
breadcrumb: [{ name: 'Skins' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,9 +9,7 @@ export const DEFAULT_FEATURE_FLAGS = {
|
||||
server_ram_as_bytes_always_on: false,
|
||||
always_show_app_controls: false,
|
||||
skip_unknown_pack_warning: false,
|
||||
pride_fundraiser: true,
|
||||
i18n_debug: false,
|
||||
show_instance_play_time: true,
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
|
||||
@@ -23,7 +21,6 @@ export type ColorTheme = (typeof THEME_OPTIONS)[number]
|
||||
export type ThemeStore = {
|
||||
selectedTheme: ColorTheme
|
||||
advancedRendering: boolean
|
||||
hideNametagSkinsPage: boolean
|
||||
toggleSidebar: boolean
|
||||
|
||||
devMode: boolean
|
||||
@@ -33,7 +30,6 @@ export type ThemeStore = {
|
||||
export const DEFAULT_THEME_STORE: ThemeStore = {
|
||||
selectedTheme: 'dark',
|
||||
advancedRendering: true,
|
||||
hideNametagSkinsPage: false,
|
||||
toggleSidebar: false,
|
||||
|
||||
devMode: false,
|
||||
|
||||
@@ -58,9 +58,6 @@ export default defineConfig({
|
||||
params: {
|
||||
overrides: {
|
||||
removeViewBox: false,
|
||||
cleanupIds: {
|
||||
minify: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-3
@@ -114,12 +114,10 @@ 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",
|
||||
])
|
||||
|
||||
@@ -11,12 +11,10 @@ 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,
|
||||
])
|
||||
@@ -39,19 +37,29 @@ 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)`
|
||||
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)`
|
||||
///
|
||||
/// 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: Option<Cape>,
|
||||
) -> Result<Skin> {
|
||||
Ok(
|
||||
minecraft_skins::add_and_equip_custom_skin(texture_blob, variant, cape)
|
||||
.await?,
|
||||
cape_override: Option<Cape>,
|
||||
) -> Result<()> {
|
||||
Ok(minecraft_skins::add_and_equip_custom_skin(
|
||||
texture_blob,
|
||||
variant,
|
||||
cape_override,
|
||||
)
|
||||
.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)`
|
||||
@@ -70,27 +78,6 @@ 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]
|
||||
@@ -99,27 +86,6 @@ 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]
|
||||
|
||||
+2
-12
@@ -270,20 +270,10 @@ fn main() {
|
||||
Ok(app) => {
|
||||
app.run(|app, event| {
|
||||
#[cfg(not(any(feature = "updater", target_os = "macos")))]
|
||||
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}"
|
||||
);
|
||||
}
|
||||
drop((app, event));
|
||||
|
||||
#[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| {
|
||||
|
||||
@@ -75,10 +75,10 @@ export default defineNuxtConfig({
|
||||
},
|
||||
ssr: {
|
||||
// https://github.com/Akryum/floating-vue/issues/809#issuecomment-1002996240
|
||||
noExternal: ['floating-vue', '@floating-ui/core', '@floating-ui/dom'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['vue-router', 'floating-vue', '@floating-ui/dom'],
|
||||
noExternal: ['v-tooltip'],
|
||||
optimizeDeps: {
|
||||
include: ['vue-router'],
|
||||
},
|
||||
},
|
||||
define: {
|
||||
global: {},
|
||||
@@ -104,9 +104,6 @@ export default defineNuxtConfig({
|
||||
params: {
|
||||
overrides: {
|
||||
removeViewBox: false,
|
||||
cleanupIds: {
|
||||
minify: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -128,6 +125,8 @@ 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 || []
|
||||
@@ -137,6 +136,15 @@ 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
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"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"
|
||||
@@ -55,7 +54,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/*
|
||||
Cards and body styling
|
||||
*/
|
||||
// CARDS
|
||||
.base-card {
|
||||
padding: 1rem;
|
||||
padding: var(--spacing-card-lg);
|
||||
position: relative;
|
||||
min-height: var(--font-size-2xl);
|
||||
|
||||
background-color: var(--surface-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--surface-4);
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--size-rounded-card);
|
||||
|
||||
margin-bottom: var(--gap-md);
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
|
||||
box-shadow: var(--shadow-card);
|
||||
|
||||
.card__overlay {
|
||||
position: absolute;
|
||||
@@ -25,17 +25,6 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:where(&.warning, &.information) {
|
||||
padding: 1.5rem;
|
||||
line-height: 1.5;
|
||||
min-height: 0;
|
||||
|
||||
a {
|
||||
color: var(--color-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.moderation-card {
|
||||
background-color: var(--color-warning-banner-bg);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
margin-top: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.normal-page__sidebar {
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
<template>
|
||||
<div class="analytics-loading-bar" :style="{ opacity: isVisible ? 1 : 0 }" aria-hidden="true">
|
||||
<div
|
||||
class="analytics-loading-bar__track"
|
||||
:style="{
|
||||
width: `${progress}%`,
|
||||
transition: !isTransitioning
|
||||
? 'none'
|
||||
: isFinishing
|
||||
? 'width 0.1s ease-in-out'
|
||||
: isCreeping
|
||||
? 'width 2s linear'
|
||||
: 'width 0.9s ease-in-out',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const progress = ref(0)
|
||||
const isVisible = ref(false)
|
||||
const isFinishing = ref(false)
|
||||
const isCreeping = ref(false)
|
||||
const isTransitioning = ref(false)
|
||||
|
||||
let startFrame: number | null = null
|
||||
let showFrame: number | null = null
|
||||
let creepTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let resetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (showFrame !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(showFrame)
|
||||
}
|
||||
if (startFrame !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(startFrame)
|
||||
}
|
||||
if (creepTimeout) clearTimeout(creepTimeout)
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
if (resetTimeout) clearTimeout(resetTimeout)
|
||||
showFrame = null
|
||||
startFrame = null
|
||||
creepTimeout = null
|
||||
hideTimeout = null
|
||||
resetTimeout = null
|
||||
}
|
||||
|
||||
function start() {
|
||||
clearTimers()
|
||||
isVisible.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = false
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
progress.value = 98
|
||||
return
|
||||
}
|
||||
|
||||
showFrame = window.requestAnimationFrame(() => {
|
||||
isVisible.value = true
|
||||
showFrame = null
|
||||
startFrame = window.requestAnimationFrame(() => {
|
||||
isTransitioning.value = true
|
||||
progress.value = 85
|
||||
startFrame = null
|
||||
})
|
||||
})
|
||||
creepTimeout = setTimeout(() => {
|
||||
isCreeping.value = true
|
||||
progress.value = 98
|
||||
creepTimeout = null
|
||||
}, 900)
|
||||
}
|
||||
|
||||
function finish() {
|
||||
clearTimers()
|
||||
isVisible.value = true
|
||||
isFinishing.value = true
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = true
|
||||
progress.value = 100
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
isVisible.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
hideTimeout = setTimeout(() => {
|
||||
isVisible.value = false
|
||||
resetTimeout = setTimeout(() => {
|
||||
isTransitioning.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
}, 400)
|
||||
}, 350)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading) => {
|
||||
if (loading) {
|
||||
start()
|
||||
} else if (
|
||||
isVisible.value ||
|
||||
progress.value > 0 ||
|
||||
showFrame !== null ||
|
||||
startFrame !== null ||
|
||||
creepTimeout !== null
|
||||
) {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(clearTimers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-loading-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.analytics-loading-bar__track {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--loading-bar-gradient);
|
||||
}
|
||||
</style>
|
||||
-1024
File diff suppressed because it is too large
Load Diff
-78
@@ -1,78 +0,0 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
export const ANALYTICS_DASHBOARD_STATS: readonly AnalyticsDashboardStat[] = [
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
|
||||
export const TOP_GRAPH_DATASET_LIMIT = 8
|
||||
export const GRAPH_RENDER_DATASET_LIMIT = 250
|
||||
export const PREVIOUS_PERIOD_DATASET_ID_PREFIX = 'previous-period:'
|
||||
export const PREVIOUS_PERIOD_BORDER_DASH = [6, 4]
|
||||
export const PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
export const ALL_PROJECTS_DATASET_ID = 'all'
|
||||
|
||||
export const PROJECT_EVENT_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
export const MONETIZATION_LEGEND_ENTRY_ORDER = new Map([
|
||||
['breakdown:monetized', 0],
|
||||
['breakdown:unmonetized', 1],
|
||||
])
|
||||
|
||||
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES = [
|
||||
'approved',
|
||||
'unlisted',
|
||||
'private',
|
||||
] as const satisfies readonly Labrinth.Projects.v2.ProjectStatus[]
|
||||
|
||||
export type VisibleProjectStatusChangeEventStatus =
|
||||
(typeof VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)[number]
|
||||
|
||||
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET =
|
||||
new Set<Labrinth.Projects.v2.ProjectStatus>(VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)
|
||||
|
||||
export const LIGHT_LEGEND_PALETTE = [
|
||||
'hsl(152, 100%, 34%)',
|
||||
'hsl(26, 100%, 42%)',
|
||||
'hsl(202, 100%, 35%)',
|
||||
'hsl(327, 45%, 64%)',
|
||||
'hsl(41, 100%, 45%)',
|
||||
'hsl(250, 60%, 33%)',
|
||||
'hsl(170, 43%, 47%)',
|
||||
'hsl(330, 60%, 33%)',
|
||||
'hsl(46, 100%, 36%)',
|
||||
'hsl(167, 100%, 30%)',
|
||||
'hsl(343, 38%, 45%)',
|
||||
'hsl(222, 100%, 28%)',
|
||||
'hsl(270, 62%, 60%)',
|
||||
'hsl(32, 100%, 37%)',
|
||||
'hsl(349, 57%, 51%)',
|
||||
'hsl(191, 43%, 37%)',
|
||||
]
|
||||
|
||||
export const DARK_LEGEND_PALETTE = [
|
||||
'hsl(145, 78%, 48%)',
|
||||
'hsl(41, 100%, 50%)',
|
||||
'hsl(202, 77%, 63%)',
|
||||
'hsl(323, 66%, 72%)',
|
||||
'hsl(56, 85%, 60%)',
|
||||
'hsl(255, 92%, 80%)',
|
||||
'hsl(12, 100%, 67%)',
|
||||
'hsl(176, 58%, 56%)',
|
||||
'hsl(60, 100%, 41%)',
|
||||
'hsl(165, 80%, 38%)',
|
||||
'hsl(341, 36%, 56%)',
|
||||
'hsl(226, 60%, 49%)',
|
||||
'hsl(252, 53%, 62%)',
|
||||
'hsl(75, 59%, 50%)',
|
||||
'hsl(195, 56%, 42%)',
|
||||
'hsl(30, 59%, 56%)',
|
||||
]
|
||||
-297
@@ -1,297 +0,0 @@
|
||||
<template>
|
||||
<Menu
|
||||
theme="analytics-controls-menu"
|
||||
placement="bottom-end"
|
||||
:shown="isControlsMenuOpen"
|
||||
:triggers="[]"
|
||||
:popper-triggers="[]"
|
||||
:aria-id="controlsMenuId"
|
||||
no-auto-focus
|
||||
@update:shown="isControlsMenuOpen = $event"
|
||||
>
|
||||
<button
|
||||
ref="controlsMenuTrigger"
|
||||
type="button"
|
||||
:aria-expanded="isControlsMenuOpen"
|
||||
:aria-controls="controlsMenuId"
|
||||
:aria-label="
|
||||
formatMessage(analyticsChartMessages.controlsAria, {
|
||||
activeCount: activeControlCountLabel,
|
||||
})
|
||||
"
|
||||
class="btn-dropdown-animation inline-flex min-h-5 cursor-pointer items-center justify-between gap-2 rounded-xl border-0 bg-surface-4 px-3 py-2 text-left text-sm font-semibold text-button-text shadow-none transition-all duration-200 hover:brightness-[115%] focus-visible:brightness-[115%] active:brightness-[115%]"
|
||||
@click="toggleControlsMenu"
|
||||
>
|
||||
<Settings2Icon class="size-4 text-secondary" aria-hidden="true" />
|
||||
<span class="leading-tight text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.controlsButton) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="activeControlCount > 0"
|
||||
class="inline-flex min-w-5 items-center justify-center rounded-full bg-highlight-green px-1.5 text-xs font-semibold leading-5 text-green"
|
||||
>
|
||||
{{ activeControlCount }}
|
||||
</span>
|
||||
<DropdownIcon class="size-4 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<div
|
||||
ref="controlsMenuPanel"
|
||||
role="dialog"
|
||||
:aria-label="formatMessage(analyticsChartMessages.controlsDialogAria)"
|
||||
class="mt-1 flex w-[228px] max-w-[calc(100vw_-_2rem)] flex-col overflow-hidden rounded-[14px] border border-solid border-surface-4 bg-surface-3 text-sm shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-2.5 text-xs font-medium">
|
||||
<span class="font-semibold text-primary">{{ activeControlCountLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isResetDisabled"
|
||||
class="border-0 bg-transparent p-0 text-xs font-semibold text-primary transition-all disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="isResetDisabled ? '' : 'hover:text-contrast focus-visible:text-contrast'"
|
||||
@click="resetControls"
|
||||
>
|
||||
{{ formatMessage(analyticsMessages.resetButton) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasDisplayControls"
|
||||
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
|
||||
>
|
||||
<div class="mb-0.5 text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.displayControls) }}
|
||||
</div>
|
||||
<div v-if="canShowPreviousPeriod" class="flex min-h-7 items-center justify-between">
|
||||
<label
|
||||
:for="previousPeriodToggleId"
|
||||
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
>
|
||||
<HistoryIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.previousPeriod) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="previousPeriodToggleId"
|
||||
v-model="showPreviousPeriodModel"
|
||||
:small="smallToggles"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="canUseRatioMode" class="flex min-h-7 items-center justify-between">
|
||||
<label
|
||||
:for="ratioModeToggleId"
|
||||
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-4 shrink-0 items-center justify-center text-sm font-semibold leading-none text-secondary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.ratio) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle :id="ratioModeToggleId" v-model="ratioModeModel" :small="smallToggles" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
|
||||
>
|
||||
<div class="mb-0.5 text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.annotations) }}
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="projectEventsDisabledTooltip"
|
||||
class="justify3 flex min-h-7 items-center"
|
||||
:aria-disabled="!hasProjectEvents"
|
||||
>
|
||||
<label
|
||||
:for="projectEventsToggleId"
|
||||
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
:class="hasProjectEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
>
|
||||
<TagCategoryFlagIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.projectEvents) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="projectEventsToggleId"
|
||||
v-model="showProjectEventsControlModel"
|
||||
:small="smallToggles"
|
||||
:disabled="!hasProjectEvents"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="modrinthEventsDisabledTooltip"
|
||||
class="justify3 flex min-h-7 items-center"
|
||||
:aria-disabled="!hasChartEvents"
|
||||
>
|
||||
<label
|
||||
:for="modrinthEventsToggleId"
|
||||
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
:class="hasChartEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
>
|
||||
<InfoIcon class="size-4 shrink-0 text-blue" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.modrinthEvents) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="modrinthEventsToggleId"
|
||||
v-model="showChartEventsControlModel"
|
||||
:small="smallToggles"
|
||||
:disabled="!hasChartEvents"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownIcon,
|
||||
HistoryIcon,
|
||||
InfoIcon,
|
||||
Settings2Icon,
|
||||
TagCategoryFlagIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { Menu } from 'floating-vue'
|
||||
|
||||
import { analyticsChartMessages, analyticsMessages } from '../../analytics-messages'
|
||||
|
||||
const props = defineProps<{
|
||||
ratioMode: boolean
|
||||
showChartEvents: boolean
|
||||
showProjectEvents: boolean
|
||||
showPreviousPeriod: boolean
|
||||
canUseRatioMode: boolean
|
||||
canShowPreviousPeriod: boolean
|
||||
hasChartEvents: boolean
|
||||
hasProjectEvents: boolean
|
||||
smallToggles: boolean
|
||||
defaultRatioMode: boolean
|
||||
defaultShowChartEvents: boolean
|
||||
defaultShowProjectEvents: boolean
|
||||
defaultShowPreviousPeriod: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e:
|
||||
| 'update:ratioMode'
|
||||
| 'update:showChartEvents'
|
||||
| 'update:showProjectEvents'
|
||||
| 'update:showPreviousPeriod',
|
||||
value: boolean,
|
||||
): void
|
||||
}>()
|
||||
|
||||
const isControlsMenuOpen = ref(false)
|
||||
const controlsMenuTrigger = ref<HTMLElement | null>(null)
|
||||
const controlsMenuPanel = ref<HTMLElement | null>(null)
|
||||
const controlsMenuId = useId()
|
||||
const ratioModeToggleId = useId()
|
||||
const previousPeriodToggleId = useId()
|
||||
const modrinthEventsToggleId = useId()
|
||||
const projectEventsToggleId = useId()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const ratioModeModel = computed({
|
||||
get: () => props.ratioMode,
|
||||
set: (value: boolean) => emit('update:ratioMode', value),
|
||||
})
|
||||
const showChartEventsModel = computed({
|
||||
get: () => props.showChartEvents,
|
||||
set: (value: boolean) => emit('update:showChartEvents', value),
|
||||
})
|
||||
const showChartEventsControlModel = computed({
|
||||
get: () => props.hasChartEvents && props.showChartEvents,
|
||||
set: (value: boolean) => emit('update:showChartEvents', value),
|
||||
})
|
||||
const showProjectEventsModel = computed({
|
||||
get: () => props.showProjectEvents,
|
||||
set: (value: boolean) => emit('update:showProjectEvents', value),
|
||||
})
|
||||
const showProjectEventsControlModel = computed({
|
||||
get: () => props.hasProjectEvents && props.showProjectEvents,
|
||||
set: (value: boolean) => emit('update:showProjectEvents', value),
|
||||
})
|
||||
const showPreviousPeriodModel = computed({
|
||||
get: () => props.showPreviousPeriod,
|
||||
set: (value: boolean) => emit('update:showPreviousPeriod', value),
|
||||
})
|
||||
|
||||
const hasDisplayControls = computed(() => props.canShowPreviousPeriod || props.canUseRatioMode)
|
||||
const projectEventsDisabledTooltip = computed(() =>
|
||||
props.hasProjectEvents ? undefined : formatMessage(analyticsChartMessages.noProjectEvents),
|
||||
)
|
||||
const modrinthEventsDisabledTooltip = computed(() =>
|
||||
props.hasChartEvents ? undefined : formatMessage(analyticsChartMessages.noModrinthEvents),
|
||||
)
|
||||
const activeControlCount = computed(() => {
|
||||
let count = 0
|
||||
if (props.canShowPreviousPeriod && props.showPreviousPeriod) count += 1
|
||||
if (props.canUseRatioMode && props.ratioMode) count += 1
|
||||
if (props.hasProjectEvents && props.showProjectEvents) count += 1
|
||||
if (props.hasChartEvents && props.showChartEvents) count += 1
|
||||
return count
|
||||
})
|
||||
const activeControlCountLabel = computed(() =>
|
||||
formatMessage(analyticsChartMessages.activeControlCount, { count: activeControlCount.value }),
|
||||
)
|
||||
const isResetDisabled = computed(
|
||||
() =>
|
||||
props.showPreviousPeriod === props.defaultShowPreviousPeriod &&
|
||||
props.ratioMode === props.defaultRatioMode &&
|
||||
props.showProjectEvents === props.defaultShowProjectEvents &&
|
||||
props.showChartEvents === props.defaultShowChartEvents,
|
||||
)
|
||||
|
||||
function toggleControlsMenu() {
|
||||
isControlsMenuOpen.value = !isControlsMenuOpen.value
|
||||
}
|
||||
|
||||
function resetControls() {
|
||||
if (isResetDisabled.value) return
|
||||
|
||||
showPreviousPeriodModel.value = props.defaultShowPreviousPeriod
|
||||
ratioModeModel.value = props.defaultRatioMode
|
||||
showProjectEventsModel.value = props.defaultShowProjectEvents
|
||||
showChartEventsModel.value = props.defaultShowChartEvents
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (!isControlsMenuOpen.value || !(event.target instanceof Node)) return
|
||||
if (controlsMenuTrigger.value?.contains(event.target)) return
|
||||
if (controlsMenuPanel.value?.contains(event.target)) return
|
||||
isControlsMenuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.v-popper--theme-analytics-controls-menu .v-popper__inner {
|
||||
overflow: visible !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.v-popper--theme-analytics-controls-menu .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
<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>
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(analyticsChartMessages.renderLimitHeader, { count: tableProjectCount })"
|
||||
fade="warning"
|
||||
width="500px"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
>
|
||||
<p class="m-0 max-w-[32rem] text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.renderLimitDescription) }}
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="modal?.hide()">
|
||||
{{ formatMessage(analyticsChartMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button class="!shadow-none" @click="confirm">
|
||||
{{ formatMessage(analyticsChartMessages.showAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages'
|
||||
|
||||
defineProps<{
|
||||
tableProjectCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div
|
||||
class="flex min-h-[84px] w-full flex-col items-start justify-between gap-3 rounded-t-2xl border-0 border-b border-solid border-surface-5 bg-surface-3 p-4 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="w-max text-xl font-semibold text-contrast">
|
||||
{{ graphTitle }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showTableSelectionSubheading"
|
||||
class="m-0 flex w-max flex-wrap items-center gap-2 text-sm text-secondary"
|
||||
>
|
||||
<span>{{ tableSelectionSubheading }}</span>
|
||||
|
||||
<button
|
||||
v-if="showGraphRenderLimitButton"
|
||||
type="button"
|
||||
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
|
||||
@click="emit('toggle-graph-render-limit', $event)"
|
||||
>
|
||||
{{ graphRenderLimitButtonLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showTopGraphDatasetsButton"
|
||||
type="button"
|
||||
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
|
||||
@click="emit('show-top-graph-datasets')"
|
||||
>
|
||||
{{ formatMessage(analyticsChartMessages.showTopEight) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex grow select-none flex-wrap-reverse items-center justify-end gap-2 gap-y-2">
|
||||
<AnalyticsChartControls
|
||||
v-model:ratio-mode="ratioMode"
|
||||
v-model:show-chart-events="showChartEvents"
|
||||
v-model:show-project-events="showProjectEvents"
|
||||
v-model:show-previous-period="showPreviousPeriod"
|
||||
:can-use-ratio-mode="canUseRatioMode"
|
||||
:can-show-previous-period="canShowPreviousPeriod"
|
||||
:has-chart-events="hasChartEvents"
|
||||
:has-project-events="hasProjectEvents"
|
||||
:small-toggles="smallToggles"
|
||||
:default-ratio-mode="DEFAULT_ANALYTICS_GRAPH_RATIO_MODE"
|
||||
:default-show-chart-events="DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY"
|
||||
:default-show-project-events="defaultShowProjectEvents"
|
||||
:default-show-previous-period="DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY"
|
||||
/>
|
||||
<Tabs
|
||||
:value="activeGraphViewMode"
|
||||
:tabs="viewModeTabs"
|
||||
@update:value="activeGraphViewMode = $event as AnalyticsGraphViewMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChartAreaIcon, ChartColumnBigIcon, ChartSplineIcon } from '@modrinth/assets'
|
||||
import { Tabs, type TabsTab, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
|
||||
DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
|
||||
DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsGraphViewMode } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages.ts'
|
||||
import AnalyticsChartControls from './AnalyticsChartControls.vue'
|
||||
|
||||
const activeGraphViewMode = defineModel<AnalyticsGraphViewMode>('activeGraphViewMode', {
|
||||
required: true,
|
||||
})
|
||||
const ratioMode = defineModel<boolean>('ratioMode', { required: true })
|
||||
const showChartEvents = defineModel<boolean>('showChartEvents', { required: true })
|
||||
const showProjectEvents = defineModel<boolean>('showProjectEvents', { required: true })
|
||||
const showPreviousPeriod = defineModel<boolean>('showPreviousPeriod', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
graphTitle: string
|
||||
showTableSelectionSubheading: boolean
|
||||
tableSelectionSubheading: string
|
||||
showGraphRenderLimitButton: boolean
|
||||
graphRenderLimitButtonLabel: string
|
||||
showTopGraphDatasetsButton: boolean
|
||||
canUseRatioMode: boolean
|
||||
canShowPreviousPeriod: boolean
|
||||
hasChartEvents: boolean
|
||||
hasProjectEvents: boolean
|
||||
smallToggles: boolean
|
||||
defaultShowProjectEvents: boolean
|
||||
isMobileLayout: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
'toggle-graph-render-limit': [event: MouseEvent]
|
||||
'show-top-graph-datasets': []
|
||||
}>()
|
||||
|
||||
const viewModeTabs = computed<TabsTab[]>(() => [
|
||||
{
|
||||
value: 'line',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.lineView),
|
||||
icon: ChartSplineIcon,
|
||||
},
|
||||
{
|
||||
value: 'area',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.areaView),
|
||||
icon: ChartAreaIcon,
|
||||
},
|
||||
{
|
||||
value: 'bar',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.barView),
|
||||
icon: ChartColumnBigIcon,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
-450
@@ -1,450 +0,0 @@
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardProject,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages.ts'
|
||||
import { COMBINED_BREAKDOWN_DATASET_ID_PREFIX } from '../../breakdown.ts'
|
||||
import {
|
||||
ALL_PROJECTS_DATASET_ID,
|
||||
MONETIZATION_LEGEND_ENTRY_ORDER,
|
||||
PREVIOUS_PERIOD_BORDER_DASH,
|
||||
} from '../analytics-chart-constants.ts'
|
||||
import type { AnalyticsChartEvent } from '../analytics-chart-plot/AnalyticsChartEvents.vue'
|
||||
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types.ts'
|
||||
import {
|
||||
areStringArraysEqual,
|
||||
type ChartDataset,
|
||||
decodeBreakdownDatasetValue,
|
||||
getChartDatasetTotal,
|
||||
getPreviousPeriodDatasetId,
|
||||
} from '../analytics-chart-utils.ts'
|
||||
|
||||
export function useAnalyticsChartLegend({
|
||||
selectableChartDatasets,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
shouldShowPreviousPeriod,
|
||||
isRatioMode,
|
||||
hiddenGraphDatasetIds,
|
||||
selectedBreakdowns,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjects,
|
||||
selectedProjectIdSet,
|
||||
selectedProjectEventIdSet,
|
||||
}: {
|
||||
selectableChartDatasets: ComputedRef<ChartDataset[]>
|
||||
allChartDatasets: ComputedRef<ChartDataset[]>
|
||||
previousChartDatasets: ComputedRef<ChartDataset[]>
|
||||
shouldShowPreviousPeriod: ComputedRef<boolean>
|
||||
isRatioMode: Ref<boolean>
|
||||
hiddenGraphDatasetIds: Ref<string[]>
|
||||
selectedBreakdowns: Ref<readonly AnalyticsBreakdownPreset[]>
|
||||
isGraphDatasetSelectionActive: Ref<boolean>
|
||||
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>
|
||||
selectedProjectIdSet: ComputedRef<Set<string>>
|
||||
selectedProjectEventIdSet: ComputedRef<Set<string>>
|
||||
}) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const hoveredLegendEntryId = ref<string | null>(null)
|
||||
const hiddenDatasetIds = computed(() => new Set(hiddenGraphDatasetIds.value))
|
||||
const previousChartDatasetByOriginalId = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of previousChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const currentLegendEntries = computed<AnalyticsChartLegendEntry[]>(() =>
|
||||
selectableChartDatasets.value
|
||||
.map((dataset) => ({
|
||||
id: dataset.projectId,
|
||||
name: dataset.label,
|
||||
projectName: dataset.projectName,
|
||||
color: dataset.borderColor,
|
||||
totalValue: getChartDatasetTotal(dataset),
|
||||
hidden: hiddenDatasetIds.value.has(dataset.projectId),
|
||||
}))
|
||||
.sort(compareLegendEntries),
|
||||
)
|
||||
const visibleProjectEventIdSet = computed(() => {
|
||||
if (!selectedBreakdowns.value.includes('project')) {
|
||||
return selectedProjectEventIdSet.value
|
||||
}
|
||||
|
||||
const visibleProjectIds = new Set<string>()
|
||||
const projectIdsWithLegendEntries = new Set<string>()
|
||||
|
||||
for (const legendEntry of currentLegendEntries.value) {
|
||||
const projectId = getLegendEntryProjectId(legendEntry)
|
||||
if (!projectId) {
|
||||
continue
|
||||
}
|
||||
|
||||
projectIdsWithLegendEntries.add(projectId)
|
||||
if (!legendEntry.hidden) {
|
||||
visibleProjectIds.add(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
if (isGraphDatasetSelectionActive.value) {
|
||||
return visibleProjectIds
|
||||
}
|
||||
|
||||
if (projectIdsWithLegendEntries.size === 0) {
|
||||
return selectedProjectEventIdSet.value
|
||||
}
|
||||
|
||||
const eventProjectIds = new Set<string>()
|
||||
for (const projectId of selectedProjectEventIdSet.value) {
|
||||
if (!projectIdsWithLegendEntries.has(projectId) || visibleProjectIds.has(projectId)) {
|
||||
eventProjectIds.add(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
return eventProjectIds
|
||||
})
|
||||
const legendEntries = computed<AnalyticsChartLegendEntry[]>(() => {
|
||||
if (!shouldShowPreviousPeriod.value) {
|
||||
return currentLegendEntries.value
|
||||
}
|
||||
|
||||
return currentLegendEntries.value.flatMap((entry) => {
|
||||
const previousDataset = previousChartDatasetByOriginalId.value.get(entry.id)
|
||||
const previousEntry: AnalyticsChartLegendEntry = {
|
||||
id: getPreviousPeriodDatasetId(entry.id),
|
||||
name: formatMessage(analyticsChartMessages.previousPeriodSuffix, { name: entry.name }),
|
||||
projectName: entry.projectName,
|
||||
color: entry.color,
|
||||
totalValue: previousDataset ? getChartDatasetTotal(previousDataset) : 0,
|
||||
hidden: hiddenDatasetIds.value.has(getPreviousPeriodDatasetId(entry.id)),
|
||||
isPreviousPeriod: true,
|
||||
}
|
||||
|
||||
return [entry, previousEntry]
|
||||
})
|
||||
})
|
||||
const hiddenCurrentLegendEntryIds = computed(() =>
|
||||
currentLegendEntries.value.filter((entry) => entry.hidden).map((entry) => entry.id),
|
||||
)
|
||||
const hiddenCurrentLegendEntryIdsKey = computed(() =>
|
||||
hiddenCurrentLegendEntryIds.value.join('\u0000'),
|
||||
)
|
||||
const chartDatasetById = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of selectableChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
|
||||
if (!shouldShowPreviousPeriod.value) {
|
||||
continue
|
||||
}
|
||||
|
||||
const previousDataset = previousChartDatasetByOriginalId.value.get(dataset.projectId)
|
||||
const previousData = Array.from(
|
||||
{ length: dataset.data.length },
|
||||
(_, index) => previousDataset?.data[index] ?? 0,
|
||||
)
|
||||
datasets.set(getPreviousPeriodDatasetId(dataset.projectId), {
|
||||
projectId: getPreviousPeriodDatasetId(dataset.projectId),
|
||||
label: formatMessage(analyticsChartMessages.previousPeriodSuffix, {
|
||||
name: dataset.label,
|
||||
}),
|
||||
projectName: dataset.projectName,
|
||||
data: previousData,
|
||||
borderColor: dataset.borderColor,
|
||||
backgroundColor: dataset.backgroundColor,
|
||||
borderDash: PREVIOUS_PERIOD_BORDER_DASH,
|
||||
})
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const hoverRatioSliceTotals = computed(() => {
|
||||
const sliceLength = selectableChartDatasets.value.reduce(
|
||||
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
|
||||
0,
|
||||
)
|
||||
const totals = new Array<number>(sliceLength).fill(0)
|
||||
|
||||
for (const legendEntry of legendEntries.value) {
|
||||
if (legendEntry.hidden) continue
|
||||
|
||||
const dataset = chartDatasetById.value.get(legendEntry.id)
|
||||
if (!dataset) continue
|
||||
|
||||
for (let i = 0; i < sliceLength; i++) {
|
||||
totals[i] += dataset.data[i] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return totals
|
||||
})
|
||||
const baseVisibleChartDatasets = computed(() =>
|
||||
legendEntries.value
|
||||
.filter((legendEntry) => !legendEntry.hidden)
|
||||
.map((legendEntry) => {
|
||||
const dataset = chartDatasetById.value.get(legendEntry.id)
|
||||
if (!dataset) return null
|
||||
|
||||
return {
|
||||
...dataset,
|
||||
borderColor: legendEntry.color,
|
||||
backgroundColor: legendEntry.color,
|
||||
}
|
||||
})
|
||||
.filter((dataset): dataset is ChartDataset => Boolean(dataset)),
|
||||
)
|
||||
const visibleChartDatasets = computed<ChartDataset[]>(() => {
|
||||
const datasets = baseVisibleChartDatasets.value
|
||||
if (!isRatioMode.value || datasets.length === 0) return datasets
|
||||
|
||||
const sliceLength = datasets.reduce(
|
||||
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
|
||||
0,
|
||||
)
|
||||
const totals = new Array<number>(sliceLength).fill(0)
|
||||
for (const dataset of datasets) {
|
||||
for (let i = 0; i < sliceLength; i++) {
|
||||
totals[i] += dataset.data[i] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return datasets.map((dataset) => ({
|
||||
...dataset,
|
||||
data: dataset.data.map((value, i) => (totals[i] === 0 ? 0 : (value / totals[i]) * 100)),
|
||||
}))
|
||||
})
|
||||
const visibleChartDatasetById = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of visibleChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const highlightedChartDatasetId = computed(() => {
|
||||
const datasetId = hoveredLegendEntryId.value
|
||||
if (!datasetId || !visibleChartDatasetById.value.has(datasetId)) return null
|
||||
return datasetId
|
||||
})
|
||||
|
||||
function compareLegendEntries(a: AnalyticsChartLegendEntry, b: AnalyticsChartLegendEntry) {
|
||||
if (selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization') {
|
||||
const aOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(a.id)
|
||||
const bOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(b.id)
|
||||
|
||||
if (aOrder !== undefined || bOrder !== undefined) {
|
||||
return (aOrder ?? Number.MAX_SAFE_INTEGER) - (bOrder ?? Number.MAX_SAFE_INTEGER)
|
||||
}
|
||||
}
|
||||
|
||||
return b.totalValue - a.totalValue || a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
|
||||
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
|
||||
}
|
||||
|
||||
function getLegendEntryProjectId(legendEntry: AnalyticsChartLegendEntry) {
|
||||
const projectBreakdownIndex = selectedBreakdowns.value.findIndex(
|
||||
(breakdown) => breakdown === 'project',
|
||||
)
|
||||
|
||||
if (projectBreakdownIndex === -1) {
|
||||
if (selectedProjects.value.length === 1 && legendEntry.id === ALL_PROJECTS_DATASET_ID) {
|
||||
return selectedProjects.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (selectedBreakdowns.value.length === 1) {
|
||||
return selectedProjectIdSet.value.has(legendEntry.id) ? legendEntry.id : null
|
||||
}
|
||||
|
||||
if (!legendEntry.id.startsWith(COMBINED_BREAKDOWN_DATASET_ID_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const values = legendEntry.id
|
||||
.slice(COMBINED_BREAKDOWN_DATASET_ID_PREFIX.length)
|
||||
.split('+')
|
||||
.map(decodeBreakdownDatasetValue)
|
||||
const projectId = values[projectBreakdownIndex]
|
||||
return projectId && selectedProjectIdSet.value.has(projectId) ? projectId : null
|
||||
}
|
||||
|
||||
function hidePreviousPeriodEntriesForHiddenCurrentEntries() {
|
||||
if (hiddenCurrentLegendEntryIds.value.length === 0) return
|
||||
|
||||
const nextHiddenDatasetIds = new Set(hiddenGraphDatasetIds.value)
|
||||
for (const datasetId of hiddenCurrentLegendEntryIds.value) {
|
||||
nextHiddenDatasetIds.add(getPreviousPeriodDatasetId(datasetId))
|
||||
}
|
||||
|
||||
const nextHiddenDatasetIdList = Array.from(nextHiddenDatasetIds)
|
||||
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIdList)) {
|
||||
hiddenGraphDatasetIds.value = nextHiddenDatasetIdList
|
||||
}
|
||||
}
|
||||
|
||||
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
|
||||
if (legendEntry.hidden) return false
|
||||
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
|
||||
return visibleCount <= 1
|
||||
}
|
||||
|
||||
function getLegendEntryTooltip(legendEntry: AnalyticsChartLegendEntry) {
|
||||
return legendEntry.projectName ?? ''
|
||||
}
|
||||
|
||||
function isUnmonetizedLegendEntry(legendEntry: AnalyticsChartLegendEntry) {
|
||||
return (
|
||||
selectedBreakdowns.value.length === 1 &&
|
||||
selectedBreakdowns.value[0] === 'monetization' &&
|
||||
legendEntry.id === 'breakdown:unmonetized'
|
||||
)
|
||||
}
|
||||
|
||||
function setHoveredLegendEntryId(datasetId: string) {
|
||||
hoveredLegendEntryId.value = datasetId
|
||||
}
|
||||
|
||||
function clearHoveredLegendEntryId(datasetId: string) {
|
||||
if (hoveredLegendEntryId.value === datasetId) {
|
||||
hoveredLegendEntryId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearLegendHoverState() {
|
||||
hoveredLegendEntryId.value = null
|
||||
}
|
||||
|
||||
function toggleLegendEntryVisibility(datasetId: string) {
|
||||
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
|
||||
if (nextHiddenDatasetIds.has(datasetId)) {
|
||||
nextHiddenDatasetIds.delete(datasetId)
|
||||
} else {
|
||||
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
|
||||
if (visibleCount <= 1) return
|
||||
nextHiddenDatasetIds.add(datasetId)
|
||||
}
|
||||
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
|
||||
}
|
||||
|
||||
function soloLegendEntry(datasetId: string) {
|
||||
const currentLegendEntryIds = new Set(legendEntries.value.map((entry) => entry.id))
|
||||
const otherIds = legendEntries.value.map((entry) => entry.id).filter((id) => id !== datasetId)
|
||||
const isAlreadySolo =
|
||||
!hiddenDatasetIds.value.has(datasetId) &&
|
||||
otherIds.every((id) => hiddenDatasetIds.value.has(id))
|
||||
|
||||
if (isAlreadySolo) {
|
||||
hiddenGraphDatasetIds.value = hiddenGraphDatasetIds.value.filter(
|
||||
(hiddenDatasetId) => !currentLegendEntryIds.has(hiddenDatasetId),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
|
||||
for (const legendEntry of legendEntries.value) {
|
||||
if (legendEntry.id === datasetId) {
|
||||
nextHiddenDatasetIds.delete(legendEntry.id)
|
||||
} else {
|
||||
nextHiddenDatasetIds.add(legendEntry.id)
|
||||
}
|
||||
}
|
||||
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
|
||||
}
|
||||
|
||||
function onLegendEntryClick(event: MouseEvent, datasetId: string) {
|
||||
if (event.shiftKey) {
|
||||
soloLegendEntry(datasetId)
|
||||
clearLegendHoverState()
|
||||
return
|
||||
}
|
||||
toggleLegendEntryVisibility(datasetId)
|
||||
clearLegendHoverState()
|
||||
}
|
||||
|
||||
function onTooltipEntryClick(datasetId: string, shiftKey: boolean) {
|
||||
if (!chartDatasetById.value.has(datasetId)) return
|
||||
|
||||
if (shiftKey) {
|
||||
soloLegendEntry(datasetId)
|
||||
clearLegendHoverState()
|
||||
return
|
||||
}
|
||||
toggleLegendEntryVisibility(datasetId)
|
||||
clearLegendHoverState()
|
||||
}
|
||||
|
||||
watch(
|
||||
[shouldShowPreviousPeriod, hiddenCurrentLegendEntryIdsKey],
|
||||
([showPreviousPeriod]) => {
|
||||
if (!showPreviousPeriod) return
|
||||
hidePreviousPeriodEntriesForHiddenCurrentEntries()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[allChartDatasets, legendEntries],
|
||||
([datasets]) => {
|
||||
if (datasets.length === 0) return
|
||||
|
||||
const availableDatasetIds = new Set(legendEntries.value.map((entry) => entry.id))
|
||||
const nextHiddenDatasetIds = hiddenGraphDatasetIds.value.filter((datasetId) =>
|
||||
availableDatasetIds.has(datasetId),
|
||||
)
|
||||
if (
|
||||
legendEntries.value.length > 0 &&
|
||||
legendEntries.value.every((entry) => nextHiddenDatasetIds.includes(entry.id))
|
||||
) {
|
||||
const firstLegendEntry = legendEntries.value[0]
|
||||
if (firstLegendEntry) {
|
||||
const firstLegendEntryIndex = nextHiddenDatasetIds.indexOf(firstLegendEntry.id)
|
||||
if (firstLegendEntryIndex !== -1) {
|
||||
nextHiddenDatasetIds.splice(firstLegendEntryIndex, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIds)) {
|
||||
hiddenGraphDatasetIds.value = nextHiddenDatasetIds
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
hoveredLegendEntryId,
|
||||
hiddenDatasetIds,
|
||||
previousChartDatasetByOriginalId,
|
||||
currentLegendEntries,
|
||||
visibleProjectEventIdSet,
|
||||
legendEntries,
|
||||
hiddenCurrentLegendEntryIds,
|
||||
hiddenCurrentLegendEntryIdsKey,
|
||||
chartDatasetById,
|
||||
hoverRatioSliceTotals,
|
||||
baseVisibleChartDatasets,
|
||||
visibleChartDatasets,
|
||||
visibleChartDatasetById,
|
||||
highlightedChartDatasetId,
|
||||
isProjectChartEventVisibleForLegend,
|
||||
getLegendEntryProjectId,
|
||||
hidePreviousPeriodEntriesForHiddenCurrentEntries,
|
||||
isLegendEntryToggleDisabled,
|
||||
getLegendEntryTooltip,
|
||||
isUnmonetizedLegendEntry,
|
||||
setHoveredLegendEntryId,
|
||||
clearHoveredLegendEntryId,
|
||||
clearLegendHoverState,
|
||||
toggleLegendEntryVisibility,
|
||||
soloLegendEntry,
|
||||
onLegendEntryClick,
|
||||
onTooltipEntryClick,
|
||||
}
|
||||
}
|
||||
-1154
File diff suppressed because it is too large
Load Diff
-487
@@ -1,487 +0,0 @@
|
||||
<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>
|
||||
-219
@@ -1,219 +0,0 @@
|
||||
<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>
|
||||
-232
@@ -1,232 +0,0 @@
|
||||
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)
|
||||
}
|
||||
-303
@@ -1,303 +0,0 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { computed, type ComputedRef, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
ensureMinimumTimeRange,
|
||||
getDefaultAnalyticsGroupByForDurationMinutes,
|
||||
} from '../../query-builder/timeframe.ts'
|
||||
import type {
|
||||
AnalyticsChartHoverState,
|
||||
AnalyticsChartRangeBounds,
|
||||
} from '../analytics-chart-types.ts'
|
||||
import type { ChartDataset } from '../analytics-chart-utils.ts'
|
||||
import { getSliceBucketRange } from '../analytics-chart-utils.ts'
|
||||
import type {
|
||||
AnalyticsChartGeometryPayload,
|
||||
AnalyticsChartRangeSelectPayload,
|
||||
} from '../AnalyticsChart.client.vue'
|
||||
import type AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
|
||||
|
||||
export function useAnalyticsChartInteractions({
|
||||
isDataLoading,
|
||||
fetchRequest,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
allChartDatasets,
|
||||
chartRangeBounds,
|
||||
shouldShowPreviousPeriod,
|
||||
onRangeSelected,
|
||||
}: {
|
||||
isDataLoading: ComputedRef<boolean>
|
||||
fetchRequest: ComputedRef<Labrinth.Analytics.v3.FetchRequest | null>
|
||||
sliceCount: ComputedRef<number>
|
||||
chartLabels: ComputedRef<string[]>
|
||||
allChartDatasets: ComputedRef<ChartDataset[]>
|
||||
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>
|
||||
shouldShowPreviousPeriod: ComputedRef<boolean>
|
||||
onRangeSelected: (start: Date, end: Date, groupBy: AnalyticsGroupByPreset) => void
|
||||
}) {
|
||||
const chartContainer = ref<HTMLElement | null>(null)
|
||||
const chartTooltip = ref<InstanceType<typeof AnalyticsChartTooltip> | null>(null)
|
||||
const chartGeometry = ref<AnalyticsChartGeometryPayload | null>(null)
|
||||
const containerSize = reactive({ width: 0, height: 0 })
|
||||
const hoverState = reactive<AnalyticsChartHoverState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
sliceIndex: null,
|
||||
})
|
||||
const isHoverPinned = ref(false)
|
||||
const ignoreNextChartClick = ref(false)
|
||||
const isShiftKeyPressed = ref(false)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let clearIgnoredChartClickTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function setHoverState(payload: AnalyticsChartHoverState) {
|
||||
hoverState.visible = payload.visible
|
||||
hoverState.x = payload.x
|
||||
hoverState.y = payload.y
|
||||
hoverState.sliceIndex = payload.sliceIndex
|
||||
}
|
||||
|
||||
function clearHoverState() {
|
||||
hoverState.visible = false
|
||||
hoverState.sliceIndex = null
|
||||
}
|
||||
|
||||
function unpinHoverState() {
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
}
|
||||
|
||||
function updateShiftKeyState(event: KeyboardEvent) {
|
||||
isShiftKeyPressed.value = event.shiftKey
|
||||
}
|
||||
|
||||
function clearShiftKeyState() {
|
||||
isShiftKeyPressed.value = false
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!isHoverPinned.value) return
|
||||
if (event.target instanceof Node && chartContainer.value?.contains(event.target)) return
|
||||
unpinHoverState()
|
||||
}
|
||||
|
||||
function onChartHover(payload: AnalyticsChartHoverState) {
|
||||
if (isDataLoading.value) return
|
||||
if (isHoverPinned.value) return
|
||||
setHoverState(payload)
|
||||
}
|
||||
|
||||
function ignoreUpcomingChartClick() {
|
||||
ignoreNextChartClick.value = true
|
||||
if (clearIgnoredChartClickTimeout) {
|
||||
clearTimeout(clearIgnoredChartClickTimeout)
|
||||
}
|
||||
clearIgnoredChartClickTimeout = setTimeout(() => {
|
||||
ignoreNextChartClick.value = false
|
||||
clearIgnoredChartClickTimeout = null
|
||||
}, 350)
|
||||
}
|
||||
|
||||
function onPinnedDrag(payload: AnalyticsChartHoverState) {
|
||||
if (isDataLoading.value || !isHoverPinned.value) return
|
||||
ignoreUpcomingChartClick()
|
||||
setHoverState(payload)
|
||||
}
|
||||
|
||||
function onTouchDragEnd() {
|
||||
ignoreUpcomingChartClick()
|
||||
}
|
||||
|
||||
function onChartGeometry(payload: AnalyticsChartGeometryPayload) {
|
||||
chartGeometry.value = payload
|
||||
}
|
||||
|
||||
function getDefaultGroupByForRange(start: Date, end: Date) {
|
||||
const ensuredRange = ensureMinimumTimeRange(start, end)
|
||||
const durationMinutes = Math.max(
|
||||
1,
|
||||
Math.floor((ensuredRange.end.getTime() - ensuredRange.start.getTime()) / 60000),
|
||||
)
|
||||
|
||||
return getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes)
|
||||
}
|
||||
|
||||
function onRangeSelect(payload: AnalyticsChartRangeSelectPayload) {
|
||||
if (isDataLoading.value) return
|
||||
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
if (!nextFetchRequest) return
|
||||
|
||||
if (payload.startSliceIndex === payload.endSliceIndex) {
|
||||
ignoreUpcomingChartClick()
|
||||
return
|
||||
}
|
||||
|
||||
const startSliceIndex = Math.min(payload.startSliceIndex, payload.endSliceIndex)
|
||||
const endSliceIndex = Math.max(payload.startSliceIndex, payload.endSliceIndex)
|
||||
const startBucketRange = getSliceBucketRange(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
startSliceIndex,
|
||||
)
|
||||
const endBucketRange = getSliceBucketRange(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
endSliceIndex,
|
||||
)
|
||||
const start = startBucketRange.start
|
||||
const end = endBucketRange.end
|
||||
|
||||
if (
|
||||
!Number.isFinite(start.getTime()) ||
|
||||
!Number.isFinite(end.getTime()) ||
|
||||
end.getTime() <= start.getTime()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
ignoreUpcomingChartClick()
|
||||
unpinHoverState()
|
||||
onRangeSelected(start, end, getDefaultGroupByForRange(start, end))
|
||||
}
|
||||
|
||||
function onChartClick() {
|
||||
if (isDataLoading.value) return
|
||||
if (ignoreNextChartClick.value) {
|
||||
ignoreNextChartClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!hoverState.visible || hoverState.sliceIndex === null) {
|
||||
if (isHoverPinned.value) {
|
||||
unpinHoverState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isHoverPinned.value) {
|
||||
unpinHoverState()
|
||||
return
|
||||
}
|
||||
|
||||
isHoverPinned.value = true
|
||||
}
|
||||
|
||||
function onChartWheel(event: WheelEvent) {
|
||||
if (isAnalyticsEventTooltipTrigger(event.target)) return
|
||||
if (!hoverState.visible) return
|
||||
chartTooltip.value?.consumeWheel(event)
|
||||
}
|
||||
|
||||
function isAnalyticsEventTooltipTrigger(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof Element && target.closest('[data-analytics-event-tooltip-trigger]') !== null
|
||||
)
|
||||
}
|
||||
|
||||
const pinnedSliceIndex = computed(() => (isHoverPinned.value ? hoverState.sliceIndex : null))
|
||||
const showHoverGuide = computed(
|
||||
() =>
|
||||
!isDataLoading.value &&
|
||||
!isHoverPinned.value &&
|
||||
hoverState.visible &&
|
||||
hoverState.sliceIndex !== null,
|
||||
)
|
||||
const showPinnedGuide = computed(
|
||||
() =>
|
||||
!isDataLoading.value &&
|
||||
isHoverPinned.value &&
|
||||
hoverState.visible &&
|
||||
hoverState.sliceIndex !== null,
|
||||
)
|
||||
const hoverBucketRange = computed(() => {
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
if (!nextFetchRequest || hoverState.sliceIndex === null) return null
|
||||
return getSliceBucketRange(nextFetchRequest.time_range, sliceCount.value, hoverState.sliceIndex)
|
||||
})
|
||||
const previousHoverBucketRange = computed(() => {
|
||||
if (!shouldShowPreviousPeriod.value) return null
|
||||
|
||||
const bucketRange = hoverBucketRange.value
|
||||
const rangeBounds = chartRangeBounds.value
|
||||
if (!bucketRange || !rangeBounds) return null
|
||||
|
||||
const periodMs = rangeBounds.end.getTime() - rangeBounds.start.getTime()
|
||||
if (!Number.isFinite(periodMs) || periodMs <= 0) return null
|
||||
|
||||
return {
|
||||
start: new Date(bucketRange.start.getTime() - periodMs),
|
||||
end: new Date(bucketRange.end.getTime() - periodMs),
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (chartContainer.value && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
containerSize.width = entry.contentRect.width
|
||||
containerSize.height = entry.contentRect.height
|
||||
})
|
||||
resizeObserver.observe(chartContainer.value)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', updateShiftKeyState)
|
||||
window.addEventListener('keyup', updateShiftKeyState)
|
||||
window.addEventListener('blur', clearShiftKeyState)
|
||||
document.addEventListener('click', onDocumentClick, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
window.removeEventListener('keydown', updateShiftKeyState)
|
||||
window.removeEventListener('keyup', updateShiftKeyState)
|
||||
window.removeEventListener('blur', clearShiftKeyState)
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
if (clearIgnoredChartClickTimeout) {
|
||||
clearTimeout(clearIgnoredChartClickTimeout)
|
||||
clearIgnoredChartClickTimeout = null
|
||||
}
|
||||
})
|
||||
|
||||
watch([chartLabels, allChartDatasets], () => {
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
})
|
||||
|
||||
watch(isDataLoading, (loading) => {
|
||||
if (!loading) return
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
})
|
||||
|
||||
return {
|
||||
chartContainer,
|
||||
chartTooltip,
|
||||
chartGeometry,
|
||||
containerSize,
|
||||
hoverState,
|
||||
isHoverPinned,
|
||||
isShiftKeyPressed,
|
||||
setHoverState,
|
||||
clearHoverState,
|
||||
unpinHoverState,
|
||||
onChartHover,
|
||||
onPinnedDrag,
|
||||
onTouchDragEnd,
|
||||
onChartGeometry,
|
||||
onRangeSelect,
|
||||
onChartClick,
|
||||
onChartWheel,
|
||||
pinnedSliceIndex,
|
||||
showHoverGuide,
|
||||
showPinnedGuide,
|
||||
hoverBucketRange,
|
||||
previousHoverBucketRange,
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import { computed, type ComputedRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
export function useAnalyticsChartLayout(showEmptyChartState: ComputedRef<boolean>) {
|
||||
const graphSection = ref<HTMLElement | null>(null)
|
||||
const rememberedGraphSectionHeight = ref(0)
|
||||
const graphSectionStyle = computed(() =>
|
||||
showEmptyChartState.value && rememberedGraphSectionHeight.value > 0
|
||||
? { height: `${rememberedGraphSectionHeight.value}px` }
|
||||
: undefined,
|
||||
)
|
||||
let graphSectionResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function rememberGraphSectionHeight() {
|
||||
if (!graphSection.value) return
|
||||
|
||||
const height = graphSection.value.getBoundingClientRect().height
|
||||
if (height > 0) {
|
||||
rememberedGraphSectionHeight.value = height
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (graphSection.value && typeof ResizeObserver !== 'undefined') {
|
||||
graphSectionResizeObserver = new ResizeObserver(() => {
|
||||
if (showEmptyChartState.value) return
|
||||
rememberGraphSectionHeight()
|
||||
})
|
||||
graphSectionResizeObserver.observe(graphSection.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
graphSectionResizeObserver?.disconnect()
|
||||
graphSectionResizeObserver = null
|
||||
})
|
||||
|
||||
watch(showEmptyChartState, (showEmpty) => {
|
||||
if (showEmpty) {
|
||||
rememberGraphSectionHeight()
|
||||
} else {
|
||||
nextTick(rememberGraphSectionHeight)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
graphSection,
|
||||
graphSectionStyle,
|
||||
rememberGraphSectionHeight,
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
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
|
||||
}
|
||||
-862
@@ -1,862 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
<template>
|
||||
<AnalyticsChartRenderLimitModal
|
||||
ref="showAllSelectedGraphDatasetsModal"
|
||||
:table-project-count="tableProjectCount"
|
||||
@confirm="confirmShowAllSelectedGraphDatasets"
|
||||
/>
|
||||
|
||||
<section
|
||||
ref="graphSection"
|
||||
class="relative flex flex-col rounded-2xl border border-solid border-surface-5 bg-surface-3"
|
||||
:style="graphSectionStyle"
|
||||
>
|
||||
<AnalyticsChartHeader
|
||||
v-model:active-graph-view-mode="activeGraphViewMode"
|
||||
v-model:ratio-mode="isRatioMode"
|
||||
v-model:show-chart-events="showChartEvents"
|
||||
v-model:show-project-events="showProjectEvents"
|
||||
v-model:show-previous-period="showPreviousPeriod"
|
||||
:graph-title="graphTitle"
|
||||
:show-table-selection-subheading="showTableSelectionSubheading"
|
||||
:table-selection-subheading="tableSelectionSubheading"
|
||||
:show-graph-render-limit-button="showGraphRenderLimitButton"
|
||||
:graph-render-limit-button-label="graphRenderLimitButtonLabel"
|
||||
:show-top-graph-datasets-button="showTopGraphDatasetsButton"
|
||||
:can-use-ratio-mode="canUseRatioMode"
|
||||
:can-show-previous-period="canShowPreviousPeriodToggle"
|
||||
:has-chart-events="hasChartEvents"
|
||||
:has-project-events="hasProjectEvents"
|
||||
:small-toggles="!isMobileLayout"
|
||||
:default-show-project-events="defaultShowProjectEvents"
|
||||
:is-mobile-layout="isMobileLayout"
|
||||
@toggle-graph-render-limit="toggleGraphRenderLimit"
|
||||
@show-top-graph-datasets="showTopGraphDatasets"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-6 px-4 pb-6 pt-5"
|
||||
:class="['transition-opacity', isDataLoading ? 'pointer-events-none opacity-75' : '']"
|
||||
>
|
||||
<AnalyticsChartLegend
|
||||
:legend-entries="legendEntries"
|
||||
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
|
||||
:show-unmonetized-info="showUnmonetizedInfo"
|
||||
@entry-hover="setHoveredLegendEntryId"
|
||||
@entry-hover-clear="clearHoveredLegendEntryId"
|
||||
@entry-click="onLegendEntryClick"
|
||||
/>
|
||||
|
||||
<AnalyticsChartPlot
|
||||
:chart-type="chartType"
|
||||
:is-area="isArea"
|
||||
:is-stacked="isStacked"
|
||||
:is-ratio-mode="isRatioMode"
|
||||
:is-data-loading="isDataLoading"
|
||||
:show-empty-chart-state="showEmptyChartState"
|
||||
:empty-chart-message="emptyChartMessage"
|
||||
:visible-chart-datasets="visibleChartDatasets"
|
||||
:chart-labels="chartLabels"
|
||||
:x-axis-tick-limit="xAxisTickLimit"
|
||||
:active-stat="activeStat"
|
||||
:highlighted-chart-dataset-id="highlightedChartDatasetId"
|
||||
:has-visible-timeline-events="hasVisibleTimelineEvents"
|
||||
:visible-timeline-events="visibleTimelineEvents"
|
||||
:selected-group-by="selectedGroupBy"
|
||||
:chart-range-bounds="chartRangeBounds"
|
||||
:fetch-request="fetchRequest"
|
||||
:slice-count="sliceCount"
|
||||
:should-show-previous-period="shouldShowPreviousPeriod"
|
||||
:all-chart-datasets="allChartDatasets"
|
||||
:current-legend-entries="currentLegendEntries"
|
||||
:legend-entries="legendEntries"
|
||||
:chart-dataset-by-id="chartDatasetById"
|
||||
:hover-ratio-slice-totals="hoverRatioSliceTotals"
|
||||
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
|
||||
@range-select="onRangeSelect"
|
||||
@entry-click="onTooltipEntryClick"
|
||||
@entry-hover="setHoveredLegendEntryId"
|
||||
@entry-hover-clear="clearHoveredLegendEntryId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-[20] overflow-hidden rounded-xl">
|
||||
<AnalyticsLoadingBar :loading="isDataLoading" />
|
||||
</div>
|
||||
|
||||
<div v-if="isDataLoading" class="absolute inset-0 z-[19] overflow-hidden rounded-xl">
|
||||
<div class="absolute inset-0 bg-surface-3 opacity-50" />
|
||||
<div class="absolute inset-0 backdrop-blur-[3px]" />
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="relative bottom-6 inline-flex items-center gap-2 text-lg font-semibold text-primary"
|
||||
>
|
||||
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { getDefaultAnalyticsGraphProjectEventsVisibility } from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
import { injectAnalyticsDashboardContext } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsMessages } from '../analytics-messages.ts'
|
||||
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
|
||||
import AnalyticsChartLegend from './analytics-chart-header/AnalyticsChartLegend.vue'
|
||||
import AnalyticsChartRenderLimitModal from './analytics-chart-header/AnalyticsChartRenderLimitModal.vue'
|
||||
import AnalyticsChartHeader from './analytics-chart-header/index.vue'
|
||||
import { useAnalyticsChartLegend } from './analytics-chart-header/use-analytics-chart-legend.ts'
|
||||
import AnalyticsChartPlot from './analytics-chart-plot/index.vue'
|
||||
import { useAnalyticsChartEvents } from './analytics-chart-plot/use-analytics-chart-events.ts'
|
||||
import { useAnalyticsChartLayout } from './analytics-chart-plot/use-analytics-chart-layout.ts'
|
||||
import { useAnalyticsChartDatasets } from './use-analytics-chart-datasets.ts'
|
||||
import { useAnalyticsChartProjects } from './use-analytics-chart-projects.ts'
|
||||
|
||||
const dashboardContext = injectAnalyticsDashboardContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
activeStat,
|
||||
activeGraphViewMode,
|
||||
isRatioMode,
|
||||
showChartEvents,
|
||||
showProjectEvents,
|
||||
showPreviousPeriod,
|
||||
isMobileLayout,
|
||||
hiddenGraphDatasetIds,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjectIds: currentSelectedProjectIds,
|
||||
selectedTimeframeMode,
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy: selectedDashboardGroupBy,
|
||||
displayedFetchRequest: fetchRequest,
|
||||
displayedSelectedGroupBy: selectedGroupBy,
|
||||
displayedSelectedBreakdowns: selectedBreakdowns,
|
||||
isLoading,
|
||||
} = dashboardContext
|
||||
|
||||
const isDataLoading = computed(() => isLoading.value)
|
||||
const defaultShowProjectEvents = computed(() =>
|
||||
getDefaultAnalyticsGraphProjectEventsVisibility(currentSelectedProjectIds.value),
|
||||
)
|
||||
|
||||
const {
|
||||
selectedProjectIdSet,
|
||||
hasAvailableProjects,
|
||||
selectedProjects,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
} = useAnalyticsChartProjects(dashboardContext)
|
||||
const {
|
||||
showAllSelectedGraphDatasets,
|
||||
chartRangeBounds,
|
||||
tableProjectCount,
|
||||
showEmptyChartState,
|
||||
emptyChartMessage,
|
||||
graphTitle,
|
||||
showTableSelectionSubheading,
|
||||
shouldCapitalizeDatasetLabels,
|
||||
chartType,
|
||||
canShowPreviousPeriodToggle,
|
||||
shouldShowPreviousPeriod,
|
||||
isArea,
|
||||
isStacked,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
xAxisTickLimit,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
tableSelectionSubheading,
|
||||
showGraphRenderLimitButton,
|
||||
graphRenderLimitButtonLabel,
|
||||
showTopGraphDatasetsButton,
|
||||
selectableChartDatasets,
|
||||
showTopGraphDatasets,
|
||||
} = useAnalyticsChartDatasets(dashboardContext, selectedProjects, hasAvailableProjects)
|
||||
const {
|
||||
currentLegendEntries,
|
||||
visibleProjectEventIdSet,
|
||||
legendEntries,
|
||||
chartDatasetById,
|
||||
hoverRatioSliceTotals,
|
||||
visibleChartDatasets,
|
||||
highlightedChartDatasetId,
|
||||
setHoveredLegendEntryId,
|
||||
clearHoveredLegendEntryId,
|
||||
onLegendEntryClick,
|
||||
onTooltipEntryClick,
|
||||
} = useAnalyticsChartLegend({
|
||||
selectableChartDatasets,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
shouldShowPreviousPeriod,
|
||||
isRatioMode,
|
||||
hiddenGraphDatasetIds,
|
||||
selectedBreakdowns,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjects,
|
||||
selectedProjectIdSet,
|
||||
selectedProjectEventIdSet,
|
||||
})
|
||||
const { hasChartEvents, hasProjectEvents, visibleTimelineEvents, hasVisibleTimelineEvents } =
|
||||
useAnalyticsChartEvents(
|
||||
dashboardContext,
|
||||
chartRangeBounds,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
visibleProjectEventIdSet,
|
||||
)
|
||||
const { graphSection, graphSectionStyle } = useAnalyticsChartLayout(showEmptyChartState)
|
||||
|
||||
const showAllSelectedGraphDatasetsModal = ref<InstanceType<
|
||||
typeof AnalyticsChartRenderLimitModal
|
||||
> | null>(null)
|
||||
const canUseRatioMode = computed(
|
||||
() =>
|
||||
(activeGraphViewMode.value === 'area' || activeGraphViewMode.value === 'bar') &&
|
||||
legendEntries.value.length > 1,
|
||||
)
|
||||
const showUnmonetizedInfo = computed(
|
||||
() => selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization',
|
||||
)
|
||||
|
||||
function toggleGraphRenderLimit(event: MouseEvent) {
|
||||
if (showAllSelectedGraphDatasets.value) {
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
return
|
||||
}
|
||||
|
||||
showAllSelectedGraphDatasetsModal.value?.show(event)
|
||||
}
|
||||
|
||||
function confirmShowAllSelectedGraphDatasets() {
|
||||
showAllSelectedGraphDatasets.value = true
|
||||
}
|
||||
|
||||
function onRangeSelect(start: Date, end: Date, groupBy: AnalyticsGroupByPreset) {
|
||||
selectedTimeframeMode.value = 'custom_datetime_range'
|
||||
selectedCustomTimeframeStartDate.value = start.toISOString()
|
||||
selectedCustomTimeframeEndDate.value = end.toISOString()
|
||||
selectedDashboardGroupBy.value = groupBy
|
||||
}
|
||||
|
||||
watch(canUseRatioMode, (canUse) => {
|
||||
if (!canUse) {
|
||||
isRatioMode.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
-395
@@ -1,395 +0,0 @@
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { computed, type ComputedRef, ref, watch } from 'vue'
|
||||
|
||||
import { useTheme } from '~/composables/nuxt-accessors'
|
||||
import { isDarkTheme } from '~/plugins/theme/index.ts'
|
||||
import type {
|
||||
AnalyticsDashboardContextValue,
|
||||
AnalyticsDashboardProject,
|
||||
AnalyticsDashboardStat,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsChartMessages,
|
||||
analyticsMessages,
|
||||
formatAnalyticsGraphTitle,
|
||||
type FormatMessage,
|
||||
getAnalyticsBreakdownItemType,
|
||||
} from '../analytics-messages'
|
||||
import {
|
||||
ANALYTICS_DASHBOARD_STATS,
|
||||
DARK_LEGEND_PALETTE,
|
||||
GRAPH_RENDER_DATASET_LIMIT,
|
||||
LIGHT_LEGEND_PALETTE,
|
||||
TOP_GRAPH_DATASET_LIMIT,
|
||||
} from './analytics-chart-constants'
|
||||
import {
|
||||
buildChartDatasets,
|
||||
buildTimeAxisLabels,
|
||||
type ChartDataset,
|
||||
getChartDatasetTotal,
|
||||
getShortHourlyAxisTickLimit,
|
||||
getSliceCount,
|
||||
shouldCapitalizeBreakdownLabel,
|
||||
} from './analytics-chart-utils'
|
||||
|
||||
export function useAnalyticsChartDatasets(
|
||||
context: Pick<
|
||||
AnalyticsDashboardContextValue,
|
||||
| 'activeStat'
|
||||
| 'activeGraphViewMode'
|
||||
| 'isRatioMode'
|
||||
| 'showPreviousPeriod'
|
||||
| 'hasPreviousPeriodComparison'
|
||||
| 'hasProjectContext'
|
||||
| 'displayedFetchRequest'
|
||||
| 'displayedTimeSlices'
|
||||
| 'displayedPreviousTimeSlices'
|
||||
| 'displayedSelectedGroupBy'
|
||||
| 'displayedSelectedBreakdowns'
|
||||
| 'hiddenGraphDatasetIds'
|
||||
| 'hasExplicitGraphDatasetSelection'
|
||||
| 'isGraphDatasetSelectionActive'
|
||||
| 'selectedGraphDatasetIds'
|
||||
| 'defaultGraphDatasetIds'
|
||||
| 'topGraphDatasetIds'
|
||||
| 'getVersionDisplayName'
|
||||
| 'getVersionProjectName'
|
||||
>,
|
||||
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>,
|
||||
hasAvailableProjects: ComputedRef<boolean>,
|
||||
) {
|
||||
const theme = useTheme()
|
||||
const { formatMessage } = useVIntl()
|
||||
const showAllSelectedGraphDatasets = ref(false)
|
||||
|
||||
const chartRangeBounds = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
if (!nextFetchRequest) return null
|
||||
return {
|
||||
start: new Date(nextFetchRequest.time_range.start),
|
||||
end: new Date(nextFetchRequest.time_range.end),
|
||||
}
|
||||
})
|
||||
const showProjectVersionNames = computed(
|
||||
() =>
|
||||
context.displayedSelectedBreakdowns.value.includes('version_id') &&
|
||||
selectedProjects.value.length > 1,
|
||||
)
|
||||
const tableProjectCount = computed(() => context.selectedGraphDatasetIds.value.length)
|
||||
const isTableGraphSelectionEmpty = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
context.hasExplicitGraphDatasetSelection.value &&
|
||||
tableProjectCount.value === 0,
|
||||
)
|
||||
const showEmptyChartState = computed(
|
||||
() => selectedProjects.value.length === 0 || isTableGraphSelectionEmpty.value,
|
||||
)
|
||||
const emptyChartMessage = computed(() => {
|
||||
if (isTableGraphSelectionEmpty.value) {
|
||||
return formatMessage(analyticsChartMessages.selectTableItemsEmpty)
|
||||
}
|
||||
|
||||
if (context.hasProjectContext.value) {
|
||||
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
|
||||
}
|
||||
|
||||
return hasAvailableProjects.value
|
||||
? formatMessage(analyticsMessages.noDataAvailable)
|
||||
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
|
||||
})
|
||||
const legendPalette = computed(() =>
|
||||
isDarkTheme(theme.active) ? DARK_LEGEND_PALETTE : LIGHT_LEGEND_PALETTE,
|
||||
)
|
||||
const graphTitle = computed(() =>
|
||||
formatAnalyticsGraphTitle(context.activeStat.value, formatMessage),
|
||||
)
|
||||
const showTableSelectionSubheading = computed(
|
||||
() => context.isGraphDatasetSelectionActive.value && tableProjectCount.value > 0,
|
||||
)
|
||||
const tableBreakdownItemType = computed(() =>
|
||||
getAnalyticsBreakdownItemType(context.displayedSelectedBreakdowns.value),
|
||||
)
|
||||
const shouldCapitalizeDatasetLabels = computed(() =>
|
||||
shouldCapitalizeBreakdownLabel(context.displayedSelectedBreakdowns.value),
|
||||
)
|
||||
const chartType = computed<'line' | 'bar'>(() =>
|
||||
context.activeGraphViewMode.value === 'bar' ? 'bar' : 'line',
|
||||
)
|
||||
const canShowPreviousPeriodToggle = computed(
|
||||
() => context.activeGraphViewMode.value === 'line' && context.hasPreviousPeriodComparison.value,
|
||||
)
|
||||
const shouldShowPreviousPeriod = computed(
|
||||
() => canShowPreviousPeriodToggle.value && context.showPreviousPeriod.value,
|
||||
)
|
||||
const isArea = computed(() => context.activeGraphViewMode.value === 'area')
|
||||
const isStacked = computed(
|
||||
() =>
|
||||
context.isRatioMode.value ||
|
||||
context.activeGraphViewMode.value === 'area' ||
|
||||
context.activeGraphViewMode.value === 'bar',
|
||||
)
|
||||
const sliceCount = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
const fallback = context.displayedTimeSlices.value.length
|
||||
if (!nextFetchRequest) return Math.max(1, fallback)
|
||||
return getSliceCount(nextFetchRequest.time_range, fallback)
|
||||
})
|
||||
const chartLabels = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
if (!nextFetchRequest) return []
|
||||
return buildTimeAxisLabels(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
context.displayedSelectedGroupBy.value,
|
||||
)
|
||||
})
|
||||
const xAxisTickLimit = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
return nextFetchRequest
|
||||
? getShortHourlyAxisTickLimit(
|
||||
nextFetchRequest.time_range,
|
||||
context.displayedSelectedGroupBy.value,
|
||||
)
|
||||
: undefined
|
||||
})
|
||||
const chartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
|
||||
buildDatasetsByStat(
|
||||
context.displayedTimeSlices.value,
|
||||
selectedProjects.value,
|
||||
legendPalette.value,
|
||||
context.displayedSelectedBreakdowns.value,
|
||||
context.getVersionDisplayName,
|
||||
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
|
||||
formatMessage,
|
||||
sliceCount.value,
|
||||
),
|
||||
)
|
||||
const previousChartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
|
||||
buildDatasetsByStat(
|
||||
context.displayedPreviousTimeSlices.value,
|
||||
selectedProjects.value,
|
||||
legendPalette.value,
|
||||
context.displayedSelectedBreakdowns.value,
|
||||
context.getVersionDisplayName,
|
||||
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
|
||||
formatMessage,
|
||||
sliceCount.value,
|
||||
),
|
||||
)
|
||||
const allChartDatasets = computed(() => chartDatasetsByStat.value[context.activeStat.value])
|
||||
const previousChartDatasets = computed(
|
||||
() => previousChartDatasetsByStat.value[context.activeStat.value],
|
||||
)
|
||||
const sortedChartDatasetIds = computed(() => sortDatasetsByTotal(allChartDatasets.value))
|
||||
const chartTopGraphDatasetIds = computed(() =>
|
||||
sortedChartDatasetIds.value.slice(0, TOP_GRAPH_DATASET_LIMIT),
|
||||
)
|
||||
const fallbackDefaultGraphDatasetIds = computed(() =>
|
||||
context.defaultGraphDatasetIds.value.length > 0
|
||||
? context.defaultGraphDatasetIds.value
|
||||
: chartTopGraphDatasetIds.value,
|
||||
)
|
||||
const isShowingAllTableItems = computed(() => {
|
||||
if (context.selectedGraphDatasetIds.value.length !== sortedChartDatasetIds.value.length) {
|
||||
return false
|
||||
}
|
||||
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
|
||||
return sortedChartDatasetIds.value.every((datasetId) => selectedDatasetIds.has(datasetId))
|
||||
})
|
||||
const isShowingTopGraphDatasets = computed(() => {
|
||||
if (
|
||||
context.selectedGraphDatasetIds.value.length !== fallbackDefaultGraphDatasetIds.value.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
|
||||
return fallbackDefaultGraphDatasetIds.value.every((datasetId) =>
|
||||
selectedDatasetIds.has(datasetId),
|
||||
)
|
||||
})
|
||||
const isShowingTopTableItems = computed(() => {
|
||||
const topDatasetIds = new Set(
|
||||
context.topGraphDatasetIds.value.slice(0, context.selectedGraphDatasetIds.value.length),
|
||||
)
|
||||
return context.selectedGraphDatasetIds.value.every((datasetId) => topDatasetIds.has(datasetId))
|
||||
})
|
||||
const isGraphRenderDatasetOverLimit = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
selectedChartDatasets.value.length > GRAPH_RENDER_DATASET_LIMIT,
|
||||
)
|
||||
const isGraphRenderDatasetLimitActive = computed(
|
||||
() => isGraphRenderDatasetOverLimit.value && !showAllSelectedGraphDatasets.value,
|
||||
)
|
||||
const tableSelectionSubheading = computed(() => {
|
||||
if (isGraphRenderDatasetLimitActive.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionLimited, {
|
||||
limit: GRAPH_RENDER_DATASET_LIMIT,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (isShowingAllTableItems.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionAll, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (isShowingTopTableItems.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionTop, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
return formatMessage(analyticsChartMessages.tableSelectionCount, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
})
|
||||
const shouldUseDefaultGraphDatasetSelection = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
!context.hasExplicitGraphDatasetSelection.value &&
|
||||
context.selectedGraphDatasetIds.value.length === 0,
|
||||
)
|
||||
const selectedGraphDatasetIdSet = computed(() => {
|
||||
if (shouldUseDefaultGraphDatasetSelection.value) {
|
||||
return new Set(fallbackDefaultGraphDatasetIds.value)
|
||||
}
|
||||
|
||||
return new Set(context.selectedGraphDatasetIds.value)
|
||||
})
|
||||
const selectedChartDatasets = computed(() => {
|
||||
if (!context.isGraphDatasetSelectionActive.value) {
|
||||
return allChartDatasets.value
|
||||
}
|
||||
|
||||
return allChartDatasets.value.filter((dataset) =>
|
||||
selectedGraphDatasetIdSet.value.has(dataset.projectId),
|
||||
)
|
||||
})
|
||||
const sortedSelectedChartDatasetIds = computed(() =>
|
||||
sortDatasetsByTotal(selectedChartDatasets.value),
|
||||
)
|
||||
const showGraphRenderLimitButton = computed(() => isGraphRenderDatasetOverLimit.value)
|
||||
const graphRenderLimitButtonLabel = computed(() =>
|
||||
showAllSelectedGraphDatasets.value
|
||||
? formatMessage(analyticsChartMessages.showLimited)
|
||||
: formatMessage(analyticsChartMessages.showAll),
|
||||
)
|
||||
const showTopGraphDatasetsButton = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
context.topGraphDatasetIds.value.length > 0 &&
|
||||
!isShowingTopGraphDatasets.value,
|
||||
)
|
||||
const limitedGraphDatasetIds = computed(
|
||||
() => new Set(sortedSelectedChartDatasetIds.value.slice(0, GRAPH_RENDER_DATASET_LIMIT)),
|
||||
)
|
||||
const selectableChartDatasets = computed(() => {
|
||||
if (!isGraphRenderDatasetLimitActive.value) {
|
||||
return selectedChartDatasets.value
|
||||
}
|
||||
|
||||
return selectedChartDatasets.value.filter((dataset) =>
|
||||
limitedGraphDatasetIds.value.has(dataset.projectId),
|
||||
)
|
||||
})
|
||||
|
||||
function showTopGraphDatasets() {
|
||||
context.selectedGraphDatasetIds.value = []
|
||||
context.hasExplicitGraphDatasetSelection.value = false
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
}
|
||||
|
||||
watch([() => context.selectedGraphDatasetIds.value.join('\u0000'), allChartDatasets], () => {
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
showAllSelectedGraphDatasets,
|
||||
chartRangeBounds,
|
||||
showProjectVersionNames,
|
||||
tableProjectCount,
|
||||
isTableGraphSelectionEmpty,
|
||||
showEmptyChartState,
|
||||
emptyChartMessage,
|
||||
legendPalette,
|
||||
graphTitle,
|
||||
showTableSelectionSubheading,
|
||||
shouldCapitalizeDatasetLabels,
|
||||
chartType,
|
||||
canShowPreviousPeriodToggle,
|
||||
shouldShowPreviousPeriod,
|
||||
isArea,
|
||||
isStacked,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
xAxisTickLimit,
|
||||
chartDatasetsByStat,
|
||||
previousChartDatasetsByStat,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
sortedChartDatasetIds,
|
||||
chartTopGraphDatasetIds,
|
||||
fallbackDefaultGraphDatasetIds,
|
||||
isShowingAllTableItems,
|
||||
isShowingTopGraphDatasets,
|
||||
isShowingTopTableItems,
|
||||
tableSelectionSubheading,
|
||||
shouldUseDefaultGraphDatasetSelection,
|
||||
selectedGraphDatasetIdSet,
|
||||
selectedChartDatasets,
|
||||
sortedSelectedChartDatasetIds,
|
||||
isGraphRenderDatasetOverLimit,
|
||||
showGraphRenderLimitButton,
|
||||
graphRenderLimitButtonLabel,
|
||||
showTopGraphDatasetsButton,
|
||||
isGraphRenderDatasetLimitActive,
|
||||
limitedGraphDatasetIds,
|
||||
selectableChartDatasets,
|
||||
showTopGraphDatasets,
|
||||
}
|
||||
}
|
||||
|
||||
function buildDatasetsByStat(
|
||||
timeSlices: Parameters<typeof buildChartDatasets>[0],
|
||||
selectedProjects: AnalyticsDashboardProject[],
|
||||
palette: string[],
|
||||
selectedBreakdowns: Parameters<typeof buildChartDatasets>[4],
|
||||
getVersionDisplayName: Parameters<typeof buildChartDatasets>[5],
|
||||
getVersionProjectName: Parameters<typeof buildChartDatasets>[6],
|
||||
formatMessage: FormatMessage,
|
||||
sliceCount: number,
|
||||
) {
|
||||
const datasetsByStat = {} as Record<AnalyticsDashboardStat, ChartDataset[]>
|
||||
for (const stat of ANALYTICS_DASHBOARD_STATS) {
|
||||
datasetsByStat[stat] = buildChartDatasets(
|
||||
timeSlices,
|
||||
selectedProjects,
|
||||
stat,
|
||||
palette,
|
||||
selectedBreakdowns,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
formatMessage,
|
||||
sliceCount,
|
||||
)
|
||||
}
|
||||
return datasetsByStat
|
||||
}
|
||||
|
||||
function sortDatasetsByTotal(datasets: ChartDataset[]) {
|
||||
return [...datasets]
|
||||
.sort((a, b) => {
|
||||
const totalDifference = getChartDatasetTotal(b) - getChartDatasetTotal(a)
|
||||
return (
|
||||
totalDifference || a.label.localeCompare(b.label) || a.projectId.localeCompare(b.projectId)
|
||||
)
|
||||
})
|
||||
.map((dataset) => dataset.projectId)
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,897 +0,0 @@
|
||||
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)}`
|
||||
}
|
||||
@@ -1,908 +0,0 @@
|
||||
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],
|
||||
)
|
||||
)
|
||||
}
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
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_')
|
||||
}
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
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()
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
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(', ')
|
||||
}
|
||||
-286
@@ -1,286 +0,0 @@
|
||||
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)
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
const SEARCHABLE_COLUMN_KEYS = new Set<AnalyticsTableColumnKey>(['date', 'project'])
|
||||
|
||||
export function getAnalyticsTableSearchableColumns(
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
): TableColumn<AnalyticsTableColumnKey>[] {
|
||||
return columns.filter(
|
||||
(column) =>
|
||||
SEARCHABLE_COLUMN_KEYS.has(column.key) || isAnalyticsTableBreakdownColumnKey(column.key),
|
||||
)
|
||||
}
|
||||
|
||||
export function filterAnalyticsTableRowsBySearch(
|
||||
rows: AnalyticsTableRow[],
|
||||
searchableColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
query: string,
|
||||
): AnalyticsTableRow[] {
|
||||
if (!query || searchableColumns.length === 0) {
|
||||
return rows
|
||||
}
|
||||
|
||||
return rows.filter((row) =>
|
||||
searchableColumns.some((column) =>
|
||||
String(getAnalyticsTableSearchableCellValue(row, column.key)).toLowerCase().includes(query),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function getAnalyticsTableSearchableCellValue(
|
||||
row: AnalyticsTableRow,
|
||||
key: AnalyticsTableColumnKey,
|
||||
): string {
|
||||
switch (key) {
|
||||
case 'date':
|
||||
return row.date
|
||||
case 'project':
|
||||
return row.project
|
||||
case 'breakdown':
|
||||
return row.breakdownDisplay
|
||||
default:
|
||||
return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : ''
|
||||
}
|
||||
}
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import {
|
||||
buildAnalyticsTableSortRouteQuery,
|
||||
readAnalyticsTableSortState,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import {
|
||||
getAnalyticsTableDefaultSortColumn,
|
||||
getAnalyticsTableDefaultSortDirection,
|
||||
} from './analytics-table-sorting'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
AnalyticsTableSortState,
|
||||
} from './analytics-table-types'
|
||||
|
||||
type GetDefaultAnalyticsTableSortStateOptions = {
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[]
|
||||
showGraphDatasetSelection: boolean
|
||||
activeStat: AnalyticsDashboardStat
|
||||
}
|
||||
|
||||
export function getRouteAnalyticsTableSortState(
|
||||
query: LocationQuery,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
): AnalyticsTableSortState {
|
||||
return getAvailableAnalyticsTableSortState(
|
||||
readAnalyticsTableSortState(query, getDefaultAnalyticsTableSortState(defaultSortOptions)),
|
||||
columns,
|
||||
defaultSortOptions,
|
||||
)
|
||||
}
|
||||
|
||||
export function getAvailableAnalyticsTableSortState(
|
||||
state: AnalyticsTableSortState,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
): AnalyticsTableSortState {
|
||||
const availableColumns = new Set(columns.map((column) => column.key))
|
||||
if (state.sortColumn && availableColumns.has(state.sortColumn)) {
|
||||
return state
|
||||
}
|
||||
if (state.sortColumn === 'breakdown') {
|
||||
const firstBreakdownColumn = columns.find((column) =>
|
||||
isAnalyticsTableBreakdownColumnKey(column.key),
|
||||
)
|
||||
if (firstBreakdownColumn) {
|
||||
return {
|
||||
sortColumn: firstBreakdownColumn.key,
|
||||
sortDirection: state.sortDirection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultAnalyticsTableSortState(defaultSortOptions)
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsTableSortState({
|
||||
columns,
|
||||
showGraphDatasetSelection,
|
||||
activeStat,
|
||||
}: GetDefaultAnalyticsTableSortStateOptions): AnalyticsTableSortState {
|
||||
const nextSortColumn = getAnalyticsTableDefaultSortColumn(
|
||||
columns,
|
||||
showGraphDatasetSelection,
|
||||
activeStat,
|
||||
)
|
||||
return {
|
||||
sortColumn: nextSortColumn,
|
||||
sortDirection: getAnalyticsTableDefaultSortDirection(nextSortColumn, columns),
|
||||
}
|
||||
}
|
||||
|
||||
export function areAnalyticsTableSortStatesEqual(
|
||||
left: AnalyticsTableSortState,
|
||||
right: AnalyticsTableSortState,
|
||||
): boolean {
|
||||
return left.sortColumn === right.sortColumn && left.sortDirection === right.sortDirection
|
||||
}
|
||||
|
||||
export function buildSyncedAnalyticsTableSortRouteQuery(
|
||||
query: LocationQuery,
|
||||
sortState: AnalyticsTableSortState,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
) {
|
||||
const nextSortState = getAvailableAnalyticsTableSortState(sortState, columns, defaultSortOptions)
|
||||
|
||||
return buildAnalyticsTableSortRouteQuery(
|
||||
query,
|
||||
nextSortState,
|
||||
getDefaultAnalyticsTableSortState(defaultSortOptions),
|
||||
)
|
||||
}
|
||||
|
||||
export function toAnalyticsTableSortState(
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortDirection: AnalyticsTableSortDirectionValue,
|
||||
): AnalyticsTableSortState {
|
||||
return {
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
}
|
||||
}
|
||||
-210
@@ -1,210 +0,0 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableRow,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types'
|
||||
|
||||
export function sortAnalyticsTableRows(
|
||||
rows: AnalyticsTableRow[],
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortDirection: AnalyticsTableSortDirectionValue,
|
||||
sortCollator: Intl.Collator,
|
||||
): AnalyticsTableRow[] {
|
||||
const nextRows = [...rows]
|
||||
|
||||
if (!sortColumn) {
|
||||
return nextRows
|
||||
}
|
||||
|
||||
const directionFactor = sortDirection === 'asc' ? 1 : -1
|
||||
nextRows.sort(getAnalyticsTableRowComparator(sortColumn, directionFactor, sortCollator))
|
||||
|
||||
return nextRows
|
||||
}
|
||||
|
||||
export function getAnalyticsTableDefaultSortColumn(
|
||||
nextColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
showGraphDatasetSelection: boolean,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
): AnalyticsTableColumnKey | undefined {
|
||||
const availableColumns = new Set(nextColumns.map((column) => column.key))
|
||||
if (availableColumns.has('date')) {
|
||||
return 'date'
|
||||
}
|
||||
|
||||
if (showGraphDatasetSelection && availableColumns.has(activeStat)) {
|
||||
return activeStat
|
||||
}
|
||||
|
||||
if (availableColumns.has('downloads')) {
|
||||
return 'downloads'
|
||||
}
|
||||
|
||||
return nextColumns[0]?.key
|
||||
}
|
||||
|
||||
export function getAnalyticsTableDefaultSortDirection(
|
||||
column: AnalyticsTableColumnKey | undefined,
|
||||
nextColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
): AnalyticsTableSortDirectionValue {
|
||||
return nextColumns.find((nextColumn) => nextColumn.key === column)?.defaultSortDirection ?? 'asc'
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricSortedGraphDatasetIds(
|
||||
rows: AnalyticsTableRow[],
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortCollator: Intl.Collator,
|
||||
): string[] {
|
||||
const metricColumn = getAnalyticsTableMetricSortColumn(sortColumn)
|
||||
if (!metricColumn) {
|
||||
return []
|
||||
}
|
||||
|
||||
const totalsByGraphDatasetId = new Map<string, number>()
|
||||
const labelsByGraphDatasetId = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
totalsByGraphDatasetId.set(
|
||||
row.graphDatasetId,
|
||||
(totalsByGraphDatasetId.get(row.graphDatasetId) ?? 0) + row[metricColumn],
|
||||
)
|
||||
if (!labelsByGraphDatasetId.has(row.graphDatasetId)) {
|
||||
labelsByGraphDatasetId.set(row.graphDatasetId, row.breakdownDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(totalsByGraphDatasetId.keys()).sort((left, right) => {
|
||||
const totalDifference =
|
||||
(totalsByGraphDatasetId.get(right) ?? 0) - (totalsByGraphDatasetId.get(left) ?? 0)
|
||||
return (
|
||||
totalDifference ||
|
||||
sortCollator.compare(
|
||||
labelsByGraphDatasetId.get(left) ?? left,
|
||||
labelsByGraphDatasetId.get(right) ?? right,
|
||||
) ||
|
||||
left.localeCompare(right)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricSortColumn(
|
||||
column: AnalyticsTableColumnKey | undefined,
|
||||
): AnalyticsDashboardStat | null {
|
||||
switch (column) {
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
case 'revenue':
|
||||
case 'playtime':
|
||||
return column
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableRowComparator(
|
||||
column: AnalyticsTableColumnKey,
|
||||
directionFactor: number,
|
||||
sortCollator: Intl.Collator,
|
||||
): (left: AnalyticsTableRow, right: AnalyticsTableRow) => number {
|
||||
switch (column) {
|
||||
case 'date':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.dateMs - right.dateMs,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'project':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(left.project, right.project),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'breakdown':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(left.breakdownDisplay, right.breakdownDisplay),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'views':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.views - right.views,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'downloads':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.downloads - right.downloads,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'revenue':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.revenue - right.revenue,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'playtime':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.playtime - right.playtime,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
default:
|
||||
if (isAnalyticsTableBreakdownColumnKey(column)) {
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(String(left[column] ?? ''), String(right[column] ?? '')),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
}
|
||||
|
||||
return () => 0
|
||||
}
|
||||
}
|
||||
|
||||
function compareAnalyticsTableRows(
|
||||
left: AnalyticsTableRow,
|
||||
right: AnalyticsTableRow,
|
||||
primaryResult: number,
|
||||
directionFactor: number,
|
||||
sortCollator: Intl.Collator,
|
||||
): number {
|
||||
if (primaryResult !== 0) {
|
||||
return primaryResult * directionFactor
|
||||
}
|
||||
|
||||
const dateResult = left.dateMs - right.dateMs
|
||||
if (dateResult !== 0) {
|
||||
return dateResult * directionFactor
|
||||
}
|
||||
|
||||
return sortCollator.compare(left.breakdown, right.breakdown) * directionFactor
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
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[]
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden rounded-2xl">
|
||||
<AnalyticsLoadingBar :loading="isDataLoading" />
|
||||
<Table
|
||||
v-model:selected-ids="tableSelectedGraphDatasetIds"
|
||||
:sort-column="displayedSortColumn"
|
||||
:sort-direction="displayedSortDirection"
|
||||
:columns="columns"
|
||||
:data="paginatedRows"
|
||||
row-key="id"
|
||||
selection-key="graphDatasetId"
|
||||
:selection-ids="filteredSelectableGraphDatasetIds"
|
||||
:show-selection="showGraphDatasetSelection"
|
||||
table-min-width="44rem"
|
||||
virtualized
|
||||
:virtual-row-height="56"
|
||||
@sort="applyRequestedSort"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(analyticsBreakdownMessages.breakdown) }}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-wrap items-center gap-2 md:w-auto">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(analyticsTableMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full sm:w-64"
|
||||
@focusin="selectSearchInputText"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<OverflowMenu
|
||||
class="!shadow-none"
|
||||
:options="csvExportOptions"
|
||||
:disabled="isDataLoading || filteredRows.length === 0"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(analyticsTableMessages.exportCsvButton) }}
|
||||
<DropdownIcon />
|
||||
<template #cumulative-csv>
|
||||
{{ formatMessage(analyticsTableMessages.cumulativeCsv) }}
|
||||
</template>
|
||||
<template #grouped-csv>
|
||||
{{ formatMessage(analyticsTableMessages.groupedCsv, { groupBy: groupByLabel }) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-date="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_project="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_country="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_monetization="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_user_agent="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_download_reason="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_version_id="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_loader="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_game_version="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-project="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-views="{ row }">
|
||||
<span>{{ formatInteger(row.views) }}</span>
|
||||
</template>
|
||||
<template #cell-downloads="{ row }">
|
||||
<span>{{ formatInteger(row.downloads) }}</span>
|
||||
</template>
|
||||
<template #cell-revenue="{ row }">
|
||||
<span>{{ formatRevenue(row.revenue) }}</span>
|
||||
</template>
|
||||
<template #cell-playtime="{ row }">
|
||||
<span v-tooltip="formatFullPlaytime(row.playtime)">
|
||||
{{ formatCompactPlaytime(row.playtime) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<div class="flex h-64 items-center justify-center text-secondary">
|
||||
{{ !isDataLoading ? emptyTableMessage : '' }}
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div
|
||||
v-if="filteredRows.length > PAGE_SIZE"
|
||||
class="mt-3 flex flex-wrap items-center justify-between gap-3 px-1 text-sm text-secondary"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
formatMessage(analyticsTableMessages.paginationSummary, {
|
||||
start: visibleRowStart,
|
||||
end: visibleRowEnd,
|
||||
total: filteredRows.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
<div v-if="isDataLoading" class="absolute inset-0 z-10 overflow-hidden rounded-xl">
|
||||
<div class="absolute inset-0 bg-surface-3 opacity-50" />
|
||||
<div class="absolute inset-0 backdrop-blur-[4px]" />
|
||||
<div class="absolute inset-0 flex h-full max-h-[500px] items-center justify-center pt-10">
|
||||
<div class="inline-flex items-center gap-2 text-lg font-semibold text-primary opacity-100">
|
||||
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
Table,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import {
|
||||
hasAnalyticsTableSortQuery,
|
||||
hasAnalyticsTableSortRouteChange,
|
||||
readAnalyticsTableSortState,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import {
|
||||
doesProjectStatusMatchFilters,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
isTimeRelevantForGroupBy,
|
||||
isYearRelevantForTimeRange,
|
||||
} from '../analytics-chart/analytics-chart-utils.ts'
|
||||
import {
|
||||
analyticsBreakdownMessages,
|
||||
analyticsMessages,
|
||||
analyticsTableMessages,
|
||||
} from '../analytics-messages.ts'
|
||||
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
|
||||
import {
|
||||
buildAnalyticsTableColumns,
|
||||
getAnalyticsTableBreakdownColumnLabel,
|
||||
} from './analytics-table-columns.ts'
|
||||
import {
|
||||
buildAnalyticsTableCsvContent,
|
||||
downloadAnalyticsTableCsv,
|
||||
getAnalyticsTableCsvFilename,
|
||||
} from './analytics-table-csv-export.ts'
|
||||
import {
|
||||
formatAnalyticsTableCompactPlaytime,
|
||||
formatAnalyticsTableFullPlaytime,
|
||||
formatAnalyticsTableInteger,
|
||||
formatAnalyticsTableRevenue,
|
||||
getAnalyticsTableGroupByLabel,
|
||||
} from './analytics-table-formatting.ts'
|
||||
import { buildAnalyticsTableRows } from './analytics-table-row-builder.ts'
|
||||
import {
|
||||
filterAnalyticsTableRowsBySearch,
|
||||
getAnalyticsTableSearchableColumns,
|
||||
} from './analytics-table-search-filtering.ts'
|
||||
import {
|
||||
areAnalyticsTableSortStatesEqual,
|
||||
buildSyncedAnalyticsTableSortRouteQuery,
|
||||
getRouteAnalyticsTableSortState,
|
||||
toAnalyticsTableSortState,
|
||||
} from './analytics-table-sort-route.ts'
|
||||
import { sortAnalyticsTableRows } from './analytics-table-sorting.ts'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableMode,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types.ts'
|
||||
import { useAnalyticsTableGraphSelection } from './use-analytics-table-graph-selection.ts'
|
||||
import { useAnalyticsTablePagination } from './use-analytics-table-pagination.ts'
|
||||
import { useAnalyticsTableRowCache } from './use-analytics-table-row-cache.ts'
|
||||
|
||||
const {
|
||||
hasProjectContext,
|
||||
projects,
|
||||
selectedProjectIds: currentSelectedProjectIds,
|
||||
selectedBreakdowns: currentSelectedBreakdowns,
|
||||
displayedSelectedProjectIds: selectedProjectIds,
|
||||
displayedSelectedGroupBy: selectedGroupBy,
|
||||
displayedSelectedBreakdowns: selectedBreakdowns,
|
||||
displayedSelectedFilters: selectedFilters,
|
||||
displayedFetchRequest: fetchRequest,
|
||||
displayedTimeSlices: timeSlices,
|
||||
activeStat,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedGraphDatasetIds,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
isLoading,
|
||||
versionNumbersById,
|
||||
versionProjectNamesById,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
const formatNumber = useFormatNumber()
|
||||
const { formatMessage } = useVIntl()
|
||||
const isDataLoading = computed(() => isLoading.value)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const initialTableSortState = readAnalyticsTableSortState(route.query, {
|
||||
sortColumn: 'date',
|
||||
sortDirection: 'desc',
|
||||
})
|
||||
|
||||
const tableMode = ref<AnalyticsTableMode>('breakdown_only')
|
||||
const sortColumn = ref<AnalyticsTableColumnKey | undefined>(initialTableSortState.sortColumn)
|
||||
const sortDirection = ref<AnalyticsTableSortDirectionValue>(initialTableSortState.sortDirection)
|
||||
const PAGE_SIZE = 500
|
||||
const GRAPH_DATASET_SELECTION_LIMIT = 8
|
||||
const INACTIVE_MODE_WARMUP_POINT_LIMIT = 12000
|
||||
const searchQuery = ref('')
|
||||
const sortCollator = new Intl.Collator(undefined, { sensitivity: 'base' })
|
||||
|
||||
const selectedProjectIdSet = computed(
|
||||
() =>
|
||||
new Set(
|
||||
projects.value
|
||||
.filter(
|
||||
(project) =>
|
||||
selectedProjectIds.value.includes(project.id) &&
|
||||
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
|
||||
)
|
||||
.map((project) => project.id),
|
||||
),
|
||||
)
|
||||
const selectedBreakdownSet = computed(() => new Set(selectedBreakdowns.value))
|
||||
const showBreakdownColumn = computed(() => selectedBreakdowns.value.length > 0)
|
||||
const showGraphDatasetSelection = computed(() =>
|
||||
selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'project'
|
||||
? selectedProjectIdSet.value.size > 1
|
||||
: selectedBreakdowns.value.length > 0,
|
||||
)
|
||||
const showProjectVersionProjectColumn = computed(
|
||||
() =>
|
||||
selectedBreakdownSet.value.has('version_id') &&
|
||||
!selectedBreakdownSet.value.has('project') &&
|
||||
selectedProjectIdSet.value.size > 1,
|
||||
)
|
||||
const includeDateColumn = computed(
|
||||
() =>
|
||||
selectedBreakdowns.value.length === 0 ||
|
||||
(!showGraphDatasetSelection.value && tableMode.value === 'date_breakdown'),
|
||||
)
|
||||
const activeTableMode = computed<AnalyticsTableMode>(() =>
|
||||
selectedBreakdowns.value.length === 0
|
||||
? 'date_breakdown'
|
||||
: showGraphDatasetSelection.value
|
||||
? 'breakdown_only'
|
||||
: tableMode.value,
|
||||
)
|
||||
const displayedIncludeDateColumn = computed(() =>
|
||||
selectedBreakdowns.value.length === 0
|
||||
? true
|
||||
: showGraphDatasetSelection.value
|
||||
? false
|
||||
: displayedTableMode.value === 'date_breakdown',
|
||||
)
|
||||
const groupByLabel = computed(() =>
|
||||
getAnalyticsTableGroupByLabel(selectedGroupBy.value, formatMessage),
|
||||
)
|
||||
const csvExportOptions = computed<OverflowMenuOption[]>(() => {
|
||||
if (showGraphDatasetSelection.value) {
|
||||
return [
|
||||
{
|
||||
id: 'cumulative-csv',
|
||||
action: () => downloadCsv('breakdown_only'),
|
||||
},
|
||||
{
|
||||
id: 'grouped-csv',
|
||||
action: () => downloadCsv('date_breakdown'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const mode = displayedTableMode.value
|
||||
|
||||
return [
|
||||
{
|
||||
id: mode === 'date_breakdown' ? 'grouped-csv' : 'cumulative-csv',
|
||||
action: () => downloadCsv(mode),
|
||||
},
|
||||
]
|
||||
})
|
||||
const projectNamesById = computed(
|
||||
() => new Map(projects.value.map((project) => [project.id, project.name])),
|
||||
)
|
||||
const hasAvailableProjects = computed(() => projects.value.length > 0)
|
||||
const analyticsPointCount = computed(() =>
|
||||
timeSlices.value.reduce((sum, slice) => sum + slice.length, 0),
|
||||
)
|
||||
const emptyTableMessage = computed(() => {
|
||||
if (trimmedSearchQuery.value && sortedRows.value.length > 0) {
|
||||
return formatMessage(analyticsTableMessages.noMatchingRows)
|
||||
}
|
||||
|
||||
if (hasProjectContext.value) {
|
||||
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
|
||||
}
|
||||
|
||||
return hasAvailableProjects.value
|
||||
? formatMessage(analyticsMessages.noDataAvailable)
|
||||
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
|
||||
})
|
||||
|
||||
const breakdownColumnLabel = computed(() =>
|
||||
selectedBreakdowns.value.length === 1
|
||||
? getAnalyticsTableBreakdownColumnLabel(selectedBreakdowns.value[0], formatMessage)
|
||||
: formatMessage(analyticsBreakdownMessages.breakdown),
|
||||
)
|
||||
const relevantStats = computed(
|
||||
() =>
|
||||
new Set(getRelevantAnalyticsDashboardStats(selectedBreakdowns.value, selectedFilters.value)),
|
||||
)
|
||||
|
||||
const showTimeInBucketLabel = computed(() => isTimeRelevantForGroupBy(selectedGroupBy.value))
|
||||
const showYearInBucketLabel = computed(() => {
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
return nextFetchRequest
|
||||
? isYearRelevantForTimeRange(nextFetchRequest.time_range) || selectedGroupBy.value === 'year'
|
||||
: false
|
||||
})
|
||||
|
||||
function buildTableRows(mode: AnalyticsTableMode) {
|
||||
return buildAnalyticsTableRows({
|
||||
mode,
|
||||
fetchRequest: fetchRequest.value,
|
||||
timeSlices: timeSlices.value,
|
||||
selectedBreakdowns: selectedBreakdowns.value,
|
||||
selectedProjectIds: selectedProjectIdSet.value,
|
||||
relevantStats: relevantStats.value,
|
||||
projectNamesById: projectNamesById.value,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
showTimeInBucketLabel: showTimeInBucketLabel.value,
|
||||
showYearInBucketLabel: showYearInBucketLabel.value,
|
||||
formatMessage,
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => buildColumns(displayedIncludeDateColumn.value))
|
||||
const activeColumns = computed(() => buildColumns(includeDateColumn.value))
|
||||
|
||||
function buildColumns(includeDate: boolean) {
|
||||
return buildAnalyticsTableColumns({
|
||||
includeDate,
|
||||
selectedBreakdowns: selectedBreakdowns.value,
|
||||
selectedFilters: selectedFilters.value,
|
||||
showBreakdownColumn: showBreakdownColumn.value,
|
||||
showProjectVersionProjectColumn: showProjectVersionProjectColumn.value,
|
||||
formatMessage,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
activeColumns,
|
||||
(nextColumns) => {
|
||||
applyRouteOrDefaultSort(nextColumns)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function sortTableRows(rows: ReturnType<typeof buildTableRows>) {
|
||||
return sortAnalyticsTableRows(rows, sortColumn.value, sortDirection.value, sortCollator)
|
||||
}
|
||||
|
||||
const {
|
||||
displayedTableMode,
|
||||
displayedSortColumn,
|
||||
displayedSortDirection,
|
||||
displayedSortedRows,
|
||||
invalidateTableCaches,
|
||||
invalidateSortedCaches,
|
||||
scheduleRowsForMode,
|
||||
scheduleInactiveModeWarmup,
|
||||
resortDisplayedRowsForCurrentSort,
|
||||
getSortedRowsForMode,
|
||||
} = useAnalyticsTableRowCache({
|
||||
activeTableMode,
|
||||
showBreakdownColumn,
|
||||
analyticsPointCount,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
buildRows: buildTableRows,
|
||||
sortRows: sortTableRows,
|
||||
inactiveModeWarmupPointLimit: INACTIVE_MODE_WARMUP_POINT_LIMIT,
|
||||
})
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
return displayedSortedRows.value
|
||||
})
|
||||
const trimmedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase())
|
||||
const searchableColumns = computed(() => getAnalyticsTableSearchableColumns(columns.value))
|
||||
const filteredRows = computed(() => {
|
||||
if (!trimmedSearchQuery.value) {
|
||||
return sortedRows.value
|
||||
}
|
||||
|
||||
return filterAnalyticsTableRowsBySearch(
|
||||
sortedRows.value,
|
||||
searchableColumns.value,
|
||||
trimmedSearchQuery.value,
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
fetchRequest,
|
||||
timeSlices,
|
||||
selectedProjectIds,
|
||||
selectedGroupBy,
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
projects,
|
||||
versionNumbersById,
|
||||
versionProjectNamesById,
|
||||
],
|
||||
() => {
|
||||
invalidateTableCaches()
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
watch(activeTableMode, () => {
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
(nextQuery) => {
|
||||
const nextSortState = getRouteTableSortState(nextQuery, activeColumns.value)
|
||||
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
|
||||
applyTableSortState(nextSortState)
|
||||
return
|
||||
}
|
||||
|
||||
syncTableSortRouteQuery()
|
||||
},
|
||||
)
|
||||
|
||||
watch([sortColumn, sortDirection], () => {
|
||||
syncTableSortRouteQuery()
|
||||
|
||||
if (resortDisplayedRowsForCurrentSort()) {
|
||||
scheduleInactiveModeWarmup()
|
||||
return
|
||||
}
|
||||
|
||||
invalidateSortedCaches()
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
})
|
||||
|
||||
const { filteredSelectableGraphDatasetIds, tableSelectedGraphDatasetIds } =
|
||||
useAnalyticsTableGraphSelection({
|
||||
sortedRows,
|
||||
filteredRows,
|
||||
sortColumn,
|
||||
showGraphDatasetSelection,
|
||||
selectedGraphDatasetIds,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
currentSelectedBreakdowns,
|
||||
currentSelectedProjectIds,
|
||||
activeStat,
|
||||
sortCollator,
|
||||
hasTableSortQuery: () => hasAnalyticsTableSortQuery(route.query),
|
||||
applyActiveStatSort,
|
||||
graphDatasetSelectionLimit: GRAPH_DATASET_SELECTION_LIMIT,
|
||||
})
|
||||
|
||||
const { currentPage, pageCount, visibleRowStart, visibleRowEnd, paginatedRows, switchPage } =
|
||||
useAnalyticsTablePagination({
|
||||
filteredRows,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const revenueFormatter = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
function formatInteger(value: number): string {
|
||||
return formatAnalyticsTableInteger(formatNumber, value)
|
||||
}
|
||||
|
||||
function formatRevenue(value: number): string {
|
||||
return formatAnalyticsTableRevenue(revenueFormatter.value, value, formatMessage)
|
||||
}
|
||||
|
||||
function formatCompactPlaytime(value: number): string {
|
||||
return formatAnalyticsTableCompactPlaytime(value, formatMessage)
|
||||
}
|
||||
|
||||
function formatFullPlaytime(value: number): string {
|
||||
return formatAnalyticsTableFullPlaytime(value, formatMessage)
|
||||
}
|
||||
|
||||
function applyRouteOrDefaultSort(nextColumns = activeColumns.value) {
|
||||
const nextSortState = getRouteTableSortState(route.query, nextColumns)
|
||||
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
|
||||
applyTableSortState(nextSortState)
|
||||
}
|
||||
|
||||
syncTableSortRouteQuery()
|
||||
}
|
||||
|
||||
function applyTableSortState(state: {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
}) {
|
||||
sortColumn.value = state.sortColumn
|
||||
sortDirection.value = state.sortDirection
|
||||
}
|
||||
|
||||
function getRouteTableSortState(
|
||||
query: LocationQuery,
|
||||
nextColumns = activeColumns.value,
|
||||
): {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
} {
|
||||
return getRouteAnalyticsTableSortState(query, nextColumns, getDefaultSortOptions(nextColumns))
|
||||
}
|
||||
|
||||
function getCurrentSortState() {
|
||||
return toAnalyticsTableSortState(sortColumn.value, sortDirection.value)
|
||||
}
|
||||
|
||||
function getDefaultSortOptions(nextColumns = activeColumns.value) {
|
||||
return {
|
||||
columns: nextColumns,
|
||||
showGraphDatasetSelection: showGraphDatasetSelection.value,
|
||||
activeStat: activeStat.value,
|
||||
}
|
||||
}
|
||||
|
||||
function syncTableSortRouteQuery() {
|
||||
if (import.meta.server) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextRouteQuery = buildSyncedAnalyticsTableSortRouteQuery(
|
||||
route.query,
|
||||
getCurrentSortState(),
|
||||
activeColumns.value,
|
||||
getDefaultSortOptions(),
|
||||
)
|
||||
|
||||
if (!hasAnalyticsTableSortRouteChange(route.query, nextRouteQuery)) {
|
||||
return
|
||||
}
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: nextRouteQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function applyActiveStatSort() {
|
||||
const availableColumns = new Set(activeColumns.value.map((column) => column.key))
|
||||
if (!availableColumns.has(activeStat.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
sortColumn.value = activeStat.value
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
|
||||
function applyRequestedSort(column: string, direction: AnalyticsTableSortDirectionValue) {
|
||||
sortColumn.value = column as AnalyticsTableColumnKey
|
||||
sortDirection.value = direction
|
||||
}
|
||||
|
||||
function selectSearchInputText(event: FocusEvent) {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLInputElement) {
|
||||
target.select()
|
||||
}
|
||||
}
|
||||
|
||||
function getCsvRows(mode: AnalyticsTableMode) {
|
||||
const visibleColumns = getCsvColumns(mode)
|
||||
return filterAnalyticsTableRowsBySearch(
|
||||
getSortedRowsForMode(mode),
|
||||
visibleColumns,
|
||||
trimmedSearchQuery.value,
|
||||
)
|
||||
}
|
||||
|
||||
function getCsvColumns(mode: AnalyticsTableMode) {
|
||||
return buildColumns(mode === 'date_breakdown' || !showBreakdownColumn.value)
|
||||
}
|
||||
|
||||
function getCsvFilename(): string {
|
||||
return getAnalyticsTableCsvFilename(breakdownColumnLabel.value, fetchRequest.value, formatMessage)
|
||||
}
|
||||
|
||||
function downloadCsv(mode: AnalyticsTableMode) {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const csvRows = getCsvRows(mode)
|
||||
if (csvRows.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const visibleColumns = getCsvColumns(mode)
|
||||
const csvContent = buildAnalyticsTableCsvContent(csvRows, visibleColumns, formatMessage)
|
||||
downloadAnalyticsTableCsv(getCsvFilename(), csvContent)
|
||||
}
|
||||
</script>
|
||||
-191
@@ -1,191 +0,0 @@
|
||||
import type { ComputedRef, Ref, WritableComputedRef } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { areStringArraysEqual } from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type {
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsSelectedBreakdowns,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { getAnalyticsTableMetricSortedGraphDatasetIds } from './analytics-table-sorting'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTableGraphSelectionOptions = {
|
||||
sortedRows: ComputedRef<AnalyticsTableRow[]>
|
||||
filteredRows: ComputedRef<AnalyticsTableRow[]>
|
||||
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
|
||||
showGraphDatasetSelection: ComputedRef<boolean>
|
||||
selectedGraphDatasetIds: Ref<string[]>
|
||||
hasExplicitGraphDatasetSelection: Ref<boolean>
|
||||
isGraphDatasetSelectionActive: Ref<boolean>
|
||||
defaultGraphDatasetIds: Ref<string[]>
|
||||
topGraphDatasetIds: Ref<string[]>
|
||||
queryResetToken: Ref<number>
|
||||
currentSelectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
|
||||
currentSelectedProjectIds: Ref<string[]>
|
||||
activeStat: Ref<AnalyticsDashboardStat>
|
||||
sortCollator: Intl.Collator
|
||||
hasTableSortQuery: () => boolean
|
||||
applyActiveStatSort: () => void
|
||||
graphDatasetSelectionLimit: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTableGraphSelection({
|
||||
sortedRows,
|
||||
filteredRows,
|
||||
sortColumn,
|
||||
showGraphDatasetSelection,
|
||||
selectedGraphDatasetIds,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
currentSelectedBreakdowns,
|
||||
currentSelectedProjectIds,
|
||||
activeStat,
|
||||
sortCollator,
|
||||
hasTableSortQuery,
|
||||
applyActiveStatSort,
|
||||
graphDatasetSelectionLimit,
|
||||
}: UseAnalyticsTableGraphSelectionOptions): {
|
||||
filteredSelectableGraphDatasetIds: ComputedRef<string[]>
|
||||
tableSelectedGraphDatasetIds: WritableComputedRef<unknown[]>
|
||||
} {
|
||||
const selectableGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableSelectableGraphDatasetIds(sortedRows.value),
|
||||
)
|
||||
const filteredSelectableGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableSelectableGraphDatasetIds(filteredRows.value),
|
||||
)
|
||||
const sortedMetricGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableMetricSortedGraphDatasetIds(sortedRows.value, sortColumn.value, sortCollator),
|
||||
)
|
||||
const defaultSelectedGraphDatasetIds = computed(() => {
|
||||
const sortedMetricIds = sortedMetricGraphDatasetIds.value
|
||||
const defaultIds =
|
||||
sortedMetricIds.length > 0 ? sortedMetricIds : selectableGraphDatasetIds.value
|
||||
return defaultIds.slice(0, graphDatasetSelectionLimit)
|
||||
})
|
||||
const tableSelectedGraphDatasetIds = computed<unknown[]>({
|
||||
get: () => selectedGraphDatasetIds.value,
|
||||
set: (ids) => {
|
||||
const nextGraphDatasetIds = ids.filter((id): id is string => typeof id === 'string')
|
||||
if (showGraphDatasetSelection.value && isDefaultGraphDatasetSelection(nextGraphDatasetIds)) {
|
||||
setSelectedGraphDatasetIds(defaultSelectedGraphDatasetIds.value, false)
|
||||
return
|
||||
}
|
||||
|
||||
selectedGraphDatasetIds.value = nextGraphDatasetIds
|
||||
hasExplicitGraphDatasetSelection.value = showGraphDatasetSelection.value
|
||||
},
|
||||
})
|
||||
|
||||
function setSelectedGraphDatasetIds(ids: string[], explicit: boolean) {
|
||||
selectedGraphDatasetIds.value = ids
|
||||
hasExplicitGraphDatasetSelection.value = explicit
|
||||
}
|
||||
|
||||
function resetGraphDatasetSelection() {
|
||||
setSelectedGraphDatasetIds([], false)
|
||||
}
|
||||
|
||||
function isDefaultGraphDatasetSelection(ids: string[]) {
|
||||
const defaultIds = defaultSelectedGraphDatasetIds.value
|
||||
if (defaultIds.length === 0 || ids.length !== defaultIds.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedIdSet = new Set(ids)
|
||||
return defaultIds.every((id) => selectedIdSet.has(id))
|
||||
}
|
||||
|
||||
watch(
|
||||
[showGraphDatasetSelection, queryResetToken],
|
||||
([nextShowSelection]) => {
|
||||
isGraphDatasetSelectionActive.value = nextShowSelection
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(activeStat, () => {
|
||||
if (!showGraphDatasetSelection.value) {
|
||||
return
|
||||
}
|
||||
if (hasTableSortQuery()) {
|
||||
return
|
||||
}
|
||||
|
||||
applyActiveStatSort()
|
||||
})
|
||||
|
||||
watch(
|
||||
currentSelectedBreakdowns,
|
||||
(nextBreakdowns, previousBreakdowns) => {
|
||||
if (areStringArraysEqual([...nextBreakdowns], [...(previousBreakdowns ?? [])])) {
|
||||
return
|
||||
}
|
||||
|
||||
resetGraphDatasetSelection()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
currentSelectedProjectIds,
|
||||
(nextProjectIds, previousProjectIds) => {
|
||||
if (areStringArraysEqual(nextProjectIds, previousProjectIds ?? [])) {
|
||||
return
|
||||
}
|
||||
|
||||
resetGraphDatasetSelection()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[defaultSelectedGraphDatasetIds, sortedMetricGraphDatasetIds, showGraphDatasetSelection],
|
||||
([nextDefaultGraphDatasetIds, nextTopGraphDatasetIds, nextShowGraphDatasetSelection]) => {
|
||||
defaultGraphDatasetIds.value = nextShowGraphDatasetSelection
|
||||
? [...nextDefaultGraphDatasetIds]
|
||||
: []
|
||||
topGraphDatasetIds.value = nextShowGraphDatasetSelection ? [...nextTopGraphDatasetIds] : []
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
defaultSelectedGraphDatasetIds,
|
||||
showGraphDatasetSelection,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
queryResetToken,
|
||||
],
|
||||
([nextDefaultGraphDatasetIds, nextShowGraphDatasetSelection, nextHasExplicitSelection]) => {
|
||||
if (!nextShowGraphDatasetSelection) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextHasExplicitSelection) {
|
||||
if (isDefaultGraphDatasetSelection(selectedGraphDatasetIds.value)) {
|
||||
setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!areStringArraysEqual(selectedGraphDatasetIds.value, nextDefaultGraphDatasetIds)) {
|
||||
setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function getAnalyticsTableSelectableGraphDatasetIds(rows: AnalyticsTableRow[]): string[] {
|
||||
return Array.from(new Set(rows.map((row) => row.graphDatasetId)))
|
||||
}
|
||||
|
||||
return {
|
||||
filteredSelectableGraphDatasetIds,
|
||||
tableSelectedGraphDatasetIds,
|
||||
}
|
||||
}
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTablePaginationOptions = {
|
||||
filteredRows: ComputedRef<AnalyticsTableRow[]>
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTablePagination({
|
||||
filteredRows,
|
||||
pageSize,
|
||||
}: UseAnalyticsTablePaginationOptions): {
|
||||
currentPage: Ref<number>
|
||||
pageCount: ComputedRef<number>
|
||||
visibleRowStart: ComputedRef<number>
|
||||
visibleRowEnd: ComputedRef<number>
|
||||
paginatedRows: ComputedRef<AnalyticsTableRow[]>
|
||||
switchPage: (page: number) => void
|
||||
} {
|
||||
const currentPage = ref(1)
|
||||
const pageCount = computed(() => Math.max(Math.ceil(filteredRows.value.length / pageSize), 1))
|
||||
const visibleRowStart = computed(() =>
|
||||
filteredRows.value.length === 0 ? 0 : (currentPage.value - 1) * pageSize + 1,
|
||||
)
|
||||
const visibleRowEnd = computed(() =>
|
||||
Math.min(currentPage.value * pageSize, filteredRows.value.length),
|
||||
)
|
||||
const paginatedRows = computed<AnalyticsTableRow[]>(() =>
|
||||
filteredRows.value.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize),
|
||||
)
|
||||
|
||||
watch(filteredRows, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
watch(pageCount, (nextPageCount) => {
|
||||
if (currentPage.value > nextPageCount) {
|
||||
currentPage.value = nextPageCount
|
||||
}
|
||||
})
|
||||
|
||||
function switchPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
pageCount,
|
||||
visibleRowStart,
|
||||
visibleRowEnd,
|
||||
paginatedRows,
|
||||
switchPage,
|
||||
}
|
||||
}
|
||||
-230
@@ -1,230 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<div class="flex touch-manipulation flex-col gap-4 pb-20 lg:pl-4 lg:pt-1.5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xl font-semibold text-contrast md:text-2xl">
|
||||
{{ formatMessage(analyticsMessages.title) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isAnalyticsQueryBuilderDefault"
|
||||
@click="resetAnalyticsQueryBuilder"
|
||||
>
|
||||
{{ formatMessage(analyticsMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="projects.length === 0 || !fetchRequest || isRefetching"
|
||||
@click="refreshAnalyticsQuery"
|
||||
>
|
||||
<RefreshCwIcon :class="isRefetching ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(analyticsMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<QueryBuilder />
|
||||
</div>
|
||||
<StatCards />
|
||||
<AnalyticsChart />
|
||||
<AnalyticsTable />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RefreshCwIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectProjectPageContext, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
createAnalyticsDashboardContext,
|
||||
provideAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
import { injectOrganizationContext } from '~/providers/organization-context'
|
||||
|
||||
import AnalyticsChart from './analytics-chart/index.vue'
|
||||
import { analyticsMessages } from './analytics-messages.ts'
|
||||
import AnalyticsTable from './analytics-table/index.vue'
|
||||
import QueryBuilder from './query-builder/index.vue'
|
||||
import StatCards from './stat-cards/StatCards.vue'
|
||||
|
||||
const auth = await useAuth()
|
||||
const { formatMessage } = useVIntl()
|
||||
const projectPageContext = injectProjectPageContext(null)
|
||||
const organizationContext = injectOrganizationContext(null)
|
||||
|
||||
const analyticsDashboardContext = createAnalyticsDashboardContext({
|
||||
auth,
|
||||
projectPageContext,
|
||||
organizationContext,
|
||||
})
|
||||
const {
|
||||
fetchRequest,
|
||||
isAnalyticsQueryBuilderDefault,
|
||||
isRefetching,
|
||||
projects,
|
||||
refreshAnalyticsQuery,
|
||||
resetAnalyticsQueryBuilder,
|
||||
} = analyticsDashboardContext
|
||||
|
||||
provideAnalyticsDashboardContext(analyticsDashboardContext)
|
||||
</script>
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
<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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user