mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d320c994 | ||
|
|
7366c32df3 | ||
|
|
707e219ff8 | ||
|
|
dfe12d4ecb | ||
|
|
c653228fe7 | ||
|
|
352a196795 | ||
|
|
cef9b1efe5 | ||
|
|
7d6c54cff9 | ||
|
|
bd97ace974 | ||
|
|
58ad58f958 | ||
|
|
d907083d83 | ||
|
|
8371ff641a | ||
|
|
b1cd16f966 | ||
|
|
40a06921ea | ||
|
|
a7dc063e08 | ||
|
|
64b61d8fd0 | ||
|
|
5e7d4cc838 | ||
|
|
a0c80b13a4 | ||
|
|
3c051f5b1d | ||
|
|
940a796ba5 | ||
|
|
6ee5e4df19 | ||
|
|
d61397097c | ||
|
|
cfe45b368c | ||
|
|
6b0a0c1897 | ||
|
|
f27691340a | ||
|
|
c3a58aba9e | ||
|
|
1550dfb3f0 | ||
|
|
4762a0a725 | ||
|
|
cf82943766 | ||
|
|
b527e8f8f2 | ||
|
|
3c53b5793b | ||
|
|
e6afc6f4f3 | ||
|
|
05699a90d6 | ||
|
|
142d560f76 | ||
|
|
e80e27884e | ||
|
|
75788938b5 | ||
|
|
2570cf1bd7 | ||
|
|
b0cca873b6 | ||
|
|
325926ad9b | ||
|
|
34b87991bc | ||
|
|
cc8d556448 | ||
|
|
71286f7b2b | ||
|
|
c29973ec1a | ||
|
|
8c95f0bb81 | ||
|
|
627ab6734b | ||
|
|
36e1cbbf3e | ||
|
|
93fe87e57f | ||
|
|
02363c27a2 | ||
|
|
67e1743d6c | ||
|
|
cef20abceb | ||
|
|
62cedab6dd | ||
|
|
ed5a74a9d3 | ||
|
|
e2318ee776 | ||
|
|
a29c1c61c4 | ||
|
|
206813b74c | ||
|
|
99611d22c7 | ||
|
|
11b2b6e6c0 | ||
|
|
f49951084e | ||
|
|
047b8c3bf7 | ||
|
|
5c1ffd9ff2 | ||
|
|
670b3c17b6 | ||
|
|
ec7c538888 | ||
|
|
b88341e3a8 | ||
|
|
2048d8008a | ||
|
|
527f2f800b | ||
|
|
c7d3229fd3 |
@@ -56,6 +56,17 @@ jobs:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install script dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Configure Git author
|
||||
id: git-author
|
||||
uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2
|
||||
@@ -95,6 +106,9 @@ jobs:
|
||||
shell: bash
|
||||
run: sudo chown -R $USER:$USER .
|
||||
|
||||
- name: Prune stale ICU translations
|
||||
run: pnpm scripts i18n-icu-contract prune-local
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
|
||||
@@ -4,11 +4,14 @@ on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- '.github/workflows/i18n.push.yml'
|
||||
- '.github/workflows/i18n-push.yml'
|
||||
- 'apps/*/src/locales/en-US/**'
|
||||
- 'apps/*/locales/en-US/**'
|
||||
- 'packages/*/src/locales/en-US/**'
|
||||
- 'packages/*/locales/en-US/**'
|
||||
- 'scripts/i18n-icu-contract.ts'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'crowdin.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -56,6 +59,18 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install script dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Query branch name
|
||||
id: branch-name
|
||||
@@ -79,3 +94,9 @@ jobs:
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Clear stale ICU translations in Crowdin
|
||||
run: pnpm scripts i18n-icu-contract clear-crowdin-changed --base-ref HEAD^ --crowdin-branch "[${{ github.repository_owner }}.${{ github.event.repository.name }}] ${{ steps.branch-name.outputs.safe_branch_name }}"
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -57,8 +57,11 @@ jobs:
|
||||
|
||||
- name: Extract app changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ env.VERSION_TAG }}
|
||||
run: npx --yes tsx scripts/build-theseus-release-notes.ts
|
||||
run: |
|
||||
LAST_GITHUB_RELEASE_PUBLISHED_AT=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.published_at // ""' 2>/dev/null || true)
|
||||
LAST_GITHUB_RELEASE_PUBLISHED_AT="$LAST_GITHUB_RELEASE_PUBLISHED_AT" npx --yes tsx scripts/build-theseus-release-notes.ts
|
||||
|
||||
- name: Generate version manifest
|
||||
run: |
|
||||
|
||||
@@ -204,4 +204,5 @@ jobs:
|
||||
- name: Verify intl:extract has been run
|
||||
run: |
|
||||
pnpm turbo run intl:extract --force
|
||||
git diff --exit-code --color */*/src/locales/en-US/index.json
|
||||
pnpm scripts i18n-icu-contract prune-local --check
|
||||
git diff --exit-code --color */*/src/locales/*/index.json
|
||||
|
||||
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
||||
"files.insertFinalNewline": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "always"
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[vue]": {
|
||||
|
||||
@@ -64,7 +64,7 @@ The website and app `prepr` commands
|
||||
|
||||
Each project may have its own `CLAUDE.md` with detailed instructions:
|
||||
|
||||
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
|
||||
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
|
||||
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
Generated
+1
@@ -10514,6 +10514,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"fs4",
|
||||
"futures",
|
||||
"futures-lite 2.6.1",
|
||||
"heck 0.5.0",
|
||||
"hickory-resolver 0.25.2",
|
||||
"indicatif",
|
||||
|
||||
@@ -85,6 +85,7 @@ 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,6 +10,7 @@
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend",
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
ContentInstallModal,
|
||||
ContentUpdaterModal,
|
||||
CreationFlowModal,
|
||||
defineMessages,
|
||||
I18nDebugPanel,
|
||||
@@ -75,7 +76,6 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
import ErrorModal from '@/components/ui/ErrorModal.vue'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
|
||||
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
|
||||
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
|
||||
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
@@ -84,6 +84,7 @@ 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'
|
||||
@@ -94,13 +95,14 @@ import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads
|
||||
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { check_reachable } from '@/helpers/auth.js'
|
||||
import { get_user, get_version } from '@/helpers/cache.js'
|
||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||
import { create_profile_and_install_from_file } from '@/helpers/pack'
|
||||
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,
|
||||
@@ -134,6 +136,7 @@ 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(() => {
|
||||
@@ -144,6 +147,9 @@ 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
|
||||
@@ -192,6 +198,12 @@ 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),
|
||||
@@ -229,6 +241,7 @@ const {
|
||||
|
||||
const news = ref([])
|
||||
const availableSurvey = ref(false)
|
||||
const displayedServerInviteNotifications = new Set()
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
@@ -599,6 +612,16 @@ const {
|
||||
handleModpackDuplicateCreateAnyway: handleContentInstallModpackDuplicateCreateAnyway,
|
||||
handleModpackDuplicateGoToInstance: handleContentInstallModpackDuplicateGoToInstance,
|
||||
setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
|
||||
incompatibilityWarningVersions: contentInstallIncompatibilityWarningVersions,
|
||||
incompatibilityWarningCurrentGameVersion: contentInstallIncompatibilityWarningCurrentGameVersion,
|
||||
incompatibilityWarningCurrentLoader: contentInstallIncompatibilityWarningCurrentLoader,
|
||||
incompatibilityWarningProjectType: contentInstallIncompatibilityWarningProjectType,
|
||||
incompatibilityWarningProjectIconUrl: contentInstallIncompatibilityWarningProjectIconUrl,
|
||||
incompatibilityWarningProjectName: contentInstallIncompatibilityWarningProjectName,
|
||||
incompatibilityWarningMessage: contentInstallIncompatibilityWarningMessage,
|
||||
incompatibilityWarningInstalling: contentInstallIncompatibilityWarningInstalling,
|
||||
handleIncompatibilityWarningInstall: handleContentInstallIncompatibilityWarningInstall,
|
||||
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
|
||||
} = contentInstall
|
||||
|
||||
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
|
||||
@@ -620,6 +643,12 @@ const updateToPlayModal = ref()
|
||||
|
||||
const modrinthLoginFlowWaitModal = ref()
|
||||
|
||||
watch(incompatibilityWarningModal, (modal) => {
|
||||
if (modal) {
|
||||
setContentIncompatibilityWarningModal(modal)
|
||||
}
|
||||
})
|
||||
|
||||
setupAuthProvider(credentials, async (_redirectPath) => {
|
||||
await signIn()
|
||||
})
|
||||
@@ -676,12 +705,11 @@ async function logOut() {
|
||||
await fetchCredentials()
|
||||
}
|
||||
|
||||
const MIDAS_BITFLAG = 1 << 0
|
||||
const hasPlus = computed(
|
||||
() =>
|
||||
credentials.value &&
|
||||
credentials.value.user &&
|
||||
(credentials.value.user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG,
|
||||
!!credentials.value?.user &&
|
||||
(hasMidasBadge(credentials.value.user) ||
|
||||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
||||
)
|
||||
|
||||
const showAd = computed(
|
||||
@@ -741,6 +769,86 @@ const accounts = ref(null)
|
||||
provide('accountsCard', accounts)
|
||||
|
||||
command_listener(handleCommand)
|
||||
notification_listener(handleLiveNotification)
|
||||
|
||||
async function markLiveNotificationRead(notification) {
|
||||
try {
|
||||
await tauriApiClient.labrinth.notifications_v2.markAsRead(notification.id)
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthApiError && error.statusCode === 404) {
|
||||
console.warn(`notification ${notification.id} could not be marked as read`, error)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function respondToServerInvite(notification, action) {
|
||||
const serverId = notification.body?.server_id
|
||||
if (typeof serverId !== 'string') {
|
||||
throw new Error('Missing server ID for invite notification.')
|
||||
}
|
||||
|
||||
await tauriApiClient.request(`/servers/${serverId}/invites/${action}`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
})
|
||||
await markLiveNotificationRead(notification)
|
||||
|
||||
return serverId
|
||||
}
|
||||
|
||||
async function acceptServerInviteNotification(notification) {
|
||||
try {
|
||||
const serverId = await respondToServerInvite(notification, 'accept')
|
||||
await router.push(`/hosting/manage/${encodeURIComponent(serverId)}`)
|
||||
queryClient.invalidateQueries({ queryKey: ['servers'] })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function declineServerInviteNotification(notification) {
|
||||
try {
|
||||
await respondToServerInvite(notification, 'decline')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function openServerInviteInviterProfile(inviterName) {
|
||||
if (!inviterName) return
|
||||
openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`)
|
||||
}
|
||||
|
||||
async function handleLiveNotification(notification) {
|
||||
if (notification?.body?.type !== 'server_invite' || notification.read) return
|
||||
if (displayedServerInviteNotifications.has(notification.id)) return
|
||||
|
||||
displayedServerInviteNotifications.add(notification.id)
|
||||
|
||||
const serverName =
|
||||
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
|
||||
const inviterId = notification.body.invited_by
|
||||
const invitedBy =
|
||||
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
|
||||
|
||||
addPopupNotification({
|
||||
title: serverName,
|
||||
autoCloseMs: null,
|
||||
toast: {
|
||||
type: 'server-invite',
|
||||
actorName: invitedBy?.username ?? null,
|
||||
actorAvatarUrl: invitedBy?.avatar_url ?? null,
|
||||
entityName: serverName,
|
||||
onAccept: () => acceptServerInviteNotification(notification),
|
||||
onDecline: () => declineServerInviteNotification(notification),
|
||||
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCommand(e) {
|
||||
if (!e) return
|
||||
|
||||
@@ -1479,6 +1587,10 @@ 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">
|
||||
@@ -1535,7 +1647,22 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
@go-to-instance="handleModpackDuplicateGoToInstance"
|
||||
/>
|
||||
<AddServerToInstanceModal ref="addServerToInstanceModal" />
|
||||
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
|
||||
<ContentUpdaterModal
|
||||
ref="incompatibilityWarningModal"
|
||||
mode="incompatibility-warning"
|
||||
:versions="contentInstallIncompatibilityWarningVersions"
|
||||
:current-game-version="contentInstallIncompatibilityWarningCurrentGameVersion"
|
||||
:current-loader="contentInstallIncompatibilityWarningCurrentLoader"
|
||||
current-version-id=""
|
||||
:is-app="true"
|
||||
:project-type="contentInstallIncompatibilityWarningProjectType"
|
||||
:project-icon-url="contentInstallIncompatibilityWarningProjectIconUrl"
|
||||
:project-name="contentInstallIncompatibilityWarningProjectName"
|
||||
:warning="contentInstallIncompatibilityWarningMessage"
|
||||
:action-loading="contentInstallIncompatibilityWarningInstalling"
|
||||
@update="handleContentInstallIncompatibilityWarningInstall"
|
||||
@cancel="handleContentInstallIncompatibilityWarningCancel"
|
||||
/>
|
||||
<ModpackAlreadyInstalledModal
|
||||
ref="contentInstallModpackAlreadyInstalledModal"
|
||||
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
|
||||
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
type PopupNotificationProgressItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -284,6 +285,7 @@ function goToTerminal(path?: string) {
|
||||
}
|
||||
|
||||
const currentLoadingBars = ref<LoadingBar[]>([])
|
||||
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
|
||||
const notificationId = ref<string | number | null>(null)
|
||||
const dismissed = ref(false)
|
||||
|
||||
@@ -303,6 +305,16 @@ function getLoadingText(loadingBar: LoadingBar): string {
|
||||
return loadingBar.message ? `${percent}% ${loadingBar.message}` : `${percent}%`
|
||||
}
|
||||
|
||||
function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
if (!icon) {
|
||||
return null
|
||||
}
|
||||
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
|
||||
return icon
|
||||
}
|
||||
return convertFileSrc(icon)
|
||||
}
|
||||
|
||||
function getNotification(): PopupNotification | null {
|
||||
if (!notificationId.value) {
|
||||
return null
|
||||
@@ -326,6 +338,7 @@ function buildDownloadItems(): PopupNotificationProgressItem[] {
|
||||
id: getLoadingBarKey(bar),
|
||||
title: bar.title ?? '',
|
||||
text: getLoadingText(bar),
|
||||
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
|
||||
progress: getLoadingProgress(bar),
|
||||
waiting: !bar.total || bar.total <= 0,
|
||||
}))
|
||||
@@ -400,6 +413,32 @@ async function refreshLoadingBars() {
|
||||
.map(formatLoadingBars)
|
||||
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
|
||||
|
||||
const profilePaths = Array.from(
|
||||
new Set(
|
||||
currentLoadingBars.value
|
||||
.map((bar) => bar.bar_type?.profile_path)
|
||||
.filter((path): path is string => !!path),
|
||||
),
|
||||
)
|
||||
const profiles = profilePaths.length
|
||||
? await getInstances(profilePaths).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
: []
|
||||
const profileIconUrls = new Map(
|
||||
profiles.map((profile) => [profile.path, getDisplayIconUrl(profile.icon_path)]),
|
||||
)
|
||||
currentLoadingBarIconUrls.value = Object.fromEntries(
|
||||
currentLoadingBars.value.map((bar) => {
|
||||
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
|
||||
const profileIconUrl = bar.bar_type?.profile_path
|
||||
? profileIconUrls.get(bar.bar_type.profile_path)
|
||||
: null
|
||||
return [getLoadingBarKey(bar), barIconUrl ?? profileIconUrl ?? null]
|
||||
}),
|
||||
)
|
||||
|
||||
currentLoadingBars.value.sort((a, b) => {
|
||||
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
|
||||
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<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,7 +393,6 @@ const messages = defineMessages({
|
||||
<FriendsSection
|
||||
v-if="pendingFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="pendingFriends"
|
||||
:heading="formatMessage(messages.pending)"
|
||||
:remove-friend="removeFriend"
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
<template>
|
||||
<ModalWrapper ref="incompatibleModal" header="Incompatibility warning" :on-hide="onInstall">
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance
|
||||
you're trying to install it on. Are you sure you want to continue? Dependencies will not be
|
||||
installed.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="header">
|
||||
<th>{{ instance?.name }}</th>
|
||||
<th>{{ project.title }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="content">
|
||||
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
|
||||
<td>
|
||||
<Combobox
|
||||
v-if="versions?.length > 1"
|
||||
v-model="selectedVersionId"
|
||||
:options="versionOptions"
|
||||
:searchable="true"
|
||||
placeholder="Select version"
|
||||
force-direction="up"
|
||||
:max-height="150"
|
||||
/>
|
||||
<span v-else>
|
||||
<span>{{ selectedVersionLabel }}</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="button-group">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="() => incompatibleModal.hide()"><XIcon />Cancel</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="installing" @click="install()">
|
||||
<DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
formatLoader,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { add_project_from_version as installMod } from '@/helpers/profile'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const instance = ref(null)
|
||||
const project = ref(null)
|
||||
const versions = ref(null)
|
||||
const selectedVersion = ref(null)
|
||||
const incompatibleModal = ref(null)
|
||||
const installing = ref(false)
|
||||
|
||||
const onInstall = ref(() => {})
|
||||
|
||||
const selectedVersionLabel = computed(() => {
|
||||
if (!selectedVersion.value) return ''
|
||||
return `${selectedVersion.value.name} (${selectedVersion.value.loaders
|
||||
.map((name) => formatLoader(formatMessage, name))
|
||||
.join(', ')} - ${selectedVersion.value.game_versions.join(', ')})`
|
||||
})
|
||||
|
||||
const versionOptions = computed(() =>
|
||||
(versions.value ?? []).map((version) => ({
|
||||
value: version.id,
|
||||
label: `${version.name} (${version.loaders
|
||||
.map((name) => formatLoader(formatMessage, name))
|
||||
.join(', ')} - ${version.game_versions.join(', ')})`,
|
||||
})),
|
||||
)
|
||||
|
||||
const selectedVersionId = computed({
|
||||
get: () => selectedVersion.value?.id ?? null,
|
||||
set: (value) => {
|
||||
if (!value) return
|
||||
selectedVersion.value = (versions.value ?? []).find((version) => version.id === value) ?? null
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
|
||||
instance.value = instanceVal
|
||||
versions.value = projectVersions ?? []
|
||||
selectedVersion.value = selected ?? projectVersions?.[0] ?? null
|
||||
|
||||
project.value = projectVal
|
||||
|
||||
onInstall.value = callback
|
||||
installing.value = false
|
||||
|
||||
incompatibleModal.value.show()
|
||||
|
||||
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
|
||||
},
|
||||
})
|
||||
|
||||
const install = async () => {
|
||||
installing.value = true
|
||||
await installMod(instance.value.path, selectedVersion.value.id, 'standalone').catch(handleError)
|
||||
installing.value = false
|
||||
onInstall.value(selectedVersion.value.id)
|
||||
incompatibleModal.value.hide()
|
||||
|
||||
trackEvent('ProjectInstall', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
id: project.value,
|
||||
version_id: selectedVersion.value.id,
|
||||
project_type: project.value.project_type,
|
||||
title: project.value.title,
|
||||
source: 'ProjectIncompatibilityWarningModal',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.data {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
border-collapse: collapse;
|
||||
box-shadow: 0 0 0 1px var(--color-button-bg);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg);
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--color-button-bg);
|
||||
}
|
||||
|
||||
th:first-child {
|
||||
border-top-left-radius: var(--radius-lg);
|
||||
border-right: 1px solid var(--color-button-bg);
|
||||
}
|
||||
|
||||
th:last-child {
|
||||
border-top-right-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
border-right: 1px solid var(--color-button-bg);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InstallationSettingsLayout,
|
||||
provideAppBackup,
|
||||
provideInstallationSettings,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
@@ -34,9 +35,17 @@ import type { Manifest } from '../../../helpers/types'
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const debug = useDebugLogger('AppInstallationSettings')
|
||||
|
||||
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
|
||||
|
||||
debug('metadata load: start', {
|
||||
instancePath: instance.value.path,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
installStage: instance.value.install_stage,
|
||||
})
|
||||
|
||||
const [
|
||||
fabric_versions,
|
||||
forge_versions,
|
||||
@@ -72,6 +81,15 @@ const [
|
||||
.catch(handleError),
|
||||
])
|
||||
|
||||
debug('metadata load: done', {
|
||||
hasFabricManifest: !!fabric_versions?.value,
|
||||
hasForgeManifest: !!forge_versions?.value,
|
||||
hasQuiltManifest: !!quilt_versions?.value,
|
||||
hasNeoforgeManifest: !!neoforge_versions?.value,
|
||||
gameVersions: all_game_versions?.value?.length ?? 0,
|
||||
availablePlatforms: loaders?.value?.map((loader) => loader.name) ?? [],
|
||||
})
|
||||
|
||||
const { data: modpackInfo } = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.path]),
|
||||
queryFn: () => get_linked_modpack_info(instance.value.path, 'must_revalidate'),
|
||||
@@ -95,11 +113,21 @@ function getManifest(loader: string) {
|
||||
quilt: quilt_versions,
|
||||
neoforge: neoforge_versions,
|
||||
}
|
||||
return map[loader]
|
||||
const manifest = map[loader]
|
||||
debug('getManifest:', {
|
||||
loader,
|
||||
hasManifest: !!manifest?.value,
|
||||
gameVersions: manifest?.value?.gameVersions?.length ?? 0,
|
||||
})
|
||||
return manifest
|
||||
}
|
||||
|
||||
provideAppBackup({
|
||||
async createBackup() {
|
||||
debug('createBackup: start', {
|
||||
instancePath: instance.value.path,
|
||||
instanceName: instance.value.name,
|
||||
})
|
||||
const allProfiles = await list()
|
||||
const prefix = `${instance.value.name} - Backup #`
|
||||
const existingNums = allProfiles
|
||||
@@ -109,6 +137,7 @@ provideAppBackup({
|
||||
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
|
||||
const newPath = await duplicate(instance.value.path)
|
||||
await edit(newPath, { name: `${prefix}${nextNum}` })
|
||||
debug('createBackup: done', { newPath, backupName: `${prefix}${nextNum}` })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -165,32 +194,72 @@ provideInstallationSettings({
|
||||
const manifest = getManifest(loader)
|
||||
return !!manifest?.value?.gameVersions?.some((x) => item.version === x.id)
|
||||
})
|
||||
return (showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')).map(
|
||||
(x) => ({ value: x.version, label: x.version }),
|
||||
)
|
||||
const result = (
|
||||
showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')
|
||||
).map((x) => ({ value: x.version, label: x.version }))
|
||||
debug('resolveGameVersions:', {
|
||||
loader,
|
||||
showSnapshots,
|
||||
totalVersions: versions.length,
|
||||
filteredVersions: filtered.length,
|
||||
resultVersions: result.length,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
resolveLoaderVersions(loader, gameVersion) {
|
||||
if (loader === 'vanilla' || !gameVersion) return []
|
||||
const manifest = getManifest(loader)
|
||||
if (!manifest?.value) return []
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
return manifest.value.gameVersions[0]?.loaders ?? []
|
||||
if (loader === 'vanilla' || !gameVersion) {
|
||||
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
return manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
|
||||
const manifest = getManifest(loader)
|
||||
if (!manifest?.value) {
|
||||
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const result = manifest.value.gameVersions[0]?.loaders ?? []
|
||||
debug('resolveLoaderVersions: fabric/quilt result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const result =
|
||||
manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
|
||||
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
||||
return result
|
||||
},
|
||||
|
||||
resolveHasSnapshots(loader) {
|
||||
const versions = all_game_versions?.value ?? []
|
||||
if (loader === 'vanilla') return versions.some((x) => x.version_type !== 'release')
|
||||
if (loader === 'vanilla') {
|
||||
const result = versions.some((x) => x.version_type !== 'release')
|
||||
debug('resolveHasSnapshots: vanilla', { loader, result })
|
||||
return result
|
||||
}
|
||||
const manifest = getManifest(loader)
|
||||
const supported = versions.filter(
|
||||
(item) => !!manifest?.value?.gameVersions?.some((x) => item.version === x.id),
|
||||
)
|
||||
return supported.some((x) => x.version_type !== 'release')
|
||||
const result = supported.some((x) => x.version_type !== 'release')
|
||||
debug('resolveHasSnapshots:', {
|
||||
loader,
|
||||
totalVersions: versions.length,
|
||||
supportedVersions: supported.length,
|
||||
result,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
async save(platform, gameVersion, loaderVersionId) {
|
||||
debug('save: called', {
|
||||
instancePath: instance.value.path,
|
||||
platform,
|
||||
gameVersion,
|
||||
loaderVersionId,
|
||||
})
|
||||
const editProfile: Record<string, string | undefined> = {
|
||||
loader: platform,
|
||||
game_version: gameVersion,
|
||||
@@ -199,17 +268,21 @@ provideInstallationSettings({
|
||||
editProfile.loader_version = loaderVersionId
|
||||
}
|
||||
await edit(instance.value.path, editProfile).catch(handleError)
|
||||
debug('save: edit complete', { editProfile })
|
||||
},
|
||||
|
||||
afterSave: async () => {
|
||||
debug('afterSave: installing', { instancePath: instance.value.path })
|
||||
await install(instance.value.path, false).catch(handleError)
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('afterSave: done')
|
||||
},
|
||||
|
||||
async repair() {
|
||||
debug('repair: called', { instancePath: instance.value.path })
|
||||
repairing.value = true
|
||||
await install(instance.value.path, true).catch(handleError)
|
||||
repairing.value = false
|
||||
@@ -217,9 +290,11 @@ provideInstallationSettings({
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('repair: done')
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
debug('reinstallModpack: called', { instancePath: instance.value.path })
|
||||
reinstalling.value = true
|
||||
await update_repair_modrinth(instance.value.path).catch(handleError)
|
||||
reinstalling.value = false
|
||||
@@ -227,9 +302,11 @@ provideInstallationSettings({
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('reinstallModpack: done')
|
||||
},
|
||||
|
||||
async unlinkModpack() {
|
||||
debug('unlinkModpack: called', { instancePath: instance.value.path })
|
||||
await edit(instance.value.path, {
|
||||
linked_data: null as unknown as undefined,
|
||||
})
|
||||
@@ -237,27 +314,38 @@ provideInstallationSettings({
|
||||
queryKey: ['linkedModpackInfo', instance.value.path],
|
||||
})
|
||||
onUnlinked()
|
||||
debug('unlinkModpack: done')
|
||||
},
|
||||
|
||||
getCachedModpackVersions: () => null,
|
||||
async fetchModpackVersions() {
|
||||
debug('fetchModpackVersions: called', {
|
||||
projectId: instance.value.linked_data?.project_id,
|
||||
})
|
||||
const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch(
|
||||
handleError,
|
||||
)
|
||||
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
|
||||
return (versions ?? []) as Labrinth.Versions.v2.Version[]
|
||||
},
|
||||
|
||||
async getVersionChangelog(versionId: string) {
|
||||
debug('getVersionChangelog: called', { versionId })
|
||||
return (await get_version(versionId, 'must_revalidate').catch(
|
||||
() => null,
|
||||
)) as Labrinth.Versions.v2.Version | null
|
||||
},
|
||||
|
||||
async onModpackVersionConfirm(version) {
|
||||
debug('onModpackVersionConfirm: called', {
|
||||
versionId: version.id,
|
||||
instancePath: instance.value.path,
|
||||
})
|
||||
await update_managed_modrinth_version(instance.value.path, version.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.path],
|
||||
})
|
||||
debug('onModpackVersionConfirm: done')
|
||||
},
|
||||
|
||||
updaterModalProps: computed(() => ({
|
||||
|
||||
@@ -10,8 +10,9 @@ import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
|
||||
const themeStore = useTheming()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const worldsInHomeFeatureFlag = 'worlds_in_home' as FeatureFlag
|
||||
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
|
||||
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
|
||||
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
|
||||
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
|
||||
|
||||
const messages = defineMessages({
|
||||
colorThemeTitle: {
|
||||
@@ -100,6 +101,14 @@ 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())
|
||||
@@ -153,25 +162,6 @@ 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"
|
||||
:model-value="themeStore.hideNametagSkinsPage"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
themeStore.hideNametagSkinsPage = !!e
|
||||
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
@@ -192,6 +182,44 @@ 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">
|
||||
@@ -226,12 +254,12 @@ watch(
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="themeStore.getFeatureFlag(worldsInHomeFeatureFlag)"
|
||||
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag(worldsInHomeFeatureFlag)
|
||||
themeStore.featureFlags[worldsInHomeFeatureFlag] = newValue
|
||||
settings.feature_flags[worldsInHomeFeatureFlag] = newValue
|
||||
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
|
||||
themeStore.featureFlags[worldsInHomeFlag] = newValue
|
||||
settings.feature_flags[worldsInHomeFlag] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -245,13 +273,13 @@ watch(
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.unknownPackWarningDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)"
|
||||
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
const warnBeforeUnknownPackInstall = !!e
|
||||
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
|
||||
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = skipUnknownPackWarning
|
||||
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = skipUnknownPackWarning
|
||||
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
|
||||
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
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'
|
||||
@@ -24,6 +25,7 @@ type AddSkinButtonRef = SkinLikeTextButtonExpose | SkinLikeTextButtonExpose[]
|
||||
|
||||
interface DefaultSkinSection {
|
||||
title: string
|
||||
infoTooltip?: string
|
||||
skins: Skin[]
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ interface SkinSection {
|
||||
key: string
|
||||
title: string
|
||||
kind: SkinSectionKind
|
||||
infoTooltip?: string
|
||||
skins: Skin[]
|
||||
}
|
||||
|
||||
@@ -145,6 +148,7 @@ const sections = computed<SkinSection[]>(() => [
|
||||
key: defaultSkinSectionKey(section.title),
|
||||
title: section.title,
|
||||
kind: 'default' as const,
|
||||
infoTooltip: section.infoTooltip,
|
||||
skins: section.skins,
|
||||
})),
|
||||
])
|
||||
@@ -330,6 +334,24 @@ defineExpose({ getAddSkinButtonElement })
|
||||
<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
|
||||
|
||||
@@ -244,7 +244,7 @@ const messages = defineMessages({
|
||||
</TagItem>
|
||||
<div
|
||||
v-if="world.type === 'singleplayer'"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
|
||||
>
|
||||
<UserIcon
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -98,6 +98,10 @@ export async function friend_listener(callback) {
|
||||
return await listen('friend', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
export async function notification_listener(callback) {
|
||||
return await listen('notification', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
/// Payload for the 'log' event
|
||||
/*
|
||||
LogPayload {
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface LoadingBarType {
|
||||
version?: string
|
||||
profile_path?: string
|
||||
pack_name?: string
|
||||
icon?: string | null
|
||||
}
|
||||
|
||||
export interface LoadingBar {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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,9 +5,36 @@
|
||||
"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": "صفحة الرئيسية"
|
||||
},
|
||||
@@ -581,6 +608,9 @@
|
||||
"minecraft-account.select-account": {
|
||||
"message": "اختيار الحساب"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "سجل دخولك لـ ماينكرافت"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "مقدَّم من النسخة"
|
||||
},
|
||||
@@ -604,5 +634,8 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "يتم توفير المحمّل من قبل الخادم"
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "لا تعرض هذا التحذير مجددا"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Procházet servery"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "\"{name}\" bude **navždy smazáno** a nebude způsob, jak to obnovit."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Opravdu si jsi jistý, že chceš navždy smazat tenhle svět?"
|
||||
},
|
||||
@@ -248,9 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Žádné servery nebo světy nebyly přidány"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "„{name}“ bude odstraněn z tvého seznamu, včetně hry, a nebude žádný způsob, jak ho obnovit."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "'{name}' ({address}) bude odstraněn z vašeho seznamu, a to i ve hře, a nebude možné jej obnovit."
|
||||
},
|
||||
@@ -443,9 +437,6 @@
|
||||
"friends.heading.pending": {
|
||||
"message": "Čeká na vyřízení"
|
||||
},
|
||||
"friends.no-friends-match": {
|
||||
"message": "Žádní přátelé neodpovídají '{query}''"
|
||||
},
|
||||
"friends.search-friends-placeholder": {
|
||||
"message": "Hledat přátele..."
|
||||
},
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -77,6 +80,12 @@
|
||||
"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."
|
||||
},
|
||||
@@ -89,6 +98,15 @@
|
||||
"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}"
|
||||
},
|
||||
@@ -98,18 +116,33 @@
|
||||
"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..."
|
||||
},
|
||||
@@ -119,12 +152,18 @@
|
||||
"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"
|
||||
},
|
||||
@@ -149,6 +188,12 @@
|
||||
"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"
|
||||
},
|
||||
@@ -167,6 +212,42 @@
|
||||
"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"
|
||||
},
|
||||
@@ -203,6 +284,15 @@
|
||||
"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."
|
||||
},
|
||||
@@ -227,6 +317,48 @@
|
||||
"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."
|
||||
},
|
||||
@@ -569,6 +701,9 @@
|
||||
"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,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Server durchsuchen"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "„{name}“ wird **endgültig gelöscht**, und es gibt keine Möglichkeit, sie wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Möchtest du diese Welt wirklich endgültig löschen?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Keine Server oder Welten hinzugefügt"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "„{name}“ wird aus deiner Liste entfernt, auch im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "„{name}“ ({address}) wird aus deiner Liste entfernt, inklusive im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Möchtest du {name} wirklich entfernen?"
|
||||
},
|
||||
@@ -303,7 +294,7 @@
|
||||
"message": "Dieses Projekt ist bereits installiert"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Zurück zum Entdecken"
|
||||
"message": "Zurück zu Entdecken"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Inhalt in Instanz installieren"
|
||||
@@ -332,6 +323,147 @@
|
||||
"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."
|
||||
},
|
||||
@@ -611,6 +743,24 @@
|
||||
"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"
|
||||
},
|
||||
@@ -626,6 +776,9 @@
|
||||
"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,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Server durchsuchen"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "„{name}“ wird **endgültig gelöscht**, und es gibt keine Möglichkeit, sie wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Möchtest du diese Welt wirklich endgültig löschen?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Keine Server oder Welten hinzugefügt"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "„{name}“ wird aus deiner Liste entfernt, auch im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "„{name}“ ({address}) wird aus deiner Liste entfernt, inklusive im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Möchtest du {name} wirklich entfernen?"
|
||||
},
|
||||
@@ -332,6 +323,147 @@
|
||||
"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."
|
||||
},
|
||||
@@ -611,6 +743,24 @@
|
||||
"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"
|
||||
},
|
||||
@@ -626,6 +776,9 @@
|
||||
"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,6 +86,12 @@
|
||||
"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."
|
||||
},
|
||||
@@ -143,6 +149,9 @@
|
||||
"app.browse.server.installing": {
|
||||
"message": "Installing"
|
||||
},
|
||||
"app.content-install.no-compatible-versions": {
|
||||
"message": "No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically."
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
@@ -431,6 +440,15 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -224,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Explorar servidores"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "\"{name}\" se **eliminará permanentemente** y no habrá forma de recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "¿Estás seguro de que quieres eliminar este mundo de forma permanente?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "No hay servidores ni mundos añadidos"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "\"{name}\" se eliminará de tu lista, también dentro del juego, y no podrás recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "\"{name}\" ({address}) se eliminará de tu lista, también dentro del juego, y no podrás recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "¿Estás seguro de que quieres eliminar {name}?"
|
||||
},
|
||||
@@ -300,10 +291,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 está actualmente instalado"
|
||||
"message": "Este proyecto ya está instalado"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Volver al explorador"
|
||||
"message": "Volver a descubrir contenido"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Instalar contenido a la instancia"
|
||||
@@ -332,6 +323,138 @@
|
||||
"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."
|
||||
},
|
||||
@@ -645,7 +768,7 @@
|
||||
"message": "Memoria asignada"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/ruta/a/java"
|
||||
"message": "/ruta/de/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Ventana"
|
||||
|
||||
@@ -332,6 +332,15 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -104,6 +104,12 @@
|
||||
"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"
|
||||
},
|
||||
@@ -116,6 +122,9 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Nadagdag na"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Bumalik sa instansiya"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Tumuklas ng kontento"
|
||||
},
|
||||
@@ -128,6 +137,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -287,6 +299,12 @@
|
||||
"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."
|
||||
},
|
||||
@@ -311,6 +329,36 @@
|
||||
"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,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Parcourir des serveurs"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "« {name} » sera supprimé **pour toujours**, et il sera impossible de le récupérer."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Êtes-vous sûr·e de vouloir supprimer ce monde pour toujours ?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Aucun serveur ou monde ajoutés"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "« {name} » sera retiré de votre liste, y compris en jeu, et il sera impossible de le récupérer."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "« {name} » ({address}) sera retiré de votre liste, y compris en jeu, et il sera impossible de le récupérer."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Êtes-vous sûr·e de vouloir retirer {name} ?"
|
||||
},
|
||||
@@ -332,6 +323,147 @@
|
||||
"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."
|
||||
},
|
||||
@@ -389,9 +521,6 @@
|
||||
"app.world.world-item.offline": {
|
||||
"message": "Hors ligne"
|
||||
},
|
||||
"app.world.world-item.players-online": {
|
||||
"message": "{count, plural, =0 {Personne en ligne} one {# est en ligne} other {# sont en ligne}}"
|
||||
},
|
||||
"friends.action.add-friend": {
|
||||
"message": "Ajouter un ami"
|
||||
},
|
||||
@@ -644,6 +773,9 @@
|
||||
"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,4 +1,16 @@
|
||||
{
|
||||
"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 מושבתים כרגע. יש לבדוק את חיבור האינטרנט שלך ולנסות שוב מאוחר יותר."
|
||||
},
|
||||
@@ -254,9 +266,6 @@
|
||||
"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ó profilok elrejtése"
|
||||
"message": "További futó példányok elrejtése"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Beállítás elsődleges profilként"
|
||||
"message": "Beállítás elsődleges példányként"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Nincsenek futó profilok"
|
||||
"message": "Nincsenek futó példányok"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Elsődleges profil"
|
||||
"message": "Elsődleges példány"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "További futó profilok megjelenítése"
|
||||
"message": "További futó pédányok megjelenítése"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Profil megállítása"
|
||||
"message": "Példány leállítása"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Aktív letöltések megtekintése"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Profil megtekintése"
|
||||
"message": "Példányok 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 Skinek oldalon."
|
||||
"message": "Letiltja a játékosod felett megjelenő névcímkét a Kinézetek 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 a profilhoz"
|
||||
"message": "Szerver hozzáadása az példányhoz"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Hozzáadás egy profilhoz"
|
||||
"message": "Hozzáadás egy példányhoz"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Hozzáadás a profilhoz"
|
||||
"message": "Példányhoz adás"
|
||||
},
|
||||
"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 profilhoz"
|
||||
"message": "Vissza a példányhoz"
|
||||
},
|
||||
"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": "Profil"
|
||||
"message": "Példány"
|
||||
},
|
||||
"app.instance.mods.content-type-project": {
|
||||
"message": "projekt"
|
||||
@@ -272,9 +272,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Telepítés"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count} mod"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Szükséges modcsomag"
|
||||
},
|
||||
@@ -332,6 +329,138 @@
|
||||
"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."
|
||||
},
|
||||
@@ -395,9 +524,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Barát hozzáadása"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} barát {count, plural, other {kérelem}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Barátkérelem elküldése"
|
||||
},
|
||||
@@ -611,6 +737,24 @@
|
||||
"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"
|
||||
},
|
||||
@@ -626,6 +770,9 @@
|
||||
"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,21 +56,45 @@
|
||||
"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"
|
||||
},
|
||||
@@ -113,6 +137,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -245,9 +272,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Pasang"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, other {# mod}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Paket mod yang diperlukan"
|
||||
},
|
||||
@@ -272,6 +296,12 @@
|
||||
"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"
|
||||
},
|
||||
@@ -299,6 +329,147 @@
|
||||
"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."
|
||||
},
|
||||
@@ -362,9 +533,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Tambah teman"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} {count, plural, other {permintaan}} teman"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Kirim permintaan teman"
|
||||
},
|
||||
@@ -578,6 +746,24 @@
|
||||
"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"
|
||||
},
|
||||
@@ -593,6 +779,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -680,6 +869,9 @@
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Pilih akun"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Masuk ke Minecraft"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Disediakan oleh instans"
|
||||
},
|
||||
@@ -704,6 +896,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -713,6 +908,9 @@
|
||||
"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,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Esplora i server"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "\"{name}\" verrà **eliminato permanentemente** e non ci sarà modo di recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Vuoi davvero eliminare questo mondo per sempre?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Nessun server o mondo aggiunto"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "\"{name}\" sarà rimosso dalla tua lista, inclusa quella nel gioco, e non ci sarà modo di recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "\"{name}\" ({address}) sarà rimosso dalla tua lista, inclusa quella nel gioco, e non ci sarà modo di recuperarlo."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Vuoi davvero rimuovere {name}?"
|
||||
},
|
||||
@@ -272,9 +263,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Installa"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count} mod"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Pacchetto di mod richiesto"
|
||||
},
|
||||
@@ -332,6 +320,147 @@
|
||||
"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,9 +254,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "インストール"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, other {#個のMod}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "必須のModパック"
|
||||
},
|
||||
@@ -368,9 +365,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "フレンドを追加"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count}件の友達リクエスト"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "フレンド申請を送信"
|
||||
},
|
||||
|
||||
@@ -332,6 +332,135 @@
|
||||
"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을 종료하면 자동으로 업데이트됩니다."
|
||||
},
|
||||
@@ -395,9 +524,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "친구 추가"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count}건의 친구 요청"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "친구 요청 보내기"
|
||||
},
|
||||
|
||||
@@ -272,9 +272,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Pasang"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, other {# mod}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Pek mod yang diperlukan"
|
||||
},
|
||||
@@ -302,6 +299,9 @@
|
||||
"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,9 +392,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Tambah rakan"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} {count, plural, other {permintaan}} rakan"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Hantar permintaan rakan"
|
||||
},
|
||||
@@ -608,6 +605,21 @@
|
||||
"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"
|
||||
},
|
||||
@@ -623,6 +635,9 @@
|
||||
"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,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Zoek servers"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "{name} Zal **permanent verwijderd** worden en kan niet hersteld worden."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Weet je zeker dat je deze wereld definitief wilt verwijderen?"
|
||||
},
|
||||
@@ -257,9 +254,6 @@
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Ben je zeker dat je {name} wil verwijderen?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Zoek werelden"
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "deze server"
|
||||
},
|
||||
|
||||
@@ -206,9 +206,6 @@
|
||||
"app.instance.mods.project-was-added": {
|
||||
"message": "Dodano \"{name}\""
|
||||
},
|
||||
"app.instance.mods.projects-were-added": {
|
||||
"message": "Dodano {count} {count, plural, one {projekt} few {projekty} other {projektów}}"
|
||||
},
|
||||
"app.instance.mods.share-text": {
|
||||
"message": "Sprawdź projekty, które używam w mojej paczce modów!"
|
||||
},
|
||||
@@ -272,9 +269,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Zainstaluj"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, one {# mod} few {# mody} other {# modów}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Wymagana paczka modów"
|
||||
},
|
||||
@@ -332,6 +326,69 @@
|
||||
"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."
|
||||
},
|
||||
@@ -395,9 +452,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Dodaj znajomego"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} {count, plural, one {zaproszenie} few {zaproszenia} other {zaproszeń}} do znajomych"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Wyślij zaproszenie"
|
||||
},
|
||||
@@ -611,6 +665,24 @@
|
||||
"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"
|
||||
},
|
||||
@@ -626,6 +698,9 @@
|
||||
"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,9 +272,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Instalar"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, =0 {Nenhum mod} one {# mod} other {# mods}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Pacote de mods necessário"
|
||||
},
|
||||
@@ -332,6 +329,147 @@
|
||||
"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."
|
||||
},
|
||||
@@ -395,9 +533,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Adicionar um amigo"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count, plural, =0 {Nenhuma solicitação de amizade} one {{count} solicitação de amizade} other {{count} solicitações de amizade}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Enviar amizade"
|
||||
},
|
||||
|
||||
@@ -149,9 +149,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Procurar servidores"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "\"{name}\" vai ser **apagado permanentemente**, e não haverá maneira de o recuperar."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Tens a certeza que queres apagar permanentemente este mundo?"
|
||||
},
|
||||
@@ -173,12 +170,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Sem servidores ou mundos adicionados"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "\"{name}\" vai ser removido da tua lista, incluindo dentro do jogo, e não haverá maneira de o recuperar."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "\"{name}\" ({address}) vai ser removido da tua lista, incluindo dentro do jogo, e não haverá maneira de o recuperar."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Tens a certeza que queres remover {name}?"
|
||||
},
|
||||
|
||||
@@ -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,9 +206,6 @@
|
||||
"app.instance.mods.project-was-added": {
|
||||
"message": "«{name}» добавлен"
|
||||
},
|
||||
"app.instance.mods.projects-were-added": {
|
||||
"message": "{count, plural, one {Добавлен # проект} few {Добавлено # проекта} other {Добавлено # проектов}}"
|
||||
},
|
||||
"app.instance.mods.share-text": {
|
||||
"message": "Что в моей сборке:"
|
||||
},
|
||||
@@ -224,9 +221,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Найти серверы"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "«{name}» будет **удалён навсегда**. Его невозможно восстановить."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Вы действительно хотите удалить этот мир?"
|
||||
},
|
||||
@@ -248,18 +242,9 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Нет серверов и миров"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "«{name}» будет удалён из списка — в том числе в игре. Его невозможно восстановить."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "«{name}» ({address}) будет удалён из списка — в том числе в игре. Его невозможно восстановить."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Вы действительно хотите удалить {name}?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Поиск по {count, plural, one {# миру} other {# мирам}}..."
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "этот сервер"
|
||||
},
|
||||
@@ -272,9 +257,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Установить"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, one {# мод} few {# мода} other {# модов}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Необходимая сборка"
|
||||
},
|
||||
@@ -332,6 +314,141 @@
|
||||
"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} готова к установке! Перезапустите приложение, чтобы обновить его, или оно обновится автоматически после закрытия."
|
||||
},
|
||||
@@ -395,9 +512,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Добавить в друзья"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} {count, plural, one {запрос} few {запроса} other {запросов}} дружбы"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Отправить запрос дружбы"
|
||||
},
|
||||
@@ -491,9 +605,6 @@
|
||||
"instance.edit-world.title": {
|
||||
"message": "Настройка мира"
|
||||
},
|
||||
"instance.files.adding-files": {
|
||||
"message": "Добавление файлов ({completed, number}/{total, number})"
|
||||
},
|
||||
"instance.files.save-as": {
|
||||
"message": "Сохранить как..."
|
||||
},
|
||||
@@ -612,19 +723,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,6 +56,18 @@
|
||||
"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."
|
||||
},
|
||||
@@ -86,6 +98,9 @@
|
||||
"app.browse.server.installing": {
|
||||
"message": "Installerar"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Ange modpaketets beskrivning..."
|
||||
},
|
||||
@@ -95,6 +110,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -254,6 +272,60 @@
|
||||
"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."
|
||||
},
|
||||
@@ -620,6 +692,24 @@
|
||||
"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"
|
||||
},
|
||||
@@ -643,5 +733,8 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Loader tillhandahålls av servern"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Installera ändå"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,9 +260,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "ติดตั้ง"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, other {# ม็อด}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "มอดแพ็กที่จำเป็น"
|
||||
},
|
||||
@@ -374,9 +371,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "เพิ่มเพื่อน"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "คำขอเป็นเพื่อน {count} {count, plural, other {คน}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "ส่งคำขอเป็นเพื่อน"
|
||||
},
|
||||
|
||||
@@ -104,6 +104,12 @@
|
||||
"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"
|
||||
},
|
||||
@@ -116,6 +122,9 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Zaten ekli"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Kuruluma dön"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "İçerik keşfet"
|
||||
},
|
||||
@@ -128,6 +137,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -146,12 +158,18 @@
|
||||
"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ı"
|
||||
},
|
||||
@@ -281,6 +299,15 @@
|
||||
"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ı."
|
||||
},
|
||||
@@ -305,6 +332,138 @@
|
||||
"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."
|
||||
},
|
||||
@@ -368,9 +527,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Bir arkadaş ekle"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} arkadaşlık isteği"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Arkadaşlık isteği gönder"
|
||||
},
|
||||
@@ -584,6 +740,24 @@
|
||||
"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"
|
||||
},
|
||||
@@ -599,6 +773,9 @@
|
||||
"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,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Переглянути сервери"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "«{name}» буде **назавжди видалено** й у вас не буде можливости відновлення."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Ви впевнені, що хочете назавжди видалити цей світ?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Сервера або світи не додано"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "«{name}» буде видалено з вашого списку, включно в грі, і без можливости відновлення."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "«{name}» ({address}) буде видалено з вашого списку, включно в грі, і без можливости відновлення."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Ви впевнені, що хочете видалити «{name}»?"
|
||||
},
|
||||
@@ -272,9 +263,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Установити"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, one {# мод} few {# мода} other {# модів}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Потрібна збірка"
|
||||
},
|
||||
@@ -332,6 +320,138 @@
|
||||
"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."
|
||||
},
|
||||
@@ -395,9 +515,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "Додати друга"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} запит{count, plural, one { у} few {и в} many {ів у} other {у в}} друзі"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Надіслати запит у друзі"
|
||||
},
|
||||
@@ -723,10 +840,10 @@
|
||||
"message": "Додати обліковий запис"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Майнкрафт аккаунт"
|
||||
"message": "Обліковий запис Minecraft"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Не ввійдено в систему"
|
||||
"message": "Не авторизовано"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Видалити обліковий запис"
|
||||
@@ -735,7 +852,7 @@
|
||||
"message": "Обрати обліковий запис"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Увійти в Майнкрафт"
|
||||
"message": "Увійти в Minecraft"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Надано профілем"
|
||||
@@ -768,10 +885,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,6 +332,147 @@
|
||||
"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,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "浏览服务器"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "“{name}”将被**永久删除**,并且无法恢复。"
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "你确定要永久删除这个世界吗?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "未添加服务器或世界"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "将从你的列表中删除“{name}”,包括游戏内,并且无法恢复。"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "将从你的列表中删除“{name}”({address}),包括游戏内,并且无法恢复。"
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "你确定要删除 {name} 吗?"
|
||||
},
|
||||
@@ -272,9 +263,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "安装"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count} 个模组"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "需求整合包"
|
||||
},
|
||||
@@ -332,6 +320,141 @@
|
||||
"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} 更新已就绪!立即重启更新,或退出时自动安装。"
|
||||
},
|
||||
@@ -395,9 +518,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "添加好友"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} 个好友请求"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "发送好友请求"
|
||||
},
|
||||
@@ -611,6 +731,24 @@
|
||||
"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": "环境变量"
|
||||
},
|
||||
@@ -626,6 +764,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "内存分配"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "游戏窗口"
|
||||
},
|
||||
|
||||
@@ -224,9 +224,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "瀏覽伺服器"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "「{name}」將**永久刪除**,且無法還原。"
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "確定要永久刪除這個世界嗎?"
|
||||
},
|
||||
@@ -248,12 +245,6 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "未新增任何伺服器或世界"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "「{name}」將從你的清單中移除(包含遊戲內),且無法還原。"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "「{name}」({address}) 將從你的清單中移除(包含遊戲內),且無法還原。"
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "確定要移除「{name}」嗎?"
|
||||
},
|
||||
@@ -272,9 +263,6 @@
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "安裝"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count} 個模組"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "所需模組包"
|
||||
},
|
||||
@@ -395,9 +383,6 @@
|
||||
"friends.action.add-friend": {
|
||||
"message": "新增好友"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} 個好友邀請"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "送出好友邀請"
|
||||
},
|
||||
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
commonMessages,
|
||||
ConfirmModal,
|
||||
defineMessages,
|
||||
injectAuth,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
SkinPreviewRenderer,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { type DragDropEvent, getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
normalize_skin_texture,
|
||||
remove_custom_skin,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
@@ -53,11 +57,25 @@ type VirtualSkinSectionListExpose = {
|
||||
}
|
||||
|
||||
const PENDING_SKIN_REFRESH_DELAY_MS = 11_000
|
||||
const DEFAULT_SKIN_SECTION_SORT_ORDER = ['Default skins', 'Modrinth Pride']
|
||||
const messages = defineMessages({
|
||||
skinSelectorTitle: {
|
||||
id: 'app.skins.title',
|
||||
defaultMessage: 'Skin selector',
|
||||
},
|
||||
modrinthPrideSection: {
|
||||
id: 'app.skins.section.modrinth-pride',
|
||||
defaultMessage: 'Modrinth Pride',
|
||||
},
|
||||
modrinthPrideTooltip: {
|
||||
id: 'app.skins.section.modrinth-pride.tooltip',
|
||||
defaultMessage:
|
||||
'You received these skins for donating to a Modrinth Pride fundraiser during Pride Month.',
|
||||
},
|
||||
modrinthSection: {
|
||||
id: 'app.skins.section.modrinth',
|
||||
defaultMessage: 'Modrinth',
|
||||
},
|
||||
defaultSkinsSection: {
|
||||
id: 'app.skins.section.default-skins',
|
||||
defaultMessage: 'Default skins',
|
||||
@@ -157,6 +175,8 @@ const skinSectionList = useTemplateRef<VirtualSkinSectionListExpose>('skinSectio
|
||||
const { formatMessage } = useVIntl()
|
||||
const notifications = injectNotificationManager()
|
||||
const { addNotification, handleError } = notifications
|
||||
const auth = injectAuth()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const themeStore = useTheming()
|
||||
const skins = ref<Skin[]>([])
|
||||
@@ -180,22 +200,42 @@ const savedSkins = computed(() => {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
|
||||
const { data: modrinthUser } = useQuery({
|
||||
queryKey: computed(() => ['authenticated-user', 'campaigns', auth.user.value?.id]),
|
||||
queryFn: () => client.labrinth.users_v3.getAuthenticated(),
|
||||
enabled: () => !!auth.session_token.value,
|
||||
retry: false,
|
||||
})
|
||||
const hasModrinthPrideCampaign = computed(
|
||||
() => !!auth.session_token.value && hasPride26Badge(modrinthUser.value?.campaigns?.pride_26),
|
||||
)
|
||||
const defaultSkins = computed(() =>
|
||||
filterDefaultSkins(skins.value).filter(
|
||||
(skin) => skin.section !== 'Modrinth Pride' || hasModrinthPrideCampaign.value,
|
||||
),
|
||||
)
|
||||
const defaultSkinSections = computed(() => {
|
||||
const sections = new Map<string, Skin[]>()
|
||||
|
||||
for (const skin of defaultSkins.value) {
|
||||
const sectionTitle = getDefaultSkinSectionTitle(skin.section)
|
||||
const sectionSkins = sections.get(sectionTitle)
|
||||
const section = skin.section ?? 'Default skins'
|
||||
const sectionSkins = sections.get(section)
|
||||
|
||||
if (sectionSkins) {
|
||||
sectionSkins.push(skin)
|
||||
} else {
|
||||
sections.set(sectionTitle, [skin])
|
||||
sections.set(section, [skin])
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(sections, ([title, skins]) => ({ title, skins }))
|
||||
return Array.from(sections, ([section, skins]) => ({
|
||||
section,
|
||||
title: getDefaultSkinSectionTitle(section),
|
||||
infoTooltip: getDefaultSkinSectionInfoTooltip(section),
|
||||
skins,
|
||||
})).sort(
|
||||
(a, b) => getDefaultSkinSectionSortIndex(a.section) - getDefaultSkinSectionSortIndex(b.section),
|
||||
)
|
||||
})
|
||||
|
||||
const currentCape = computed(() => {
|
||||
@@ -303,6 +343,10 @@ function isMinecraftSkinRateLimitError(error: unknown) {
|
||||
|
||||
function getDefaultSkinSectionTitle(section?: string) {
|
||||
switch (section) {
|
||||
case 'Modrinth Pride':
|
||||
return formatMessage(messages.modrinthPrideSection)
|
||||
case 'Modrinth':
|
||||
return formatMessage(messages.modrinthSection)
|
||||
case 'MINECON Earth 2017':
|
||||
return formatMessage(messages.mineconEarth2017Section)
|
||||
case 'Builders & Biomes':
|
||||
@@ -326,6 +370,20 @@ function getDefaultSkinSectionTitle(section?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultSkinSectionInfoTooltip(section: string) {
|
||||
switch (section) {
|
||||
case 'Modrinth Pride':
|
||||
return formatMessage(messages.modrinthPrideTooltip)
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultSkinSectionSortIndex(section: string) {
|
||||
const index = DEFAULT_SKIN_SECTION_SORT_ORDER.indexOf(section)
|
||||
return index === -1 ? DEFAULT_SKIN_SECTION_SORT_ORDER.length : index
|
||||
}
|
||||
|
||||
function changeSkin(newSkin: Skin) {
|
||||
selectedSkin.value = newSkin
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
ServersManageAccessPage,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'users', 'v1', serverId],
|
||||
queryFn: () => client.archon.server_users_v1.list(serverId),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'v1', 'detail', serverId],
|
||||
queryFn: () => client.archon.servers_v1.get(serverId),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||
}
|
||||
|
||||
function userProfileLink(username: string) {
|
||||
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ServersManageAccessPage :user-profile-link="userProfileLink" />
|
||||
</template>
|
||||
@@ -1,7 +1,8 @@
|
||||
import Access from './Access.vue'
|
||||
import Backups from './Backups.vue'
|
||||
import Content from './Content.vue'
|
||||
import Files from './Files.vue'
|
||||
import Index from './Index.vue'
|
||||
import Overview from './Overview.vue'
|
||||
|
||||
export { Backups, Content, Files, Index, Overview }
|
||||
export { Access, Backups, Content, Files, Index, Overview }
|
||||
|
||||
@@ -32,14 +32,16 @@
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</div>
|
||||
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
<template v-if="showInstancePlayTime">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<template v-else>
|
||||
@@ -326,7 +328,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 } from '@/store/state'
|
||||
import { useBreadcrumbs, useTheming } from '@/store/state'
|
||||
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(relativeTime)
|
||||
@@ -338,6 +340,8 @@ 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,6 +297,58 @@ 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({
|
||||
@@ -543,16 +595,14 @@ async function handleUpdate(id: string) {
|
||||
})
|
||||
contentUpdaterModal.value?.show(initialVersionId)
|
||||
|
||||
const versions = (await get_project_versions(item.project.id).catch((e) => {
|
||||
return handleError(e)
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
const versions = await getUpdaterProjectVersions(item.project.id, initialVersionId)
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
|
||||
return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
if (!versions) {
|
||||
if (versions.length === 0) {
|
||||
debug('handleUpdate: no versions returned', { projectId: item.project.id })
|
||||
return
|
||||
}
|
||||
@@ -571,9 +621,6 @@ 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', {
|
||||
@@ -611,23 +658,16 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(item.version.id, { switchMode: true })
|
||||
const initialVersionId = item.version.id
|
||||
contentUpdaterModal.value?.show(initialVersionId, { switchMode: true })
|
||||
|
||||
const versions = (await get_project_versions(item.project.id).catch((e) => {
|
||||
return handleError(e)
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
const versions = await getUpdaterProjectVersions(item.project.id, initialVersionId)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -713,19 +753,17 @@ async function handleModpackUpdate() {
|
||||
})
|
||||
contentUpdaterModal.value?.show(initialVersionId)
|
||||
|
||||
const versions = (await get_project_versions(props.instance.linked_data.project_id).catch(
|
||||
handleError,
|
||||
)) as Labrinth.Versions.v2.Version[] | null
|
||||
const versions = await getUpdaterProjectVersions(
|
||||
props.instance.linked_data.project_id,
|
||||
initialVersionId,
|
||||
)
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
if (!versions) return
|
||||
if (versions.length === 0) 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', {
|
||||
@@ -760,12 +798,7 @@ async function fetchAndSpliceVersion(
|
||||
)) as Labrinth.Versions.v2.Version | null
|
||||
if (!isActiveUpdateRequest(requestId)) return
|
||||
if (!fullVersion) return
|
||||
const index = updatingProjectVersions.value.findIndex((v) => v.id === versionId)
|
||||
if (index !== -1) {
|
||||
const newVersions = [...updatingProjectVersions.value]
|
||||
newVersions[index] = fullVersion
|
||||
updatingProjectVersions.value = newVersions
|
||||
}
|
||||
updatingProjectVersions.value = mergeVersionIntoList(updatingProjectVersions.value, fullVersion)
|
||||
}
|
||||
|
||||
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ContentInstallInstance, ContentInstallProjectInfo, ContentItem } from '@modrinth/ui'
|
||||
import { createContext } from '@modrinth/ui'
|
||||
import { createContext, defineMessage, useVIntl } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from '@/store/install.js'
|
||||
|
||||
interface ModalRef {
|
||||
show: () => void
|
||||
show: (initialVersionId?: string) => void
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
@@ -42,19 +42,14 @@ interface ModpackAlreadyInstalledModalRef {
|
||||
show: (instanceName: string, instancePath: string) => void
|
||||
}
|
||||
|
||||
interface IncompatibilityWarningModalRef {
|
||||
show: (
|
||||
instance: GameInstance,
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
version: Labrinth.Versions.v2.Version,
|
||||
callback: (versionId?: string) => void,
|
||||
) => void
|
||||
}
|
||||
|
||||
const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge']
|
||||
const SUPPORTED_LOADERS: Set<string> = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge'])
|
||||
const VANILLA_COMPATIBLE_LOADERS: Set<string> = new Set(['minecraft', 'datapack'])
|
||||
const noCompatibleVersionsMessage = defineMessage({
|
||||
id: 'app.content-install.no-compatible-versions',
|
||||
defaultMessage:
|
||||
'No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically.',
|
||||
})
|
||||
|
||||
function sortLoaders(loaders: string[]): string[] {
|
||||
return loaders.slice().sort((a, b) => {
|
||||
@@ -91,7 +86,17 @@ export interface ContentInstallContext {
|
||||
setModpackAlreadyInstalledModal: (ref: ModpackAlreadyInstalledModalRef) => void
|
||||
handleModpackDuplicateCreateAnyway: () => Promise<void>
|
||||
handleModpackDuplicateGoToInstance: (instancePath: string) => void
|
||||
setIncompatibilityWarningModal: (ref: IncompatibilityWarningModalRef) => void
|
||||
setIncompatibilityWarningModal: (ref: ModalRef) => void
|
||||
incompatibilityWarningVersions: Ref<Labrinth.Versions.v2.Version[]>
|
||||
incompatibilityWarningCurrentGameVersion: Ref<string>
|
||||
incompatibilityWarningCurrentLoader: Ref<string>
|
||||
incompatibilityWarningProjectType: Ref<string | undefined>
|
||||
incompatibilityWarningProjectIconUrl: Ref<string | undefined>
|
||||
incompatibilityWarningProjectName: Ref<string | undefined>
|
||||
incompatibilityWarningMessage: Ref<string | undefined>
|
||||
incompatibilityWarningInstalling: Ref<boolean>
|
||||
handleIncompatibilityWarningInstall: (version: Labrinth.Versions.v2.Version) => Promise<void>
|
||||
handleIncompatibilityWarningCancel: () => void
|
||||
install: (
|
||||
projectId: string,
|
||||
versionId?: string | null,
|
||||
@@ -113,6 +118,7 @@ export function createContentInstall(opts: {
|
||||
router: Router
|
||||
handleError: (err: unknown) => void
|
||||
}): ContentInstallContext {
|
||||
const { formatMessage } = useVIntl()
|
||||
const instances = ref<ContentInstallInstance[]>([])
|
||||
const compatibleLoaders = ref<string[]>([])
|
||||
const gameVersions = ref<string[]>([])
|
||||
@@ -124,6 +130,14 @@ export function createContentInstall(opts: {
|
||||
|
||||
const projectInfo = ref<ContentInstallProjectInfo | null>(null)
|
||||
const installingItems = ref<Map<string, ContentItem[]>>(new Map())
|
||||
const incompatibilityWarningVersions = ref<Labrinth.Versions.v2.Version[]>([])
|
||||
const incompatibilityWarningCurrentGameVersion = ref('')
|
||||
const incompatibilityWarningCurrentLoader = ref('')
|
||||
const incompatibilityWarningProjectType = ref<string | undefined>(undefined)
|
||||
const incompatibilityWarningProjectIconUrl = ref<string | undefined>(undefined)
|
||||
const incompatibilityWarningProjectName = ref<string | undefined>(undefined)
|
||||
const incompatibilityWarningMessage = ref<string | undefined>(undefined)
|
||||
const incompatibilityWarningInstalling = ref(false)
|
||||
|
||||
function addInstallingItem(
|
||||
instancePath: string,
|
||||
@@ -239,11 +253,15 @@ export function createContentInstall(opts: {
|
||||
|
||||
let modalRef: ModalRef | null = null
|
||||
let modpackAlreadyInstalledModalRef: ModpackAlreadyInstalledModalRef | null = null
|
||||
let incompatibilityWarningModalRef: IncompatibilityWarningModalRef | null = null
|
||||
let incompatibilityWarningModalRef: ModalRef | null = null
|
||||
let currentProject: Labrinth.Projects.v2.Project | null = null
|
||||
let currentVersions: Labrinth.Versions.v2.Version[] = []
|
||||
let currentCallback: (versionId?: string) => void = () => {}
|
||||
let profileMap: Record<string, GameInstance> = {}
|
||||
let incompatibilityWarningInstance: GameInstance | null = null
|
||||
let incompatibilityWarningProject: Labrinth.Projects.v2.Project | null = null
|
||||
let incompatibilityWarningCallback: (versionId?: string) => void = () => {}
|
||||
let incompatibilityWarningInstalled = false
|
||||
|
||||
let pendingModpackInstall: {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
@@ -410,15 +428,35 @@ export function createContentInstall(opts: {
|
||||
async function handleInstallToInstance(instance: ContentInstallInstance) {
|
||||
const profile = profileMap[instance.id]
|
||||
const storeInstance = instances.value.find((i) => i.id === instance.id)
|
||||
if (storeInstance) storeInstance.installing = true
|
||||
if (!currentProject || !profile) {
|
||||
opts.handleError('No project or instance found')
|
||||
return
|
||||
}
|
||||
|
||||
const version = findPreferredVersion(currentVersions, currentProject, profile)
|
||||
if (!version) {
|
||||
if (storeInstance) storeInstance.installing = false
|
||||
opts.handleError('No compatible version found')
|
||||
if (currentVersions.length > 0 && incompatibilityWarningModalRef) {
|
||||
const onIncompatibleInstall = (versionId?: string) => {
|
||||
if (versionId && storeInstance) {
|
||||
storeInstance.installed = true
|
||||
}
|
||||
currentCallback(versionId)
|
||||
}
|
||||
await showIncompatibilityWarning(
|
||||
profile,
|
||||
currentProject,
|
||||
currentVersions,
|
||||
currentVersions[0],
|
||||
onIncompatibleInstall,
|
||||
)
|
||||
} else {
|
||||
opts.handleError('No version found')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (storeInstance) storeInstance.installing = true
|
||||
|
||||
const installedProjectIds: string[] = []
|
||||
if (currentProject) {
|
||||
addInstallingItem(instance.id, currentProject, version)
|
||||
@@ -458,6 +496,73 @@ export function createContentInstall(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
async function showIncompatibilityWarning(
|
||||
instance: GameInstance,
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
version: Labrinth.Versions.v2.Version,
|
||||
callback: (versionId?: string) => void,
|
||||
) {
|
||||
incompatibilityWarningInstance = instance
|
||||
incompatibilityWarningProject = project
|
||||
incompatibilityWarningCallback = callback
|
||||
incompatibilityWarningInstalled = false
|
||||
incompatibilityWarningInstalling.value = false
|
||||
incompatibilityWarningVersions.value = versions
|
||||
incompatibilityWarningCurrentGameVersion.value = instance.game_version ?? ''
|
||||
incompatibilityWarningCurrentLoader.value = instance.loader ?? ''
|
||||
incompatibilityWarningProjectType.value = project.project_type
|
||||
incompatibilityWarningProjectIconUrl.value = project.icon_url ?? undefined
|
||||
incompatibilityWarningProjectName.value = project.title
|
||||
|
||||
const compatibilityLabel =
|
||||
project.project_type === 'resourcepack' || project.project_type === 'datapack'
|
||||
? (instance.game_version ?? '')
|
||||
: `${instance.loader ?? ''} ${instance.game_version ?? ''}`.trim()
|
||||
incompatibilityWarningMessage.value = formatMessage(noCompatibleVersionsMessage, {
|
||||
compatibilityLabel,
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
incompatibilityWarningModalRef?.show(version.id)
|
||||
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
|
||||
}
|
||||
|
||||
async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) {
|
||||
if (!incompatibilityWarningInstance || !incompatibilityWarningProject) return
|
||||
|
||||
incompatibilityWarningInstalling.value = true
|
||||
try {
|
||||
await add_project_from_version(incompatibilityWarningInstance.path, version.id, 'standalone')
|
||||
} catch (err) {
|
||||
opts.handleError(err)
|
||||
incompatibilityWarningInstalling.value = false
|
||||
return
|
||||
}
|
||||
|
||||
incompatibilityWarningInstalling.value = false
|
||||
incompatibilityWarningInstalled = true
|
||||
incompatibilityWarningCallback(version.id)
|
||||
incompatibilityWarningModalRef?.hide()
|
||||
|
||||
trackEvent('ProjectInstall', {
|
||||
loader: incompatibilityWarningInstance.loader,
|
||||
game_version: incompatibilityWarningInstance.game_version,
|
||||
id: incompatibilityWarningProject.id,
|
||||
version_id: version.id,
|
||||
project_type: incompatibilityWarningProject.project_type,
|
||||
title: incompatibilityWarningProject.title,
|
||||
source: 'ProjectIncompatibilityWarningModal',
|
||||
})
|
||||
}
|
||||
|
||||
function handleIncompatibilityWarningCancel() {
|
||||
if (!incompatibilityWarningInstalled) {
|
||||
incompatibilityWarningCallback()
|
||||
}
|
||||
incompatibilityWarningInstalled = false
|
||||
}
|
||||
|
||||
async function handleCreateAndInstall(data: {
|
||||
name: string
|
||||
iconPath: string | null
|
||||
@@ -614,7 +719,7 @@ export function createContentInstall(opts: {
|
||||
removeInstallingItems(instancePath, installedProjectIds)
|
||||
}
|
||||
} else {
|
||||
incompatibilityWarningModalRef?.show(instance, project, projectVersions, version, callback)
|
||||
await showIncompatibilityWarning(instance, project, projectVersions, version, callback)
|
||||
}
|
||||
} else {
|
||||
let versions = (
|
||||
@@ -668,9 +773,19 @@ export function createContentInstall(opts: {
|
||||
pendingModpackInstall = null
|
||||
opts.router.push(`/instance/${encodeURIComponent(instancePath)}`)
|
||||
},
|
||||
setIncompatibilityWarningModal(ref: IncompatibilityWarningModalRef) {
|
||||
setIncompatibilityWarningModal(ref: ModalRef) {
|
||||
incompatibilityWarningModalRef = ref
|
||||
},
|
||||
incompatibilityWarningVersions,
|
||||
incompatibilityWarningCurrentGameVersion,
|
||||
incompatibilityWarningCurrentLoader,
|
||||
incompatibilityWarningProjectType,
|
||||
incompatibilityWarningProjectIconUrl,
|
||||
incompatibilityWarningProjectName,
|
||||
incompatibilityWarningMessage,
|
||||
incompatibilityWarningInstalling,
|
||||
handleIncompatibilityWarningInstall,
|
||||
handleIncompatibilityWarningCancel,
|
||||
install,
|
||||
installingItems,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { type AuthProvider, provideAuth } from '@modrinth/ui'
|
||||
import { type AuthProvider, type AuthUser, 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<Labrinth.Users.v2.User | null>(null)
|
||||
const user = ref<AuthUser | 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() {
|
||||
async pickModpackFile(options) {
|
||||
const result = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: 'Modpack', extensions: ['mrpack'] }],
|
||||
@@ -34,12 +34,19 @@ export function setupFilePickerProvider() {
|
||||
if (!result) return null
|
||||
const path = result.path ?? result
|
||||
if (!path) return null
|
||||
const file = await createFileFromPath(
|
||||
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',
|
||||
),
|
||||
path,
|
||||
'modpack.mrpack',
|
||||
'application/x-modrinth-modpack+zip',
|
||||
)
|
||||
return { file, path, previewUrl: '' }
|
||||
previewUrl: '',
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +73,14 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'access',
|
||||
name: 'ServerManageAccess',
|
||||
component: Hosting.Access,
|
||||
meta: {
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,7 +9,9 @@ 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
|
||||
|
||||
@@ -58,6 +58,9 @@ export default defineConfig({
|
||||
params: {
|
||||
overrides: {
|
||||
removeViewBox: false,
|
||||
cleanupIds: {
|
||||
minify: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -42,6 +42,12 @@
|
||||
{
|
||||
"url": "https://api.purpurmc.org/*"
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/*"
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:8000/*"
|
||||
},
|
||||
{
|
||||
"url": "http://*.taila228c5.ts.net/*"
|
||||
},
|
||||
|
||||
@@ -40,4 +40,3 @@ These composables are deprecated and should not be used in new code:
|
||||
|
||||
- **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility.
|
||||
- **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead.
|
||||
- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information.
|
||||
|
||||
@@ -75,10 +75,10 @@ export default defineNuxtConfig({
|
||||
},
|
||||
ssr: {
|
||||
// https://github.com/Akryum/floating-vue/issues/809#issuecomment-1002996240
|
||||
noExternal: ['v-tooltip'],
|
||||
optimizeDeps: {
|
||||
include: ['vue-router'],
|
||||
},
|
||||
noExternal: ['floating-vue', '@floating-ui/core', '@floating-ui/dom'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['vue-router', 'floating-vue', '@floating-ui/dom'],
|
||||
},
|
||||
define: {
|
||||
global: {},
|
||||
@@ -104,6 +104,9 @@ export default defineNuxtConfig({
|
||||
params: {
|
||||
overrides: {
|
||||
removeViewBox: false,
|
||||
cleanupIds: {
|
||||
minify: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -125,8 +128,6 @@ export default defineNuxtConfig({
|
||||
const docTemplates = Object.keys(
|
||||
await import('./src/templates/docs/index.ts').then((m) => m.default),
|
||||
)
|
||||
const blogArticles = await import('@modrinth/blog').then((m) => m.articles)
|
||||
const { getChangelog } = await import('@modrinth/blog')
|
||||
|
||||
nitroConfig.prerender = nitroConfig.prerender || {}
|
||||
nitroConfig.prerender.routes = nitroConfig.prerender.routes || []
|
||||
@@ -136,15 +137,6 @@ export default defineNuxtConfig({
|
||||
for (const template of docTemplates) {
|
||||
nitroConfig.prerender.routes.push(`/_internal/templates/doc/${template}`)
|
||||
}
|
||||
nitroConfig.prerender.routes.push('/news')
|
||||
for (const article of blogArticles) {
|
||||
nitroConfig.prerender.routes.push(`/news/article/${article.slug}`)
|
||||
}
|
||||
nitroConfig.prerender.routes.push('/news/changelog')
|
||||
for (const entry of getChangelog()) {
|
||||
const id = entry.version ?? entry.date.unix()
|
||||
nitroConfig.prerender.routes.push(`/news/changelog/${entry.product}/${id}`)
|
||||
}
|
||||
},
|
||||
async 'build:before'() {
|
||||
// 30 minutes
|
||||
@@ -232,6 +224,7 @@ export default defineNuxtConfig({
|
||||
globalThis.INTERCOM_APP_ID ||
|
||||
'ykeritl9',
|
||||
production: isProduction(),
|
||||
cookieSecure: isProduction(),
|
||||
buildEnv: process.env.BUILD_ENV,
|
||||
preview: process.env.PREVIEW === 'true',
|
||||
featureFlagOverrides: getFeatureFlagOverrides(),
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"src/{components,composables,layouts,middleware,modules,pages,plugins,utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/frontend",
|
||||
"cf-deploy": "pnpm run build && wrangler deploy --env staging",
|
||||
"cf-dev": "pnpm run build && wrangler dev --env staging",
|
||||
"cf-typegen": "wrangler types"
|
||||
@@ -25,7 +26,7 @@
|
||||
"@types/semver": "^7.7.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"glob": "^10.2.7",
|
||||
"nuxt": "^3.20.2",
|
||||
"nuxt": "=3.20.2",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"sass": "^1.58.0",
|
||||
@@ -54,6 +55,7 @@
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"dayjs": "^1.11.7",
|
||||
"dompurify": "^3.1.7",
|
||||
"floating-vue": "^5.2.2",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/*
|
||||
Cards and body styling
|
||||
*/
|
||||
// CARDS
|
||||
.base-card {
|
||||
padding: var(--spacing-card-lg);
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
min-height: var(--font-size-2xl);
|
||||
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--size-rounded-card);
|
||||
background-color: var(--surface-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--surface-4);
|
||||
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
margin-bottom: var(--gap-md);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
|
||||
box-shadow: var(--shadow-card);
|
||||
|
||||
.card__overlay {
|
||||
position: absolute;
|
||||
@@ -25,6 +25,17 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:where(&.warning, &.information) {
|
||||
padding: 1.5rem;
|
||||
line-height: 1.5;
|
||||
min-height: 0;
|
||||
|
||||
a {
|
||||
color: var(--color-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.moderation-card {
|
||||
background-color: var(--color-warning-banner-bg);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
margin-top: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.normal-page__sidebar {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="analytics-loading-bar" :style="{ opacity: isVisible ? 1 : 0 }" aria-hidden="true">
|
||||
<div
|
||||
class="analytics-loading-bar__track"
|
||||
:style="{
|
||||
width: `${progress}%`,
|
||||
transition: !isTransitioning
|
||||
? 'none'
|
||||
: isFinishing
|
||||
? 'width 0.1s ease-in-out'
|
||||
: isCreeping
|
||||
? 'width 2s linear'
|
||||
: 'width 0.9s ease-in-out',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const progress = ref(0)
|
||||
const isVisible = ref(false)
|
||||
const isFinishing = ref(false)
|
||||
const isCreeping = ref(false)
|
||||
const isTransitioning = ref(false)
|
||||
|
||||
let startFrame: number | null = null
|
||||
let showFrame: number | null = null
|
||||
let creepTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let resetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (showFrame !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(showFrame)
|
||||
}
|
||||
if (startFrame !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(startFrame)
|
||||
}
|
||||
if (creepTimeout) clearTimeout(creepTimeout)
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
if (resetTimeout) clearTimeout(resetTimeout)
|
||||
showFrame = null
|
||||
startFrame = null
|
||||
creepTimeout = null
|
||||
hideTimeout = null
|
||||
resetTimeout = null
|
||||
}
|
||||
|
||||
function start() {
|
||||
clearTimers()
|
||||
isVisible.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = false
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
progress.value = 98
|
||||
return
|
||||
}
|
||||
|
||||
showFrame = window.requestAnimationFrame(() => {
|
||||
isVisible.value = true
|
||||
showFrame = null
|
||||
startFrame = window.requestAnimationFrame(() => {
|
||||
isTransitioning.value = true
|
||||
progress.value = 85
|
||||
startFrame = null
|
||||
})
|
||||
})
|
||||
creepTimeout = setTimeout(() => {
|
||||
isCreeping.value = true
|
||||
progress.value = 98
|
||||
creepTimeout = null
|
||||
}, 900)
|
||||
}
|
||||
|
||||
function finish() {
|
||||
clearTimers()
|
||||
isVisible.value = true
|
||||
isFinishing.value = true
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = true
|
||||
progress.value = 100
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
isVisible.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
isTransitioning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
hideTimeout = setTimeout(() => {
|
||||
isVisible.value = false
|
||||
resetTimeout = setTimeout(() => {
|
||||
isTransitioning.value = false
|
||||
progress.value = 0
|
||||
isFinishing.value = false
|
||||
isCreeping.value = false
|
||||
}, 400)
|
||||
}, 350)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading) => {
|
||||
if (loading) {
|
||||
start()
|
||||
} else if (
|
||||
isVisible.value ||
|
||||
progress.value > 0 ||
|
||||
showFrame !== null ||
|
||||
startFrame !== null ||
|
||||
creepTimeout !== null
|
||||
) {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(clearTimers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-loading-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.analytics-loading-bar__track {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--loading-bar-gradient);
|
||||
}
|
||||
</style>
|
||||
+1024
File diff suppressed because it is too large
Load Diff
+78
@@ -0,0 +1,78 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
export const ANALYTICS_DASHBOARD_STATS: readonly AnalyticsDashboardStat[] = [
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
|
||||
export const TOP_GRAPH_DATASET_LIMIT = 8
|
||||
export const GRAPH_RENDER_DATASET_LIMIT = 250
|
||||
export const PREVIOUS_PERIOD_DATASET_ID_PREFIX = 'previous-period:'
|
||||
export const PREVIOUS_PERIOD_BORDER_DASH = [6, 4]
|
||||
export const PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
export const ALL_PROJECTS_DATASET_ID = 'all'
|
||||
|
||||
export const PROJECT_EVENT_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
export const MONETIZATION_LEGEND_ENTRY_ORDER = new Map([
|
||||
['breakdown:monetized', 0],
|
||||
['breakdown:unmonetized', 1],
|
||||
])
|
||||
|
||||
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES = [
|
||||
'approved',
|
||||
'unlisted',
|
||||
'private',
|
||||
] as const satisfies readonly Labrinth.Projects.v2.ProjectStatus[]
|
||||
|
||||
export type VisibleProjectStatusChangeEventStatus =
|
||||
(typeof VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)[number]
|
||||
|
||||
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET =
|
||||
new Set<Labrinth.Projects.v2.ProjectStatus>(VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)
|
||||
|
||||
export const LIGHT_LEGEND_PALETTE = [
|
||||
'hsl(152, 100%, 34%)',
|
||||
'hsl(26, 100%, 42%)',
|
||||
'hsl(202, 100%, 35%)',
|
||||
'hsl(327, 45%, 64%)',
|
||||
'hsl(41, 100%, 45%)',
|
||||
'hsl(250, 60%, 33%)',
|
||||
'hsl(170, 43%, 47%)',
|
||||
'hsl(330, 60%, 33%)',
|
||||
'hsl(46, 100%, 36%)',
|
||||
'hsl(167, 100%, 30%)',
|
||||
'hsl(343, 38%, 45%)',
|
||||
'hsl(222, 100%, 28%)',
|
||||
'hsl(270, 62%, 60%)',
|
||||
'hsl(32, 100%, 37%)',
|
||||
'hsl(349, 57%, 51%)',
|
||||
'hsl(191, 43%, 37%)',
|
||||
]
|
||||
|
||||
export const DARK_LEGEND_PALETTE = [
|
||||
'hsl(145, 78%, 48%)',
|
||||
'hsl(41, 100%, 50%)',
|
||||
'hsl(202, 77%, 63%)',
|
||||
'hsl(323, 66%, 72%)',
|
||||
'hsl(56, 85%, 60%)',
|
||||
'hsl(255, 92%, 80%)',
|
||||
'hsl(12, 100%, 67%)',
|
||||
'hsl(176, 58%, 56%)',
|
||||
'hsl(60, 100%, 41%)',
|
||||
'hsl(165, 80%, 38%)',
|
||||
'hsl(341, 36%, 56%)',
|
||||
'hsl(226, 60%, 49%)',
|
||||
'hsl(252, 53%, 62%)',
|
||||
'hsl(75, 59%, 50%)',
|
||||
'hsl(195, 56%, 42%)',
|
||||
'hsl(30, 59%, 56%)',
|
||||
]
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<Menu
|
||||
theme="analytics-controls-menu"
|
||||
placement="bottom-end"
|
||||
:shown="isControlsMenuOpen"
|
||||
:triggers="[]"
|
||||
:popper-triggers="[]"
|
||||
:aria-id="controlsMenuId"
|
||||
no-auto-focus
|
||||
@update:shown="isControlsMenuOpen = $event"
|
||||
>
|
||||
<button
|
||||
ref="controlsMenuTrigger"
|
||||
type="button"
|
||||
:aria-expanded="isControlsMenuOpen"
|
||||
:aria-controls="controlsMenuId"
|
||||
:aria-label="
|
||||
formatMessage(analyticsChartMessages.controlsAria, {
|
||||
activeCount: activeControlCountLabel,
|
||||
})
|
||||
"
|
||||
class="btn-dropdown-animation inline-flex min-h-5 cursor-pointer items-center justify-between gap-2 rounded-xl border-0 bg-surface-4 px-3 py-2 text-left text-sm font-semibold text-button-text shadow-none transition-all duration-200 hover:brightness-[115%] focus-visible:brightness-[115%] active:brightness-[115%]"
|
||||
@click="toggleControlsMenu"
|
||||
>
|
||||
<Settings2Icon class="size-4 text-secondary" aria-hidden="true" />
|
||||
<span class="leading-tight text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.controlsButton) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="activeControlCount > 0"
|
||||
class="inline-flex min-w-5 items-center justify-center rounded-full bg-highlight-green px-1.5 text-xs font-semibold leading-5 text-green"
|
||||
>
|
||||
{{ activeControlCount }}
|
||||
</span>
|
||||
<DropdownIcon class="size-4 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<div
|
||||
ref="controlsMenuPanel"
|
||||
role="dialog"
|
||||
:aria-label="formatMessage(analyticsChartMessages.controlsDialogAria)"
|
||||
class="mt-1 flex w-[228px] max-w-[calc(100vw_-_2rem)] flex-col overflow-hidden rounded-[14px] border border-solid border-surface-4 bg-surface-3 text-sm shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-2.5 text-xs font-medium">
|
||||
<span class="font-semibold text-primary">{{ activeControlCountLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isResetDisabled"
|
||||
class="border-0 bg-transparent p-0 text-xs font-semibold text-primary transition-all disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="isResetDisabled ? '' : 'hover:text-contrast focus-visible:text-contrast'"
|
||||
@click="resetControls"
|
||||
>
|
||||
{{ formatMessage(analyticsMessages.resetButton) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasDisplayControls"
|
||||
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
|
||||
>
|
||||
<div class="mb-0.5 text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.displayControls) }}
|
||||
</div>
|
||||
<div v-if="canShowPreviousPeriod" class="flex min-h-7 items-center justify-between">
|
||||
<label
|
||||
:for="previousPeriodToggleId"
|
||||
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
>
|
||||
<HistoryIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.previousPeriod) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="previousPeriodToggleId"
|
||||
v-model="showPreviousPeriodModel"
|
||||
:small="smallToggles"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="canUseRatioMode" class="flex min-h-7 items-center justify-between">
|
||||
<label
|
||||
:for="ratioModeToggleId"
|
||||
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-4 shrink-0 items-center justify-center text-sm font-semibold leading-none text-secondary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.ratio) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle :id="ratioModeToggleId" v-model="ratioModeModel" :small="smallToggles" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
|
||||
>
|
||||
<div class="mb-0.5 text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.annotations) }}
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="projectEventsDisabledTooltip"
|
||||
class="justify3 flex min-h-7 items-center"
|
||||
:aria-disabled="!hasProjectEvents"
|
||||
>
|
||||
<label
|
||||
:for="projectEventsToggleId"
|
||||
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
:class="hasProjectEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
>
|
||||
<TagCategoryFlagIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.projectEvents) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="projectEventsToggleId"
|
||||
v-model="showProjectEventsControlModel"
|
||||
:small="smallToggles"
|
||||
:disabled="!hasProjectEvents"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="modrinthEventsDisabledTooltip"
|
||||
class="justify3 flex min-h-7 items-center"
|
||||
:aria-disabled="!hasChartEvents"
|
||||
>
|
||||
<label
|
||||
:for="modrinthEventsToggleId"
|
||||
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
|
||||
:class="hasChartEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
>
|
||||
<InfoIcon class="size-4 shrink-0 text-blue" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(analyticsChartMessages.modrinthEvents) }}
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="modrinthEventsToggleId"
|
||||
v-model="showChartEventsControlModel"
|
||||
:small="smallToggles"
|
||||
:disabled="!hasChartEvents"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownIcon,
|
||||
HistoryIcon,
|
||||
InfoIcon,
|
||||
Settings2Icon,
|
||||
TagCategoryFlagIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { Menu } from 'floating-vue'
|
||||
|
||||
import { analyticsChartMessages, analyticsMessages } from '../../analytics-messages'
|
||||
|
||||
const props = defineProps<{
|
||||
ratioMode: boolean
|
||||
showChartEvents: boolean
|
||||
showProjectEvents: boolean
|
||||
showPreviousPeriod: boolean
|
||||
canUseRatioMode: boolean
|
||||
canShowPreviousPeriod: boolean
|
||||
hasChartEvents: boolean
|
||||
hasProjectEvents: boolean
|
||||
smallToggles: boolean
|
||||
defaultRatioMode: boolean
|
||||
defaultShowChartEvents: boolean
|
||||
defaultShowProjectEvents: boolean
|
||||
defaultShowPreviousPeriod: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e:
|
||||
| 'update:ratioMode'
|
||||
| 'update:showChartEvents'
|
||||
| 'update:showProjectEvents'
|
||||
| 'update:showPreviousPeriod',
|
||||
value: boolean,
|
||||
): void
|
||||
}>()
|
||||
|
||||
const isControlsMenuOpen = ref(false)
|
||||
const controlsMenuTrigger = ref<HTMLElement | null>(null)
|
||||
const controlsMenuPanel = ref<HTMLElement | null>(null)
|
||||
const controlsMenuId = useId()
|
||||
const ratioModeToggleId = useId()
|
||||
const previousPeriodToggleId = useId()
|
||||
const modrinthEventsToggleId = useId()
|
||||
const projectEventsToggleId = useId()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const ratioModeModel = computed({
|
||||
get: () => props.ratioMode,
|
||||
set: (value: boolean) => emit('update:ratioMode', value),
|
||||
})
|
||||
const showChartEventsModel = computed({
|
||||
get: () => props.showChartEvents,
|
||||
set: (value: boolean) => emit('update:showChartEvents', value),
|
||||
})
|
||||
const showChartEventsControlModel = computed({
|
||||
get: () => props.hasChartEvents && props.showChartEvents,
|
||||
set: (value: boolean) => emit('update:showChartEvents', value),
|
||||
})
|
||||
const showProjectEventsModel = computed({
|
||||
get: () => props.showProjectEvents,
|
||||
set: (value: boolean) => emit('update:showProjectEvents', value),
|
||||
})
|
||||
const showProjectEventsControlModel = computed({
|
||||
get: () => props.hasProjectEvents && props.showProjectEvents,
|
||||
set: (value: boolean) => emit('update:showProjectEvents', value),
|
||||
})
|
||||
const showPreviousPeriodModel = computed({
|
||||
get: () => props.showPreviousPeriod,
|
||||
set: (value: boolean) => emit('update:showPreviousPeriod', value),
|
||||
})
|
||||
|
||||
const hasDisplayControls = computed(() => props.canShowPreviousPeriod || props.canUseRatioMode)
|
||||
const projectEventsDisabledTooltip = computed(() =>
|
||||
props.hasProjectEvents ? undefined : formatMessage(analyticsChartMessages.noProjectEvents),
|
||||
)
|
||||
const modrinthEventsDisabledTooltip = computed(() =>
|
||||
props.hasChartEvents ? undefined : formatMessage(analyticsChartMessages.noModrinthEvents),
|
||||
)
|
||||
const activeControlCount = computed(() => {
|
||||
let count = 0
|
||||
if (props.canShowPreviousPeriod && props.showPreviousPeriod) count += 1
|
||||
if (props.canUseRatioMode && props.ratioMode) count += 1
|
||||
if (props.hasProjectEvents && props.showProjectEvents) count += 1
|
||||
if (props.hasChartEvents && props.showChartEvents) count += 1
|
||||
return count
|
||||
})
|
||||
const activeControlCountLabel = computed(() =>
|
||||
formatMessage(analyticsChartMessages.activeControlCount, { count: activeControlCount.value }),
|
||||
)
|
||||
const isResetDisabled = computed(
|
||||
() =>
|
||||
props.showPreviousPeriod === props.defaultShowPreviousPeriod &&
|
||||
props.ratioMode === props.defaultRatioMode &&
|
||||
props.showProjectEvents === props.defaultShowProjectEvents &&
|
||||
props.showChartEvents === props.defaultShowChartEvents,
|
||||
)
|
||||
|
||||
function toggleControlsMenu() {
|
||||
isControlsMenuOpen.value = !isControlsMenuOpen.value
|
||||
}
|
||||
|
||||
function resetControls() {
|
||||
if (isResetDisabled.value) return
|
||||
|
||||
showPreviousPeriodModel.value = props.defaultShowPreviousPeriod
|
||||
ratioModeModel.value = props.defaultRatioMode
|
||||
showProjectEventsModel.value = props.defaultShowProjectEvents
|
||||
showChartEventsModel.value = props.defaultShowChartEvents
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (!isControlsMenuOpen.value || !(event.target instanceof Node)) return
|
||||
if (controlsMenuTrigger.value?.contains(event.target)) return
|
||||
if (controlsMenuPanel.value?.contains(event.target)) return
|
||||
isControlsMenuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.v-popper--theme-analytics-controls-menu .v-popper__inner {
|
||||
overflow: visible !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.v-popper--theme-analytics-controls-menu .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-5"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-5"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showLegendTopFade"
|
||||
class="z-1 pointer-events-none absolute left-0 right-0 top-0 h-5 bg-gradient-to-b from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
ref="legendContainer"
|
||||
class="flex max-h-[130px] flex-wrap items-center gap-y-1 overflow-y-auto px-3"
|
||||
@scroll="checkLegendScrollState"
|
||||
>
|
||||
<div
|
||||
v-for="legendEntry in legendEntries"
|
||||
:key="legendEntry.id"
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<button
|
||||
v-tooltip="legendEntry.projectName ?? ''"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-sm !outline-0 transition-all focus-within:!outline-0 focus:!outline-0 focus-visible:!outline-0"
|
||||
:class="[
|
||||
legendEntry.hidden ? 'text-secondary opacity-70' : 'text-primary',
|
||||
isLegendEntryToggleDisabled(legendEntry) && !isShiftKeyPressed
|
||||
? 'cursor-default'
|
||||
: 'cursor-pointer hover:brightness-125',
|
||||
]"
|
||||
:aria-pressed="!legendEntry.hidden"
|
||||
@mouseenter="emit('entry-hover', legendEntry.id)"
|
||||
@mouseleave="emit('entry-hover-clear', legendEntry.id)"
|
||||
@focus="emit('entry-hover', legendEntry.id)"
|
||||
@blur="emit('entry-hover-clear', legendEntry.id)"
|
||||
@click="emit('entry-click', $event, legendEntry.id)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
legendEntry.isPreviousPeriod
|
||||
? 'h-0 w-2 rounded-none border-0 border-t-2 border-dashed bg-transparent'
|
||||
: 'size-2 rounded-full'
|
||||
"
|
||||
:style="
|
||||
legendEntry.isPreviousPeriod
|
||||
? { borderColor: legendEntry.color }
|
||||
: { backgroundColor: legendEntry.color }
|
||||
"
|
||||
/>
|
||||
<span
|
||||
:class="{
|
||||
'line-through': legendEntry.hidden,
|
||||
capitalize: shouldCapitalizeDatasetLabels,
|
||||
}"
|
||||
>
|
||||
{{ legendEntry.name }}
|
||||
</span>
|
||||
</button>
|
||||
<Dropdown
|
||||
v-if="showUnmonetizedInfo && legendEntry.id === 'breakdown:unmonetized'"
|
||||
theme="analytics-monetization-popover"
|
||||
:triggers="['hover', 'focus']"
|
||||
:popper-triggers="['hover', 'focus']"
|
||||
:delay="{ show: 0, hide: 250 }"
|
||||
placement="top"
|
||||
:aria-id="monetizationPopoverId"
|
||||
no-auto-focus
|
||||
>
|
||||
<InfoIcon
|
||||
class="-ml-1 mt-px inline-flex size-4 items-center justify-center rounded-full border-0 bg-transparent p-0 text-secondary transition-all hover:text-contrast focus-visible:text-contrast"
|
||||
:aria-label="formatMessage(analyticsChartMessages.viewMonetizedAnalyticsDetails)"
|
||||
/>
|
||||
<template #popper>
|
||||
<div
|
||||
role="dialog"
|
||||
:aria-label="formatMessage(analyticsChartMessages.monetizedAnalyticsDetails)"
|
||||
class="font-base w-[292px] rounded-xl border border-solid border-surface-5 bg-surface-3 p-3 text-sm leading-snug shadow-2xl"
|
||||
>
|
||||
{{ formatMessage(analyticsChartMessages.monetizedAnalyticsDetailsDescription) }}
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-5"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-5"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showLegendBottomFade"
|
||||
class="z-1 pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { InfoIcon } from '@modrinth/assets'
|
||||
import { useScrollIndicator, useVIntl } from '@modrinth/ui'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages'
|
||||
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types'
|
||||
|
||||
const props = defineProps<{
|
||||
legendEntries: AnalyticsChartLegendEntry[]
|
||||
shouldCapitalizeDatasetLabels: boolean
|
||||
showUnmonetizedInfo: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'entry-hover': [datasetId: string]
|
||||
'entry-hover-clear': [datasetId: string]
|
||||
'entry-click': [event: MouseEvent, datasetId: string]
|
||||
}>()
|
||||
|
||||
const monetizationPopoverId = useId()
|
||||
const legendContainer = ref<HTMLElement | null>(null)
|
||||
const isShiftKeyPressed = ref(false)
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
showTopFade: showLegendTopFade,
|
||||
showBottomFade: showLegendBottomFade,
|
||||
checkScrollState: checkLegendScrollState,
|
||||
forceCheck: forceCheckLegendScrollState,
|
||||
} = useScrollIndicator(legendContainer)
|
||||
|
||||
function updateShiftKeyState(event: KeyboardEvent) {
|
||||
isShiftKeyPressed.value = event.shiftKey
|
||||
}
|
||||
|
||||
function clearShiftKeyState() {
|
||||
isShiftKeyPressed.value = false
|
||||
}
|
||||
|
||||
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
|
||||
if (legendEntry.hidden) return false
|
||||
const visibleCount = props.legendEntries.filter((entry) => !entry.hidden).length
|
||||
return visibleCount <= 1
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.legendEntries,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
forceCheckLegendScrollState()
|
||||
})
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', updateShiftKeyState)
|
||||
window.addEventListener('keyup', updateShiftKeyState)
|
||||
window.addEventListener('blur', clearShiftKeyState)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', updateShiftKeyState)
|
||||
window.removeEventListener('keyup', updateShiftKeyState)
|
||||
window.removeEventListener('blur', clearShiftKeyState)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.v-popper--theme-analytics-monetization-popover .v-popper__inner {
|
||||
overflow: visible !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.v-popper--theme-analytics-monetization-popover .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(analyticsChartMessages.renderLimitHeader, { count: tableProjectCount })"
|
||||
fade="warning"
|
||||
width="500px"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
>
|
||||
<p class="m-0 max-w-[32rem] text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.renderLimitDescription) }}
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="modal?.hide()">
|
||||
{{ formatMessage(analyticsChartMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button class="!shadow-none" @click="confirm">
|
||||
{{ formatMessage(analyticsChartMessages.showAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages'
|
||||
|
||||
defineProps<{
|
||||
tableProjectCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div
|
||||
class="flex min-h-[84px] w-full flex-col items-start justify-between gap-3 rounded-t-2xl border-0 border-b border-solid border-surface-5 bg-surface-3 p-4 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="w-max text-xl font-semibold text-contrast">
|
||||
{{ graphTitle }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showTableSelectionSubheading"
|
||||
class="m-0 flex w-max flex-wrap items-center gap-2 text-sm text-secondary"
|
||||
>
|
||||
<span>{{ tableSelectionSubheading }}</span>
|
||||
|
||||
<button
|
||||
v-if="showGraphRenderLimitButton"
|
||||
type="button"
|
||||
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
|
||||
@click="emit('toggle-graph-render-limit', $event)"
|
||||
>
|
||||
{{ graphRenderLimitButtonLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showTopGraphDatasetsButton"
|
||||
type="button"
|
||||
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
|
||||
@click="emit('show-top-graph-datasets')"
|
||||
>
|
||||
{{ formatMessage(analyticsChartMessages.showTopEight) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex grow select-none flex-wrap-reverse items-center justify-end gap-2 gap-y-2">
|
||||
<AnalyticsChartControls
|
||||
v-model:ratio-mode="ratioMode"
|
||||
v-model:show-chart-events="showChartEvents"
|
||||
v-model:show-project-events="showProjectEvents"
|
||||
v-model:show-previous-period="showPreviousPeriod"
|
||||
:can-use-ratio-mode="canUseRatioMode"
|
||||
:can-show-previous-period="canShowPreviousPeriod"
|
||||
:has-chart-events="hasChartEvents"
|
||||
:has-project-events="hasProjectEvents"
|
||||
:small-toggles="smallToggles"
|
||||
:default-ratio-mode="DEFAULT_ANALYTICS_GRAPH_RATIO_MODE"
|
||||
:default-show-chart-events="DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY"
|
||||
:default-show-project-events="defaultShowProjectEvents"
|
||||
:default-show-previous-period="DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY"
|
||||
/>
|
||||
<Tabs
|
||||
:value="activeGraphViewMode"
|
||||
:tabs="viewModeTabs"
|
||||
@update:value="activeGraphViewMode = $event as AnalyticsGraphViewMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChartAreaIcon, ChartColumnBigIcon, ChartSplineIcon } from '@modrinth/assets'
|
||||
import { Tabs, type TabsTab, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
|
||||
DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
|
||||
DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsGraphViewMode } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages.ts'
|
||||
import AnalyticsChartControls from './AnalyticsChartControls.vue'
|
||||
|
||||
const activeGraphViewMode = defineModel<AnalyticsGraphViewMode>('activeGraphViewMode', {
|
||||
required: true,
|
||||
})
|
||||
const ratioMode = defineModel<boolean>('ratioMode', { required: true })
|
||||
const showChartEvents = defineModel<boolean>('showChartEvents', { required: true })
|
||||
const showProjectEvents = defineModel<boolean>('showProjectEvents', { required: true })
|
||||
const showPreviousPeriod = defineModel<boolean>('showPreviousPeriod', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
graphTitle: string
|
||||
showTableSelectionSubheading: boolean
|
||||
tableSelectionSubheading: string
|
||||
showGraphRenderLimitButton: boolean
|
||||
graphRenderLimitButtonLabel: string
|
||||
showTopGraphDatasetsButton: boolean
|
||||
canUseRatioMode: boolean
|
||||
canShowPreviousPeriod: boolean
|
||||
hasChartEvents: boolean
|
||||
hasProjectEvents: boolean
|
||||
smallToggles: boolean
|
||||
defaultShowProjectEvents: boolean
|
||||
isMobileLayout: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
'toggle-graph-render-limit': [event: MouseEvent]
|
||||
'show-top-graph-datasets': []
|
||||
}>()
|
||||
|
||||
const viewModeTabs = computed<TabsTab[]>(() => [
|
||||
{
|
||||
value: 'line',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.lineView),
|
||||
icon: ChartSplineIcon,
|
||||
},
|
||||
{
|
||||
value: 'area',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.areaView),
|
||||
icon: ChartAreaIcon,
|
||||
},
|
||||
{
|
||||
value: 'bar',
|
||||
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.barView),
|
||||
icon: ChartColumnBigIcon,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardProject,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages.ts'
|
||||
import { COMBINED_BREAKDOWN_DATASET_ID_PREFIX } from '../../breakdown.ts'
|
||||
import {
|
||||
ALL_PROJECTS_DATASET_ID,
|
||||
MONETIZATION_LEGEND_ENTRY_ORDER,
|
||||
PREVIOUS_PERIOD_BORDER_DASH,
|
||||
} from '../analytics-chart-constants.ts'
|
||||
import type { AnalyticsChartEvent } from '../analytics-chart-plot/AnalyticsChartEvents.vue'
|
||||
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types.ts'
|
||||
import {
|
||||
areStringArraysEqual,
|
||||
type ChartDataset,
|
||||
decodeBreakdownDatasetValue,
|
||||
getChartDatasetTotal,
|
||||
getPreviousPeriodDatasetId,
|
||||
} from '../analytics-chart-utils.ts'
|
||||
|
||||
export function useAnalyticsChartLegend({
|
||||
selectableChartDatasets,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
shouldShowPreviousPeriod,
|
||||
isRatioMode,
|
||||
hiddenGraphDatasetIds,
|
||||
selectedBreakdowns,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjects,
|
||||
selectedProjectIdSet,
|
||||
selectedProjectEventIdSet,
|
||||
}: {
|
||||
selectableChartDatasets: ComputedRef<ChartDataset[]>
|
||||
allChartDatasets: ComputedRef<ChartDataset[]>
|
||||
previousChartDatasets: ComputedRef<ChartDataset[]>
|
||||
shouldShowPreviousPeriod: ComputedRef<boolean>
|
||||
isRatioMode: Ref<boolean>
|
||||
hiddenGraphDatasetIds: Ref<string[]>
|
||||
selectedBreakdowns: Ref<readonly AnalyticsBreakdownPreset[]>
|
||||
isGraphDatasetSelectionActive: Ref<boolean>
|
||||
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>
|
||||
selectedProjectIdSet: ComputedRef<Set<string>>
|
||||
selectedProjectEventIdSet: ComputedRef<Set<string>>
|
||||
}) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const hoveredLegendEntryId = ref<string | null>(null)
|
||||
const hiddenDatasetIds = computed(() => new Set(hiddenGraphDatasetIds.value))
|
||||
const previousChartDatasetByOriginalId = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of previousChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const currentLegendEntries = computed<AnalyticsChartLegendEntry[]>(() =>
|
||||
selectableChartDatasets.value
|
||||
.map((dataset) => ({
|
||||
id: dataset.projectId,
|
||||
name: dataset.label,
|
||||
projectName: dataset.projectName,
|
||||
color: dataset.borderColor,
|
||||
totalValue: getChartDatasetTotal(dataset),
|
||||
hidden: hiddenDatasetIds.value.has(dataset.projectId),
|
||||
}))
|
||||
.sort(compareLegendEntries),
|
||||
)
|
||||
const visibleProjectEventIdSet = computed(() => {
|
||||
if (!selectedBreakdowns.value.includes('project')) {
|
||||
return selectedProjectEventIdSet.value
|
||||
}
|
||||
|
||||
const visibleProjectIds = new Set<string>()
|
||||
const projectIdsWithLegendEntries = new Set<string>()
|
||||
|
||||
for (const legendEntry of currentLegendEntries.value) {
|
||||
const projectId = getLegendEntryProjectId(legendEntry)
|
||||
if (!projectId) {
|
||||
continue
|
||||
}
|
||||
|
||||
projectIdsWithLegendEntries.add(projectId)
|
||||
if (!legendEntry.hidden) {
|
||||
visibleProjectIds.add(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
if (isGraphDatasetSelectionActive.value) {
|
||||
return visibleProjectIds
|
||||
}
|
||||
|
||||
if (projectIdsWithLegendEntries.size === 0) {
|
||||
return selectedProjectEventIdSet.value
|
||||
}
|
||||
|
||||
const eventProjectIds = new Set<string>()
|
||||
for (const projectId of selectedProjectEventIdSet.value) {
|
||||
if (!projectIdsWithLegendEntries.has(projectId) || visibleProjectIds.has(projectId)) {
|
||||
eventProjectIds.add(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
return eventProjectIds
|
||||
})
|
||||
const legendEntries = computed<AnalyticsChartLegendEntry[]>(() => {
|
||||
if (!shouldShowPreviousPeriod.value) {
|
||||
return currentLegendEntries.value
|
||||
}
|
||||
|
||||
return currentLegendEntries.value.flatMap((entry) => {
|
||||
const previousDataset = previousChartDatasetByOriginalId.value.get(entry.id)
|
||||
const previousEntry: AnalyticsChartLegendEntry = {
|
||||
id: getPreviousPeriodDatasetId(entry.id),
|
||||
name: formatMessage(analyticsChartMessages.previousPeriodSuffix, { name: entry.name }),
|
||||
projectName: entry.projectName,
|
||||
color: entry.color,
|
||||
totalValue: previousDataset ? getChartDatasetTotal(previousDataset) : 0,
|
||||
hidden: hiddenDatasetIds.value.has(getPreviousPeriodDatasetId(entry.id)),
|
||||
isPreviousPeriod: true,
|
||||
}
|
||||
|
||||
return [entry, previousEntry]
|
||||
})
|
||||
})
|
||||
const hiddenCurrentLegendEntryIds = computed(() =>
|
||||
currentLegendEntries.value.filter((entry) => entry.hidden).map((entry) => entry.id),
|
||||
)
|
||||
const hiddenCurrentLegendEntryIdsKey = computed(() =>
|
||||
hiddenCurrentLegendEntryIds.value.join('\u0000'),
|
||||
)
|
||||
const chartDatasetById = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of selectableChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
|
||||
if (!shouldShowPreviousPeriod.value) {
|
||||
continue
|
||||
}
|
||||
|
||||
const previousDataset = previousChartDatasetByOriginalId.value.get(dataset.projectId)
|
||||
const previousData = Array.from(
|
||||
{ length: dataset.data.length },
|
||||
(_, index) => previousDataset?.data[index] ?? 0,
|
||||
)
|
||||
datasets.set(getPreviousPeriodDatasetId(dataset.projectId), {
|
||||
projectId: getPreviousPeriodDatasetId(dataset.projectId),
|
||||
label: formatMessage(analyticsChartMessages.previousPeriodSuffix, {
|
||||
name: dataset.label,
|
||||
}),
|
||||
projectName: dataset.projectName,
|
||||
data: previousData,
|
||||
borderColor: dataset.borderColor,
|
||||
backgroundColor: dataset.backgroundColor,
|
||||
borderDash: PREVIOUS_PERIOD_BORDER_DASH,
|
||||
})
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const hoverRatioSliceTotals = computed(() => {
|
||||
const sliceLength = selectableChartDatasets.value.reduce(
|
||||
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
|
||||
0,
|
||||
)
|
||||
const totals = new Array<number>(sliceLength).fill(0)
|
||||
|
||||
for (const legendEntry of legendEntries.value) {
|
||||
if (legendEntry.hidden) continue
|
||||
|
||||
const dataset = chartDatasetById.value.get(legendEntry.id)
|
||||
if (!dataset) continue
|
||||
|
||||
for (let i = 0; i < sliceLength; i++) {
|
||||
totals[i] += dataset.data[i] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return totals
|
||||
})
|
||||
const baseVisibleChartDatasets = computed(() =>
|
||||
legendEntries.value
|
||||
.filter((legendEntry) => !legendEntry.hidden)
|
||||
.map((legendEntry) => {
|
||||
const dataset = chartDatasetById.value.get(legendEntry.id)
|
||||
if (!dataset) return null
|
||||
|
||||
return {
|
||||
...dataset,
|
||||
borderColor: legendEntry.color,
|
||||
backgroundColor: legendEntry.color,
|
||||
}
|
||||
})
|
||||
.filter((dataset): dataset is ChartDataset => Boolean(dataset)),
|
||||
)
|
||||
const visibleChartDatasets = computed<ChartDataset[]>(() => {
|
||||
const datasets = baseVisibleChartDatasets.value
|
||||
if (!isRatioMode.value || datasets.length === 0) return datasets
|
||||
|
||||
const sliceLength = datasets.reduce(
|
||||
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
|
||||
0,
|
||||
)
|
||||
const totals = new Array<number>(sliceLength).fill(0)
|
||||
for (const dataset of datasets) {
|
||||
for (let i = 0; i < sliceLength; i++) {
|
||||
totals[i] += dataset.data[i] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return datasets.map((dataset) => ({
|
||||
...dataset,
|
||||
data: dataset.data.map((value, i) => (totals[i] === 0 ? 0 : (value / totals[i]) * 100)),
|
||||
}))
|
||||
})
|
||||
const visibleChartDatasetById = computed(() => {
|
||||
const datasets = new Map<string, ChartDataset>()
|
||||
for (const dataset of visibleChartDatasets.value) {
|
||||
datasets.set(dataset.projectId, dataset)
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const highlightedChartDatasetId = computed(() => {
|
||||
const datasetId = hoveredLegendEntryId.value
|
||||
if (!datasetId || !visibleChartDatasetById.value.has(datasetId)) return null
|
||||
return datasetId
|
||||
})
|
||||
|
||||
function compareLegendEntries(a: AnalyticsChartLegendEntry, b: AnalyticsChartLegendEntry) {
|
||||
if (selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization') {
|
||||
const aOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(a.id)
|
||||
const bOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(b.id)
|
||||
|
||||
if (aOrder !== undefined || bOrder !== undefined) {
|
||||
return (aOrder ?? Number.MAX_SAFE_INTEGER) - (bOrder ?? Number.MAX_SAFE_INTEGER)
|
||||
}
|
||||
}
|
||||
|
||||
return b.totalValue - a.totalValue || a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
|
||||
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
|
||||
}
|
||||
|
||||
function getLegendEntryProjectId(legendEntry: AnalyticsChartLegendEntry) {
|
||||
const projectBreakdownIndex = selectedBreakdowns.value.findIndex(
|
||||
(breakdown) => breakdown === 'project',
|
||||
)
|
||||
|
||||
if (projectBreakdownIndex === -1) {
|
||||
if (selectedProjects.value.length === 1 && legendEntry.id === ALL_PROJECTS_DATASET_ID) {
|
||||
return selectedProjects.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (selectedBreakdowns.value.length === 1) {
|
||||
return selectedProjectIdSet.value.has(legendEntry.id) ? legendEntry.id : null
|
||||
}
|
||||
|
||||
if (!legendEntry.id.startsWith(COMBINED_BREAKDOWN_DATASET_ID_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const values = legendEntry.id
|
||||
.slice(COMBINED_BREAKDOWN_DATASET_ID_PREFIX.length)
|
||||
.split('+')
|
||||
.map(decodeBreakdownDatasetValue)
|
||||
const projectId = values[projectBreakdownIndex]
|
||||
return projectId && selectedProjectIdSet.value.has(projectId) ? projectId : null
|
||||
}
|
||||
|
||||
function hidePreviousPeriodEntriesForHiddenCurrentEntries() {
|
||||
if (hiddenCurrentLegendEntryIds.value.length === 0) return
|
||||
|
||||
const nextHiddenDatasetIds = new Set(hiddenGraphDatasetIds.value)
|
||||
for (const datasetId of hiddenCurrentLegendEntryIds.value) {
|
||||
nextHiddenDatasetIds.add(getPreviousPeriodDatasetId(datasetId))
|
||||
}
|
||||
|
||||
const nextHiddenDatasetIdList = Array.from(nextHiddenDatasetIds)
|
||||
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIdList)) {
|
||||
hiddenGraphDatasetIds.value = nextHiddenDatasetIdList
|
||||
}
|
||||
}
|
||||
|
||||
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
|
||||
if (legendEntry.hidden) return false
|
||||
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
|
||||
return visibleCount <= 1
|
||||
}
|
||||
|
||||
function getLegendEntryTooltip(legendEntry: AnalyticsChartLegendEntry) {
|
||||
return legendEntry.projectName ?? ''
|
||||
}
|
||||
|
||||
function isUnmonetizedLegendEntry(legendEntry: AnalyticsChartLegendEntry) {
|
||||
return (
|
||||
selectedBreakdowns.value.length === 1 &&
|
||||
selectedBreakdowns.value[0] === 'monetization' &&
|
||||
legendEntry.id === 'breakdown:unmonetized'
|
||||
)
|
||||
}
|
||||
|
||||
function setHoveredLegendEntryId(datasetId: string) {
|
||||
hoveredLegendEntryId.value = datasetId
|
||||
}
|
||||
|
||||
function clearHoveredLegendEntryId(datasetId: string) {
|
||||
if (hoveredLegendEntryId.value === datasetId) {
|
||||
hoveredLegendEntryId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearLegendHoverState() {
|
||||
hoveredLegendEntryId.value = null
|
||||
}
|
||||
|
||||
function toggleLegendEntryVisibility(datasetId: string) {
|
||||
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
|
||||
if (nextHiddenDatasetIds.has(datasetId)) {
|
||||
nextHiddenDatasetIds.delete(datasetId)
|
||||
} else {
|
||||
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
|
||||
if (visibleCount <= 1) return
|
||||
nextHiddenDatasetIds.add(datasetId)
|
||||
}
|
||||
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
|
||||
}
|
||||
|
||||
function soloLegendEntry(datasetId: string) {
|
||||
const currentLegendEntryIds = new Set(legendEntries.value.map((entry) => entry.id))
|
||||
const otherIds = legendEntries.value.map((entry) => entry.id).filter((id) => id !== datasetId)
|
||||
const isAlreadySolo =
|
||||
!hiddenDatasetIds.value.has(datasetId) &&
|
||||
otherIds.every((id) => hiddenDatasetIds.value.has(id))
|
||||
|
||||
if (isAlreadySolo) {
|
||||
hiddenGraphDatasetIds.value = hiddenGraphDatasetIds.value.filter(
|
||||
(hiddenDatasetId) => !currentLegendEntryIds.has(hiddenDatasetId),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
|
||||
for (const legendEntry of legendEntries.value) {
|
||||
if (legendEntry.id === datasetId) {
|
||||
nextHiddenDatasetIds.delete(legendEntry.id)
|
||||
} else {
|
||||
nextHiddenDatasetIds.add(legendEntry.id)
|
||||
}
|
||||
}
|
||||
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
|
||||
}
|
||||
|
||||
function onLegendEntryClick(event: MouseEvent, datasetId: string) {
|
||||
if (event.shiftKey) {
|
||||
soloLegendEntry(datasetId)
|
||||
clearLegendHoverState()
|
||||
return
|
||||
}
|
||||
toggleLegendEntryVisibility(datasetId)
|
||||
clearLegendHoverState()
|
||||
}
|
||||
|
||||
function onTooltipEntryClick(datasetId: string, shiftKey: boolean) {
|
||||
if (!chartDatasetById.value.has(datasetId)) return
|
||||
|
||||
if (shiftKey) {
|
||||
soloLegendEntry(datasetId)
|
||||
clearLegendHoverState()
|
||||
return
|
||||
}
|
||||
toggleLegendEntryVisibility(datasetId)
|
||||
clearLegendHoverState()
|
||||
}
|
||||
|
||||
watch(
|
||||
[shouldShowPreviousPeriod, hiddenCurrentLegendEntryIdsKey],
|
||||
([showPreviousPeriod]) => {
|
||||
if (!showPreviousPeriod) return
|
||||
hidePreviousPeriodEntriesForHiddenCurrentEntries()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[allChartDatasets, legendEntries],
|
||||
([datasets]) => {
|
||||
if (datasets.length === 0) return
|
||||
|
||||
const availableDatasetIds = new Set(legendEntries.value.map((entry) => entry.id))
|
||||
const nextHiddenDatasetIds = hiddenGraphDatasetIds.value.filter((datasetId) =>
|
||||
availableDatasetIds.has(datasetId),
|
||||
)
|
||||
if (
|
||||
legendEntries.value.length > 0 &&
|
||||
legendEntries.value.every((entry) => nextHiddenDatasetIds.includes(entry.id))
|
||||
) {
|
||||
const firstLegendEntry = legendEntries.value[0]
|
||||
if (firstLegendEntry) {
|
||||
const firstLegendEntryIndex = nextHiddenDatasetIds.indexOf(firstLegendEntry.id)
|
||||
if (firstLegendEntryIndex !== -1) {
|
||||
nextHiddenDatasetIds.splice(firstLegendEntryIndex, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIds)) {
|
||||
hiddenGraphDatasetIds.value = nextHiddenDatasetIds
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
hoveredLegendEntryId,
|
||||
hiddenDatasetIds,
|
||||
previousChartDatasetByOriginalId,
|
||||
currentLegendEntries,
|
||||
visibleProjectEventIdSet,
|
||||
legendEntries,
|
||||
hiddenCurrentLegendEntryIds,
|
||||
hiddenCurrentLegendEntryIdsKey,
|
||||
chartDatasetById,
|
||||
hoverRatioSliceTotals,
|
||||
baseVisibleChartDatasets,
|
||||
visibleChartDatasets,
|
||||
visibleChartDatasetById,
|
||||
highlightedChartDatasetId,
|
||||
isProjectChartEventVisibleForLegend,
|
||||
getLegendEntryProjectId,
|
||||
hidePreviousPeriodEntriesForHiddenCurrentEntries,
|
||||
isLegendEntryToggleDisabled,
|
||||
getLegendEntryTooltip,
|
||||
isUnmonetizedLegendEntry,
|
||||
setHoveredLegendEntryId,
|
||||
clearHoveredLegendEntryId,
|
||||
clearLegendHoverState,
|
||||
toggleLegendEntryVisibility,
|
||||
soloLegendEntry,
|
||||
onLegendEntryClick,
|
||||
onTooltipEntryClick,
|
||||
}
|
||||
}
|
||||
+1154
File diff suppressed because it is too large
Load Diff
+488
@@ -0,0 +1,488 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="visible"
|
||||
ref="tooltipElement"
|
||||
class="analytics-chart-tooltip absolute left-0 top-0 z-10 flex max-h-[356px] flex-col overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-3 py-2 text-sm shadow-lg"
|
||||
:class="pinned ? '' : 'pointer-events-none'"
|
||||
:style="positionStyle"
|
||||
@wheel.stop
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
class="mb-1.5 flex shrink-0 items-start justify-between gap-2 border-0 border-b border-solid border-surface-5 px-3 pb-1.5 font-medium text-contrast"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="min-w-0 truncate">
|
||||
{{ rangeLabel }}
|
||||
<span v-if="durationLabel" class="text-xs font-normal text-secondary">
|
||||
({{ durationLabel }})
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="previousRangeLabel" class="min-w-0 space-x-1 truncate text-xs text-primary">
|
||||
<span class="font-medium">{{ previousRangeLabel }}</span>
|
||||
<span class="font-normal text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.previousPeriodShort) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<PinIcon
|
||||
v-if="pinned"
|
||||
v-tooltip="formatMessage(analyticsChartMessages.tooltipPinned)"
|
||||
class="pointer-events-none size-4 shrink-0 font-normal text-contrast"
|
||||
:aria-label="formatMessage(analyticsChartMessages.pinned)"
|
||||
/>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showEntriesTopFade"
|
||||
class="analytics-chart-tooltip-entries-fade-top pointer-events-none absolute left-0 right-0 z-10 -mt-1 h-6 bg-gradient-to-b from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
ref="entriesElement"
|
||||
class="analytics-chart-tooltip-entries flex min-h-0 flex-col overflow-y-auto overscroll-contain px-3"
|
||||
@scroll="checkEntriesScrollState"
|
||||
@touchstart="onEntriesTouchStart"
|
||||
@touchmove="onEntriesTouchMove"
|
||||
@touchend="clearEntriesTouchScroll"
|
||||
@touchcancel="clearEntriesTouchScroll"
|
||||
>
|
||||
<div v-if="!ratioMode" class="flex shrink-0 items-center justify-between gap-4">
|
||||
<span class="font-medium text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.total) }}
|
||||
</span>
|
||||
<span class="font-semibold text-contrast">{{ formattedTotal }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in entries"
|
||||
:key="entry.projectId"
|
||||
class="flex w-full min-w-0 items-center justify-between gap-4 text-primary"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-w-0 items-center gap-1.5 border-0 bg-transparent p-0 py-0.5 text-left focus-visible:!outline-none"
|
||||
:class="
|
||||
entry.toggleDisabled && !shiftKeyPressed
|
||||
? 'cursor-default'
|
||||
: entry.hidden
|
||||
? 'cursor-pointer text-secondary opacity-70'
|
||||
: 'cursor-pointer text-primary transition-all hover:brightness-125'
|
||||
"
|
||||
:aria-label="getEntryAriaLabel(entry)"
|
||||
@mouseenter="emit('entry-hover', entry.projectId)"
|
||||
@mouseleave="emit('entry-hover-clear', entry.projectId)"
|
||||
@focus="emit('entry-hover', entry.projectId)"
|
||||
@blur="emit('entry-hover-clear', entry.projectId)"
|
||||
@click="onEntryClick($event, entry)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
entry.isPreviousPeriod
|
||||
? 'h-0 w-2 rounded-none border-0 border-t-2 border-dashed bg-transparent'
|
||||
: 'size-2 rounded-full'
|
||||
"
|
||||
class="shrink-0"
|
||||
:style="
|
||||
entry.isPreviousPeriod
|
||||
? { borderColor: entry.color }
|
||||
: { backgroundColor: entry.color }
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-tooltip="entry.projectName ?? ''"
|
||||
class="min-w-0 truncate"
|
||||
:class="{
|
||||
'line-through': entry.hidden,
|
||||
capitalize: capitalizeLabels,
|
||||
}"
|
||||
>
|
||||
{{ entry.name }}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
:class="[
|
||||
'shrink-0',
|
||||
entry.isPreviousPeriod ? 'font-medium text-secondary' : 'font-semibold',
|
||||
entry.hidden ? 'text-primary line-through opacity-70' : 'text-contrast',
|
||||
]"
|
||||
>
|
||||
{{ entry.formattedValue }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showEntriesBottomFade"
|
||||
class="analytics-chart-tooltip-entries-fade-bottom pointer-events-none absolute left-0 right-0 z-10 -mb-1 h-6 bg-gradient-to-t from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PinIcon } from '@modrinth/assets'
|
||||
import { useScrollIndicator, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages'
|
||||
|
||||
export type AnalyticsChartTooltipEntry = {
|
||||
projectId: string
|
||||
name: string
|
||||
projectName?: string
|
||||
color: string
|
||||
formattedValue: string
|
||||
hidden: boolean
|
||||
toggleDisabled: boolean
|
||||
isPreviousPeriod?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
start: Date | null
|
||||
end: Date | null
|
||||
previousStart: Date | null
|
||||
previousEnd: Date | null
|
||||
chartStart: Date | null
|
||||
chartEnd: Date | null
|
||||
formattedTotal: string
|
||||
entries: AnalyticsChartTooltipEntry[]
|
||||
containerWidth: number
|
||||
containerHeight: number
|
||||
pinned: boolean
|
||||
ratioMode: boolean
|
||||
capitalizeLabels: boolean
|
||||
shiftKeyPressed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'entry-click': [projectId: string, shiftKey: boolean]
|
||||
'entry-hover': [projectId: string]
|
||||
'entry-hover-clear': [projectId: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
function onEntryClick(event: MouseEvent, entry: AnalyticsChartTooltipEntry) {
|
||||
if (entry.toggleDisabled && !event.shiftKey) return
|
||||
emit('entry-click', entry.projectId, event.shiftKey)
|
||||
}
|
||||
|
||||
function getEntryAriaLabel(entry: AnalyticsChartTooltipEntry) {
|
||||
return formatMessage(
|
||||
entry.hidden
|
||||
? analyticsChartMessages.showEntryInGraph
|
||||
: analyticsChartMessages.hideEntryInGraph,
|
||||
{ name: entry.name },
|
||||
)
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000
|
||||
const ONE_MINUTE_MS = 60 * 1000
|
||||
const DATE_LOCALE = 'en-US'
|
||||
|
||||
function formatRangeLabel(
|
||||
start: Date,
|
||||
end: Date,
|
||||
chartStart: Date | null,
|
||||
chartEnd: Date | null,
|
||||
): string {
|
||||
const includeTime = end.getTime() - start.getTime() < ONE_DAY_MS
|
||||
const yearsDiffer = start.getFullYear() !== end.getFullYear()
|
||||
const chartYearsDiffer =
|
||||
chartStart !== null && chartEnd !== null && chartStart.getFullYear() !== chartEnd.getFullYear()
|
||||
const rangeYearDiffersFromChart =
|
||||
chartStart !== null && start.getFullYear() !== chartStart.getFullYear()
|
||||
const showTrailingYear = !yearsDiffer && (chartYearsDiffer || rangeYearDiffersFromChart)
|
||||
const monthsDiffer = yearsDiffer || start.getMonth() !== end.getMonth()
|
||||
|
||||
const timeOptions: Intl.DateTimeFormatOptions = includeTime
|
||||
? { hour: 'numeric', minute: '2-digit', hour12: true }
|
||||
: {}
|
||||
|
||||
const startOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(yearsDiffer ? { year: 'numeric' } : {}),
|
||||
...timeOptions,
|
||||
}
|
||||
|
||||
if (includeTime) {
|
||||
const startLabel = new Intl.DateTimeFormat(DATE_LOCALE, startOptions).format(start)
|
||||
const endLabel = new Intl.DateTimeFormat(DATE_LOCALE, timeOptions).format(end)
|
||||
const range = `${startLabel}–${endLabel}`
|
||||
|
||||
if (!showTrailingYear) return range
|
||||
|
||||
const yearLabel = new Intl.DateTimeFormat(DATE_LOCALE, { year: 'numeric' }).format(end)
|
||||
return `${range}, ${yearLabel}`
|
||||
}
|
||||
|
||||
let endOptions: Intl.DateTimeFormatOptions
|
||||
if (yearsDiffer) {
|
||||
endOptions = { month: 'short', day: 'numeric', year: 'numeric' }
|
||||
} else if (monthsDiffer) {
|
||||
endOptions = { month: 'short', day: 'numeric' }
|
||||
} else {
|
||||
endOptions = { day: 'numeric' }
|
||||
}
|
||||
|
||||
const startLabel = new Intl.DateTimeFormat(DATE_LOCALE, startOptions).format(start)
|
||||
const endLabel = new Intl.DateTimeFormat(DATE_LOCALE, endOptions).format(end)
|
||||
const range = `${startLabel}–${endLabel}`
|
||||
|
||||
if (!showTrailingYear) return range
|
||||
|
||||
const yearLabel = new Intl.DateTimeFormat(DATE_LOCALE, { year: 'numeric' }).format(end)
|
||||
return `${range}, ${yearLabel}`
|
||||
}
|
||||
|
||||
function formatDurationLabel(start: Date, end: Date): string {
|
||||
const durationMs = end.getTime() - start.getTime()
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) return ''
|
||||
|
||||
if (durationMs >= ONE_DAY_MS) {
|
||||
const days = Math.round(durationMs / ONE_DAY_MS)
|
||||
return formatMessage(analyticsChartMessages.durationDays, { count: days })
|
||||
}
|
||||
if (durationMs >= ONE_HOUR_MS) {
|
||||
const hours = Math.round(durationMs / ONE_HOUR_MS)
|
||||
return formatMessage(analyticsChartMessages.durationHours, { count: hours })
|
||||
}
|
||||
const minutes = Math.max(1, Math.round(durationMs / ONE_MINUTE_MS))
|
||||
return formatMessage(analyticsChartMessages.durationMinutes, { count: minutes })
|
||||
}
|
||||
|
||||
const rangeLabel = computed(() =>
|
||||
props.start && props.end
|
||||
? formatRangeLabel(props.start, props.end, props.chartStart, props.chartEnd)
|
||||
: '',
|
||||
)
|
||||
|
||||
const durationLabel = computed(() =>
|
||||
props.start && props.end ? formatDurationLabel(props.start, props.end) : '',
|
||||
)
|
||||
const previousRangeLabel = computed(() =>
|
||||
props.previousStart && props.previousEnd
|
||||
? formatRangeLabel(props.previousStart, props.previousEnd, props.chartStart, props.chartEnd)
|
||||
: '',
|
||||
)
|
||||
|
||||
const tooltipElement = ref<HTMLDivElement | null>(null)
|
||||
const entriesElement = ref<HTMLDivElement | null>(null)
|
||||
const {
|
||||
showTopFade: showEntriesTopFade,
|
||||
showBottomFade: showEntriesBottomFade,
|
||||
checkScrollState: checkEntriesScrollState,
|
||||
forceCheck: forceCheckEntriesScrollState,
|
||||
} = useScrollIndicator(entriesElement)
|
||||
const tooltipWidth = ref(0)
|
||||
const tooltipHeight = ref(0)
|
||||
const entriesTopOffset = ref(0)
|
||||
const entriesBottomOffset = ref(0)
|
||||
const tooltipOffsetParentLeft = ref(0)
|
||||
const viewportWidth = ref(0)
|
||||
let entriesTouchStartY = 0
|
||||
let entriesTouchStartScrollTop = 0
|
||||
|
||||
const CURSOR_OFFSET = 12
|
||||
const EDGE_PADDING = 8
|
||||
const TOOLTIP_MAX_WIDTH = 26 * 16
|
||||
const WHEEL_DELTA_LINE = 1
|
||||
const WHEEL_DELTA_PAGE = 2
|
||||
const WHEEL_LINE_HEIGHT = 16
|
||||
|
||||
function getTooltipFallbackWidth() {
|
||||
const availableWidth = (viewportWidth.value || props.containerWidth) - EDGE_PADDING * 2
|
||||
if (availableWidth <= 0) return TOOLTIP_MAX_WIDTH
|
||||
return Math.min(TOOLTIP_MAX_WIDTH, availableWidth)
|
||||
}
|
||||
|
||||
function updateTooltipMeasurements() {
|
||||
nextTick(() => {
|
||||
const element = tooltipElement.value
|
||||
if (!element) return
|
||||
|
||||
tooltipWidth.value = element.offsetWidth
|
||||
tooltipHeight.value = element.offsetHeight
|
||||
|
||||
const entries = entriesElement.value
|
||||
if (entries) {
|
||||
entriesTopOffset.value = entries.offsetTop
|
||||
entriesBottomOffset.value = Math.max(
|
||||
0,
|
||||
element.offsetHeight - entries.offsetTop - entries.offsetHeight,
|
||||
)
|
||||
}
|
||||
|
||||
const offsetParent =
|
||||
element.offsetParent instanceof HTMLElement ? element.offsetParent : element.parentElement
|
||||
tooltipOffsetParentLeft.value = offsetParent?.getBoundingClientRect().left ?? 0
|
||||
viewportWidth.value =
|
||||
document.documentElement.clientWidth || window.innerWidth || props.containerWidth
|
||||
forceCheckEntriesScrollState()
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.visible,
|
||||
props.entries,
|
||||
rangeLabel.value,
|
||||
durationLabel.value,
|
||||
previousRangeLabel.value,
|
||||
props.pinned,
|
||||
props.containerWidth,
|
||||
props.containerHeight,
|
||||
],
|
||||
updateTooltipMeasurements,
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
updateTooltipMeasurements()
|
||||
window.addEventListener('resize', updateTooltipMeasurements)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateTooltipMeasurements)
|
||||
})
|
||||
|
||||
function getNormalizedWheelDeltaY(event: WheelEvent, element: HTMLElement) {
|
||||
if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * element.clientHeight
|
||||
if (event.deltaMode === WHEEL_DELTA_LINE) return event.deltaY * WHEEL_LINE_HEIGHT
|
||||
return event.deltaY
|
||||
}
|
||||
|
||||
function getMaxScrollTop(element: HTMLElement) {
|
||||
return Math.max(0, element.scrollHeight - element.clientHeight)
|
||||
}
|
||||
|
||||
function consumeWheel(event: WheelEvent): boolean {
|
||||
const element = entriesElement.value
|
||||
if (!props.visible || !element) return false
|
||||
|
||||
const maxScrollTop = getMaxScrollTop(element)
|
||||
if (maxScrollTop <= 0) return false
|
||||
|
||||
const deltaY = getNormalizedWheelDeltaY(event, element)
|
||||
if (deltaY === 0) return false
|
||||
|
||||
const scrollTop = element.scrollTop
|
||||
element.scrollTop = Math.min(maxScrollTop, Math.max(0, scrollTop + deltaY))
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
function onEntriesTouchStart(event: TouchEvent) {
|
||||
const element = entriesElement.value
|
||||
const touch = event.touches[0]
|
||||
if (!element || !touch) return
|
||||
|
||||
entriesTouchStartY = touch.clientY
|
||||
entriesTouchStartScrollTop = element.scrollTop
|
||||
}
|
||||
|
||||
function onEntriesTouchMove(event: TouchEvent) {
|
||||
const element = entriesElement.value
|
||||
const touch = event.touches[0]
|
||||
if (!props.visible || !element || !touch) return
|
||||
|
||||
const maxScrollTop = getMaxScrollTop(element)
|
||||
if (maxScrollTop <= 0) return
|
||||
|
||||
const nextScrollTop = Math.min(
|
||||
maxScrollTop,
|
||||
Math.max(0, entriesTouchStartScrollTop + entriesTouchStartY - touch.clientY),
|
||||
)
|
||||
element.scrollTop = nextScrollTop
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function clearEntriesTouchScroll() {
|
||||
entriesTouchStartY = 0
|
||||
entriesTouchStartScrollTop = 0
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
consumeWheel,
|
||||
})
|
||||
|
||||
const positionStyle = computed(() => {
|
||||
const tooltipMaxWidth = getTooltipFallbackWidth()
|
||||
const tooltipWidthForPosition = tooltipWidth.value || tooltipMaxWidth
|
||||
const desiredLeft = props.x + CURSOR_OFFSET
|
||||
const viewportRight = viewportWidth.value || tooltipOffsetParentLeft.value + props.containerWidth
|
||||
const desiredViewportRight = tooltipOffsetParentLeft.value + desiredLeft + tooltipWidthForPosition
|
||||
const shouldPlaceLeft =
|
||||
props.x <= props.containerWidth / 4 || desiredViewportRight > viewportRight - EDGE_PADDING
|
||||
const candidateLeft = shouldPlaceLeft
|
||||
? props.x - tooltipWidthForPosition - CURSOR_OFFSET
|
||||
: desiredLeft
|
||||
const minLeft = EDGE_PADDING - tooltipOffsetParentLeft.value
|
||||
const maxLeft = Math.max(
|
||||
minLeft,
|
||||
viewportRight - tooltipOffsetParentLeft.value - tooltipWidthForPosition - EDGE_PADDING,
|
||||
)
|
||||
const clampedLeft = Math.min(maxLeft, Math.max(minLeft, candidateLeft))
|
||||
|
||||
const desiredTop = props.y - tooltipHeight.value / 2
|
||||
const maxTop = Math.max(EDGE_PADDING, props.containerHeight - tooltipHeight.value - EDGE_PADDING)
|
||||
const clampedTop = Math.min(maxTop, Math.max(EDGE_PADDING, desiredTop))
|
||||
|
||||
return {
|
||||
'--analytics-chart-tooltip-max-width': `${tooltipMaxWidth}px`,
|
||||
'--analytics-chart-tooltip-entries-top': `${entriesTopOffset.value}px`,
|
||||
'--analytics-chart-tooltip-entries-bottom': `${entriesBottomOffset.value}px`,
|
||||
transform: `translate3d(${clampedLeft}px, ${clampedTop}px, 0)`,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-chart-tooltip {
|
||||
min-width: min(14rem, var(--analytics-chart-tooltip-max-width, calc(100vw - 1rem)));
|
||||
max-width: var(--analytics-chart-tooltip-max-width, min(26rem, calc(100vw - 1rem)));
|
||||
transition: transform 750ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.analytics-chart-tooltip-entries {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.analytics-chart-tooltip-entries-fade-top {
|
||||
top: var(--analytics-chart-tooltip-entries-top, 0rem);
|
||||
}
|
||||
|
||||
.analytics-chart-tooltip-entries-fade-bottom {
|
||||
bottom: var(--analytics-chart-tooltip-entries-bottom, 0rem);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.analytics-chart-tooltip {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div
|
||||
ref="chartContainer"
|
||||
class="relative -ml-4 h-[460px] select-none"
|
||||
@click="onChartClick"
|
||||
@wheel.capture="onChartWheel"
|
||||
>
|
||||
<div :class="['h-full']">
|
||||
<div v-if="showEmptyChartState" class="flex h-full items-center justify-center rounded-xl">
|
||||
<div v-if="!isDataLoading" class="relative bottom-6 text-base font-normal text-secondary">
|
||||
{{ emptyChartMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<ClientOnly>
|
||||
<AnalyticsChartClient
|
||||
:type="chartType"
|
||||
:fill="isArea"
|
||||
:stacked="isStacked"
|
||||
:ratio-mode="isRatioMode"
|
||||
:datasets="visibleChartDatasets"
|
||||
:labels="chartLabels"
|
||||
:x-axis-tick-limit="xAxisTickLimit"
|
||||
:active-stat="activeStat"
|
||||
:pinned-slice-index="pinnedSliceIndex"
|
||||
:highlighted-dataset-id="highlightedChartDatasetId"
|
||||
@hover="onChartHover"
|
||||
@geometry="onChartGeometry"
|
||||
@pinned-drag="onPinnedDrag"
|
||||
@range-select="onRangeSelect"
|
||||
@touch-drag="onTouchDragEnd"
|
||||
/>
|
||||
</ClientOnly>
|
||||
<AnalyticsChartEvents
|
||||
v-if="hasVisibleTimelineEvents"
|
||||
:events="visibleTimelineEvents"
|
||||
:active-stat="activeStat"
|
||||
:group-by="selectedGroupBy"
|
||||
:chart-start="chartRangeBounds?.start ?? null"
|
||||
:chart-end="chartRangeBounds?.end ?? null"
|
||||
:geometry="chartGeometry"
|
||||
/>
|
||||
<div
|
||||
v-if="showHoverGuide"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute bottom-0 left-0 top-0 z-10 mb-[1.8rem] mt-2.5 border-0 border-l border-solid border-contrast opacity-25"
|
||||
:style="{ transform: `translate(${hoverState.x}px, 0)` }"
|
||||
/>
|
||||
<div
|
||||
v-if="showPinnedGuide"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute bottom-0 left-0 top-0 z-10 mb-[1.8rem] mt-2.5 border-0 border-l border-dashed border-green opacity-75"
|
||||
:style="{ transform: `translate(${hoverState.x}px, 0)` }"
|
||||
/>
|
||||
<AnalyticsChartTooltip
|
||||
ref="chartTooltip"
|
||||
:visible="hoverState.visible"
|
||||
:x="hoverState.x"
|
||||
:y="hoverState.y"
|
||||
:start="hoverBucketRange?.start ?? null"
|
||||
:end="hoverBucketRange?.end ?? null"
|
||||
:previous-start="previousHoverBucketRange?.start ?? null"
|
||||
:previous-end="previousHoverBucketRange?.end ?? null"
|
||||
:chart-start="chartRangeBounds?.start ?? null"
|
||||
:chart-end="chartRangeBounds?.end ?? null"
|
||||
:formatted-total="hoverFormattedTotal"
|
||||
:entries="hoverEntries"
|
||||
:container-width="containerSize.width"
|
||||
:container-height="containerSize.height"
|
||||
:pinned="isHoverPinned"
|
||||
:ratio-mode="isRatioMode"
|
||||
:capitalize-labels="shouldCapitalizeDatasetLabels"
|
||||
:shift-key-pressed="isShiftKeyPressed"
|
||||
@entry-click="(datasetId, shiftKey) => emit('entry-click', datasetId, shiftKey)"
|
||||
@entry-hover="emit('entry-hover', $event)"
|
||||
@entry-hover-clear="emit('entry-hover-clear', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { useFormatNumber, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type {
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGroupByPreset,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import type {
|
||||
AnalyticsChartLegendEntry,
|
||||
AnalyticsChartRangeBounds,
|
||||
} from '../analytics-chart-types.ts'
|
||||
import type { ChartDataset } from '../analytics-chart-utils.ts'
|
||||
import { formatMetricValue } from '../analytics-chart-utils.ts'
|
||||
import AnalyticsChartClient from '../AnalyticsChart.client.vue'
|
||||
import AnalyticsChartEvents, { type AnalyticsChartEvent } from './AnalyticsChartEvents.vue'
|
||||
import AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
|
||||
import { useAnalyticsChartInteractions } from './use-analytics-chart-interactions.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
chartType: 'line' | 'bar'
|
||||
isArea: boolean
|
||||
isStacked: boolean
|
||||
isRatioMode: boolean
|
||||
isDataLoading: boolean
|
||||
showEmptyChartState: boolean
|
||||
emptyChartMessage: string
|
||||
visibleChartDatasets: ChartDataset[]
|
||||
chartLabels: string[]
|
||||
xAxisTickLimit?: number
|
||||
activeStat: AnalyticsDashboardStat
|
||||
highlightedChartDatasetId: string | null
|
||||
hasVisibleTimelineEvents: boolean
|
||||
visibleTimelineEvents: AnalyticsChartEvent[]
|
||||
selectedGroupBy: AnalyticsGroupByPreset
|
||||
chartRangeBounds: AnalyticsChartRangeBounds | null
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null
|
||||
sliceCount: number
|
||||
shouldShowPreviousPeriod: boolean
|
||||
allChartDatasets: ChartDataset[]
|
||||
currentLegendEntries: AnalyticsChartLegendEntry[]
|
||||
legendEntries: AnalyticsChartLegendEntry[]
|
||||
chartDatasetById: Map<string, ChartDataset>
|
||||
hoverRatioSliceTotals: number[]
|
||||
shouldCapitalizeDatasetLabels: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'range-select': [start: Date, end: Date, groupBy: AnalyticsGroupByPreset]
|
||||
'entry-click': [datasetId: string, shiftKey: boolean]
|
||||
'entry-hover': [datasetId: string]
|
||||
'entry-hover-clear': [datasetId: string]
|
||||
}>()
|
||||
|
||||
const formatNumber = useFormatNumber()
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
chartContainer,
|
||||
chartTooltip,
|
||||
chartGeometry,
|
||||
containerSize,
|
||||
hoverState,
|
||||
isHoverPinned,
|
||||
isShiftKeyPressed,
|
||||
onChartHover,
|
||||
onPinnedDrag,
|
||||
onTouchDragEnd,
|
||||
onChartGeometry,
|
||||
onRangeSelect,
|
||||
onChartClick,
|
||||
onChartWheel,
|
||||
pinnedSliceIndex,
|
||||
showHoverGuide,
|
||||
showPinnedGuide,
|
||||
hoverBucketRange,
|
||||
previousHoverBucketRange,
|
||||
} = useAnalyticsChartInteractions({
|
||||
isDataLoading: computed(() => props.isDataLoading),
|
||||
fetchRequest: computed(() => props.fetchRequest),
|
||||
sliceCount: computed(() => props.sliceCount),
|
||||
chartLabels: computed(() => props.chartLabels),
|
||||
allChartDatasets: computed(() => props.allChartDatasets),
|
||||
chartRangeBounds: computed(() => props.chartRangeBounds),
|
||||
shouldShowPreviousPeriod: computed(() => props.shouldShowPreviousPeriod),
|
||||
onRangeSelected: (start, end, groupBy) => emit('range-select', start, end, groupBy),
|
||||
})
|
||||
|
||||
const hoverTotalValue = computed(() => {
|
||||
if (hoverState.sliceIndex === null) return 0
|
||||
const sliceIndex = hoverState.sliceIndex
|
||||
if (props.isRatioMode) return props.hoverRatioSliceTotals[sliceIndex] ?? 0
|
||||
|
||||
return props.currentLegendEntries.reduce((sum, legendEntry) => {
|
||||
if (legendEntry.hidden) return sum
|
||||
const dataset = props.chartDatasetById.get(legendEntry.id)
|
||||
return sum + (dataset?.data[sliceIndex] ?? 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const hoverFormattedTotal = computed(() => {
|
||||
if (props.isRatioMode) {
|
||||
return hoverTotalValue.value > 0 ? '100%' : '0%'
|
||||
}
|
||||
return formatMetricValue(hoverTotalValue.value, props.activeStat, formatNumber, formatMessage)
|
||||
})
|
||||
|
||||
const hoverEntries = computed(() => {
|
||||
if (hoverState.sliceIndex === null) return []
|
||||
const sliceIndex = hoverState.sliceIndex
|
||||
const totalValue = hoverTotalValue.value
|
||||
|
||||
return props.legendEntries.map((legendEntry) => {
|
||||
const dataset = props.chartDatasetById.get(legendEntry.id)
|
||||
const value = dataset?.data[sliceIndex] ?? 0
|
||||
const ratioValue = legendEntry.hidden || totalValue === 0 ? 0 : (value / totalValue) * 100
|
||||
return {
|
||||
projectId: legendEntry.id,
|
||||
name: legendEntry.name,
|
||||
projectName: legendEntry.projectName,
|
||||
color: legendEntry.color,
|
||||
formattedValue: props.isRatioMode
|
||||
? `${ratioValue.toFixed(1)}%`
|
||||
: formatMetricValue(value, props.activeStat, formatNumber, formatMessage),
|
||||
hidden: legendEntry.hidden,
|
||||
toggleDisabled: !legendEntry.hidden && isLegendEntryToggleDisabled(legendEntry),
|
||||
isPreviousPeriod: legendEntry.isPreviousPeriod,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
|
||||
if (legendEntry.hidden) return false
|
||||
const visibleCount = props.legendEntries.filter((entry) => !entry.hidden).length
|
||||
return visibleCount <= 1
|
||||
}
|
||||
</script>
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { injectModrinthClient, useVIntl } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
import type { AnalyticsDashboardContextValue } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsProjectEventMessages, type FormatMessage } from '../../analytics-messages.ts'
|
||||
import {
|
||||
PROJECT_EVENT_DATE_FORMATTER,
|
||||
PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS,
|
||||
VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET,
|
||||
type VisibleProjectStatusChangeEventStatus,
|
||||
} from '../analytics-chart-constants.ts'
|
||||
import type { AnalyticsChartRangeBounds } from '../analytics-chart-types.ts'
|
||||
import type { AnalyticsChartEvent } from './AnalyticsChartEvents.vue'
|
||||
|
||||
const analyticsEventsQueryKey = ['analytics-events'] as const
|
||||
|
||||
export function useAnalyticsChartEvents(
|
||||
context: Pick<
|
||||
AnalyticsDashboardContextValue,
|
||||
| 'activeStat'
|
||||
| 'showChartEvents'
|
||||
| 'showProjectEvents'
|
||||
| 'displayedProjectEvents'
|
||||
| 'hasCompletedAnalyticsLoading'
|
||||
>,
|
||||
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>,
|
||||
selectedProjectNameById: ComputedRef<Map<string, string>>,
|
||||
selectedProjectEventIdSet: ComputedRef<Set<string>>,
|
||||
visibleProjectEventIdSet: ComputedRef<Set<string>>,
|
||||
) {
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { data: analyticsEvents } = useQuery({
|
||||
queryKey: analyticsEventsQueryKey,
|
||||
queryFn: () => client.labrinth.analytics_v3.getEvents(),
|
||||
enabled: computed(() => context.hasCompletedAnalyticsLoading.value),
|
||||
placeholderData: [],
|
||||
refetchOnMount: 'always',
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const localAnalyticsChartEvents = computed(() => analyticsEvents.value ?? [])
|
||||
const hasChartEvents = computed(() =>
|
||||
localAnalyticsChartEvents.value.some(isTimelineEventVisibleInCurrentGraph),
|
||||
)
|
||||
const visibleModrinthChartEvents = computed<AnalyticsChartEvent[]>(() =>
|
||||
context.showChartEvents.value
|
||||
? localAnalyticsChartEvents.value.map((event) => ({
|
||||
...event,
|
||||
markerIcon: 'info' as const,
|
||||
groupKey: 'modrinth',
|
||||
}))
|
||||
: [],
|
||||
)
|
||||
const localProjectChartEvents = computed<AnalyticsChartEvent[]>(() =>
|
||||
dedupeProjectVersionUploadEvents(
|
||||
context.displayedProjectEvents.value.filter(
|
||||
(event) =>
|
||||
selectedProjectEventIdSet.value.has(event.project_id) && shouldShowProjectEvent(event),
|
||||
),
|
||||
).map((event) => ({
|
||||
title: getProjectEventTitle(event, formatMessage),
|
||||
starts: event.timestamp,
|
||||
ends: event.timestamp,
|
||||
projectId: event.project_id,
|
||||
projectName: selectedProjectNameById.value.get(event.project_id),
|
||||
subtitle: formatProjectEventDate(event.timestamp),
|
||||
markerIcon: 'flag' as const,
|
||||
groupKey: 'project',
|
||||
})),
|
||||
)
|
||||
const hasProjectEvents = computed(() =>
|
||||
localProjectChartEvents.value.some(
|
||||
(event) =>
|
||||
isProjectChartEventVisibleForLegend(event) && isTimelineEventVisibleInCurrentGraph(event),
|
||||
),
|
||||
)
|
||||
const visibleProjectChartEvents = computed(() =>
|
||||
context.showProjectEvents.value
|
||||
? localProjectChartEvents.value.filter(isProjectChartEventVisibleForLegend)
|
||||
: [],
|
||||
)
|
||||
const visibleTimelineEvents = computed(() => [
|
||||
...visibleModrinthChartEvents.value,
|
||||
...visibleProjectChartEvents.value,
|
||||
])
|
||||
const hasVisibleTimelineEvents = computed(
|
||||
() => visibleModrinthChartEvents.value.length > 0 || visibleProjectChartEvents.value.length > 0,
|
||||
)
|
||||
|
||||
function isTimelineEventVisibleInCurrentGraph(event: AnalyticsChartEvent) {
|
||||
const rangeBounds = chartRangeBounds.value
|
||||
if (!rangeBounds) return false
|
||||
if (!doesTimelineEventMatchActiveStat(event)) return false
|
||||
|
||||
const eventStartMs = new Date(event.starts).getTime()
|
||||
const eventEndMs = new Date(event.ends).getTime()
|
||||
if (!Number.isFinite(eventStartMs) || !Number.isFinite(eventEndMs)) return false
|
||||
if (eventEndMs < eventStartMs) return false
|
||||
|
||||
return eventEndMs >= rangeBounds.start.getTime() && eventStartMs <= rangeBounds.end.getTime()
|
||||
}
|
||||
|
||||
function doesTimelineEventMatchActiveStat(event: AnalyticsChartEvent) {
|
||||
if (!event.for_metric_kind?.length) return true
|
||||
return event.for_metric_kind.some((metricKind) => metricKind === context.activeStat.value)
|
||||
}
|
||||
|
||||
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
|
||||
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
|
||||
}
|
||||
|
||||
return {
|
||||
localAnalyticsChartEvents,
|
||||
hasChartEvents,
|
||||
visibleModrinthChartEvents,
|
||||
localProjectChartEvents,
|
||||
hasProjectEvents,
|
||||
visibleProjectChartEvents,
|
||||
visibleTimelineEvents,
|
||||
hasVisibleTimelineEvents,
|
||||
isTimelineEventVisibleInCurrentGraph,
|
||||
isProjectChartEventVisibleForLegend,
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectEventTitle(
|
||||
event: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
|
||||
formatMessage: FormatMessage,
|
||||
) {
|
||||
if (event.kind === 'version_uploaded') {
|
||||
const versionNumber = event.version_number.trim()
|
||||
return versionNumber
|
||||
? formatMessage(analyticsProjectEventMessages.versionReleased, { version: versionNumber })
|
||||
: formatMessage(analyticsProjectEventMessages.versionUploaded)
|
||||
}
|
||||
|
||||
if (isVisibleProjectStatusChangeEventStatus(event.status_to)) {
|
||||
return getProjectStatusEventTitle(event.status_to, formatMessage)
|
||||
}
|
||||
|
||||
return formatMessage(analyticsProjectEventMessages.projectStatusChanged)
|
||||
}
|
||||
|
||||
function getProjectStatusEventTitle(
|
||||
status: VisibleProjectStatusChangeEventStatus,
|
||||
formatMessage: FormatMessage,
|
||||
) {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return formatMessage(analyticsProjectEventMessages.projectApproved)
|
||||
case 'unlisted':
|
||||
return formatMessage(analyticsProjectEventMessages.projectUnlisted)
|
||||
case 'private':
|
||||
return formatMessage(analyticsProjectEventMessages.projectPrivate)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowProjectEvent(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) {
|
||||
if (event.kind !== 'status_changed') {
|
||||
return true
|
||||
}
|
||||
|
||||
return isVisibleProjectStatusChangeEventStatus(event.status_to)
|
||||
}
|
||||
|
||||
function isVisibleProjectStatusChangeEventStatus(
|
||||
status: Labrinth.Projects.v2.ProjectStatus,
|
||||
): status is VisibleProjectStatusChangeEventStatus {
|
||||
return VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET.has(status)
|
||||
}
|
||||
|
||||
function dedupeProjectVersionUploadEvents(events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]) {
|
||||
const keptEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[] = []
|
||||
const keptVersionUploadEventsByKey = new Map<
|
||||
string,
|
||||
Labrinth.Analytics.v3.ProjectAnalyticsEvent[]
|
||||
>()
|
||||
|
||||
for (const event of events) {
|
||||
const key = getProjectVersionUploadDedupeKey(event)
|
||||
if (!key) {
|
||||
keptEvents.push(event)
|
||||
continue
|
||||
}
|
||||
|
||||
const matchingEvents = keptVersionUploadEventsByKey.get(key) ?? []
|
||||
if (
|
||||
matchingEvents.some((matchingEvent) =>
|
||||
areProjectEventsWithinDedupeWindow(event, matchingEvent),
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
keptEvents.push(event)
|
||||
matchingEvents.push(event)
|
||||
keptVersionUploadEventsByKey.set(key, matchingEvents)
|
||||
}
|
||||
|
||||
return keptEvents
|
||||
}
|
||||
|
||||
function getProjectVersionUploadDedupeKey(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) {
|
||||
if (event.kind !== 'version_uploaded') return null
|
||||
|
||||
const versionNumber = event.version_number.trim()
|
||||
if (versionNumber.length === 0) return null
|
||||
|
||||
return `${event.project_id}:${versionNumber}`
|
||||
}
|
||||
|
||||
function areProjectEventsWithinDedupeWindow(
|
||||
left: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
|
||||
right: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
|
||||
) {
|
||||
const leftTimestamp = new Date(left.timestamp).getTime()
|
||||
const rightTimestamp = new Date(right.timestamp).getTime()
|
||||
if (!Number.isFinite(leftTimestamp) || !Number.isFinite(rightTimestamp)) return false
|
||||
|
||||
return Math.abs(leftTimestamp - rightTimestamp) <= PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS
|
||||
}
|
||||
|
||||
function formatProjectEventDate(timestamp: string) {
|
||||
const date = new Date(timestamp)
|
||||
if (Number.isNaN(date.getTime())) return timestamp
|
||||
return PROJECT_EVENT_DATE_FORMATTER.format(date)
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { computed, type ComputedRef, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
ensureMinimumTimeRange,
|
||||
getDefaultAnalyticsGroupByForDurationMinutes,
|
||||
} from '../../query-builder/timeframe.ts'
|
||||
import type {
|
||||
AnalyticsChartHoverState,
|
||||
AnalyticsChartRangeBounds,
|
||||
} from '../analytics-chart-types.ts'
|
||||
import type { ChartDataset } from '../analytics-chart-utils.ts'
|
||||
import { getSliceBucketRange } from '../analytics-chart-utils.ts'
|
||||
import type {
|
||||
AnalyticsChartGeometryPayload,
|
||||
AnalyticsChartRangeSelectPayload,
|
||||
} from '../AnalyticsChart.client.vue'
|
||||
import type AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
|
||||
|
||||
export function useAnalyticsChartInteractions({
|
||||
isDataLoading,
|
||||
fetchRequest,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
allChartDatasets,
|
||||
chartRangeBounds,
|
||||
shouldShowPreviousPeriod,
|
||||
onRangeSelected,
|
||||
}: {
|
||||
isDataLoading: ComputedRef<boolean>
|
||||
fetchRequest: ComputedRef<Labrinth.Analytics.v3.FetchRequest | null>
|
||||
sliceCount: ComputedRef<number>
|
||||
chartLabels: ComputedRef<string[]>
|
||||
allChartDatasets: ComputedRef<ChartDataset[]>
|
||||
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>
|
||||
shouldShowPreviousPeriod: ComputedRef<boolean>
|
||||
onRangeSelected: (start: Date, end: Date, groupBy: AnalyticsGroupByPreset) => void
|
||||
}) {
|
||||
const chartContainer = ref<HTMLElement | null>(null)
|
||||
const chartTooltip = ref<InstanceType<typeof AnalyticsChartTooltip> | null>(null)
|
||||
const chartGeometry = ref<AnalyticsChartGeometryPayload | null>(null)
|
||||
const containerSize = reactive({ width: 0, height: 0 })
|
||||
const hoverState = reactive<AnalyticsChartHoverState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
sliceIndex: null,
|
||||
})
|
||||
const isHoverPinned = ref(false)
|
||||
const ignoreNextChartClick = ref(false)
|
||||
const isShiftKeyPressed = ref(false)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let clearIgnoredChartClickTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function setHoverState(payload: AnalyticsChartHoverState) {
|
||||
hoverState.visible = payload.visible
|
||||
hoverState.x = payload.x
|
||||
hoverState.y = payload.y
|
||||
hoverState.sliceIndex = payload.sliceIndex
|
||||
}
|
||||
|
||||
function clearHoverState() {
|
||||
hoverState.visible = false
|
||||
hoverState.sliceIndex = null
|
||||
}
|
||||
|
||||
function unpinHoverState() {
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
}
|
||||
|
||||
function updateShiftKeyState(event: KeyboardEvent) {
|
||||
isShiftKeyPressed.value = event.shiftKey
|
||||
}
|
||||
|
||||
function clearShiftKeyState() {
|
||||
isShiftKeyPressed.value = false
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!isHoverPinned.value) return
|
||||
if (event.target instanceof Node && chartContainer.value?.contains(event.target)) return
|
||||
unpinHoverState()
|
||||
}
|
||||
|
||||
function onChartHover(payload: AnalyticsChartHoverState) {
|
||||
if (isDataLoading.value) return
|
||||
if (isHoverPinned.value) return
|
||||
setHoverState(payload)
|
||||
}
|
||||
|
||||
function ignoreUpcomingChartClick() {
|
||||
ignoreNextChartClick.value = true
|
||||
if (clearIgnoredChartClickTimeout) {
|
||||
clearTimeout(clearIgnoredChartClickTimeout)
|
||||
}
|
||||
clearIgnoredChartClickTimeout = setTimeout(() => {
|
||||
ignoreNextChartClick.value = false
|
||||
clearIgnoredChartClickTimeout = null
|
||||
}, 350)
|
||||
}
|
||||
|
||||
function onPinnedDrag(payload: AnalyticsChartHoverState) {
|
||||
if (isDataLoading.value || !isHoverPinned.value) return
|
||||
ignoreUpcomingChartClick()
|
||||
setHoverState(payload)
|
||||
}
|
||||
|
||||
function onTouchDragEnd() {
|
||||
ignoreUpcomingChartClick()
|
||||
}
|
||||
|
||||
function onChartGeometry(payload: AnalyticsChartGeometryPayload) {
|
||||
chartGeometry.value = payload
|
||||
}
|
||||
|
||||
function getDefaultGroupByForRange(start: Date, end: Date) {
|
||||
const ensuredRange = ensureMinimumTimeRange(start, end)
|
||||
const durationMinutes = Math.max(
|
||||
1,
|
||||
Math.floor((ensuredRange.end.getTime() - ensuredRange.start.getTime()) / 60000),
|
||||
)
|
||||
|
||||
return getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes)
|
||||
}
|
||||
|
||||
function onRangeSelect(payload: AnalyticsChartRangeSelectPayload) {
|
||||
if (isDataLoading.value) return
|
||||
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
if (!nextFetchRequest) return
|
||||
|
||||
if (payload.startSliceIndex === payload.endSliceIndex) {
|
||||
ignoreUpcomingChartClick()
|
||||
return
|
||||
}
|
||||
|
||||
const startSliceIndex = Math.min(payload.startSliceIndex, payload.endSliceIndex)
|
||||
const endSliceIndex = Math.max(payload.startSliceIndex, payload.endSliceIndex)
|
||||
const startBucketRange = getSliceBucketRange(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
startSliceIndex,
|
||||
)
|
||||
const endBucketRange = getSliceBucketRange(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
endSliceIndex,
|
||||
)
|
||||
const start = startBucketRange.start
|
||||
const end = endBucketRange.end
|
||||
|
||||
if (
|
||||
!Number.isFinite(start.getTime()) ||
|
||||
!Number.isFinite(end.getTime()) ||
|
||||
end.getTime() <= start.getTime()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
ignoreUpcomingChartClick()
|
||||
unpinHoverState()
|
||||
onRangeSelected(start, end, getDefaultGroupByForRange(start, end))
|
||||
}
|
||||
|
||||
function onChartClick() {
|
||||
if (isDataLoading.value) return
|
||||
if (ignoreNextChartClick.value) {
|
||||
ignoreNextChartClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!hoverState.visible || hoverState.sliceIndex === null) {
|
||||
if (isHoverPinned.value) {
|
||||
unpinHoverState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isHoverPinned.value) {
|
||||
unpinHoverState()
|
||||
return
|
||||
}
|
||||
|
||||
isHoverPinned.value = true
|
||||
}
|
||||
|
||||
function onChartWheel(event: WheelEvent) {
|
||||
if (isAnalyticsEventTooltipTrigger(event.target)) return
|
||||
if (!hoverState.visible) return
|
||||
chartTooltip.value?.consumeWheel(event)
|
||||
}
|
||||
|
||||
function isAnalyticsEventTooltipTrigger(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof Element && target.closest('[data-analytics-event-tooltip-trigger]') !== null
|
||||
)
|
||||
}
|
||||
|
||||
const pinnedSliceIndex = computed(() => (isHoverPinned.value ? hoverState.sliceIndex : null))
|
||||
const showHoverGuide = computed(
|
||||
() =>
|
||||
!isDataLoading.value &&
|
||||
!isHoverPinned.value &&
|
||||
hoverState.visible &&
|
||||
hoverState.sliceIndex !== null,
|
||||
)
|
||||
const showPinnedGuide = computed(
|
||||
() =>
|
||||
!isDataLoading.value &&
|
||||
isHoverPinned.value &&
|
||||
hoverState.visible &&
|
||||
hoverState.sliceIndex !== null,
|
||||
)
|
||||
const hoverBucketRange = computed(() => {
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
if (!nextFetchRequest || hoverState.sliceIndex === null) return null
|
||||
return getSliceBucketRange(nextFetchRequest.time_range, sliceCount.value, hoverState.sliceIndex)
|
||||
})
|
||||
const previousHoverBucketRange = computed(() => {
|
||||
if (!shouldShowPreviousPeriod.value) return null
|
||||
|
||||
const bucketRange = hoverBucketRange.value
|
||||
const rangeBounds = chartRangeBounds.value
|
||||
if (!bucketRange || !rangeBounds) return null
|
||||
|
||||
const periodMs = rangeBounds.end.getTime() - rangeBounds.start.getTime()
|
||||
if (!Number.isFinite(periodMs) || periodMs <= 0) return null
|
||||
|
||||
return {
|
||||
start: new Date(bucketRange.start.getTime() - periodMs),
|
||||
end: new Date(bucketRange.end.getTime() - periodMs),
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (chartContainer.value && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
containerSize.width = entry.contentRect.width
|
||||
containerSize.height = entry.contentRect.height
|
||||
})
|
||||
resizeObserver.observe(chartContainer.value)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', updateShiftKeyState)
|
||||
window.addEventListener('keyup', updateShiftKeyState)
|
||||
window.addEventListener('blur', clearShiftKeyState)
|
||||
document.addEventListener('click', onDocumentClick, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
window.removeEventListener('keydown', updateShiftKeyState)
|
||||
window.removeEventListener('keyup', updateShiftKeyState)
|
||||
window.removeEventListener('blur', clearShiftKeyState)
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
if (clearIgnoredChartClickTimeout) {
|
||||
clearTimeout(clearIgnoredChartClickTimeout)
|
||||
clearIgnoredChartClickTimeout = null
|
||||
}
|
||||
})
|
||||
|
||||
watch([chartLabels, allChartDatasets], () => {
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
})
|
||||
|
||||
watch(isDataLoading, (loading) => {
|
||||
if (!loading) return
|
||||
isHoverPinned.value = false
|
||||
clearHoverState()
|
||||
})
|
||||
|
||||
return {
|
||||
chartContainer,
|
||||
chartTooltip,
|
||||
chartGeometry,
|
||||
containerSize,
|
||||
hoverState,
|
||||
isHoverPinned,
|
||||
isShiftKeyPressed,
|
||||
setHoverState,
|
||||
clearHoverState,
|
||||
unpinHoverState,
|
||||
onChartHover,
|
||||
onPinnedDrag,
|
||||
onTouchDragEnd,
|
||||
onChartGeometry,
|
||||
onRangeSelect,
|
||||
onChartClick,
|
||||
onChartWheel,
|
||||
pinnedSliceIndex,
|
||||
showHoverGuide,
|
||||
showPinnedGuide,
|
||||
hoverBucketRange,
|
||||
previousHoverBucketRange,
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { computed, type ComputedRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
export function useAnalyticsChartLayout(showEmptyChartState: ComputedRef<boolean>) {
|
||||
const graphSection = ref<HTMLElement | null>(null)
|
||||
const rememberedGraphSectionHeight = ref(0)
|
||||
const graphSectionStyle = computed(() =>
|
||||
showEmptyChartState.value && rememberedGraphSectionHeight.value > 0
|
||||
? { height: `${rememberedGraphSectionHeight.value}px` }
|
||||
: undefined,
|
||||
)
|
||||
let graphSectionResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function rememberGraphSectionHeight() {
|
||||
if (!graphSection.value) return
|
||||
|
||||
const height = graphSection.value.getBoundingClientRect().height
|
||||
if (height > 0) {
|
||||
rememberedGraphSectionHeight.value = height
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (graphSection.value && typeof ResizeObserver !== 'undefined') {
|
||||
graphSectionResizeObserver = new ResizeObserver(() => {
|
||||
if (showEmptyChartState.value) return
|
||||
rememberGraphSectionHeight()
|
||||
})
|
||||
graphSectionResizeObserver.observe(graphSection.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
graphSectionResizeObserver?.disconnect()
|
||||
graphSectionResizeObserver = null
|
||||
})
|
||||
|
||||
watch(showEmptyChartState, (showEmpty) => {
|
||||
if (showEmpty) {
|
||||
rememberGraphSectionHeight()
|
||||
} else {
|
||||
nextTick(rememberGraphSectionHeight)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
graphSection,
|
||||
graphSectionStyle,
|
||||
rememberGraphSectionHeight,
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
export type AnalyticsChartRangeBounds = {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
export type AnalyticsChartHoverState = {
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
sliceIndex: number | null
|
||||
}
|
||||
|
||||
export type AnalyticsChartLegendEntry = {
|
||||
id: string
|
||||
name: string
|
||||
projectName?: string
|
||||
color: string
|
||||
totalValue: number
|
||||
hidden: boolean
|
||||
isPreviousPeriod?: boolean
|
||||
}
|
||||
+869
@@ -0,0 +1,869 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardProject,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGroupByPreset,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsChartMessages,
|
||||
analyticsMessages,
|
||||
analyticsStatCardMessages,
|
||||
formatAnalyticsDownloadReasonLabel,
|
||||
formatAnalyticsLoaderLabel,
|
||||
formatAnalyticsMonetizationLabel,
|
||||
type FormatMessage,
|
||||
} from '../analytics-messages'
|
||||
import {
|
||||
ALL_BREAKDOWN_VALUE,
|
||||
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
|
||||
getAnalyticsBreakdownDatasetId,
|
||||
getAnalyticsBreakdownKey,
|
||||
getAnalyticsBreakdownValues,
|
||||
UNKNOWN_BREAKDOWN_VALUE,
|
||||
} from '../breakdown'
|
||||
import { PREVIOUS_PERIOD_DATASET_ID_PREFIX } from './analytics-chart-constants'
|
||||
|
||||
export type ChartDataset = {
|
||||
projectId: string
|
||||
label: string
|
||||
projectName?: string
|
||||
data: number[]
|
||||
borderColor: string
|
||||
backgroundColor: string
|
||||
borderDash?: number[]
|
||||
}
|
||||
|
||||
export function getChartDatasetTotal(dataset: ChartDataset) {
|
||||
return dataset.data.reduce((sum, value) => sum + value, 0)
|
||||
}
|
||||
|
||||
export function getPreviousPeriodDatasetId(datasetId: string) {
|
||||
return `${PREVIOUS_PERIOD_DATASET_ID_PREFIX}${datasetId}`
|
||||
}
|
||||
|
||||
export function decodeBreakdownDatasetValue(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function areStringArraysEqual(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const LOADER_CHART_COLORS: Record<string, string> = {
|
||||
fabric: 'var(--color-platform-fabric)',
|
||||
'legacy-fabric': 'var(--color-platform-fabric)',
|
||||
quilt: 'var(--color-platform-quilt)',
|
||||
forge: 'var(--color-platform-forge)',
|
||||
neoforge: 'var(--color-platform-neoforge)',
|
||||
neo_forge: 'var(--color-platform-neoforge)',
|
||||
liteloader: 'var(--color-platform-liteloader)',
|
||||
bukkit: 'var(--color-platform-bukkit)',
|
||||
bungeecord: 'var(--color-platform-bungeecord)',
|
||||
folia: 'var(--color-platform-folia)',
|
||||
paper: 'var(--color-platform-paper)',
|
||||
purpur: 'var(--color-platform-purpur)',
|
||||
spigot: 'var(--color-platform-spigot)',
|
||||
velocity: 'var(--color-platform-velocity)',
|
||||
waterfall: 'var(--color-platform-waterfall)',
|
||||
sponge: 'var(--color-platform-sponge)',
|
||||
ornithe: 'var(--color-platform-ornithe)',
|
||||
'bta-babric': 'var(--color-platform-bta-babric)',
|
||||
nilloader: 'var(--color-platform-nilloader)',
|
||||
}
|
||||
|
||||
const REGION_CODE_PATTERN = /^[a-z]{2}$/i
|
||||
const OTHER_COUNTRY_CODE = 'XX'
|
||||
const ALL_PROJECTS_DATASET_ID = 'all'
|
||||
const MONETIZATION_CHART_COLOR_INDEX: Record<string, number> = {
|
||||
monetized: 0,
|
||||
unmonetized: 1,
|
||||
}
|
||||
const regionDisplayNamesByLocale = new Map<string, Intl.DisplayNames | null>()
|
||||
|
||||
function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
|
||||
if (regionDisplayNamesByLocale.has(locale)) {
|
||||
return regionDisplayNamesByLocale.get(locale) ?? null
|
||||
}
|
||||
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames(locale, { type: 'region' })
|
||||
regionDisplayNamesByLocale.set(locale, displayNames)
|
||||
return displayNames
|
||||
} catch {
|
||||
regionDisplayNamesByLocale.set(locale, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountryCode(countryCode: string, formatMessage: FormatMessage): string {
|
||||
const normalized = countryCode.trim().toUpperCase()
|
||||
if (normalized === OTHER_COUNTRY_CODE) {
|
||||
return formatMessage(analyticsMessages.other)
|
||||
}
|
||||
|
||||
if (!REGION_CODE_PATTERN.test(normalized)) {
|
||||
return countryCode
|
||||
}
|
||||
|
||||
const locale = new Intl.DateTimeFormat().resolvedOptions().locale || 'en'
|
||||
const localizedDisplayNames = getRegionDisplayNames(locale)
|
||||
const localizedValue = localizedDisplayNames?.of(normalized)
|
||||
if (localizedValue && localizedValue !== normalized) {
|
||||
return localizedValue
|
||||
}
|
||||
|
||||
const englishDisplayNames = getRegionDisplayNames('en')
|
||||
const englishValue = englishDisplayNames?.of(normalized)
|
||||
if (englishValue && englishValue !== normalized) {
|
||||
return englishValue
|
||||
}
|
||||
|
||||
return countryCode
|
||||
}
|
||||
|
||||
export function formatBreakdownLabel(
|
||||
breakdownValue: string,
|
||||
selectedBreakdown: AnalyticsBreakdownPreset,
|
||||
getVersionDisplayName: ((versionId: string) => string) | undefined,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const normalizedValue = breakdownValue.trim()
|
||||
const normalizedLowercaseValue = normalizedValue.toLowerCase()
|
||||
|
||||
if (
|
||||
normalizedValue === UNKNOWN_BREAKDOWN_VALUE ||
|
||||
normalizedLowercaseValue === 'other' ||
|
||||
normalizedLowercaseValue === 'unknown'
|
||||
) {
|
||||
if (selectedBreakdown === 'country') {
|
||||
return formatMessage(analyticsMessages.other)
|
||||
}
|
||||
return formatMessage(analyticsMessages.unknown)
|
||||
}
|
||||
if (selectedBreakdown === 'country') {
|
||||
return formatCountryCode(breakdownValue, formatMessage)
|
||||
}
|
||||
if (selectedBreakdown === 'monetization') {
|
||||
return formatAnalyticsMonetizationLabel(normalizedLowercaseValue, formatMessage)
|
||||
}
|
||||
if (selectedBreakdown === 'download_reason') {
|
||||
return formatAnalyticsDownloadReasonLabel(normalizedLowercaseValue, formatMessage)
|
||||
}
|
||||
if (selectedBreakdown === 'version_id') {
|
||||
return getVersionDisplayName?.(breakdownValue) ?? breakdownValue
|
||||
}
|
||||
if (selectedBreakdown === 'loader') {
|
||||
return formatAnalyticsLoaderLabel(normalizedValue, formatMessage)
|
||||
}
|
||||
|
||||
return breakdownValue
|
||||
}
|
||||
|
||||
export function formatBreakdownLabels(
|
||||
breakdownValues: readonly string[],
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
getVersionDisplayName: ((versionId: string) => string) | undefined,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return collapseRepeatedUnknownBreakdownLabels(
|
||||
selectedBreakdowns
|
||||
.filter((breakdown) => breakdown !== 'none')
|
||||
.map((breakdown, index) =>
|
||||
formatBreakdownLabel(
|
||||
breakdownValues[index] ?? '',
|
||||
breakdown,
|
||||
getVersionDisplayName,
|
||||
formatMessage,
|
||||
),
|
||||
),
|
||||
formatMessage,
|
||||
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
|
||||
}
|
||||
|
||||
function collapseRepeatedUnknownBreakdownLabels(
|
||||
labels: string[],
|
||||
formatMessage: FormatMessage,
|
||||
): string[] {
|
||||
let hasUnknownLabel = false
|
||||
const collapsedLabels: string[] = []
|
||||
const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown)
|
||||
|
||||
for (const label of labels) {
|
||||
if (label === unknownBreakdownLabel) {
|
||||
if (hasUnknownLabel) {
|
||||
continue
|
||||
}
|
||||
hasUnknownLabel = true
|
||||
}
|
||||
|
||||
collapsedLabels.push(label)
|
||||
}
|
||||
|
||||
return collapsedLabels
|
||||
}
|
||||
|
||||
export function shouldCapitalizeBreakdownLabel(
|
||||
selectedBreakdown: AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[],
|
||||
): boolean {
|
||||
const selectedBreakdowns = Array.isArray(selectedBreakdown)
|
||||
? selectedBreakdown
|
||||
: [selectedBreakdown]
|
||||
return (
|
||||
selectedBreakdowns.length > 0 &&
|
||||
selectedBreakdowns.every(
|
||||
(breakdown) =>
|
||||
breakdown === 'download_reason' ||
|
||||
breakdown === 'monetization' ||
|
||||
breakdown === 'loader' ||
|
||||
breakdown === 'country',
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getBreakdownColor(
|
||||
breakdownValue: string,
|
||||
selectedBreakdown: AnalyticsBreakdownPreset,
|
||||
fallbackColor: string,
|
||||
palette: string[],
|
||||
): string {
|
||||
if (selectedBreakdown === 'monetization') {
|
||||
const colorIndex = MONETIZATION_CHART_COLOR_INDEX[breakdownValue]
|
||||
if (colorIndex !== undefined) {
|
||||
return getPaletteColorForIndex(colorIndex, palette)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedBreakdown !== 'loader') {
|
||||
return fallbackColor
|
||||
}
|
||||
|
||||
const normalizedLoader = breakdownValue.trim().toLowerCase()
|
||||
return LOADER_CHART_COLORS[normalizedLoader] ?? fallbackColor
|
||||
}
|
||||
|
||||
type PaletteRankEntry = {
|
||||
key: string
|
||||
label: string
|
||||
total: number
|
||||
}
|
||||
|
||||
function getPaletteColorForIndex(index: number, palette: string[]): string {
|
||||
if (palette.length === 0) return ''
|
||||
|
||||
return palette[index % palette.length]
|
||||
}
|
||||
|
||||
function buildPaletteColorsByDownloadRank(
|
||||
entries: PaletteRankEntry[],
|
||||
palette: string[],
|
||||
): Map<string, string> {
|
||||
const colorsByKey = new Map<string, string>()
|
||||
if (palette.length === 0) return colorsByKey
|
||||
|
||||
const sortedEntries = [...entries].sort(
|
||||
(a, b) => b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
|
||||
)
|
||||
sortedEntries.forEach((entry, index) => {
|
||||
colorsByKey.set(entry.key, getPaletteColorForIndex(index, palette))
|
||||
})
|
||||
|
||||
return colorsByKey
|
||||
}
|
||||
|
||||
export function getMetricValue(
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
): number {
|
||||
switch (activeStat) {
|
||||
case 'views':
|
||||
return point.metric_kind === 'views' ? point.views : 0
|
||||
case 'downloads':
|
||||
return point.metric_kind === 'downloads' ? point.downloads : 0
|
||||
case 'playtime':
|
||||
return point.metric_kind === 'playtime' ? point.seconds : 0
|
||||
case 'revenue': {
|
||||
if (point.metric_kind !== 'revenue') return 0
|
||||
const value = Number.parseFloat(point.revenue)
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isMetricKindForStat(
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
): boolean {
|
||||
return point.metric_kind === activeStat
|
||||
}
|
||||
|
||||
function isProjectAnalyticsPointInSelectedProjects(
|
||||
point: Labrinth.Analytics.v3.AnalyticsData,
|
||||
selectedProjectIds: Set<string>,
|
||||
): point is Labrinth.Analytics.v3.ProjectAnalytics {
|
||||
return 'source_project' in point && selectedProjectIds.has(point.source_project)
|
||||
}
|
||||
|
||||
export function buildChartDatasets(
|
||||
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
|
||||
selectedProjects: AnalyticsDashboardProject[],
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
palette: string[],
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
getVersionDisplayName: ((versionId: string) => string) | undefined,
|
||||
getVersionProjectName: ((versionId: string) => string | undefined) | undefined,
|
||||
formatMessage: FormatMessage,
|
||||
sliceCount: number = timeSlices.length,
|
||||
): ChartDataset[] {
|
||||
const selectedProjectIds = new Set(selectedProjects.map((project) => project.id))
|
||||
if (selectedProjectIds.size === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const dataLength = Math.max(sliceCount, timeSlices.length)
|
||||
const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none')
|
||||
const projectNamesById = new Map(selectedProjects.map((project) => [project.id, project.name]))
|
||||
|
||||
function formatChartBreakdownLabels(breakdownValues: readonly string[]): string {
|
||||
return collapseRepeatedUnknownBreakdownLabels(
|
||||
normalizedBreakdowns.map((breakdown, index) => {
|
||||
const breakdownValue = breakdownValues[index] ?? ''
|
||||
if (breakdown === 'project') {
|
||||
return projectNamesById.get(breakdownValue) ?? breakdownValue
|
||||
}
|
||||
|
||||
return formatBreakdownLabel(breakdownValue, breakdown, getVersionDisplayName, formatMessage)
|
||||
}),
|
||||
formatMessage,
|
||||
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedBreakdowns.length > 0 &&
|
||||
!(normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'project')
|
||||
) {
|
||||
const dataByBreakdown = new Map<string, number[]>()
|
||||
const breakdownValuesByKey = new Map<string, string[]>()
|
||||
const downloadTotalsByBreakdown = new Map<string, number>()
|
||||
|
||||
timeSlices.forEach((slice, sliceIndex) => {
|
||||
for (const point of slice) {
|
||||
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
|
||||
|
||||
const breakdownValues = getAnalyticsBreakdownValues(
|
||||
point,
|
||||
normalizedBreakdowns,
|
||||
formatMessage,
|
||||
)
|
||||
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
|
||||
continue
|
||||
}
|
||||
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
|
||||
|
||||
if (!dataByBreakdown.has(breakdownKey)) {
|
||||
dataByBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
|
||||
breakdownValuesByKey.set(breakdownKey, breakdownValues)
|
||||
}
|
||||
|
||||
if (point.metric_kind === 'downloads') {
|
||||
downloadTotalsByBreakdown.set(
|
||||
breakdownKey,
|
||||
(downloadTotalsByBreakdown.get(breakdownKey) ?? 0) + getMetricValue(point, 'downloads'),
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMetricKindForStat(point, activeStat)) continue
|
||||
|
||||
const breakdownData = dataByBreakdown.get(breakdownKey)
|
||||
if (!breakdownData) continue
|
||||
breakdownData[sliceIndex] += getMetricValue(point, activeStat)
|
||||
}
|
||||
})
|
||||
|
||||
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
|
||||
Array.from(dataByBreakdown.keys()).map((breakdownKey) => ({
|
||||
key: breakdownKey,
|
||||
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
|
||||
total: downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
|
||||
})),
|
||||
palette,
|
||||
)
|
||||
|
||||
return Array.from(dataByBreakdown.entries()).map(([breakdownKey, data]) => {
|
||||
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
|
||||
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
|
||||
const color =
|
||||
normalizedBreakdowns.length === 1
|
||||
? getBreakdownColor(
|
||||
breakdownValues[0] ?? '',
|
||||
normalizedBreakdowns[0],
|
||||
fallbackColor,
|
||||
palette,
|
||||
)
|
||||
: fallbackColor
|
||||
return {
|
||||
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
|
||||
label: formatChartBreakdownLabels(breakdownValues),
|
||||
projectName:
|
||||
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
|
||||
? getVersionProjectName?.(breakdownValues[0] ?? '')
|
||||
: undefined,
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedBreakdowns.length === 0) {
|
||||
const data = new Array(dataLength).fill(0)
|
||||
let downloadTotal = 0
|
||||
|
||||
timeSlices.forEach((slice, sliceIndex) => {
|
||||
for (const point of slice) {
|
||||
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
|
||||
|
||||
if (point.metric_kind === 'downloads') {
|
||||
downloadTotal += getMetricValue(point, 'downloads')
|
||||
}
|
||||
|
||||
if (!isMetricKindForStat(point, activeStat)) continue
|
||||
|
||||
data[sliceIndex] += getMetricValue(point, activeStat)
|
||||
}
|
||||
})
|
||||
|
||||
const color =
|
||||
buildPaletteColorsByDownloadRank(
|
||||
[
|
||||
{
|
||||
key: ALL_PROJECTS_DATASET_ID,
|
||||
label: formatMessage(analyticsMessages.allProjects),
|
||||
total: downloadTotal,
|
||||
},
|
||||
],
|
||||
palette,
|
||||
).get(ALL_PROJECTS_DATASET_ID) ?? ''
|
||||
const selectedProject = selectedProjects.length === 1 ? selectedProjects[0] : undefined
|
||||
|
||||
return [
|
||||
{
|
||||
projectId: ALL_PROJECTS_DATASET_ID,
|
||||
label: selectedProject?.name ?? formatMessage(analyticsMessages.allProjects),
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const dataByProjectBreakdown = new Map<string, number[]>()
|
||||
const breakdownValuesByKey = new Map<string, string[]>()
|
||||
const downloadTotalsByProjectBreakdown = new Map<string, number>()
|
||||
for (const project of selectedProjects) {
|
||||
const breakdownValues = [project.id]
|
||||
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
|
||||
dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
|
||||
breakdownValuesByKey.set(breakdownKey, breakdownValues)
|
||||
downloadTotalsByProjectBreakdown.set(breakdownKey, 0)
|
||||
}
|
||||
|
||||
timeSlices.forEach((slice, sliceIndex) => {
|
||||
for (const point of slice) {
|
||||
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
|
||||
|
||||
const breakdownValues = getAnalyticsBreakdownValues(
|
||||
point,
|
||||
normalizedBreakdowns,
|
||||
formatMessage,
|
||||
)
|
||||
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
|
||||
continue
|
||||
}
|
||||
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
|
||||
if (!dataByProjectBreakdown.has(breakdownKey)) {
|
||||
dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
|
||||
breakdownValuesByKey.set(breakdownKey, breakdownValues)
|
||||
downloadTotalsByProjectBreakdown.set(breakdownKey, 0)
|
||||
}
|
||||
|
||||
if (point.metric_kind === 'downloads') {
|
||||
downloadTotalsByProjectBreakdown.set(
|
||||
breakdownKey,
|
||||
(downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0) +
|
||||
getMetricValue(point, 'downloads'),
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMetricKindForStat(point, activeStat)) continue
|
||||
|
||||
const projectData = dataByProjectBreakdown.get(breakdownKey)
|
||||
if (!projectData) continue
|
||||
|
||||
projectData[sliceIndex] += getMetricValue(point, activeStat)
|
||||
}
|
||||
})
|
||||
|
||||
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
|
||||
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) => ({
|
||||
key: breakdownKey,
|
||||
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
|
||||
total: downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
|
||||
})),
|
||||
palette,
|
||||
)
|
||||
|
||||
return Array.from(dataByProjectBreakdown.entries()).map(([breakdownKey, data]) => {
|
||||
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
|
||||
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
|
||||
const color =
|
||||
normalizedBreakdowns.length === 1
|
||||
? getBreakdownColor(
|
||||
breakdownValues[0] ?? '',
|
||||
normalizedBreakdowns[0],
|
||||
fallbackColor,
|
||||
palette,
|
||||
)
|
||||
: fallbackColor
|
||||
return {
|
||||
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
|
||||
label: formatChartBreakdownLabels(breakdownValues),
|
||||
projectName:
|
||||
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
|
||||
? getVersionProjectName?.(breakdownValues[0] ?? '')
|
||||
: undefined,
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getSliceCount(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
fallback: number,
|
||||
): number {
|
||||
if ('slices' in timeRange.resolution) {
|
||||
return Math.max(1, timeRange.resolution.slices)
|
||||
}
|
||||
if ('minutes' in timeRange.resolution) {
|
||||
const duration = new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime()
|
||||
const bucketMs = timeRange.resolution.minutes * 60 * 1000
|
||||
if (bucketMs > 0 && duration > 0) {
|
||||
return Math.max(1, Math.ceil(duration / bucketMs))
|
||||
}
|
||||
}
|
||||
return Math.max(1, fallback)
|
||||
}
|
||||
|
||||
export function getSliceBucketRange(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
sliceCount: number,
|
||||
index: number,
|
||||
): { start: Date; end: Date } {
|
||||
const startMs = new Date(timeRange.start).getTime()
|
||||
const endMs = new Date(timeRange.end).getTime()
|
||||
const bucketMs = sliceCount > 0 ? (endMs - startMs) / sliceCount : 0
|
||||
|
||||
return {
|
||||
start: new Date(startMs + index * bucketMs),
|
||||
end: new Date(startMs + (index + 1) * bucketMs),
|
||||
}
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const ONE_MINUTE_MS = 60 * 1000
|
||||
const YEAR_LABEL_TIME_RANGE_YEARS = 2
|
||||
const COMPACT_AXIS_THRESHOLD = 5
|
||||
const SHORT_HOURLY_TIME_LABEL_DURATION_MS = 6 * ONE_DAY_MS
|
||||
export const DEFAULT_X_AXIS_TICK_LIMIT = 12
|
||||
export const SHORT_HOURLY_AXIS_TICK_LIMIT = 8
|
||||
|
||||
export function buildTimeAxisLabels(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
sliceCount: number,
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
): string[] {
|
||||
const startMs = new Date(timeRange.start).getTime()
|
||||
const endMs = new Date(timeRange.end).getTime()
|
||||
const totalMs = endMs - startMs
|
||||
const bucketMs = sliceCount > 0 ? totalMs / sliceCount : 0
|
||||
const includeTime = shouldShowTimeForHourlyAxis(timeRange, groupBy)
|
||||
const includeYear = isYearRelevantForTimeRange(timeRange) || groupBy === 'year'
|
||||
|
||||
const dates: Date[] = []
|
||||
const dateKeys: string[] = []
|
||||
for (let i = 0; i < sliceCount; i++) {
|
||||
const date = new Date(startMs + (i + 1) * bucketMs)
|
||||
dates.push(date)
|
||||
dateKeys.push(`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`)
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(includeYear ? { year: 'numeric' } : {}),
|
||||
})
|
||||
|
||||
if (!includeTime) {
|
||||
return dates.map((date) => dateFormatter.format(date))
|
||||
}
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric' })
|
||||
const uniqueDateCount = new Set(dateKeys).size
|
||||
|
||||
if (uniqueDateCount <= 1 || isSingleFullDayTimeRange(new Date(startMs), new Date(endMs))) {
|
||||
return dates.map((date) => timeFormatter.format(date))
|
||||
}
|
||||
|
||||
if (includeTime || sliceCount <= COMPACT_AXIS_THRESHOLD) {
|
||||
const dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
...(includeYear ? { year: 'numeric' } : {}),
|
||||
})
|
||||
return dates.map((date) => dateAndTimeFormatter.format(date))
|
||||
}
|
||||
|
||||
return dates.map((date) => dateFormatter.format(date))
|
||||
}
|
||||
|
||||
export function isTimeRelevantForGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
|
||||
return groupBy === '1h' || groupBy === '6h'
|
||||
}
|
||||
|
||||
export function shouldUseShortHourlyAxis(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
): boolean {
|
||||
if (!isTimeRelevantForGroupBy(groupBy)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const durationMs = getTimeRangeDurationMs(timeRange)
|
||||
|
||||
return (
|
||||
Number.isFinite(durationMs) &&
|
||||
durationMs > 0 &&
|
||||
durationMs <= DEFAULT_X_AXIS_TICK_LIMIT * ONE_DAY_MS
|
||||
)
|
||||
}
|
||||
|
||||
export function getShortHourlyAxisTickLimit(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
): number | undefined {
|
||||
if (!shouldUseShortHourlyAxis(timeRange, groupBy)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const durationMs = getTimeRangeDurationMs(timeRange)
|
||||
if (durationMs > SHORT_HOURLY_TIME_LABEL_DURATION_MS) {
|
||||
return Math.min(DEFAULT_X_AXIS_TICK_LIMIT, Math.ceil(durationMs / ONE_DAY_MS))
|
||||
}
|
||||
|
||||
return SHORT_HOURLY_AXIS_TICK_LIMIT
|
||||
}
|
||||
|
||||
function shouldShowTimeForHourlyAxis(
|
||||
timeRange: Labrinth.Analytics.v3.TimeRange,
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
): boolean {
|
||||
const durationMs = getTimeRangeDurationMs(timeRange)
|
||||
return (
|
||||
isTimeRelevantForGroupBy(groupBy) &&
|
||||
Number.isFinite(durationMs) &&
|
||||
durationMs > 0 &&
|
||||
durationMs <= SHORT_HOURLY_TIME_LABEL_DURATION_MS
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeRangeDurationMs(timeRange: Labrinth.Analytics.v3.TimeRange): number {
|
||||
return new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime()
|
||||
}
|
||||
|
||||
export function isYearRelevantForTimeRange(timeRange: Labrinth.Analytics.v3.TimeRange): boolean {
|
||||
const start = new Date(timeRange.start)
|
||||
const end = new Date(timeRange.end)
|
||||
const yearLabelThreshold = new Date(start)
|
||||
yearLabelThreshold.setFullYear(start.getFullYear() + YEAR_LABEL_TIME_RANGE_YEARS)
|
||||
|
||||
return (
|
||||
Number.isFinite(start.getTime()) &&
|
||||
Number.isFinite(end.getTime()) &&
|
||||
end.getTime() > yearLabelThreshold.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
export function formatBucketEndLabel(end: Date, includeTime: boolean, includeYear = false): string {
|
||||
if (includeTime) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(includeYear ? { year: 'numeric' } : {}),
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(end)
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(includeYear ? { year: 'numeric' } : {}),
|
||||
}).format(end)
|
||||
}
|
||||
|
||||
function isStartOfDay(date: Date): boolean {
|
||||
return (
|
||||
date.getHours() === 0 &&
|
||||
date.getMinutes() === 0 &&
|
||||
date.getSeconds() === 0 &&
|
||||
date.getMilliseconds() === 0
|
||||
)
|
||||
}
|
||||
|
||||
function isSingleFullDayTimeRange(start: Date, end: Date): boolean {
|
||||
const durationMs = end.getTime() - start.getTime()
|
||||
return (
|
||||
Math.abs(durationMs - ONE_DAY_MS) < ONE_MINUTE_MS && isStartOfDay(start) && isStartOfDay(end)
|
||||
)
|
||||
}
|
||||
|
||||
export function formatMetricValue(
|
||||
value: number,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
formatNumber: (value: number) => string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (activeStat) {
|
||||
case 'revenue': {
|
||||
const amount = Math.round(value * 100) / 100
|
||||
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatNumber(amount),
|
||||
})
|
||||
}
|
||||
case 'playtime': {
|
||||
const hours = value / 3600
|
||||
return formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: Math.abs(hours) < 1 ? hours.toFixed(2) : hours.toFixed(1),
|
||||
})
|
||||
}
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
default:
|
||||
return formatNumber(Math.round(value))
|
||||
}
|
||||
}
|
||||
|
||||
function formatSmallAxisNumber(value: number): string {
|
||||
const rounded = Math.round(value)
|
||||
if (Math.abs(value - rounded) < 0.0000001) {
|
||||
return String(rounded)
|
||||
}
|
||||
|
||||
const formattedValue = Math.abs(value) < 1 ? value.toFixed(2) : value.toFixed(1)
|
||||
return trimTrailingFractionZeros(formattedValue)
|
||||
}
|
||||
|
||||
function trimTrailingFractionZeros(value: string): string {
|
||||
return value.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
|
||||
}
|
||||
|
||||
const COMPACT_AXIS_UNITS = [
|
||||
{ threshold: 1_000_000, divisor: 1_000_000, suffix: 'M' },
|
||||
{ threshold: 1_000, divisor: 1_000, suffix: 'K' },
|
||||
] as const
|
||||
const MAX_COMPACT_AXIS_DIGITS = 3
|
||||
|
||||
function getCompactAxisUnit(values: readonly number[]) {
|
||||
let maxAbsoluteValue = 0
|
||||
for (const value of values) {
|
||||
if (Number.isFinite(value)) {
|
||||
maxAbsoluteValue = Math.max(maxAbsoluteValue, Math.abs(value))
|
||||
}
|
||||
}
|
||||
|
||||
return COMPACT_AXIS_UNITS.find((unit) => maxAbsoluteValue >= unit.threshold) ?? null
|
||||
}
|
||||
|
||||
function formatCompactAxisNumber(value: number, axisValues: readonly number[]): string | null {
|
||||
if (Math.abs(value) === 0) return '0'
|
||||
|
||||
const unit = getCompactAxisUnit(axisValues)
|
||||
if (!unit) return null
|
||||
|
||||
return `${formatCompactAxisValue(value / unit.divisor)}${unit.suffix}`
|
||||
}
|
||||
|
||||
function formatCompactAxisValue(value: number): string {
|
||||
const absoluteValue = Math.abs(value)
|
||||
if (absoluteValue === 0) return '0'
|
||||
|
||||
const integerDigitCount = absoluteValue < 1 ? 1 : Math.floor(absoluteValue).toString().length
|
||||
const fractionDigitCount = Math.max(0, MAX_COMPACT_AXIS_DIGITS - integerDigitCount)
|
||||
const roundedValue = Number(value.toFixed(fractionDigitCount))
|
||||
const roundedIntegerDigitCount =
|
||||
Math.abs(roundedValue) < 1 ? 1 : Math.floor(Math.abs(roundedValue)).toString().length
|
||||
|
||||
if (roundedIntegerDigitCount > MAX_COMPACT_AXIS_DIGITS) {
|
||||
const truncatedValue = Math.sign(value) * (10 ** MAX_COMPACT_AXIS_DIGITS - 1)
|
||||
return String(truncatedValue)
|
||||
}
|
||||
|
||||
return trimTrailingFractionZeros(roundedValue.toFixed(fractionDigitCount))
|
||||
}
|
||||
|
||||
export function formatAxisValue(
|
||||
value: number,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
formatCompact: (value: number) => string,
|
||||
formatMessage: FormatMessage,
|
||||
axisValues: readonly number[] = [value],
|
||||
): string {
|
||||
switch (activeStat) {
|
||||
case 'revenue': {
|
||||
const amount = Math.round(value * 100) / 100
|
||||
const axisAmounts = axisValues.map((axisValue) => Math.round(axisValue * 100) / 100)
|
||||
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatCompactAxisNumber(amount, axisAmounts) ?? formatCompact(amount),
|
||||
})
|
||||
}
|
||||
case 'playtime': {
|
||||
const formattedHours = formatCompactAxisNumber(value, axisValues)
|
||||
if (formattedHours) {
|
||||
return formatMessage(analyticsChartMessages.playtimeAxisHours, { hours: formattedHours })
|
||||
}
|
||||
if (Math.abs(value) < 10) {
|
||||
return formatMessage(analyticsChartMessages.playtimeAxisHours, {
|
||||
hours: formatSmallAxisNumber(value),
|
||||
})
|
||||
}
|
||||
return formatMessage(analyticsChartMessages.playtimeAxisHours, {
|
||||
hours: formatCompact(Math.round(value)),
|
||||
})
|
||||
}
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
default: {
|
||||
const roundedValue = Math.round(value)
|
||||
const roundedAxisValues = axisValues.map((axisValue) => Math.round(axisValue))
|
||||
const formattedValue = formatCompactAxisNumber(roundedValue, roundedAxisValues)
|
||||
if (formattedValue) return formattedValue
|
||||
if (Math.abs(value) < 10) {
|
||||
return formatSmallAxisNumber(value)
|
||||
}
|
||||
return formatCompact(roundedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<AnalyticsChartRenderLimitModal
|
||||
ref="showAllSelectedGraphDatasetsModal"
|
||||
:table-project-count="tableProjectCount"
|
||||
@confirm="confirmShowAllSelectedGraphDatasets"
|
||||
/>
|
||||
|
||||
<section
|
||||
ref="graphSection"
|
||||
class="relative flex flex-col rounded-2xl border border-solid border-surface-5 bg-surface-3"
|
||||
:style="graphSectionStyle"
|
||||
>
|
||||
<AnalyticsChartHeader
|
||||
v-model:active-graph-view-mode="activeGraphViewMode"
|
||||
v-model:ratio-mode="isRatioMode"
|
||||
v-model:show-chart-events="showChartEvents"
|
||||
v-model:show-project-events="showProjectEvents"
|
||||
v-model:show-previous-period="showPreviousPeriod"
|
||||
:graph-title="graphTitle"
|
||||
:show-table-selection-subheading="showTableSelectionSubheading"
|
||||
:table-selection-subheading="tableSelectionSubheading"
|
||||
:show-graph-render-limit-button="showGraphRenderLimitButton"
|
||||
:graph-render-limit-button-label="graphRenderLimitButtonLabel"
|
||||
:show-top-graph-datasets-button="showTopGraphDatasetsButton"
|
||||
:can-use-ratio-mode="canUseRatioMode"
|
||||
:can-show-previous-period="canShowPreviousPeriodToggle"
|
||||
:has-chart-events="hasChartEvents"
|
||||
:has-project-events="hasProjectEvents"
|
||||
:small-toggles="!isMobileLayout"
|
||||
:default-show-project-events="defaultShowProjectEvents"
|
||||
:is-mobile-layout="isMobileLayout"
|
||||
@toggle-graph-render-limit="toggleGraphRenderLimit"
|
||||
@show-top-graph-datasets="showTopGraphDatasets"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-6 px-4 pb-6 pt-5"
|
||||
:class="['transition-opacity', isDataLoading ? 'pointer-events-none opacity-75' : '']"
|
||||
>
|
||||
<AnalyticsChartLegend
|
||||
:legend-entries="legendEntries"
|
||||
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
|
||||
:show-unmonetized-info="showUnmonetizedInfo"
|
||||
@entry-hover="setHoveredLegendEntryId"
|
||||
@entry-hover-clear="clearHoveredLegendEntryId"
|
||||
@entry-click="onLegendEntryClick"
|
||||
/>
|
||||
|
||||
<AnalyticsChartPlot
|
||||
:chart-type="chartType"
|
||||
:is-area="isArea"
|
||||
:is-stacked="isStacked"
|
||||
:is-ratio-mode="isRatioMode"
|
||||
:is-data-loading="isDataLoading"
|
||||
:show-empty-chart-state="showEmptyChartState"
|
||||
:empty-chart-message="emptyChartMessage"
|
||||
:visible-chart-datasets="visibleChartDatasets"
|
||||
:chart-labels="chartLabels"
|
||||
:x-axis-tick-limit="xAxisTickLimit"
|
||||
:active-stat="activeStat"
|
||||
:highlighted-chart-dataset-id="highlightedChartDatasetId"
|
||||
:has-visible-timeline-events="hasVisibleTimelineEvents"
|
||||
:visible-timeline-events="visibleTimelineEvents"
|
||||
:selected-group-by="selectedGroupBy"
|
||||
:chart-range-bounds="chartRangeBounds"
|
||||
:fetch-request="fetchRequest"
|
||||
:slice-count="sliceCount"
|
||||
:should-show-previous-period="shouldShowPreviousPeriod"
|
||||
:all-chart-datasets="allChartDatasets"
|
||||
:current-legend-entries="currentLegendEntries"
|
||||
:legend-entries="legendEntries"
|
||||
:chart-dataset-by-id="chartDatasetById"
|
||||
:hover-ratio-slice-totals="hoverRatioSliceTotals"
|
||||
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
|
||||
@range-select="onRangeSelect"
|
||||
@entry-click="onTooltipEntryClick"
|
||||
@entry-hover="setHoveredLegendEntryId"
|
||||
@entry-hover-clear="clearHoveredLegendEntryId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-[20] overflow-hidden rounded-xl">
|
||||
<AnalyticsLoadingBar :loading="isDataLoading" />
|
||||
</div>
|
||||
|
||||
<div v-if="isDataLoading" class="absolute inset-0 z-[19] overflow-hidden rounded-xl">
|
||||
<div class="absolute inset-0 bg-surface-3 opacity-50" />
|
||||
<div class="absolute inset-0 backdrop-blur-[3px]" />
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="relative bottom-6 inline-flex items-center gap-2 text-lg font-semibold text-primary"
|
||||
>
|
||||
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { getDefaultAnalyticsGraphProjectEventsVisibility } from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
import { injectAnalyticsDashboardContext } from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsMessages } from '../analytics-messages.ts'
|
||||
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
|
||||
import AnalyticsChartLegend from './analytics-chart-header/AnalyticsChartLegend.vue'
|
||||
import AnalyticsChartRenderLimitModal from './analytics-chart-header/AnalyticsChartRenderLimitModal.vue'
|
||||
import AnalyticsChartHeader from './analytics-chart-header/index.vue'
|
||||
import { useAnalyticsChartLegend } from './analytics-chart-header/use-analytics-chart-legend.ts'
|
||||
import AnalyticsChartPlot from './analytics-chart-plot/index.vue'
|
||||
import { useAnalyticsChartEvents } from './analytics-chart-plot/use-analytics-chart-events.ts'
|
||||
import { useAnalyticsChartLayout } from './analytics-chart-plot/use-analytics-chart-layout.ts'
|
||||
import { useAnalyticsChartDatasets } from './use-analytics-chart-datasets.ts'
|
||||
import { useAnalyticsChartProjects } from './use-analytics-chart-projects.ts'
|
||||
|
||||
const dashboardContext = injectAnalyticsDashboardContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
activeStat,
|
||||
activeGraphViewMode,
|
||||
isRatioMode,
|
||||
showChartEvents,
|
||||
showProjectEvents,
|
||||
showPreviousPeriod,
|
||||
isMobileLayout,
|
||||
hiddenGraphDatasetIds,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjectIds: currentSelectedProjectIds,
|
||||
selectedTimeframeMode,
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy: selectedDashboardGroupBy,
|
||||
displayedFetchRequest: fetchRequest,
|
||||
displayedSelectedGroupBy: selectedGroupBy,
|
||||
displayedSelectedBreakdowns: selectedBreakdowns,
|
||||
isLoading,
|
||||
} = dashboardContext
|
||||
|
||||
const isDataLoading = computed(() => isLoading.value)
|
||||
const defaultShowProjectEvents = computed(() =>
|
||||
getDefaultAnalyticsGraphProjectEventsVisibility(currentSelectedProjectIds.value),
|
||||
)
|
||||
|
||||
const {
|
||||
selectedProjectIdSet,
|
||||
hasAvailableProjects,
|
||||
selectedProjects,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
} = useAnalyticsChartProjects(dashboardContext)
|
||||
const {
|
||||
showAllSelectedGraphDatasets,
|
||||
chartRangeBounds,
|
||||
tableProjectCount,
|
||||
showEmptyChartState,
|
||||
emptyChartMessage,
|
||||
graphTitle,
|
||||
showTableSelectionSubheading,
|
||||
shouldCapitalizeDatasetLabels,
|
||||
chartType,
|
||||
canShowPreviousPeriodToggle,
|
||||
shouldShowPreviousPeriod,
|
||||
isArea,
|
||||
isStacked,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
xAxisTickLimit,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
tableSelectionSubheading,
|
||||
showGraphRenderLimitButton,
|
||||
graphRenderLimitButtonLabel,
|
||||
showTopGraphDatasetsButton,
|
||||
selectableChartDatasets,
|
||||
showTopGraphDatasets,
|
||||
} = useAnalyticsChartDatasets(dashboardContext, selectedProjects, hasAvailableProjects)
|
||||
const {
|
||||
currentLegendEntries,
|
||||
visibleProjectEventIdSet,
|
||||
legendEntries,
|
||||
chartDatasetById,
|
||||
hoverRatioSliceTotals,
|
||||
visibleChartDatasets,
|
||||
highlightedChartDatasetId,
|
||||
setHoveredLegendEntryId,
|
||||
clearHoveredLegendEntryId,
|
||||
onLegendEntryClick,
|
||||
onTooltipEntryClick,
|
||||
} = useAnalyticsChartLegend({
|
||||
selectableChartDatasets,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
shouldShowPreviousPeriod,
|
||||
isRatioMode,
|
||||
hiddenGraphDatasetIds,
|
||||
selectedBreakdowns,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedProjects,
|
||||
selectedProjectIdSet,
|
||||
selectedProjectEventIdSet,
|
||||
})
|
||||
const { hasChartEvents, hasProjectEvents, visibleTimelineEvents, hasVisibleTimelineEvents } =
|
||||
useAnalyticsChartEvents(
|
||||
dashboardContext,
|
||||
chartRangeBounds,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
visibleProjectEventIdSet,
|
||||
)
|
||||
const { graphSection, graphSectionStyle } = useAnalyticsChartLayout(showEmptyChartState)
|
||||
|
||||
const showAllSelectedGraphDatasetsModal = ref<InstanceType<
|
||||
typeof AnalyticsChartRenderLimitModal
|
||||
> | null>(null)
|
||||
const canUseRatioMode = computed(
|
||||
() =>
|
||||
(activeGraphViewMode.value === 'area' || activeGraphViewMode.value === 'bar') &&
|
||||
legendEntries.value.length > 1,
|
||||
)
|
||||
const showUnmonetizedInfo = computed(
|
||||
() => selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization',
|
||||
)
|
||||
|
||||
function toggleGraphRenderLimit(event: MouseEvent) {
|
||||
if (showAllSelectedGraphDatasets.value) {
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
return
|
||||
}
|
||||
|
||||
showAllSelectedGraphDatasetsModal.value?.show(event)
|
||||
}
|
||||
|
||||
function confirmShowAllSelectedGraphDatasets() {
|
||||
showAllSelectedGraphDatasets.value = true
|
||||
}
|
||||
|
||||
function onRangeSelect(start: Date, end: Date, groupBy: AnalyticsGroupByPreset) {
|
||||
selectedTimeframeMode.value = 'custom_datetime_range'
|
||||
selectedCustomTimeframeStartDate.value = start.toISOString()
|
||||
selectedCustomTimeframeEndDate.value = end.toISOString()
|
||||
selectedDashboardGroupBy.value = groupBy
|
||||
}
|
||||
|
||||
watch(canUseRatioMode, (canUse) => {
|
||||
if (!canUse) {
|
||||
isRatioMode.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { computed, type ComputedRef, ref, watch } from 'vue'
|
||||
|
||||
import { useTheme } from '~/composables/nuxt-accessors'
|
||||
import { isDarkTheme } from '~/plugins/theme/index.ts'
|
||||
import type {
|
||||
AnalyticsDashboardContextValue,
|
||||
AnalyticsDashboardProject,
|
||||
AnalyticsDashboardStat,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsChartMessages,
|
||||
analyticsMessages,
|
||||
formatAnalyticsGraphTitle,
|
||||
type FormatMessage,
|
||||
getAnalyticsBreakdownItemType,
|
||||
} from '../analytics-messages'
|
||||
import {
|
||||
ANALYTICS_DASHBOARD_STATS,
|
||||
DARK_LEGEND_PALETTE,
|
||||
GRAPH_RENDER_DATASET_LIMIT,
|
||||
LIGHT_LEGEND_PALETTE,
|
||||
TOP_GRAPH_DATASET_LIMIT,
|
||||
} from './analytics-chart-constants'
|
||||
import {
|
||||
buildChartDatasets,
|
||||
buildTimeAxisLabels,
|
||||
type ChartDataset,
|
||||
getChartDatasetTotal,
|
||||
getShortHourlyAxisTickLimit,
|
||||
getSliceCount,
|
||||
shouldCapitalizeBreakdownLabel,
|
||||
} from './analytics-chart-utils'
|
||||
|
||||
export function useAnalyticsChartDatasets(
|
||||
context: Pick<
|
||||
AnalyticsDashboardContextValue,
|
||||
| 'activeStat'
|
||||
| 'activeGraphViewMode'
|
||||
| 'isRatioMode'
|
||||
| 'showPreviousPeriod'
|
||||
| 'hasPreviousPeriodComparison'
|
||||
| 'hasProjectContext'
|
||||
| 'displayedFetchRequest'
|
||||
| 'displayedTimeSlices'
|
||||
| 'displayedPreviousTimeSlices'
|
||||
| 'displayedSelectedGroupBy'
|
||||
| 'displayedSelectedBreakdowns'
|
||||
| 'hiddenGraphDatasetIds'
|
||||
| 'hasExplicitGraphDatasetSelection'
|
||||
| 'isGraphDatasetSelectionActive'
|
||||
| 'selectedGraphDatasetIds'
|
||||
| 'defaultGraphDatasetIds'
|
||||
| 'topGraphDatasetIds'
|
||||
| 'getVersionDisplayName'
|
||||
| 'getVersionProjectName'
|
||||
>,
|
||||
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>,
|
||||
hasAvailableProjects: ComputedRef<boolean>,
|
||||
) {
|
||||
const theme = useTheme()
|
||||
const { formatMessage } = useVIntl()
|
||||
const showAllSelectedGraphDatasets = ref(false)
|
||||
|
||||
const chartRangeBounds = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
if (!nextFetchRequest) return null
|
||||
return {
|
||||
start: new Date(nextFetchRequest.time_range.start),
|
||||
end: new Date(nextFetchRequest.time_range.end),
|
||||
}
|
||||
})
|
||||
const showProjectVersionNames = computed(
|
||||
() =>
|
||||
context.displayedSelectedBreakdowns.value.includes('version_id') &&
|
||||
selectedProjects.value.length > 1,
|
||||
)
|
||||
const tableProjectCount = computed(() => context.selectedGraphDatasetIds.value.length)
|
||||
const isTableGraphSelectionEmpty = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
context.hasExplicitGraphDatasetSelection.value &&
|
||||
tableProjectCount.value === 0,
|
||||
)
|
||||
const showEmptyChartState = computed(
|
||||
() => selectedProjects.value.length === 0 || isTableGraphSelectionEmpty.value,
|
||||
)
|
||||
const emptyChartMessage = computed(() => {
|
||||
if (isTableGraphSelectionEmpty.value) {
|
||||
return formatMessage(analyticsChartMessages.selectTableItemsEmpty)
|
||||
}
|
||||
|
||||
if (context.hasProjectContext.value) {
|
||||
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
|
||||
}
|
||||
|
||||
return hasAvailableProjects.value
|
||||
? formatMessage(analyticsMessages.noDataAvailable)
|
||||
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
|
||||
})
|
||||
const legendPalette = computed(() =>
|
||||
isDarkTheme(theme.active) ? DARK_LEGEND_PALETTE : LIGHT_LEGEND_PALETTE,
|
||||
)
|
||||
const graphTitle = computed(() =>
|
||||
formatAnalyticsGraphTitle(context.activeStat.value, formatMessage),
|
||||
)
|
||||
const showTableSelectionSubheading = computed(
|
||||
() => context.isGraphDatasetSelectionActive.value && tableProjectCount.value > 0,
|
||||
)
|
||||
const tableBreakdownItemType = computed(() =>
|
||||
getAnalyticsBreakdownItemType(context.displayedSelectedBreakdowns.value),
|
||||
)
|
||||
const shouldCapitalizeDatasetLabels = computed(() =>
|
||||
shouldCapitalizeBreakdownLabel(context.displayedSelectedBreakdowns.value),
|
||||
)
|
||||
const chartType = computed<'line' | 'bar'>(() =>
|
||||
context.activeGraphViewMode.value === 'bar' ? 'bar' : 'line',
|
||||
)
|
||||
const canShowPreviousPeriodToggle = computed(
|
||||
() => context.activeGraphViewMode.value === 'line' && context.hasPreviousPeriodComparison.value,
|
||||
)
|
||||
const shouldShowPreviousPeriod = computed(
|
||||
() => canShowPreviousPeriodToggle.value && context.showPreviousPeriod.value,
|
||||
)
|
||||
const isArea = computed(() => context.activeGraphViewMode.value === 'area')
|
||||
const isStacked = computed(
|
||||
() =>
|
||||
context.isRatioMode.value ||
|
||||
context.activeGraphViewMode.value === 'area' ||
|
||||
context.activeGraphViewMode.value === 'bar',
|
||||
)
|
||||
const sliceCount = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
const fallback = context.displayedTimeSlices.value.length
|
||||
if (!nextFetchRequest) return Math.max(1, fallback)
|
||||
return getSliceCount(nextFetchRequest.time_range, fallback)
|
||||
})
|
||||
const chartLabels = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
if (!nextFetchRequest) return []
|
||||
return buildTimeAxisLabels(
|
||||
nextFetchRequest.time_range,
|
||||
sliceCount.value,
|
||||
context.displayedSelectedGroupBy.value,
|
||||
)
|
||||
})
|
||||
const xAxisTickLimit = computed(() => {
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
return nextFetchRequest
|
||||
? getShortHourlyAxisTickLimit(
|
||||
nextFetchRequest.time_range,
|
||||
context.displayedSelectedGroupBy.value,
|
||||
)
|
||||
: undefined
|
||||
})
|
||||
const chartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
|
||||
buildDatasetsByStat(
|
||||
context.displayedTimeSlices.value,
|
||||
selectedProjects.value,
|
||||
legendPalette.value,
|
||||
context.displayedSelectedBreakdowns.value,
|
||||
context.getVersionDisplayName,
|
||||
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
|
||||
formatMessage,
|
||||
sliceCount.value,
|
||||
),
|
||||
)
|
||||
const previousChartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
|
||||
buildDatasetsByStat(
|
||||
context.displayedPreviousTimeSlices.value,
|
||||
selectedProjects.value,
|
||||
legendPalette.value,
|
||||
context.displayedSelectedBreakdowns.value,
|
||||
context.getVersionDisplayName,
|
||||
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
|
||||
formatMessage,
|
||||
sliceCount.value,
|
||||
),
|
||||
)
|
||||
const allChartDatasets = computed(() => chartDatasetsByStat.value[context.activeStat.value])
|
||||
const previousChartDatasets = computed(
|
||||
() => previousChartDatasetsByStat.value[context.activeStat.value],
|
||||
)
|
||||
const sortedChartDatasetIds = computed(() => sortDatasetsByTotal(allChartDatasets.value))
|
||||
const chartTopGraphDatasetIds = computed(() =>
|
||||
sortedChartDatasetIds.value.slice(0, TOP_GRAPH_DATASET_LIMIT),
|
||||
)
|
||||
const fallbackDefaultGraphDatasetIds = computed(() =>
|
||||
context.defaultGraphDatasetIds.value.length > 0
|
||||
? context.defaultGraphDatasetIds.value
|
||||
: chartTopGraphDatasetIds.value,
|
||||
)
|
||||
const isShowingAllTableItems = computed(() => {
|
||||
if (context.selectedGraphDatasetIds.value.length !== sortedChartDatasetIds.value.length) {
|
||||
return false
|
||||
}
|
||||
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
|
||||
return sortedChartDatasetIds.value.every((datasetId) => selectedDatasetIds.has(datasetId))
|
||||
})
|
||||
const isShowingTopGraphDatasets = computed(() => {
|
||||
if (
|
||||
context.selectedGraphDatasetIds.value.length !== fallbackDefaultGraphDatasetIds.value.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
|
||||
return fallbackDefaultGraphDatasetIds.value.every((datasetId) =>
|
||||
selectedDatasetIds.has(datasetId),
|
||||
)
|
||||
})
|
||||
const isShowingTopTableItems = computed(() => {
|
||||
const topDatasetIds = new Set(
|
||||
context.topGraphDatasetIds.value.slice(0, context.selectedGraphDatasetIds.value.length),
|
||||
)
|
||||
return context.selectedGraphDatasetIds.value.every((datasetId) => topDatasetIds.has(datasetId))
|
||||
})
|
||||
const isGraphRenderDatasetOverLimit = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
selectedChartDatasets.value.length > GRAPH_RENDER_DATASET_LIMIT,
|
||||
)
|
||||
const isGraphRenderDatasetLimitActive = computed(
|
||||
() => isGraphRenderDatasetOverLimit.value && !showAllSelectedGraphDatasets.value,
|
||||
)
|
||||
const tableSelectionSubheading = computed(() => {
|
||||
if (isGraphRenderDatasetLimitActive.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionLimited, {
|
||||
limit: GRAPH_RENDER_DATASET_LIMIT,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (isShowingAllTableItems.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionAll, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (isShowingTopTableItems.value) {
|
||||
return formatMessage(analyticsChartMessages.tableSelectionTop, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
}
|
||||
|
||||
return formatMessage(analyticsChartMessages.tableSelectionCount, {
|
||||
count: tableProjectCount.value,
|
||||
itemType: tableBreakdownItemType.value,
|
||||
})
|
||||
})
|
||||
const shouldUseDefaultGraphDatasetSelection = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
!context.hasExplicitGraphDatasetSelection.value &&
|
||||
context.selectedGraphDatasetIds.value.length === 0,
|
||||
)
|
||||
const selectedGraphDatasetIdSet = computed(() => {
|
||||
if (shouldUseDefaultGraphDatasetSelection.value) {
|
||||
return new Set(fallbackDefaultGraphDatasetIds.value)
|
||||
}
|
||||
|
||||
return new Set(context.selectedGraphDatasetIds.value)
|
||||
})
|
||||
const selectedChartDatasets = computed(() => {
|
||||
if (!context.isGraphDatasetSelectionActive.value) {
|
||||
return allChartDatasets.value
|
||||
}
|
||||
|
||||
return allChartDatasets.value.filter((dataset) =>
|
||||
selectedGraphDatasetIdSet.value.has(dataset.projectId),
|
||||
)
|
||||
})
|
||||
const sortedSelectedChartDatasetIds = computed(() =>
|
||||
sortDatasetsByTotal(selectedChartDatasets.value),
|
||||
)
|
||||
const showGraphRenderLimitButton = computed(() => isGraphRenderDatasetOverLimit.value)
|
||||
const graphRenderLimitButtonLabel = computed(() =>
|
||||
showAllSelectedGraphDatasets.value
|
||||
? formatMessage(analyticsChartMessages.showLimited)
|
||||
: formatMessage(analyticsChartMessages.showAll),
|
||||
)
|
||||
const showTopGraphDatasetsButton = computed(
|
||||
() =>
|
||||
context.isGraphDatasetSelectionActive.value &&
|
||||
context.topGraphDatasetIds.value.length > 0 &&
|
||||
!isShowingTopGraphDatasets.value,
|
||||
)
|
||||
const limitedGraphDatasetIds = computed(
|
||||
() => new Set(sortedSelectedChartDatasetIds.value.slice(0, GRAPH_RENDER_DATASET_LIMIT)),
|
||||
)
|
||||
const selectableChartDatasets = computed(() => {
|
||||
if (!isGraphRenderDatasetLimitActive.value) {
|
||||
return selectedChartDatasets.value
|
||||
}
|
||||
|
||||
return selectedChartDatasets.value.filter((dataset) =>
|
||||
limitedGraphDatasetIds.value.has(dataset.projectId),
|
||||
)
|
||||
})
|
||||
|
||||
function showTopGraphDatasets() {
|
||||
context.selectedGraphDatasetIds.value = []
|
||||
context.hasExplicitGraphDatasetSelection.value = false
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
}
|
||||
|
||||
watch([() => context.selectedGraphDatasetIds.value.join('\u0000'), allChartDatasets], () => {
|
||||
showAllSelectedGraphDatasets.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
showAllSelectedGraphDatasets,
|
||||
chartRangeBounds,
|
||||
showProjectVersionNames,
|
||||
tableProjectCount,
|
||||
isTableGraphSelectionEmpty,
|
||||
showEmptyChartState,
|
||||
emptyChartMessage,
|
||||
legendPalette,
|
||||
graphTitle,
|
||||
showTableSelectionSubheading,
|
||||
shouldCapitalizeDatasetLabels,
|
||||
chartType,
|
||||
canShowPreviousPeriodToggle,
|
||||
shouldShowPreviousPeriod,
|
||||
isArea,
|
||||
isStacked,
|
||||
sliceCount,
|
||||
chartLabels,
|
||||
xAxisTickLimit,
|
||||
chartDatasetsByStat,
|
||||
previousChartDatasetsByStat,
|
||||
allChartDatasets,
|
||||
previousChartDatasets,
|
||||
sortedChartDatasetIds,
|
||||
chartTopGraphDatasetIds,
|
||||
fallbackDefaultGraphDatasetIds,
|
||||
isShowingAllTableItems,
|
||||
isShowingTopGraphDatasets,
|
||||
isShowingTopTableItems,
|
||||
tableSelectionSubheading,
|
||||
shouldUseDefaultGraphDatasetSelection,
|
||||
selectedGraphDatasetIdSet,
|
||||
selectedChartDatasets,
|
||||
sortedSelectedChartDatasetIds,
|
||||
isGraphRenderDatasetOverLimit,
|
||||
showGraphRenderLimitButton,
|
||||
graphRenderLimitButtonLabel,
|
||||
showTopGraphDatasetsButton,
|
||||
isGraphRenderDatasetLimitActive,
|
||||
limitedGraphDatasetIds,
|
||||
selectableChartDatasets,
|
||||
showTopGraphDatasets,
|
||||
}
|
||||
}
|
||||
|
||||
function buildDatasetsByStat(
|
||||
timeSlices: Parameters<typeof buildChartDatasets>[0],
|
||||
selectedProjects: AnalyticsDashboardProject[],
|
||||
palette: string[],
|
||||
selectedBreakdowns: Parameters<typeof buildChartDatasets>[4],
|
||||
getVersionDisplayName: Parameters<typeof buildChartDatasets>[5],
|
||||
getVersionProjectName: Parameters<typeof buildChartDatasets>[6],
|
||||
formatMessage: FormatMessage,
|
||||
sliceCount: number,
|
||||
) {
|
||||
const datasetsByStat = {} as Record<AnalyticsDashboardStat, ChartDataset[]>
|
||||
for (const stat of ANALYTICS_DASHBOARD_STATS) {
|
||||
datasetsByStat[stat] = buildChartDatasets(
|
||||
timeSlices,
|
||||
selectedProjects,
|
||||
stat,
|
||||
palette,
|
||||
selectedBreakdowns,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
formatMessage,
|
||||
sliceCount,
|
||||
)
|
||||
}
|
||||
return datasetsByStat
|
||||
}
|
||||
|
||||
function sortDatasetsByTotal(datasets: ChartDataset[]) {
|
||||
return [...datasets]
|
||||
.sort((a, b) => {
|
||||
const totalDifference = getChartDatasetTotal(b) - getChartDatasetTotal(a)
|
||||
return (
|
||||
totalDifference || a.label.localeCompare(b.label) || a.projectId.localeCompare(b.projectId)
|
||||
)
|
||||
})
|
||||
.map((dataset) => dataset.projectId)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
type AnalyticsDashboardContextValue,
|
||||
doesProjectStatusMatchFilters,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
export function useAnalyticsChartProjects(
|
||||
context: Pick<
|
||||
AnalyticsDashboardContextValue,
|
||||
'displayedSelectedProjectIds' | 'projects' | 'displayedSelectedFilters'
|
||||
>,
|
||||
) {
|
||||
const selectedProjectIdSet = computed(() => new Set(context.displayedSelectedProjectIds.value))
|
||||
const hasAvailableProjects = computed(() => context.projects.value.length > 0)
|
||||
|
||||
const selectedProjects = computed(() =>
|
||||
context.projects.value.filter(
|
||||
(project) =>
|
||||
selectedProjectIdSet.value.has(project.id) &&
|
||||
doesProjectStatusMatchFilters(project.status, context.displayedSelectedFilters.value),
|
||||
),
|
||||
)
|
||||
const selectedProjectNameById = computed(
|
||||
() => new Map(selectedProjects.value.map((project) => [project.id, project.name])),
|
||||
)
|
||||
const selectedProjectEventIdSet = computed(
|
||||
() => new Set(selectedProjects.value.map((project) => project.id)),
|
||||
)
|
||||
|
||||
return {
|
||||
selectedProjectIdSet,
|
||||
hasAvailableProjects,
|
||||
selectedProjects,
|
||||
selectedProjectNameById,
|
||||
selectedProjectEventIdSet,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
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({
|
||||
monetizationBannerTitle: {
|
||||
id: 'analytics.stat.monetization-banner.title',
|
||||
defaultMessage: 'How does monetization work?',
|
||||
},
|
||||
monetizationBannerBody: {
|
||||
id: 'analytics.stat.monetization-banner.body',
|
||||
defaultMessage:
|
||||
'Only views and downloads made through Modrinth are eligible for monetization and must pass fraud-prevention filtering. Modrinth App downloads also require the user to be logged in. Because all projects have a similar ratio of monetized downloads, your revenue would not meaningfully change if all downloads were counted.',
|
||||
},
|
||||
monetizationBannerLearnMore: {
|
||||
id: 'analytics.stat.monetization-banner.learn-more',
|
||||
defaultMessage: 'Learn more',
|
||||
},
|
||||
revenueValue: {
|
||||
id: 'analytics.stat.revenue-value',
|
||||
defaultMessage: '${value}',
|
||||
},
|
||||
playtimeHours: {
|
||||
id: 'analytics.stat.playtime-hours',
|
||||
defaultMessage: '{hours} hrs',
|
||||
},
|
||||
unavailableTooltip: {
|
||||
id: 'analytics.stat.unavailable-tooltip',
|
||||
defaultMessage: 'Stat unavailable for current query',
|
||||
},
|
||||
unavailableLabel: {
|
||||
id: 'analytics.stat.unavailable',
|
||||
defaultMessage: 'N/A',
|
||||
},
|
||||
previousPeriodComparison: {
|
||||
id: 'analytics.stat.previous-period-comparison',
|
||||
defaultMessage: 'vs prev. period',
|
||||
},
|
||||
previousPeriodComparisonShort: {
|
||||
id: 'analytics.stat.previous-period-comparison-short',
|
||||
defaultMessage: 'vs prev.',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsGroupByMessages = defineMessages({
|
||||
oneHour: {
|
||||
id: 'analytics.group-by.1h',
|
||||
defaultMessage: '1h',
|
||||
},
|
||||
sixHours: {
|
||||
id: 'analytics.group-by.6h',
|
||||
defaultMessage: '6h',
|
||||
},
|
||||
day: {
|
||||
id: 'analytics.group-by.day',
|
||||
defaultMessage: 'Day',
|
||||
},
|
||||
week: {
|
||||
id: 'analytics.group-by.week',
|
||||
defaultMessage: 'Week',
|
||||
},
|
||||
month: {
|
||||
id: 'analytics.group-by.month',
|
||||
defaultMessage: 'Month',
|
||||
},
|
||||
year: {
|
||||
id: 'analytics.group-by.year',
|
||||
defaultMessage: 'Year',
|
||||
},
|
||||
date: {
|
||||
id: 'analytics.group-by.date',
|
||||
defaultMessage: 'Date',
|
||||
},
|
||||
groupByHour: {
|
||||
id: 'analytics.group-by.selected.hour',
|
||||
defaultMessage: 'Group by hour',
|
||||
},
|
||||
groupBySixHours: {
|
||||
id: 'analytics.group-by.selected.six-hours',
|
||||
defaultMessage: 'Group by 6 hours',
|
||||
},
|
||||
groupByDay: {
|
||||
id: 'analytics.group-by.selected.day',
|
||||
defaultMessage: 'Group by day',
|
||||
},
|
||||
groupByWeek: {
|
||||
id: 'analytics.group-by.selected.week',
|
||||
defaultMessage: 'Group by week',
|
||||
},
|
||||
groupByMonth: {
|
||||
id: 'analytics.group-by.selected.month',
|
||||
defaultMessage: 'Group by month',
|
||||
},
|
||||
groupByYear: {
|
||||
id: 'analytics.group-by.selected.year',
|
||||
defaultMessage: 'Group by year',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsBreakdownMessages = defineMessages({
|
||||
breakdown: {
|
||||
id: 'analytics.breakdown.generic',
|
||||
defaultMessage: 'Breakdown',
|
||||
},
|
||||
project: {
|
||||
id: 'analytics.breakdown.project',
|
||||
defaultMessage: 'Project',
|
||||
},
|
||||
country: {
|
||||
id: 'analytics.breakdown.country',
|
||||
defaultMessage: 'Country',
|
||||
},
|
||||
monetization: {
|
||||
id: 'analytics.breakdown.monetization',
|
||||
defaultMessage: 'Monetization',
|
||||
},
|
||||
userAgent: {
|
||||
id: 'analytics.breakdown.download-source',
|
||||
defaultMessage: 'Download source',
|
||||
},
|
||||
downloadReason: {
|
||||
id: 'analytics.breakdown.download-reason',
|
||||
defaultMessage: 'Download reason',
|
||||
},
|
||||
versionId: {
|
||||
id: 'analytics.breakdown.project-version',
|
||||
defaultMessage: 'Project version',
|
||||
},
|
||||
loader: {
|
||||
id: 'analytics.breakdown.loader',
|
||||
defaultMessage: 'Loader',
|
||||
},
|
||||
gameVersion: {
|
||||
id: 'analytics.breakdown.game-version',
|
||||
defaultMessage: 'Game version',
|
||||
},
|
||||
projectStatus: {
|
||||
id: 'analytics.breakdown.project-status',
|
||||
defaultMessage: 'Project status',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsMonetizationMessages = defineMessages({
|
||||
monetized: {
|
||||
id: 'analytics.value.monetized',
|
||||
defaultMessage: 'Monetized',
|
||||
},
|
||||
unmonetized: {
|
||||
id: 'analytics.value.unmonetized',
|
||||
defaultMessage: 'Unmonetized',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsDownloadReasonMessages = defineMessages({
|
||||
standalone: {
|
||||
id: 'analytics.download-reason.standalone',
|
||||
defaultMessage: 'Standalone',
|
||||
},
|
||||
dependency: {
|
||||
id: 'analytics.download-reason.dependency',
|
||||
defaultMessage: 'Dependency',
|
||||
},
|
||||
modpack: {
|
||||
id: 'analytics.download-reason.modpack',
|
||||
defaultMessage: 'Modpack',
|
||||
},
|
||||
update: {
|
||||
id: 'analytics.download-reason.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsDownloadSourceMessages = defineMessages({
|
||||
website: {
|
||||
id: 'analytics.download-source.website',
|
||||
defaultMessage: 'Modrinth Website',
|
||||
},
|
||||
app: {
|
||||
id: 'analytics.download-source.app',
|
||||
defaultMessage: 'Modrinth App',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsProjectStatusMessages = defineMessages({
|
||||
approved: {
|
||||
id: 'analytics.project-status.approved',
|
||||
defaultMessage: 'Approved',
|
||||
},
|
||||
archived: {
|
||||
id: 'analytics.project-status.archived',
|
||||
defaultMessage: 'Archived',
|
||||
},
|
||||
rejected: {
|
||||
id: 'analytics.project-status.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
},
|
||||
draft: {
|
||||
id: 'analytics.project-status.draft',
|
||||
defaultMessage: 'Draft',
|
||||
},
|
||||
unlisted: {
|
||||
id: 'analytics.project-status.unlisted',
|
||||
defaultMessage: 'Unlisted',
|
||||
},
|
||||
withheld: {
|
||||
id: 'analytics.project-status.withheld',
|
||||
defaultMessage: 'Withheld',
|
||||
},
|
||||
private: {
|
||||
id: 'analytics.project-status.private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
other: {
|
||||
id: 'analytics.project-status.other',
|
||||
defaultMessage: 'Other',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsTableMessages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'analytics.table.search.placeholder',
|
||||
defaultMessage: 'Search...',
|
||||
},
|
||||
exportCsvButton: {
|
||||
id: 'analytics.table.export-csv',
|
||||
defaultMessage: 'Export CSV',
|
||||
},
|
||||
cumulativeCsv: {
|
||||
id: 'analytics.table.export.cumulative',
|
||||
defaultMessage: 'Cumulative',
|
||||
},
|
||||
groupedCsv: {
|
||||
id: 'analytics.table.export.grouped',
|
||||
defaultMessage: 'Grouped by {groupBy}',
|
||||
},
|
||||
noMatchingRows: {
|
||||
id: 'analytics.table.empty.no-matching-rows',
|
||||
defaultMessage: 'No matching analytics rows',
|
||||
},
|
||||
paginationSummary: {
|
||||
id: 'analytics.table.pagination.summary',
|
||||
defaultMessage: 'Showing {start} to {end} of {total}',
|
||||
},
|
||||
playtimeSecondsHeader: {
|
||||
id: 'analytics.table.csv.header.playtime-seconds',
|
||||
defaultMessage: 'Playtime (seconds)',
|
||||
},
|
||||
csvSelectedRange: {
|
||||
id: 'analytics.table.csv.selected-range',
|
||||
defaultMessage: 'Selected Range',
|
||||
},
|
||||
csvDateRange: {
|
||||
id: 'analytics.table.csv.date-range',
|
||||
defaultMessage: '{start} to {end}',
|
||||
},
|
||||
csvFilename: {
|
||||
id: 'analytics.table.csv.filename',
|
||||
defaultMessage: 'Modrinth Analytics {breakdown} Breakdown - {dateRange}',
|
||||
},
|
||||
durationDays: {
|
||||
id: 'analytics.table.duration.days',
|
||||
defaultMessage: '{count, plural, one {# day} other {# days}}',
|
||||
},
|
||||
durationHours: {
|
||||
id: 'analytics.table.duration.hours',
|
||||
defaultMessage: '{count, plural, one {# hour} other {# hours}}',
|
||||
},
|
||||
durationMinutes: {
|
||||
id: 'analytics.table.duration.minutes',
|
||||
defaultMessage: '{count, plural, one {# minute} other {# minutes}}',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsChartMessages = defineMessages({
|
||||
selectTableItemsEmpty: {
|
||||
id: 'analytics.chart.empty.select-table-items',
|
||||
defaultMessage: 'Select items from table below to visualize your data.',
|
||||
},
|
||||
showLimited: {
|
||||
id: 'analytics.chart.action.show-limited',
|
||||
defaultMessage: 'Show limited',
|
||||
},
|
||||
showAll: {
|
||||
id: 'analytics.chart.action.show-all',
|
||||
defaultMessage: 'Show all',
|
||||
},
|
||||
showTopEight: {
|
||||
id: 'analytics.chart.action.show-top-eight',
|
||||
defaultMessage: 'Show top 8',
|
||||
},
|
||||
tableSelectionLimited: {
|
||||
id: 'analytics.chart.table-selection.limited',
|
||||
defaultMessage:
|
||||
'Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
tableSelectionAll: {
|
||||
id: 'analytics.chart.table-selection.all',
|
||||
defaultMessage:
|
||||
'Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
tableSelectionTop: {
|
||||
id: 'analytics.chart.table-selection.top',
|
||||
defaultMessage:
|
||||
'Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
tableSelectionCount: {
|
||||
id: 'analytics.chart.table-selection.count',
|
||||
defaultMessage:
|
||||
'Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
|
||||
},
|
||||
lineView: {
|
||||
id: 'analytics.chart.view.line',
|
||||
defaultMessage: 'Line',
|
||||
},
|
||||
areaView: {
|
||||
id: 'analytics.chart.view.area',
|
||||
defaultMessage: 'Area',
|
||||
},
|
||||
barView: {
|
||||
id: 'analytics.chart.view.bar',
|
||||
defaultMessage: 'Bar',
|
||||
},
|
||||
controlsButton: {
|
||||
id: 'analytics.chart.controls.button',
|
||||
defaultMessage: 'Controls',
|
||||
},
|
||||
controlsAria: {
|
||||
id: 'analytics.chart.controls.aria',
|
||||
defaultMessage: 'Analytics graph controls, {activeCount}',
|
||||
},
|
||||
controlsDialogAria: {
|
||||
id: 'analytics.chart.controls.dialog-aria',
|
||||
defaultMessage: 'Analytics graph controls',
|
||||
},
|
||||
activeControlCount: {
|
||||
id: 'analytics.chart.controls.active-count',
|
||||
defaultMessage: '{count} active',
|
||||
},
|
||||
displayControls: {
|
||||
id: 'analytics.chart.controls.display',
|
||||
defaultMessage: 'Display',
|
||||
},
|
||||
previousPeriod: {
|
||||
id: 'analytics.chart.controls.previous-period',
|
||||
defaultMessage: 'Previous period',
|
||||
},
|
||||
ratio: {
|
||||
id: 'analytics.chart.controls.ratio',
|
||||
defaultMessage: 'Ratio',
|
||||
},
|
||||
annotations: {
|
||||
id: 'analytics.chart.controls.annotations',
|
||||
defaultMessage: 'Annotations',
|
||||
},
|
||||
projectEvents: {
|
||||
id: 'analytics.chart.controls.project-events',
|
||||
defaultMessage: 'Project events',
|
||||
},
|
||||
modrinthEvents: {
|
||||
id: 'analytics.chart.controls.modrinth-events',
|
||||
defaultMessage: 'Modrinth events',
|
||||
},
|
||||
noProjectEvents: {
|
||||
id: 'analytics.chart.controls.no-project-events',
|
||||
defaultMessage: 'No project events in graph.',
|
||||
},
|
||||
noModrinthEvents: {
|
||||
id: 'analytics.chart.controls.no-modrinth-events',
|
||||
defaultMessage: 'No Modrinth events in graph.',
|
||||
},
|
||||
viewMonetizedAnalyticsDetails: {
|
||||
id: 'analytics.chart.legend.monetization-details.aria',
|
||||
defaultMessage: 'View monetized analytics details',
|
||||
},
|
||||
monetizedAnalyticsDetails: {
|
||||
id: 'analytics.chart.legend.monetization-details.title',
|
||||
defaultMessage: 'Monetized analytics details',
|
||||
},
|
||||
monetizedAnalyticsDetailsDescription: {
|
||||
id: 'analytics.chart.legend.monetization-details.description',
|
||||
defaultMessage:
|
||||
'Only views and downloads made through Modrinth count toward monetization, and downloads require users to be logged in.',
|
||||
},
|
||||
previousPeriodSuffix: {
|
||||
id: 'analytics.chart.legend.previous-period-suffix',
|
||||
defaultMessage: '{name} (Prev.)',
|
||||
},
|
||||
previousPeriodShort: {
|
||||
id: 'analytics.chart.tooltip.previous-period-short',
|
||||
defaultMessage: '(prev.)',
|
||||
},
|
||||
tooltipPinned: {
|
||||
id: 'analytics.chart.tooltip.pinned',
|
||||
defaultMessage: 'Chart tooltip pinned',
|
||||
},
|
||||
pinned: {
|
||||
id: 'analytics.chart.tooltip.pinned-aria',
|
||||
defaultMessage: 'Pinned',
|
||||
},
|
||||
total: {
|
||||
id: 'analytics.chart.tooltip.total',
|
||||
defaultMessage: 'Total',
|
||||
},
|
||||
showEntryInGraph: {
|
||||
id: 'analytics.chart.tooltip.show-entry',
|
||||
defaultMessage: 'Show {name} in graph',
|
||||
},
|
||||
hideEntryInGraph: {
|
||||
id: 'analytics.chart.tooltip.hide-entry',
|
||||
defaultMessage: 'Hide {name} in graph',
|
||||
},
|
||||
durationDays: {
|
||||
id: 'analytics.chart.tooltip.duration.days',
|
||||
defaultMessage: '{count, plural, one {# day} other {# days}}',
|
||||
},
|
||||
durationHours: {
|
||||
id: 'analytics.chart.tooltip.duration.hours',
|
||||
defaultMessage: '{count, plural, one {# hour} other {# hours}}',
|
||||
},
|
||||
durationMinutes: {
|
||||
id: 'analytics.chart.tooltip.duration.minutes',
|
||||
defaultMessage: '{count, plural, one {# minute} other {# minutes}}',
|
||||
},
|
||||
playtimeAxisHours: {
|
||||
id: 'analytics.chart.axis.playtime-hours',
|
||||
defaultMessage: '{hours} h',
|
||||
},
|
||||
renderLimitHeader: {
|
||||
id: 'analytics.chart.render-limit.header',
|
||||
defaultMessage: 'Show all {count} lines in graph?',
|
||||
},
|
||||
renderLimitDescription: {
|
||||
id: 'analytics.chart.render-limit.description',
|
||||
defaultMessage: 'Showing all selected lines from table may degrade page performance.',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'analytics.action.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
analyticsEventsCount: {
|
||||
id: 'analytics.chart.events.count-aria',
|
||||
defaultMessage: '{count, plural, one {# analytics event} other {# analytics events}}',
|
||||
},
|
||||
seeAnnouncement: {
|
||||
id: 'analytics.chart.events.see-announcement',
|
||||
defaultMessage: 'See announcement',
|
||||
},
|
||||
projectEventTitle: {
|
||||
id: 'analytics.chart.events.project-title',
|
||||
defaultMessage: '<project>{projectName}</project>: {title}',
|
||||
},
|
||||
})
|
||||
|
||||
export const analyticsProjectEventMessages = defineMessages({
|
||||
versionReleased: {
|
||||
id: 'analytics.project-event.version-released',
|
||||
defaultMessage: '{version} released',
|
||||
},
|
||||
versionUploaded: {
|
||||
id: 'analytics.project-event.version-uploaded',
|
||||
defaultMessage: 'Version uploaded',
|
||||
},
|
||||
projectApproved: {
|
||||
id: 'analytics.project-event.project-approved',
|
||||
defaultMessage: 'Project approved',
|
||||
},
|
||||
projectUnlisted: {
|
||||
id: 'analytics.project-event.project-unlisted',
|
||||
defaultMessage: 'Project unlisted',
|
||||
},
|
||||
projectPrivate: {
|
||||
id: 'analytics.project-event.project-private',
|
||||
defaultMessage: 'Project set to private',
|
||||
},
|
||||
projectStatusChanged: {
|
||||
id: 'analytics.project-event.project-status-changed',
|
||||
defaultMessage: 'Project status changed',
|
||||
},
|
||||
})
|
||||
|
||||
export function formatAnalyticsStatLabel(
|
||||
stat: AnalyticsDashboardStat,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatMessage(analyticsStatMessages[stat])
|
||||
}
|
||||
|
||||
export function formatAnalyticsGraphTitle(
|
||||
stat: AnalyticsDashboardStat,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatMessage(analyticsGraphTitleMessages[stat])
|
||||
}
|
||||
|
||||
export function formatAnalyticsGroupByLabel(
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (groupBy) {
|
||||
case '1h':
|
||||
return formatMessage(analyticsGroupByMessages.oneHour)
|
||||
case '6h':
|
||||
return formatMessage(analyticsGroupByMessages.sixHours)
|
||||
case 'day':
|
||||
return formatMessage(analyticsGroupByMessages.day)
|
||||
case 'week':
|
||||
return formatMessage(analyticsGroupByMessages.week)
|
||||
case 'month':
|
||||
return formatMessage(analyticsGroupByMessages.month)
|
||||
case 'year':
|
||||
return formatMessage(analyticsGroupByMessages.year)
|
||||
default:
|
||||
return formatMessage(analyticsGroupByMessages.date)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsGroupBySelectedLabel(
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (groupBy) {
|
||||
case '1h':
|
||||
return formatMessage(analyticsGroupByMessages.groupByHour)
|
||||
case '6h':
|
||||
return formatMessage(analyticsGroupByMessages.groupBySixHours)
|
||||
case 'day':
|
||||
return formatMessage(analyticsGroupByMessages.groupByDay)
|
||||
case 'week':
|
||||
return formatMessage(analyticsGroupByMessages.groupByWeek)
|
||||
case 'month':
|
||||
return formatMessage(analyticsGroupByMessages.groupByMonth)
|
||||
case 'year':
|
||||
return formatMessage(analyticsGroupByMessages.groupByYear)
|
||||
default:
|
||||
return formatMessage(analyticsGroupByMessages.groupByDay)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsBreakdownLabel(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (breakdown) {
|
||||
case 'none':
|
||||
case 'project':
|
||||
return formatMessage(analyticsBreakdownMessages.project)
|
||||
case 'country':
|
||||
return formatMessage(analyticsBreakdownMessages.country)
|
||||
case 'monetization':
|
||||
return formatMessage(analyticsBreakdownMessages.monetization)
|
||||
case 'user_agent':
|
||||
return formatMessage(analyticsBreakdownMessages.userAgent)
|
||||
case 'download_reason':
|
||||
return formatMessage(analyticsBreakdownMessages.downloadReason)
|
||||
case 'version_id':
|
||||
return formatMessage(analyticsBreakdownMessages.versionId)
|
||||
case 'loader':
|
||||
return formatMessage(analyticsBreakdownMessages.loader)
|
||||
case 'game_version':
|
||||
return formatMessage(analyticsBreakdownMessages.gameVersion)
|
||||
default:
|
||||
return formatMessage(analyticsBreakdownMessages.breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownItemType(
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
): AnalyticsBreakdownItemType {
|
||||
if (breakdowns.length !== 1) {
|
||||
return 'other'
|
||||
}
|
||||
|
||||
switch (breakdowns[0]) {
|
||||
case 'project':
|
||||
return 'project'
|
||||
case 'country':
|
||||
return 'country'
|
||||
case 'monetization':
|
||||
return 'monetization'
|
||||
case 'user_agent':
|
||||
return 'downloadSource'
|
||||
case 'download_reason':
|
||||
return 'downloadReason'
|
||||
case 'version_id':
|
||||
return 'projectVersion'
|
||||
case 'loader':
|
||||
return 'loader'
|
||||
case 'game_version':
|
||||
return 'gameVersion'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsMonetizationLabel(
|
||||
value: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case 'monetized':
|
||||
return formatMessage(analyticsMonetizationMessages.monetized)
|
||||
case 'unmonetized':
|
||||
return formatMessage(analyticsMonetizationMessages.unmonetized)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsDownloadReasonLabel(
|
||||
reason: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (reason.trim().toLowerCase()) {
|
||||
case 'standalone':
|
||||
return formatMessage(analyticsDownloadReasonMessages.standalone)
|
||||
case 'dependency':
|
||||
return formatMessage(analyticsDownloadReasonMessages.dependency)
|
||||
case 'modpack':
|
||||
return formatMessage(analyticsDownloadReasonMessages.modpack)
|
||||
case 'update':
|
||||
return formatMessage(analyticsDownloadReasonMessages.update)
|
||||
default:
|
||||
return reason
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsDownloadSourceLabel(
|
||||
source: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const normalized = source.trim()
|
||||
const normalizedLowercase = normalized.toLowerCase()
|
||||
if (normalizedLowercase === 'website') {
|
||||
return formatMessage(analyticsDownloadSourceMessages.website)
|
||||
}
|
||||
if (normalizedLowercase === 'modrinth_app') {
|
||||
return formatMessage(analyticsDownloadSourceMessages.app)
|
||||
}
|
||||
if (!normalized.includes('_')) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return normalizedLowercase
|
||||
.split('_')
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function formatAnalyticsProjectStatusLabel(
|
||||
status: string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
switch (status.trim().toLowerCase()) {
|
||||
case 'approved':
|
||||
return formatMessage(analyticsProjectStatusMessages.approved)
|
||||
case 'archived':
|
||||
return formatMessage(analyticsProjectStatusMessages.archived)
|
||||
case 'rejected':
|
||||
return formatMessage(analyticsProjectStatusMessages.rejected)
|
||||
case 'draft':
|
||||
return formatMessage(analyticsProjectStatusMessages.draft)
|
||||
case 'unlisted':
|
||||
return formatMessage(analyticsProjectStatusMessages.unlisted)
|
||||
case 'withheld':
|
||||
return formatMessage(analyticsProjectStatusMessages.withheld)
|
||||
case 'private':
|
||||
return formatMessage(analyticsProjectStatusMessages.private)
|
||||
case 'other':
|
||||
return formatMessage(analyticsProjectStatusMessages.other)
|
||||
default:
|
||||
return capitalizeAnalyticsValue(status)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnalyticsLoaderLabel(loader: string, formatMessage: FormatMessage): string {
|
||||
const normalizedLoader = loader.trim()
|
||||
const loaderMessage = getLoaderMessage(normalizedLoader)
|
||||
return loaderMessage ? formatMessage(loaderMessage) : capitalizeAnalyticsValue(normalizedLoader)
|
||||
}
|
||||
|
||||
function capitalizeAnalyticsValue(value: string): string {
|
||||
const normalizedValue = value.trim()
|
||||
if (normalizedValue.length === 0) {
|
||||
return value
|
||||
}
|
||||
|
||||
return `${normalizedValue.charAt(0).toUpperCase()}${normalizedValue.slice(1)}`
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
import type { LocationQuery, LocationQueryValue, LocationQueryValueRaw } from 'vue-router'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsGraphState,
|
||||
AnalyticsGraphViewMode,
|
||||
AnalyticsGroupByPreset,
|
||||
AnalyticsLastTimeframeUnit,
|
||||
AnalyticsQueryBuilderState,
|
||||
AnalyticsQueryFilterCategory,
|
||||
AnalyticsSelectedBreakdowns,
|
||||
AnalyticsSelectedFilters,
|
||||
AnalyticsTableSortColumn,
|
||||
AnalyticsTableSortDirection,
|
||||
AnalyticsTableSortState,
|
||||
AnalyticsTimeframeMode,
|
||||
AnalyticsTimeframePreset,
|
||||
MutableRouteQuery,
|
||||
} from '~/providers/analytics/analytics-types'
|
||||
|
||||
export const DEFAULT_TIMEFRAME_PRESET: AnalyticsTimeframePreset = 'last_30_days'
|
||||
export const DEFAULT_TIMEFRAME_MODE: AnalyticsTimeframeMode = 'preset'
|
||||
export const DEFAULT_LAST_TIMEFRAME_AMOUNT = 1
|
||||
export const DEFAULT_LAST_TIMEFRAME_UNIT: AnalyticsLastTimeframeUnit = 'days'
|
||||
export const DEFAULT_GROUP_BY_PRESET: AnalyticsGroupByPreset = 'day'
|
||||
export const DEFAULT_BREAKDOWN_PRESET: AnalyticsBreakdownPreset = 'none'
|
||||
export const DEFAULT_ANALYTICS_DASHBOARD_STAT: AnalyticsDashboardStat = 'views'
|
||||
export const DEFAULT_ANALYTICS_GRAPH_VIEW_MODE: AnalyticsGraphViewMode = 'line'
|
||||
export const DEFAULT_ANALYTICS_GRAPH_RATIO_MODE = false
|
||||
export const DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY = true
|
||||
export const DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY = false
|
||||
export const MAX_ANALYTICS_BREAKDOWN_PRESETS = 2
|
||||
|
||||
const TIMEFRAME_PRESET_VALUES: AnalyticsTimeframePreset[] = [
|
||||
'today',
|
||||
'yesterday',
|
||||
'last_7_days',
|
||||
'last_14_days',
|
||||
'last_30_days',
|
||||
'last_90_days',
|
||||
'last_180_days',
|
||||
'year_to_date',
|
||||
'all_time',
|
||||
]
|
||||
|
||||
const TIMEFRAME_MODE_VALUES: AnalyticsTimeframeMode[] = [
|
||||
'preset',
|
||||
'last',
|
||||
'custom_range',
|
||||
'custom_datetime_range',
|
||||
]
|
||||
const LAST_TIMEFRAME_UNIT_VALUES: AnalyticsLastTimeframeUnit[] = [
|
||||
'hours',
|
||||
'days',
|
||||
'weeks',
|
||||
'months',
|
||||
]
|
||||
|
||||
const GROUP_BY_PRESET_VALUES: AnalyticsGroupByPreset[] = [
|
||||
'1h',
|
||||
'6h',
|
||||
'day',
|
||||
'week',
|
||||
'month',
|
||||
'year',
|
||||
]
|
||||
|
||||
const BREAKDOWN_PRESET_VALUES: AnalyticsBreakdownPreset[] = [
|
||||
'none',
|
||||
'project',
|
||||
'country',
|
||||
'monetization',
|
||||
'user_agent',
|
||||
'download_reason',
|
||||
'version_id',
|
||||
'loader',
|
||||
'game_version',
|
||||
]
|
||||
|
||||
const ANALYTICS_DASHBOARD_STAT_VALUES: AnalyticsDashboardStat[] = [
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
|
||||
const ANALYTICS_GRAPH_VIEW_MODE_VALUES: AnalyticsGraphViewMode[] = ['line', 'area', 'bar']
|
||||
const ANALYTICS_TABLE_SORT_COLUMN_VALUES: AnalyticsTableSortColumn[] = [
|
||||
'date',
|
||||
'project',
|
||||
'breakdown',
|
||||
'breakdown_project',
|
||||
'breakdown_country',
|
||||
'breakdown_monetization',
|
||||
'breakdown_user_agent',
|
||||
'breakdown_download_reason',
|
||||
'breakdown_version_id',
|
||||
'breakdown_loader',
|
||||
'breakdown_game_version',
|
||||
'views',
|
||||
'downloads',
|
||||
'revenue',
|
||||
'playtime',
|
||||
]
|
||||
const ANALYTICS_TABLE_SORT_DIRECTION_VALUES: AnalyticsTableSortDirection[] = ['asc', 'desc']
|
||||
|
||||
const PROJECT_STATUS_FILTER_VALUES = [
|
||||
'approved',
|
||||
'archived',
|
||||
'rejected',
|
||||
'draft',
|
||||
'unlisted',
|
||||
'withheld',
|
||||
'private',
|
||||
'other',
|
||||
]
|
||||
|
||||
const QUERY_KEY_PROJECT_IDS = 'a_projects'
|
||||
const QUERY_KEY_TIMEFRAME_MODE = 'a_timeframe_mode'
|
||||
const QUERY_KEY_TIMEFRAME = 'a_timeframe'
|
||||
const QUERY_KEY_TIMEFRAME_LAST_AMOUNT = 'a_timeframe_last_amount'
|
||||
const QUERY_KEY_TIMEFRAME_LAST_UNIT = 'a_timeframe_last_unit'
|
||||
const QUERY_KEY_TIMEFRAME_START = 'a_timeframe_start'
|
||||
const QUERY_KEY_TIMEFRAME_END = 'a_timeframe_end'
|
||||
const QUERY_KEY_GROUP_BY = 'a_group_by'
|
||||
const QUERY_KEY_BREAKDOWN = 'a_breakdown'
|
||||
const QUERY_KEY_FILTER_PROJECT_STATUS = 'a_project_status'
|
||||
const QUERY_KEY_FILTER_COUNTRY = 'a_country'
|
||||
const QUERY_KEY_FILTER_MONETIZATION = 'a_monetization'
|
||||
const QUERY_KEY_FILTER_USER_AGENT = 'a_user_agent'
|
||||
const QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE = 'a_download_source'
|
||||
const QUERY_KEY_FILTER_DOWNLOAD_REASON = 'a_download_reason'
|
||||
const QUERY_KEY_FILTER_VERSION_ID = 'a_version_id'
|
||||
const QUERY_KEY_FILTER_GAME_VERSION = 'a_game_version'
|
||||
const QUERY_KEY_FILTER_LOADER_TYPE = 'a_loader_type'
|
||||
const QUERY_KEY_STAT = 'a_stat'
|
||||
const QUERY_KEY_GRAPH_VIEW_MODE = 'a_chart'
|
||||
const QUERY_KEY_GRAPH_RATIO_MODE = 'a_ratio'
|
||||
const QUERY_KEY_GRAPH_EVENTS_VISIBILITY = 'a_events'
|
||||
const QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY = 'a_project_events'
|
||||
const QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY = 'a_prev_period'
|
||||
const QUERY_KEY_GRAPH_HIDDEN_SERIES = 'a_hidden_series'
|
||||
const QUERY_KEY_GRAPH_SELECTED_SERIES = 'a_selected_series'
|
||||
const QUERY_KEY_TABLE_SORT = 'a_table_sort'
|
||||
const QUERY_KEY_TABLE_SORT_DIRECTION = 'a_table_sort_direction'
|
||||
const QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER = 'a_top_breakdown'
|
||||
const QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION = 'a_legend_expanded'
|
||||
|
||||
const URL_FILTER_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
|
||||
'project_status',
|
||||
'country',
|
||||
'monetization',
|
||||
'user_agent',
|
||||
'download_reason',
|
||||
'version_id',
|
||||
'game_version',
|
||||
'loader_type',
|
||||
]
|
||||
|
||||
const FILTER_QUERY_KEY_BY_CATEGORY: Record<
|
||||
Exclude<AnalyticsQueryFilterCategory, 'project'>,
|
||||
string
|
||||
> = {
|
||||
project_status: QUERY_KEY_FILTER_PROJECT_STATUS,
|
||||
country: QUERY_KEY_FILTER_COUNTRY,
|
||||
monetization: QUERY_KEY_FILTER_MONETIZATION,
|
||||
user_agent: QUERY_KEY_FILTER_USER_AGENT,
|
||||
download_reason: QUERY_KEY_FILTER_DOWNLOAD_REASON,
|
||||
version_id: QUERY_KEY_FILTER_VERSION_ID,
|
||||
game_version: QUERY_KEY_FILTER_GAME_VERSION,
|
||||
loader_type: QUERY_KEY_FILTER_LOADER_TYPE,
|
||||
}
|
||||
|
||||
const ANALYTICS_QUERY_KEYS = [
|
||||
QUERY_KEY_PROJECT_IDS,
|
||||
QUERY_KEY_TIMEFRAME_MODE,
|
||||
QUERY_KEY_TIMEFRAME,
|
||||
QUERY_KEY_TIMEFRAME_LAST_AMOUNT,
|
||||
QUERY_KEY_TIMEFRAME_LAST_UNIT,
|
||||
QUERY_KEY_TIMEFRAME_START,
|
||||
QUERY_KEY_TIMEFRAME_END,
|
||||
QUERY_KEY_GROUP_BY,
|
||||
QUERY_KEY_BREAKDOWN,
|
||||
QUERY_KEY_FILTER_PROJECT_STATUS,
|
||||
QUERY_KEY_FILTER_COUNTRY,
|
||||
QUERY_KEY_FILTER_MONETIZATION,
|
||||
QUERY_KEY_FILTER_USER_AGENT,
|
||||
QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE,
|
||||
QUERY_KEY_FILTER_DOWNLOAD_REASON,
|
||||
QUERY_KEY_FILTER_VERSION_ID,
|
||||
QUERY_KEY_FILTER_GAME_VERSION,
|
||||
QUERY_KEY_FILTER_LOADER_TYPE,
|
||||
QUERY_KEY_STAT,
|
||||
QUERY_KEY_GRAPH_VIEW_MODE,
|
||||
QUERY_KEY_GRAPH_RATIO_MODE,
|
||||
QUERY_KEY_GRAPH_EVENTS_VISIBILITY,
|
||||
QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY,
|
||||
QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
|
||||
QUERY_KEY_GRAPH_HIDDEN_SERIES,
|
||||
QUERY_KEY_GRAPH_SELECTED_SERIES,
|
||||
QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER,
|
||||
QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION,
|
||||
]
|
||||
|
||||
export function buildEmptySelectedFilters(): AnalyticsSelectedFilters {
|
||||
return {
|
||||
project: [],
|
||||
project_status: [],
|
||||
country: [],
|
||||
monetization: [],
|
||||
user_agent: [],
|
||||
download_reason: [],
|
||||
version_id: [],
|
||||
game_version: [],
|
||||
loader_type: [],
|
||||
}
|
||||
}
|
||||
|
||||
function parseListQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
): string[] {
|
||||
if (value === undefined) return []
|
||||
|
||||
const values = Array.isArray(value) ? value : [value]
|
||||
const parsedValues: string[] = []
|
||||
for (const item of values) {
|
||||
if (!item) continue
|
||||
const parts = item.split(',')
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed.length > 0) {
|
||||
parsedValues.push(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(parsedValues))
|
||||
}
|
||||
|
||||
function parseSelectedSeriesQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
): string[] {
|
||||
return parseListQueryValue(value).filter((item) => item.toLowerCase() !== 'null')
|
||||
}
|
||||
|
||||
function normalizeFilterQueryValues(
|
||||
category: Exclude<AnalyticsQueryFilterCategory, 'project'>,
|
||||
values: string[],
|
||||
): string[] {
|
||||
if (category === 'project_status') {
|
||||
return values
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => PROJECT_STATUS_FILTER_VALUES.includes(value))
|
||||
}
|
||||
|
||||
if (category !== 'loader_type') {
|
||||
return values
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(values.map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)),
|
||||
)
|
||||
}
|
||||
|
||||
function parsePresetQueryValue<T extends string>(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
allowedValues: readonly T[],
|
||||
fallbackValue: T,
|
||||
): T {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue) return fallbackValue
|
||||
if (!allowedValues.includes(rawValue as T)) return fallbackValue
|
||||
return rawValue as T
|
||||
}
|
||||
|
||||
function parseAnalyticsBreakdownsQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValues: AnalyticsSelectedBreakdowns,
|
||||
): AnalyticsBreakdownPreset[] {
|
||||
const rawValues = parseListQueryValue(value)
|
||||
if (rawValues.length === 0) {
|
||||
return [...fallbackValues]
|
||||
}
|
||||
|
||||
const parsedBreakdowns: AnalyticsBreakdownPreset[] = []
|
||||
for (const rawValue of rawValues) {
|
||||
const normalizedValue = rawValue === 'download_source' ? 'user_agent' : rawValue
|
||||
if (BREAKDOWN_PRESET_VALUES.includes(normalizedValue as AnalyticsBreakdownPreset)) {
|
||||
parsedBreakdowns.push(normalizedValue as AnalyticsBreakdownPreset)
|
||||
}
|
||||
}
|
||||
|
||||
return parsedBreakdowns
|
||||
}
|
||||
|
||||
function parsePositiveIntegerQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: number,
|
||||
): number {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue) return fallbackValue
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10)
|
||||
if (!Number.isFinite(parsedValue) || parsedValue < 1) return fallbackValue
|
||||
return parsedValue
|
||||
}
|
||||
|
||||
function parseEnabledQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
): boolean {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
return rawValue === '1'
|
||||
}
|
||||
|
||||
function parseVisibleQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: boolean,
|
||||
): boolean {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (rawValue === undefined) return fallbackValue
|
||||
return rawValue !== '0'
|
||||
}
|
||||
|
||||
function getLocalDateQueryValue(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getDefaultCustomStartDate(): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - 1)
|
||||
return getLocalDateQueryValue(date)
|
||||
}
|
||||
|
||||
function getDefaultCustomEndDate(): string {
|
||||
return getLocalDateQueryValue(new Date())
|
||||
}
|
||||
|
||||
function getDefaultCustomDateTimeValue(value: string): string {
|
||||
return new Date(`${value}T00:00:00`).toISOString()
|
||||
}
|
||||
|
||||
function parseDateQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: string,
|
||||
): string {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue || !/^\d{4}-\d{2}-\d{2}$/.test(rawValue)) return fallbackValue
|
||||
|
||||
const date = new Date(`${rawValue}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime())) return fallbackValue
|
||||
if (getLocalDateQueryValue(date) !== rawValue) return fallbackValue
|
||||
|
||||
return rawValue
|
||||
}
|
||||
|
||||
function parseDateTimeQueryValue(
|
||||
value: LocationQueryValue | LocationQueryValue[] | undefined,
|
||||
fallbackValue: string,
|
||||
): string {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
if (!rawValue || !/^\d{4}-\d{2}-\d{2}T/.test(rawValue)) return fallbackValue
|
||||
|
||||
const date = new Date(rawValue)
|
||||
if (Number.isNaN(date.getTime())) return fallbackValue
|
||||
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
function isTimeframeRangeEndBeforeStart(
|
||||
mode: AnalyticsTimeframeMode,
|
||||
startValue: string,
|
||||
endValue: string,
|
||||
): boolean {
|
||||
if (mode === 'custom_datetime_range') {
|
||||
return new Date(endValue).getTime() < new Date(startValue).getTime()
|
||||
}
|
||||
|
||||
return endValue < startValue
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsGraphProjectEventsVisibility(
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): boolean {
|
||||
return selectedProjectIds.length <= 1
|
||||
}
|
||||
|
||||
export function buildDefaultAnalyticsGraphState(
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): AnalyticsGraphState {
|
||||
return {
|
||||
activeStat: DEFAULT_ANALYTICS_DASHBOARD_STAT,
|
||||
activeGraphViewMode: DEFAULT_ANALYTICS_GRAPH_VIEW_MODE,
|
||||
isRatioMode: DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
|
||||
showChartEvents: DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
|
||||
showProjectEvents: getDefaultAnalyticsGraphProjectEventsVisibility(selectedProjectIds),
|
||||
showPreviousPeriod: DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
|
||||
hiddenGraphDatasetIds: [],
|
||||
selectedGraphDatasetIds: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDefaultAnalyticsQueryBuilderState(
|
||||
availableProjectIds: string[],
|
||||
): AnalyticsQueryBuilderState {
|
||||
return {
|
||||
selectedProjectIds: [...availableProjectIds],
|
||||
selectedTimeframeMode: DEFAULT_TIMEFRAME_MODE,
|
||||
selectedTimeframe: DEFAULT_TIMEFRAME_PRESET,
|
||||
selectedLastTimeframeAmount: DEFAULT_LAST_TIMEFRAME_AMOUNT,
|
||||
selectedLastTimeframeUnit: DEFAULT_LAST_TIMEFRAME_UNIT,
|
||||
selectedCustomTimeframeStartDate: getDefaultCustomStartDate(),
|
||||
selectedCustomTimeframeEndDate: getDefaultCustomEndDate(),
|
||||
selectedGroupBy: DEFAULT_GROUP_BY_PRESET,
|
||||
selectedBreakdowns: getDefaultAnalyticsBreakdownPresets(availableProjectIds),
|
||||
selectedFilters: buildEmptySelectedFilters(),
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsBreakdownPresets(
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsSelectedBreakdowns {
|
||||
return selectedProjectIds.length > 1 ? ['project'] : []
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsBreakdownPreset(
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsBreakdownPreset {
|
||||
return selectedProjectIds.length > 1 ? 'project' : DEFAULT_BREAKDOWN_PRESET
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsSelectedBreakdowns {
|
||||
const normalizedBreakdowns: AnalyticsSelectedBreakdowns = []
|
||||
const canBreakDownByProject = selectedProjectIds.length > 1
|
||||
|
||||
for (const breakdown of breakdowns) {
|
||||
if (breakdown === 'none') {
|
||||
continue
|
||||
}
|
||||
if (breakdown === 'project' && !canBreakDownByProject) {
|
||||
continue
|
||||
}
|
||||
if (!normalizedBreakdowns.includes(breakdown)) {
|
||||
normalizedBreakdowns.push(breakdown)
|
||||
}
|
||||
if (normalizedBreakdowns.length >= MAX_ANALYTICS_BREAKDOWN_PRESETS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedBreakdowns
|
||||
}
|
||||
|
||||
export function getAnalyticsBreakdownPresetForProjectSelection(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
selectedProjectIds: readonly string[],
|
||||
): AnalyticsBreakdownPreset {
|
||||
const defaultBreakdown = getDefaultAnalyticsBreakdownPreset(selectedProjectIds)
|
||||
if (
|
||||
(breakdown === 'none' && defaultBreakdown === 'project') ||
|
||||
(breakdown === 'project' && defaultBreakdown === 'none')
|
||||
) {
|
||||
return defaultBreakdown
|
||||
}
|
||||
|
||||
return breakdown
|
||||
}
|
||||
|
||||
export function isAnalyticsQueryBuilderStateDefault(
|
||||
state: AnalyticsQueryBuilderState,
|
||||
availableProjectIds: string[],
|
||||
): boolean {
|
||||
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
|
||||
const areDefaultProjectsSelected =
|
||||
availableProjectIds.length === 0
|
||||
? state.selectedProjectIds.length === 0
|
||||
: areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
|
||||
|
||||
return (
|
||||
areDefaultProjectsSelected &&
|
||||
state.selectedTimeframeMode === defaultState.selectedTimeframeMode &&
|
||||
state.selectedTimeframe === defaultState.selectedTimeframe &&
|
||||
state.selectedLastTimeframeAmount === defaultState.selectedLastTimeframeAmount &&
|
||||
state.selectedLastTimeframeUnit === defaultState.selectedLastTimeframeUnit &&
|
||||
state.selectedCustomTimeframeStartDate === defaultState.selectedCustomTimeframeStartDate &&
|
||||
state.selectedCustomTimeframeEndDate === defaultState.selectedCustomTimeframeEndDate &&
|
||||
state.selectedGroupBy === defaultState.selectedGroupBy &&
|
||||
areStringArraysEqual(
|
||||
state.selectedBreakdowns,
|
||||
getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds),
|
||||
) &&
|
||||
areSelectedFiltersEqual(state.selectedFilters, defaultState.selectedFilters)
|
||||
)
|
||||
}
|
||||
|
||||
export function isAnalyticsGraphStateDefault(
|
||||
state: AnalyticsGraphState,
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): boolean {
|
||||
const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds)
|
||||
|
||||
return (
|
||||
state.activeStat === defaultState.activeStat &&
|
||||
state.activeGraphViewMode === defaultState.activeGraphViewMode &&
|
||||
state.isRatioMode === defaultState.isRatioMode &&
|
||||
state.showChartEvents === defaultState.showChartEvents &&
|
||||
state.showProjectEvents === defaultState.showProjectEvents &&
|
||||
state.showPreviousPeriod === defaultState.showPreviousPeriod &&
|
||||
areStringArraysEqual(state.hiddenGraphDatasetIds, defaultState.hiddenGraphDatasetIds) &&
|
||||
state.selectedGraphDatasetIds === defaultState.selectedGraphDatasetIds
|
||||
)
|
||||
}
|
||||
|
||||
function serializeListQueryValue(values: string[]): string | undefined {
|
||||
if (values.length === 0) return undefined
|
||||
return values.join(',')
|
||||
}
|
||||
|
||||
function serializeExplicitListQueryValue(values: string[]): string {
|
||||
return values.join(',')
|
||||
}
|
||||
|
||||
function serializeVisibleQueryValue(value: boolean, defaultValue: boolean): string | undefined {
|
||||
if (value === defaultValue) return undefined
|
||||
return value ? '1' : '0'
|
||||
}
|
||||
|
||||
function normalizeQueryValue(
|
||||
value:
|
||||
| LocationQueryValue
|
||||
| LocationQueryValue[]
|
||||
| LocationQueryValueRaw
|
||||
| LocationQueryValueRaw[]
|
||||
| undefined,
|
||||
): string[] {
|
||||
if (value === undefined || value === null) return []
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter(
|
||||
(item): item is LocationQueryValue | LocationQueryValueRaw =>
|
||||
item !== undefined && item !== null,
|
||||
)
|
||||
.map((item) => String(item))
|
||||
}
|
||||
return [String(value)]
|
||||
}
|
||||
|
||||
function areQueryValuesEqual(
|
||||
left:
|
||||
| LocationQueryValue
|
||||
| LocationQueryValue[]
|
||||
| LocationQueryValueRaw
|
||||
| LocationQueryValueRaw[]
|
||||
| undefined,
|
||||
right:
|
||||
| LocationQueryValue
|
||||
| LocationQueryValue[]
|
||||
| LocationQueryValueRaw
|
||||
| LocationQueryValueRaw[]
|
||||
| undefined,
|
||||
): boolean {
|
||||
const leftValues = normalizeQueryValue(left)
|
||||
const rightValues = normalizeQueryValue(right)
|
||||
|
||||
if (leftValues.length !== rightValues.length) return false
|
||||
for (let index = 0; index < leftValues.length; index += 1) {
|
||||
if (leftValues[index] !== rightValues[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function areSelectedFiltersEqual(
|
||||
left: AnalyticsSelectedFilters,
|
||||
right: AnalyticsSelectedFilters,
|
||||
): boolean {
|
||||
if (!areStringArraysEqual(left.project, right.project)) return false
|
||||
for (const category of URL_FILTER_CATEGORIES) {
|
||||
if (!areStringArraysEqual(left[category], right[category])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function areAllProjectsSelected(selectedProjectIds: string[], allProjectIds: string[]): boolean {
|
||||
if (allProjectIds.length === 0 || selectedProjectIds.length !== allProjectIds.length) {
|
||||
return false
|
||||
}
|
||||
const allProjectIdSet = new Set(allProjectIds)
|
||||
return selectedProjectIds.every((projectId) => allProjectIdSet.has(projectId))
|
||||
}
|
||||
|
||||
export function readAnalyticsGraphState(
|
||||
query: LocationQuery,
|
||||
selectedProjectIds: readonly string[] = [],
|
||||
): AnalyticsGraphState {
|
||||
const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds)
|
||||
|
||||
return {
|
||||
activeStat: parsePresetQueryValue(
|
||||
query[QUERY_KEY_STAT],
|
||||
ANALYTICS_DASHBOARD_STAT_VALUES,
|
||||
defaultState.activeStat,
|
||||
),
|
||||
activeGraphViewMode: parsePresetQueryValue(
|
||||
query[QUERY_KEY_GRAPH_VIEW_MODE],
|
||||
ANALYTICS_GRAPH_VIEW_MODE_VALUES,
|
||||
defaultState.activeGraphViewMode,
|
||||
),
|
||||
isRatioMode: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_RATIO_MODE]),
|
||||
showChartEvents: parseVisibleQueryValue(
|
||||
query[QUERY_KEY_GRAPH_EVENTS_VISIBILITY],
|
||||
defaultState.showChartEvents,
|
||||
),
|
||||
showProjectEvents: parseVisibleQueryValue(
|
||||
query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY],
|
||||
defaultState.showProjectEvents,
|
||||
),
|
||||
showPreviousPeriod: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY]),
|
||||
hiddenGraphDatasetIds: parseListQueryValue(query[QUERY_KEY_GRAPH_HIDDEN_SERIES]),
|
||||
selectedGraphDatasetIds:
|
||||
query[QUERY_KEY_GRAPH_SELECTED_SERIES] === undefined
|
||||
? null
|
||||
: parseSelectedSeriesQueryValue(query[QUERY_KEY_GRAPH_SELECTED_SERIES]),
|
||||
}
|
||||
}
|
||||
|
||||
export function readAnalyticsTableSortState(
|
||||
query: LocationQuery,
|
||||
defaultState: AnalyticsTableSortState,
|
||||
): AnalyticsTableSortState {
|
||||
const rawSortColumn = Array.isArray(query[QUERY_KEY_TABLE_SORT])
|
||||
? query[QUERY_KEY_TABLE_SORT][0]
|
||||
: query[QUERY_KEY_TABLE_SORT]
|
||||
const rawSortDirection = Array.isArray(query[QUERY_KEY_TABLE_SORT_DIRECTION])
|
||||
? query[QUERY_KEY_TABLE_SORT_DIRECTION][0]
|
||||
: query[QUERY_KEY_TABLE_SORT_DIRECTION]
|
||||
|
||||
if (
|
||||
!rawSortColumn ||
|
||||
!rawSortDirection ||
|
||||
!ANALYTICS_TABLE_SORT_COLUMN_VALUES.includes(rawSortColumn as AnalyticsTableSortColumn) ||
|
||||
!ANALYTICS_TABLE_SORT_DIRECTION_VALUES.includes(rawSortDirection as AnalyticsTableSortDirection)
|
||||
) {
|
||||
return defaultState
|
||||
}
|
||||
|
||||
return {
|
||||
sortColumn: rawSortColumn as AnalyticsTableSortColumn,
|
||||
sortDirection: rawSortDirection as AnalyticsTableSortDirection,
|
||||
}
|
||||
}
|
||||
|
||||
export function readAnalyticsQueryBuilderState(
|
||||
query: LocationQuery,
|
||||
availableProjectIds: string[],
|
||||
): AnalyticsQueryBuilderState {
|
||||
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
|
||||
const selectedProjectIdsFromQuery = parseListQueryValue(query[QUERY_KEY_PROJECT_IDS])
|
||||
const selectedProjectIds =
|
||||
selectedProjectIdsFromQuery.length > 0
|
||||
? selectedProjectIdsFromQuery
|
||||
: defaultState.selectedProjectIds
|
||||
|
||||
const selectedFilters = buildEmptySelectedFilters()
|
||||
for (const category of URL_FILTER_CATEGORIES) {
|
||||
const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category]
|
||||
const rawQueryValue =
|
||||
category === 'user_agent' && query[categoryQueryKey] === undefined
|
||||
? query[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE]
|
||||
: query[categoryQueryKey]
|
||||
selectedFilters[category] = normalizeFilterQueryValues(
|
||||
category,
|
||||
parseListQueryValue(rawQueryValue),
|
||||
)
|
||||
}
|
||||
|
||||
const selectedTimeframeMode = parsePresetQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_MODE],
|
||||
TIMEFRAME_MODE_VALUES,
|
||||
defaultState.selectedTimeframeMode,
|
||||
)
|
||||
const isCustomDateTimeRange = selectedTimeframeMode === 'custom_datetime_range'
|
||||
const parseTimeframeRangeQueryValue = isCustomDateTimeRange
|
||||
? parseDateTimeQueryValue
|
||||
: parseDateQueryValue
|
||||
const customTimeframeStartFallback = isCustomDateTimeRange
|
||||
? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeStartDate)
|
||||
: defaultState.selectedCustomTimeframeStartDate
|
||||
const customTimeframeEndFallback = isCustomDateTimeRange
|
||||
? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeEndDate)
|
||||
: defaultState.selectedCustomTimeframeEndDate
|
||||
|
||||
const selectedCustomTimeframeStartDate = parseTimeframeRangeQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_START],
|
||||
customTimeframeStartFallback,
|
||||
)
|
||||
const rawCustomTimeframeEndDate = parseTimeframeRangeQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_END],
|
||||
customTimeframeEndFallback,
|
||||
)
|
||||
const selectedCustomTimeframeEndDate = isTimeframeRangeEndBeforeStart(
|
||||
selectedTimeframeMode,
|
||||
selectedCustomTimeframeStartDate,
|
||||
rawCustomTimeframeEndDate,
|
||||
)
|
||||
? selectedCustomTimeframeStartDate
|
||||
: rawCustomTimeframeEndDate
|
||||
|
||||
const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
parseAnalyticsBreakdownsQueryValue(
|
||||
query[QUERY_KEY_BREAKDOWN],
|
||||
getDefaultAnalyticsBreakdownPresets(selectedProjectIds),
|
||||
),
|
||||
selectedProjectIds,
|
||||
)
|
||||
|
||||
return {
|
||||
selectedProjectIds,
|
||||
selectedTimeframeMode,
|
||||
selectedTimeframe: parsePresetQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME],
|
||||
TIMEFRAME_PRESET_VALUES,
|
||||
defaultState.selectedTimeframe,
|
||||
),
|
||||
selectedLastTimeframeAmount: parsePositiveIntegerQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_LAST_AMOUNT],
|
||||
defaultState.selectedLastTimeframeAmount,
|
||||
),
|
||||
selectedLastTimeframeUnit: parsePresetQueryValue(
|
||||
query[QUERY_KEY_TIMEFRAME_LAST_UNIT],
|
||||
LAST_TIMEFRAME_UNIT_VALUES,
|
||||
defaultState.selectedLastTimeframeUnit,
|
||||
),
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy: parsePresetQueryValue(
|
||||
query[QUERY_KEY_GROUP_BY],
|
||||
GROUP_BY_PRESET_VALUES,
|
||||
defaultState.selectedGroupBy,
|
||||
),
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAnalyticsBreakdownQuery(query: LocationQuery): boolean {
|
||||
return parseListQueryValue(query[QUERY_KEY_BREAKDOWN]).length > 0
|
||||
}
|
||||
|
||||
export function hasAnalyticsProjectSelectionQuery(query: LocationQuery): boolean {
|
||||
return parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]).length > 0
|
||||
}
|
||||
|
||||
export function hasAnalyticsGraphProjectEventsVisibilityQuery(query: LocationQuery): boolean {
|
||||
return query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] !== undefined
|
||||
}
|
||||
|
||||
export function hasAnalyticsTableSortQuery(query: LocationQuery): boolean {
|
||||
return (
|
||||
query[QUERY_KEY_TABLE_SORT] !== undefined || query[QUERY_KEY_TABLE_SORT_DIRECTION] !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function buildAnalyticsQueryBuilderRouteQuery(
|
||||
currentRouteQuery: LocationQuery,
|
||||
state: AnalyticsQueryBuilderState,
|
||||
availableProjectIds: string[],
|
||||
graphState?: AnalyticsGraphState,
|
||||
): MutableRouteQuery {
|
||||
const nextRouteQuery = {
|
||||
...currentRouteQuery,
|
||||
} as MutableRouteQuery
|
||||
|
||||
const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
|
||||
? undefined
|
||||
: serializeListQueryValue(state.selectedProjectIds)
|
||||
const isCustomTimeframeMode =
|
||||
state.selectedTimeframeMode === 'custom_range' ||
|
||||
state.selectedTimeframeMode === 'custom_datetime_range'
|
||||
|
||||
nextRouteQuery[QUERY_KEY_PROJECT_IDS] = projectIdsQueryValue
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_MODE] =
|
||||
state.selectedTimeframeMode !== DEFAULT_TIMEFRAME_MODE ? state.selectedTimeframeMode : undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME] =
|
||||
state.selectedTimeframeMode === 'preset' && state.selectedTimeframe !== DEFAULT_TIMEFRAME_PRESET
|
||||
? state.selectedTimeframe
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_AMOUNT] =
|
||||
state.selectedTimeframeMode === 'last' ? String(state.selectedLastTimeframeAmount) : undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_UNIT] =
|
||||
state.selectedTimeframeMode === 'last' ? state.selectedLastTimeframeUnit : undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_START] = isCustomTimeframeMode
|
||||
? state.selectedCustomTimeframeStartDate
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_TIMEFRAME_END] = isCustomTimeframeMode
|
||||
? state.selectedCustomTimeframeEndDate
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_GROUP_BY] =
|
||||
state.selectedGroupBy !== DEFAULT_GROUP_BY_PRESET ? state.selectedGroupBy : undefined
|
||||
const defaultBreakdowns = getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds)
|
||||
const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
|
||||
state.selectedBreakdowns,
|
||||
state.selectedProjectIds,
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_BREAKDOWN] = areStringArraysEqual(selectedBreakdowns, defaultBreakdowns)
|
||||
? undefined
|
||||
: selectedBreakdowns.length === 0
|
||||
? 'none'
|
||||
: serializeListQueryValue(selectedBreakdowns)
|
||||
|
||||
for (const category of URL_FILTER_CATEGORIES) {
|
||||
const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category]
|
||||
nextRouteQuery[categoryQueryKey] = serializeListQueryValue(state.selectedFilters[category])
|
||||
}
|
||||
nextRouteQuery[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE] = undefined
|
||||
|
||||
if (graphState) {
|
||||
const defaultGraphState = buildDefaultAnalyticsGraphState(state.selectedProjectIds)
|
||||
|
||||
nextRouteQuery[QUERY_KEY_STAT] =
|
||||
graphState.activeStat !== DEFAULT_ANALYTICS_DASHBOARD_STAT ? graphState.activeStat : undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_VIEW_MODE] =
|
||||
graphState.activeGraphViewMode !== DEFAULT_ANALYTICS_GRAPH_VIEW_MODE
|
||||
? graphState.activeGraphViewMode
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_RATIO_MODE] = graphState.isRatioMode ? '1' : undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_EVENTS_VISIBILITY] = serializeVisibleQueryValue(
|
||||
graphState.showChartEvents,
|
||||
defaultGraphState.showChartEvents,
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] = serializeVisibleQueryValue(
|
||||
graphState.showProjectEvents,
|
||||
defaultGraphState.showProjectEvents,
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY] = graphState.showPreviousPeriod
|
||||
? '1'
|
||||
: undefined
|
||||
nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER] = undefined
|
||||
nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION] = undefined
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_HIDDEN_SERIES] = serializeListQueryValue(
|
||||
[...graphState.hiddenGraphDatasetIds].sort((left, right) => left.localeCompare(right)),
|
||||
)
|
||||
nextRouteQuery[QUERY_KEY_GRAPH_SELECTED_SERIES] =
|
||||
graphState.selectedGraphDatasetIds === null
|
||||
? undefined
|
||||
: serializeExplicitListQueryValue(graphState.selectedGraphDatasetIds)
|
||||
}
|
||||
|
||||
return nextRouteQuery
|
||||
}
|
||||
|
||||
export function buildAnalyticsTableSortRouteQuery(
|
||||
currentRouteQuery: LocationQuery,
|
||||
state: AnalyticsTableSortState,
|
||||
defaultState: AnalyticsTableSortState,
|
||||
): MutableRouteQuery {
|
||||
const nextRouteQuery = {
|
||||
...currentRouteQuery,
|
||||
} as MutableRouteQuery
|
||||
const isDefaultSort =
|
||||
state.sortColumn === defaultState.sortColumn &&
|
||||
state.sortDirection === defaultState.sortDirection
|
||||
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT] =
|
||||
isDefaultSort || state.sortColumn === undefined ? undefined : state.sortColumn
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION] =
|
||||
isDefaultSort || state.sortColumn === undefined ? undefined : state.sortDirection
|
||||
|
||||
return nextRouteQuery
|
||||
}
|
||||
|
||||
export function hasAnalyticsQueryBuilderRouteChange(
|
||||
currentRouteQuery: LocationQuery,
|
||||
nextRouteQuery: MutableRouteQuery,
|
||||
): boolean {
|
||||
return ANALYTICS_QUERY_KEYS.some(
|
||||
(key) => !areQueryValuesEqual(currentRouteQuery[key], nextRouteQuery[key]),
|
||||
)
|
||||
}
|
||||
|
||||
export function hasAnalyticsTableSortRouteChange(
|
||||
currentRouteQuery: LocationQuery,
|
||||
nextRouteQuery: MutableRouteQuery,
|
||||
): boolean {
|
||||
return (
|
||||
!areQueryValuesEqual(
|
||||
currentRouteQuery[QUERY_KEY_TABLE_SORT],
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT],
|
||||
) ||
|
||||
!areQueryValuesEqual(
|
||||
currentRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION],
|
||||
nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION],
|
||||
)
|
||||
)
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsSelectedFilters,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsGroupByMessages,
|
||||
formatAnalyticsBreakdownLabel,
|
||||
formatAnalyticsStatLabel,
|
||||
type FormatMessage,
|
||||
} from '../analytics-messages'
|
||||
import type {
|
||||
AnalyticsTableBreakdownColumnKey,
|
||||
AnalyticsTableBreakdownPreset,
|
||||
AnalyticsTableColumnKey,
|
||||
} from './analytics-table-types'
|
||||
|
||||
type BuildAnalyticsTableColumnsOptions = {
|
||||
includeDate: boolean
|
||||
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
|
||||
selectedFilters: AnalyticsSelectedFilters
|
||||
showBreakdownColumn: boolean
|
||||
showProjectVersionProjectColumn: boolean
|
||||
formatMessage: FormatMessage
|
||||
getRelevantAnalyticsDashboardStats: (
|
||||
breakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
filters?: AnalyticsSelectedFilters,
|
||||
) => readonly AnalyticsDashboardStat[]
|
||||
}
|
||||
|
||||
export function getAnalyticsTableBreakdownColumnLabel(
|
||||
breakdown: AnalyticsBreakdownPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatAnalyticsBreakdownLabel(breakdown, formatMessage)
|
||||
}
|
||||
|
||||
export function buildAnalyticsTableColumns({
|
||||
includeDate,
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
showBreakdownColumn,
|
||||
showProjectVersionProjectColumn,
|
||||
formatMessage,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
}: BuildAnalyticsTableColumnsOptions): TableColumn<AnalyticsTableColumnKey>[] {
|
||||
const nextColumns: TableColumn<AnalyticsTableColumnKey>[] = []
|
||||
const stats = getRelevantAnalyticsDashboardStats(selectedBreakdowns, selectedFilters)
|
||||
|
||||
if (includeDate) {
|
||||
nextColumns.push({
|
||||
key: 'date',
|
||||
label: formatMessage(analyticsGroupByMessages.date),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
width: stats.length > 2 ? '20%' : '',
|
||||
})
|
||||
}
|
||||
|
||||
if (showBreakdownColumn) {
|
||||
for (const breakdown of selectedBreakdowns) {
|
||||
nextColumns.push({
|
||||
key: getAnalyticsTableBreakdownColumnKey(breakdown),
|
||||
label: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage),
|
||||
enableSorting: true,
|
||||
width: breakdown === 'project' ? '25%' : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (showProjectVersionProjectColumn) {
|
||||
nextColumns.push({
|
||||
key: 'project',
|
||||
label: formatAnalyticsBreakdownLabel('project', formatMessage),
|
||||
enableSorting: true,
|
||||
width: '25%',
|
||||
})
|
||||
}
|
||||
|
||||
for (const stat of stats) {
|
||||
const column = getAnalyticsTableMetricColumn(stat, formatMessage)
|
||||
if (column) {
|
||||
nextColumns.push(column)
|
||||
}
|
||||
}
|
||||
|
||||
return nextColumns
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricColumn(
|
||||
stat: AnalyticsDashboardStat,
|
||||
formatMessage: FormatMessage,
|
||||
): TableColumn<AnalyticsTableColumnKey> | null {
|
||||
switch (stat) {
|
||||
case 'views':
|
||||
return {
|
||||
key: 'views',
|
||||
label: formatAnalyticsStatLabel('views', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
case 'downloads':
|
||||
return {
|
||||
key: 'downloads',
|
||||
label: formatAnalyticsStatLabel('downloads', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
case 'revenue':
|
||||
return {
|
||||
key: 'revenue',
|
||||
label: formatAnalyticsStatLabel('revenue', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
case 'playtime':
|
||||
return {
|
||||
key: 'playtime',
|
||||
label: formatAnalyticsStatLabel('playtime', formatMessage),
|
||||
enableSorting: true,
|
||||
defaultSortDirection: 'desc',
|
||||
align: 'right',
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyticsTableBreakdownColumnKey(
|
||||
breakdown: AnalyticsTableBreakdownPreset,
|
||||
): AnalyticsTableBreakdownColumnKey {
|
||||
return `breakdown_${breakdown}`
|
||||
}
|
||||
|
||||
export function isAnalyticsTableBreakdownColumnKey(
|
||||
key: AnalyticsTableColumnKey,
|
||||
): key is AnalyticsTableBreakdownColumnKey {
|
||||
return key.startsWith('breakdown_')
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import { analyticsTableMessages, type FormatMessage } from '../analytics-messages'
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
export function buildAnalyticsTableCsvContent(
|
||||
rows: AnalyticsTableRow[],
|
||||
visibleColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const header = visibleColumns
|
||||
.map((column) =>
|
||||
escapeAnalyticsTableCsvField(getAnalyticsTableCsvHeaderLabel(column, formatMessage)),
|
||||
)
|
||||
.join(',')
|
||||
|
||||
const csvRows = rows.map((row) =>
|
||||
visibleColumns
|
||||
.map((column) => escapeAnalyticsTableCsvField(getAnalyticsTableCsvCellValue(row, column.key)))
|
||||
.join(','),
|
||||
)
|
||||
|
||||
return [header, ...csvRows].join('\n')
|
||||
}
|
||||
|
||||
export function downloadAnalyticsTableCsv(filename: string, csvContent: string) {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const downloadLink = document.createElement('a')
|
||||
downloadLink.setAttribute('href', url)
|
||||
downloadLink.setAttribute('download', filename)
|
||||
downloadLink.style.visibility = 'hidden'
|
||||
|
||||
document.body.appendChild(downloadLink)
|
||||
downloadLink.click()
|
||||
document.body.removeChild(downloadLink)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function getAnalyticsTableCsvFilename(
|
||||
breakdownColumnLabel: string,
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return `${sanitizeAnalyticsTableCsvFilename(
|
||||
formatMessage(analyticsTableMessages.csvFilename, {
|
||||
breakdown: breakdownColumnLabel,
|
||||
dateRange: getAnalyticsTableCsvFilenameDateRange(fetchRequest, formatMessage),
|
||||
}),
|
||||
)}.csv`
|
||||
}
|
||||
|
||||
function getAnalyticsTableCsvCellValue(
|
||||
row: AnalyticsTableRow,
|
||||
key: AnalyticsTableColumnKey,
|
||||
): string | number {
|
||||
switch (key) {
|
||||
case 'date':
|
||||
return row.date
|
||||
case 'project':
|
||||
return row.project
|
||||
case 'breakdown':
|
||||
return row.breakdownDisplay
|
||||
case 'views':
|
||||
return row.views
|
||||
case 'downloads':
|
||||
return row.downloads
|
||||
case 'revenue':
|
||||
return row.revenue
|
||||
case 'playtime':
|
||||
return row.playtime
|
||||
default:
|
||||
return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : ''
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableCsvHeaderLabel(
|
||||
column: TableColumn<AnalyticsTableColumnKey>,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
if (column.key === 'playtime') {
|
||||
return formatMessage(analyticsTableMessages.playtimeSecondsHeader)
|
||||
}
|
||||
|
||||
return column.label ?? column.key
|
||||
}
|
||||
|
||||
function escapeAnalyticsTableCsvField(value: string | number): string {
|
||||
const stringValue = String(value)
|
||||
if (
|
||||
stringValue.includes(',') ||
|
||||
stringValue.includes('"') ||
|
||||
stringValue.includes('\n') ||
|
||||
stringValue.includes('\r')
|
||||
) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`
|
||||
}
|
||||
return stringValue
|
||||
}
|
||||
|
||||
function formatAnalyticsTableCsvFilenameDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function getAnalyticsTableCsvFilenameDateRange(
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const timeRange = fetchRequest?.time_range
|
||||
if (!timeRange) {
|
||||
return formatMessage(analyticsTableMessages.csvSelectedRange)
|
||||
}
|
||||
|
||||
const start = new Date(timeRange.start)
|
||||
const end = new Date(timeRange.end)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return formatMessage(analyticsTableMessages.csvSelectedRange)
|
||||
}
|
||||
|
||||
const startLabel = formatAnalyticsTableCsvFilenameDate(start)
|
||||
const endLabel = formatAnalyticsTableCsvFilenameDate(end)
|
||||
return startLabel === endLabel
|
||||
? startLabel
|
||||
: formatMessage(analyticsTableMessages.csvDateRange, {
|
||||
start: startLabel,
|
||||
end: endLabel,
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeAnalyticsTableCsvFilename(value: string): string {
|
||||
return value
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
analyticsStatCardMessages,
|
||||
analyticsTableMessages,
|
||||
formatAnalyticsGroupByLabel,
|
||||
type FormatMessage,
|
||||
} from '../analytics-messages'
|
||||
|
||||
const SECONDS_PER_MINUTE = 60
|
||||
const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
|
||||
|
||||
export function getAnalyticsTableGroupByLabel(
|
||||
groupBy: AnalyticsGroupByPreset,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
return formatAnalyticsGroupByLabel(groupBy, formatMessage)
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableInteger(
|
||||
formatNumber: (value: number) => string,
|
||||
value: number,
|
||||
): string {
|
||||
return formatNumber(Math.round(value))
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableRevenue(
|
||||
formatter: Intl.NumberFormat,
|
||||
value: number,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const rounded = Math.round(value * 100) / 100
|
||||
return formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatter.format(rounded),
|
||||
})
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableCompactPlaytime(
|
||||
value: number,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const totalSeconds = Math.max(0, Math.round(value))
|
||||
const hours = totalSeconds / SECONDS_PER_HOUR
|
||||
const fractionDigits = hours < 1 ? 2 : 1
|
||||
return formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: hours.toLocaleString(undefined, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function formatAnalyticsTableFullPlaytime(
|
||||
value: number,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const totalMinutes = Math.max(0, Math.round(value / SECONDS_PER_MINUTE))
|
||||
const days = Math.floor(totalMinutes / (24 * 60))
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
|
||||
return [
|
||||
formatMessage(analyticsTableMessages.durationDays, { count: days }),
|
||||
formatMessage(analyticsTableMessages.durationHours, { count: hours }),
|
||||
formatMessage(analyticsTableMessages.durationMinutes, { count: minutes }),
|
||||
].join(', ')
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsDashboardStat,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
formatBreakdownLabel,
|
||||
formatBucketEndLabel,
|
||||
getSliceBucketRange,
|
||||
getSliceCount,
|
||||
} from '../analytics-chart/analytics-chart-utils'
|
||||
import type { FormatMessage } from '../analytics-messages'
|
||||
import { analyticsMessages } from '../analytics-messages'
|
||||
import {
|
||||
ALL_BREAKDOWN_VALUE,
|
||||
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
|
||||
getAnalyticsBreakdownDatasetId,
|
||||
getAnalyticsBreakdownKey,
|
||||
getAnalyticsBreakdownValues,
|
||||
} from '../breakdown'
|
||||
import { getAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type {
|
||||
AnalyticsTableBreakdownDisplayValues,
|
||||
AnalyticsTableBreakdownPreset,
|
||||
AnalyticsTableMode,
|
||||
AnalyticsTableRow,
|
||||
} from './analytics-table-types'
|
||||
|
||||
const ALL_PROJECTS_BREAKDOWN_VALUE = 'all'
|
||||
|
||||
type BuildAnalyticsTableRowsOptions = {
|
||||
mode: AnalyticsTableMode
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null
|
||||
timeSlices: Labrinth.Analytics.v3.TimeSlice[]
|
||||
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
|
||||
selectedProjectIds: ReadonlySet<string>
|
||||
relevantStats: ReadonlySet<AnalyticsDashboardStat>
|
||||
projectNamesById: ReadonlyMap<string, string>
|
||||
getVersionDisplayName: (versionId: string) => string
|
||||
getVersionProjectName: (versionId: string) => string | undefined
|
||||
showTimeInBucketLabel: boolean
|
||||
showYearInBucketLabel: boolean
|
||||
formatMessage: FormatMessage
|
||||
}
|
||||
|
||||
export function buildAnalyticsTableRows({
|
||||
mode,
|
||||
fetchRequest,
|
||||
timeSlices,
|
||||
selectedBreakdowns,
|
||||
selectedProjectIds,
|
||||
relevantStats,
|
||||
projectNamesById,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
showTimeInBucketLabel,
|
||||
showYearInBucketLabel,
|
||||
formatMessage,
|
||||
}: BuildAnalyticsTableRowsOptions): AnalyticsTableRow[] {
|
||||
if (!fetchRequest || selectedProjectIds.size === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const timeRange = fetchRequest.time_range
|
||||
const sliceCount = getSliceCount(timeRange, timeSlices.length)
|
||||
const currentTimeSlices =
|
||||
timeSlices.length > sliceCount ? timeSlices.slice(timeSlices.length - sliceCount) : timeSlices
|
||||
const includeDate = mode === 'date_breakdown'
|
||||
const breakdownDisplayValues = new Map<string, string>()
|
||||
const projectDisplayValues = new Map<string, string>()
|
||||
const nextRows = new Map<string, AnalyticsTableRow>()
|
||||
const bucketLabelsBySliceIndex = new Map<number, { date: string; dateMs: number }>()
|
||||
|
||||
function getBreakdownDisplayValue(
|
||||
breakdownValue: string,
|
||||
breakdown: AnalyticsTableBreakdownPreset,
|
||||
) {
|
||||
const key = `${breakdown}:${breakdownValue}`
|
||||
let displayValue = breakdownDisplayValues.get(key)
|
||||
if (displayValue === undefined) {
|
||||
displayValue = formatAnalyticsTableBreakdownDisplayValue(
|
||||
breakdownValue,
|
||||
breakdown,
|
||||
projectNamesById,
|
||||
getVersionDisplayName,
|
||||
formatMessage,
|
||||
)
|
||||
breakdownDisplayValues.set(key, displayValue)
|
||||
}
|
||||
return displayValue
|
||||
}
|
||||
|
||||
function getProjectDisplayValueForBreakdownValues(breakdownValues: readonly string[]) {
|
||||
const versionBreakdownIndex = selectedBreakdowns.indexOf('version_id')
|
||||
if (versionBreakdownIndex === -1 || selectedBreakdowns.includes('project')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const versionId = breakdownValues[versionBreakdownIndex]
|
||||
if (!versionId) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let displayValue = projectDisplayValues.get(versionId)
|
||||
if (displayValue === undefined) {
|
||||
displayValue = getVersionProjectName(versionId) ?? ''
|
||||
projectDisplayValues.set(versionId, displayValue)
|
||||
}
|
||||
return displayValue
|
||||
}
|
||||
|
||||
function getBreakdownDisplays(breakdownValues: readonly string[]) {
|
||||
const displays: AnalyticsTableBreakdownDisplayValues = {}
|
||||
|
||||
selectedBreakdowns.forEach((breakdown, index) => {
|
||||
displays[breakdown] = getBreakdownDisplayValue(breakdownValues[index] ?? '', breakdown)
|
||||
})
|
||||
|
||||
return displays
|
||||
}
|
||||
|
||||
function getCombinedBreakdownDisplay(displays: AnalyticsTableBreakdownDisplayValues) {
|
||||
const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown)
|
||||
let hasUnknownBreakdownLabel = false
|
||||
|
||||
return selectedBreakdowns
|
||||
.map((breakdown) => displays[breakdown])
|
||||
.filter((displayValue): displayValue is string => Boolean(displayValue))
|
||||
.filter((displayValue) => {
|
||||
if (displayValue !== unknownBreakdownLabel) {
|
||||
return true
|
||||
}
|
||||
if (hasUnknownBreakdownLabel) {
|
||||
return false
|
||||
}
|
||||
hasUnknownBreakdownLabel = true
|
||||
return true
|
||||
})
|
||||
.join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
|
||||
}
|
||||
|
||||
function getBucketLabel(sliceIndex: number) {
|
||||
let bucketLabel = bucketLabelsBySliceIndex.get(sliceIndex)
|
||||
if (!bucketLabel) {
|
||||
const bucketRange = getSliceBucketRange(timeRange, sliceCount, sliceIndex)
|
||||
bucketLabel = {
|
||||
date: formatBucketEndLabel(bucketRange.end, showTimeInBucketLabel, showYearInBucketLabel),
|
||||
dateMs: bucketRange.end.getTime(),
|
||||
}
|
||||
bucketLabelsBySliceIndex.set(sliceIndex, bucketLabel)
|
||||
}
|
||||
return bucketLabel
|
||||
}
|
||||
|
||||
function createRow(
|
||||
rowId: string,
|
||||
breakdownValues: readonly string[],
|
||||
bucketLabel?: { date: string; dateMs: number },
|
||||
) {
|
||||
const breakdownKey =
|
||||
breakdownValues.length === 0
|
||||
? ALL_PROJECTS_BREAKDOWN_VALUE
|
||||
: getAnalyticsBreakdownKey(breakdownValues)
|
||||
const breakdownDisplays = getBreakdownDisplays(breakdownValues)
|
||||
const row: AnalyticsTableRow = {
|
||||
id: rowId,
|
||||
date: bucketLabel?.date ?? '',
|
||||
dateMs: bucketLabel?.dateMs ?? 0,
|
||||
project: getProjectDisplayValueForBreakdownValues(breakdownValues),
|
||||
breakdown: breakdownKey,
|
||||
breakdownValues: Object.fromEntries(
|
||||
selectedBreakdowns.map((breakdown, index) => [breakdown, breakdownValues[index] ?? '']),
|
||||
) as AnalyticsTableBreakdownDisplayValues,
|
||||
breakdownDisplays,
|
||||
graphDatasetId: getAnalyticsTableGraphDatasetId(breakdownValues, selectedBreakdowns),
|
||||
breakdownDisplay: getCombinedBreakdownDisplay(breakdownDisplays),
|
||||
views: 0,
|
||||
downloads: 0,
|
||||
revenue: 0,
|
||||
playtime: 0,
|
||||
}
|
||||
|
||||
for (const breakdown of selectedBreakdowns) {
|
||||
row[getAnalyticsTableBreakdownColumnKey(breakdown)] = breakdownDisplays[breakdown] ?? ''
|
||||
}
|
||||
|
||||
nextRows.set(rowId, row)
|
||||
return row
|
||||
}
|
||||
|
||||
if (!includeDate && selectedBreakdowns.length === 0) {
|
||||
createRow(ALL_PROJECTS_BREAKDOWN_VALUE, [])
|
||||
}
|
||||
|
||||
if (!includeDate && selectedBreakdowns.length === 1 && selectedBreakdowns[0] === 'project') {
|
||||
for (const projectId of selectedProjectIds) {
|
||||
createRow(projectId, [projectId])
|
||||
}
|
||||
}
|
||||
|
||||
currentTimeSlices.forEach((slice, sliceIndex) => {
|
||||
const bucketLabel = includeDate ? getBucketLabel(sliceIndex) : undefined
|
||||
|
||||
for (const point of slice) {
|
||||
if (!isProjectAnalyticsPoint(point)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!selectedProjectIds.has(point.source_project)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pointStat = getAnalyticsTableStatForMetric(point.metric_kind)
|
||||
if (!pointStat || !relevantStats.has(pointStat)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const breakdownValues =
|
||||
selectedBreakdowns.length === 0
|
||||
? []
|
||||
: getAnalyticsBreakdownValues(point, selectedBreakdowns, formatMessage)
|
||||
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextBucketLabel = includeDate ? (bucketLabel ?? getBucketLabel(sliceIndex)) : undefined
|
||||
const breakdownKey =
|
||||
breakdownValues.length === 0
|
||||
? ALL_PROJECTS_BREAKDOWN_VALUE
|
||||
: getAnalyticsBreakdownKey(breakdownValues)
|
||||
const rowId = includeDate ? `${nextBucketLabel?.dateMs ?? 0}::${breakdownKey}` : breakdownKey
|
||||
const row = nextRows.get(rowId) ?? createRow(rowId, breakdownValues, nextBucketLabel)
|
||||
addAnalyticsMetricToTableRow(row, point)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(nextRows.values())
|
||||
}
|
||||
|
||||
function isProjectAnalyticsPoint(
|
||||
point: Labrinth.Analytics.v3.AnalyticsData,
|
||||
): point is Labrinth.Analytics.v3.ProjectAnalytics {
|
||||
return 'source_project' in point
|
||||
}
|
||||
|
||||
function addAnalyticsMetricToTableRow(
|
||||
row: AnalyticsTableRow,
|
||||
point: Labrinth.Analytics.v3.ProjectAnalytics,
|
||||
) {
|
||||
switch (point.metric_kind) {
|
||||
case 'views':
|
||||
row.views += point.views
|
||||
break
|
||||
case 'downloads':
|
||||
row.downloads += point.downloads
|
||||
break
|
||||
case 'playtime':
|
||||
row.playtime += point.seconds
|
||||
break
|
||||
case 'revenue': {
|
||||
const parsed = Number.parseFloat(point.revenue)
|
||||
row.revenue += Number.isFinite(parsed) ? parsed : 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableStatForMetric(
|
||||
metricKind: Labrinth.Analytics.v3.ProjectAnalytics['metric_kind'],
|
||||
): AnalyticsDashboardStat | null {
|
||||
switch (metricKind) {
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
case 'revenue':
|
||||
case 'playtime':
|
||||
return metricKind
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableGraphDatasetId(
|
||||
breakdownValues: readonly string[],
|
||||
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
|
||||
): string {
|
||||
return getAnalyticsBreakdownDatasetId(breakdownValues, selectedBreakdowns)
|
||||
}
|
||||
|
||||
function formatAnalyticsTableBreakdownDisplayValue(
|
||||
value: string,
|
||||
breakdown: AnalyticsTableBreakdownPreset,
|
||||
projectNamesById: ReadonlyMap<string, string>,
|
||||
getVersionDisplayName: (versionId: string) => string,
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
if (breakdown === 'project') {
|
||||
return projectNamesById.get(value) ?? value
|
||||
}
|
||||
return formatBreakdownLabel(value, breakdown, getVersionDisplayName, formatMessage)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
const SEARCHABLE_COLUMN_KEYS = new Set<AnalyticsTableColumnKey>(['date', 'project'])
|
||||
|
||||
export function getAnalyticsTableSearchableColumns(
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
): TableColumn<AnalyticsTableColumnKey>[] {
|
||||
return columns.filter(
|
||||
(column) =>
|
||||
SEARCHABLE_COLUMN_KEYS.has(column.key) || isAnalyticsTableBreakdownColumnKey(column.key),
|
||||
)
|
||||
}
|
||||
|
||||
export function filterAnalyticsTableRowsBySearch(
|
||||
rows: AnalyticsTableRow[],
|
||||
searchableColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
query: string,
|
||||
): AnalyticsTableRow[] {
|
||||
if (!query || searchableColumns.length === 0) {
|
||||
return rows
|
||||
}
|
||||
|
||||
return rows.filter((row) =>
|
||||
searchableColumns.some((column) =>
|
||||
String(getAnalyticsTableSearchableCellValue(row, column.key)).toLowerCase().includes(query),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function getAnalyticsTableSearchableCellValue(
|
||||
row: AnalyticsTableRow,
|
||||
key: AnalyticsTableColumnKey,
|
||||
): string {
|
||||
switch (key) {
|
||||
case 'date':
|
||||
return row.date
|
||||
case 'project':
|
||||
return row.project
|
||||
case 'breakdown':
|
||||
return row.breakdownDisplay
|
||||
default:
|
||||
return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : ''
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import {
|
||||
buildAnalyticsTableSortRouteQuery,
|
||||
readAnalyticsTableSortState,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import {
|
||||
getAnalyticsTableDefaultSortColumn,
|
||||
getAnalyticsTableDefaultSortDirection,
|
||||
} from './analytics-table-sorting'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
AnalyticsTableSortState,
|
||||
} from './analytics-table-types'
|
||||
|
||||
type GetDefaultAnalyticsTableSortStateOptions = {
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[]
|
||||
showGraphDatasetSelection: boolean
|
||||
activeStat: AnalyticsDashboardStat
|
||||
}
|
||||
|
||||
export function getRouteAnalyticsTableSortState(
|
||||
query: LocationQuery,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
): AnalyticsTableSortState {
|
||||
return getAvailableAnalyticsTableSortState(
|
||||
readAnalyticsTableSortState(query, getDefaultAnalyticsTableSortState(defaultSortOptions)),
|
||||
columns,
|
||||
defaultSortOptions,
|
||||
)
|
||||
}
|
||||
|
||||
export function getAvailableAnalyticsTableSortState(
|
||||
state: AnalyticsTableSortState,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
): AnalyticsTableSortState {
|
||||
const availableColumns = new Set(columns.map((column) => column.key))
|
||||
if (state.sortColumn && availableColumns.has(state.sortColumn)) {
|
||||
return state
|
||||
}
|
||||
if (state.sortColumn === 'breakdown') {
|
||||
const firstBreakdownColumn = columns.find((column) =>
|
||||
isAnalyticsTableBreakdownColumnKey(column.key),
|
||||
)
|
||||
if (firstBreakdownColumn) {
|
||||
return {
|
||||
sortColumn: firstBreakdownColumn.key,
|
||||
sortDirection: state.sortDirection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultAnalyticsTableSortState(defaultSortOptions)
|
||||
}
|
||||
|
||||
export function getDefaultAnalyticsTableSortState({
|
||||
columns,
|
||||
showGraphDatasetSelection,
|
||||
activeStat,
|
||||
}: GetDefaultAnalyticsTableSortStateOptions): AnalyticsTableSortState {
|
||||
const nextSortColumn = getAnalyticsTableDefaultSortColumn(
|
||||
columns,
|
||||
showGraphDatasetSelection,
|
||||
activeStat,
|
||||
)
|
||||
return {
|
||||
sortColumn: nextSortColumn,
|
||||
sortDirection: getAnalyticsTableDefaultSortDirection(nextSortColumn, columns),
|
||||
}
|
||||
}
|
||||
|
||||
export function areAnalyticsTableSortStatesEqual(
|
||||
left: AnalyticsTableSortState,
|
||||
right: AnalyticsTableSortState,
|
||||
): boolean {
|
||||
return left.sortColumn === right.sortColumn && left.sortDirection === right.sortDirection
|
||||
}
|
||||
|
||||
export function buildSyncedAnalyticsTableSortRouteQuery(
|
||||
query: LocationQuery,
|
||||
sortState: AnalyticsTableSortState,
|
||||
columns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
|
||||
) {
|
||||
const nextSortState = getAvailableAnalyticsTableSortState(sortState, columns, defaultSortOptions)
|
||||
|
||||
return buildAnalyticsTableSortRouteQuery(
|
||||
query,
|
||||
nextSortState,
|
||||
getDefaultAnalyticsTableSortState(defaultSortOptions),
|
||||
)
|
||||
}
|
||||
|
||||
export function toAnalyticsTableSortState(
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortDirection: AnalyticsTableSortDirectionValue,
|
||||
): AnalyticsTableSortState {
|
||||
return {
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import type { TableColumn } from '@modrinth/ui'
|
||||
|
||||
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
|
||||
|
||||
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableRow,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types'
|
||||
|
||||
export function sortAnalyticsTableRows(
|
||||
rows: AnalyticsTableRow[],
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortDirection: AnalyticsTableSortDirectionValue,
|
||||
sortCollator: Intl.Collator,
|
||||
): AnalyticsTableRow[] {
|
||||
const nextRows = [...rows]
|
||||
|
||||
if (!sortColumn) {
|
||||
return nextRows
|
||||
}
|
||||
|
||||
const directionFactor = sortDirection === 'asc' ? 1 : -1
|
||||
nextRows.sort(getAnalyticsTableRowComparator(sortColumn, directionFactor, sortCollator))
|
||||
|
||||
return nextRows
|
||||
}
|
||||
|
||||
export function getAnalyticsTableDefaultSortColumn(
|
||||
nextColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
showGraphDatasetSelection: boolean,
|
||||
activeStat: AnalyticsDashboardStat,
|
||||
): AnalyticsTableColumnKey | undefined {
|
||||
const availableColumns = new Set(nextColumns.map((column) => column.key))
|
||||
if (availableColumns.has('date')) {
|
||||
return 'date'
|
||||
}
|
||||
|
||||
if (showGraphDatasetSelection && availableColumns.has(activeStat)) {
|
||||
return activeStat
|
||||
}
|
||||
|
||||
if (availableColumns.has('downloads')) {
|
||||
return 'downloads'
|
||||
}
|
||||
|
||||
return nextColumns[0]?.key
|
||||
}
|
||||
|
||||
export function getAnalyticsTableDefaultSortDirection(
|
||||
column: AnalyticsTableColumnKey | undefined,
|
||||
nextColumns: TableColumn<AnalyticsTableColumnKey>[],
|
||||
): AnalyticsTableSortDirectionValue {
|
||||
return nextColumns.find((nextColumn) => nextColumn.key === column)?.defaultSortDirection ?? 'asc'
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricSortedGraphDatasetIds(
|
||||
rows: AnalyticsTableRow[],
|
||||
sortColumn: AnalyticsTableColumnKey | undefined,
|
||||
sortCollator: Intl.Collator,
|
||||
): string[] {
|
||||
const metricColumn = getAnalyticsTableMetricSortColumn(sortColumn)
|
||||
if (!metricColumn) {
|
||||
return []
|
||||
}
|
||||
|
||||
const totalsByGraphDatasetId = new Map<string, number>()
|
||||
const labelsByGraphDatasetId = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
totalsByGraphDatasetId.set(
|
||||
row.graphDatasetId,
|
||||
(totalsByGraphDatasetId.get(row.graphDatasetId) ?? 0) + row[metricColumn],
|
||||
)
|
||||
if (!labelsByGraphDatasetId.has(row.graphDatasetId)) {
|
||||
labelsByGraphDatasetId.set(row.graphDatasetId, row.breakdownDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(totalsByGraphDatasetId.keys()).sort((left, right) => {
|
||||
const totalDifference =
|
||||
(totalsByGraphDatasetId.get(right) ?? 0) - (totalsByGraphDatasetId.get(left) ?? 0)
|
||||
return (
|
||||
totalDifference ||
|
||||
sortCollator.compare(
|
||||
labelsByGraphDatasetId.get(left) ?? left,
|
||||
labelsByGraphDatasetId.get(right) ?? right,
|
||||
) ||
|
||||
left.localeCompare(right)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function getAnalyticsTableMetricSortColumn(
|
||||
column: AnalyticsTableColumnKey | undefined,
|
||||
): AnalyticsDashboardStat | null {
|
||||
switch (column) {
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
case 'revenue':
|
||||
case 'playtime':
|
||||
return column
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsTableRowComparator(
|
||||
column: AnalyticsTableColumnKey,
|
||||
directionFactor: number,
|
||||
sortCollator: Intl.Collator,
|
||||
): (left: AnalyticsTableRow, right: AnalyticsTableRow) => number {
|
||||
switch (column) {
|
||||
case 'date':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.dateMs - right.dateMs,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'project':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(left.project, right.project),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'breakdown':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(left.breakdownDisplay, right.breakdownDisplay),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'views':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.views - right.views,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'downloads':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.downloads - right.downloads,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'revenue':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.revenue - right.revenue,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
case 'playtime':
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
left.playtime - right.playtime,
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
default:
|
||||
if (isAnalyticsTableBreakdownColumnKey(column)) {
|
||||
return (left, right) =>
|
||||
compareAnalyticsTableRows(
|
||||
left,
|
||||
right,
|
||||
sortCollator.compare(String(left[column] ?? ''), String(right[column] ?? '')),
|
||||
directionFactor,
|
||||
sortCollator,
|
||||
)
|
||||
}
|
||||
|
||||
return () => 0
|
||||
}
|
||||
}
|
||||
|
||||
function compareAnalyticsTableRows(
|
||||
left: AnalyticsTableRow,
|
||||
right: AnalyticsTableRow,
|
||||
primaryResult: number,
|
||||
directionFactor: number,
|
||||
sortCollator: Intl.Collator,
|
||||
): number {
|
||||
if (primaryResult !== 0) {
|
||||
return primaryResult * directionFactor
|
||||
}
|
||||
|
||||
const dateResult = left.dateMs - right.dateMs
|
||||
if (dateResult !== 0) {
|
||||
return dateResult * directionFactor
|
||||
}
|
||||
|
||||
return sortCollator.compare(left.breakdown, right.breakdown) * directionFactor
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
AnalyticsBreakdownPreset,
|
||||
AnalyticsTableSortColumn,
|
||||
AnalyticsTableSortDirection,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
export type AnalyticsTableMode = 'date_breakdown' | 'breakdown_only'
|
||||
export type AnalyticsTableBreakdownPreset = Exclude<AnalyticsBreakdownPreset, 'none'>
|
||||
export type AnalyticsTableBreakdownColumnKey = `breakdown_${AnalyticsTableBreakdownPreset}`
|
||||
export type AnalyticsTableBreakdownDisplayValues = Partial<
|
||||
Record<AnalyticsTableBreakdownPreset, string>
|
||||
>
|
||||
export type AnalyticsTableColumnKey = AnalyticsTableSortColumn
|
||||
export type AnalyticsTableSortState = {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirection
|
||||
}
|
||||
export type AnalyticsTableSortDirectionValue = AnalyticsTableSortDirection
|
||||
|
||||
export type AnalyticsTableRow = {
|
||||
[key: string]: string | number | AnalyticsTableBreakdownDisplayValues
|
||||
id: string
|
||||
date: string
|
||||
dateMs: number
|
||||
project: string
|
||||
breakdown: string
|
||||
breakdownValues: AnalyticsTableBreakdownDisplayValues
|
||||
breakdownDisplays: AnalyticsTableBreakdownDisplayValues
|
||||
graphDatasetId: string
|
||||
breakdownDisplay: string
|
||||
views: number
|
||||
downloads: number
|
||||
revenue: number
|
||||
playtime: number
|
||||
}
|
||||
|
||||
export type AnalyticsTableDisplayedRowsCache = {
|
||||
generation: number
|
||||
mode: AnalyticsTableMode
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
rows: AnalyticsTableRow[]
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden rounded-2xl">
|
||||
<AnalyticsLoadingBar :loading="isDataLoading" />
|
||||
<Table
|
||||
v-model:selected-ids="tableSelectedGraphDatasetIds"
|
||||
:sort-column="displayedSortColumn"
|
||||
:sort-direction="displayedSortDirection"
|
||||
:columns="columns"
|
||||
:data="paginatedRows"
|
||||
row-key="id"
|
||||
selection-key="graphDatasetId"
|
||||
:selection-ids="filteredSelectableGraphDatasetIds"
|
||||
:show-selection="showGraphDatasetSelection"
|
||||
table-min-width="44rem"
|
||||
virtualized
|
||||
:virtual-row-height="56"
|
||||
@sort="applyRequestedSort"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(analyticsBreakdownMessages.breakdown) }}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-wrap items-center gap-2 md:w-auto">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(analyticsTableMessages.searchPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full sm:w-64"
|
||||
@focusin="selectSearchInputText"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<OverflowMenu
|
||||
class="!shadow-none"
|
||||
:options="csvExportOptions"
|
||||
:disabled="isDataLoading || filteredRows.length === 0"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(analyticsTableMessages.exportCsvButton) }}
|
||||
<DropdownIcon />
|
||||
<template #cumulative-csv>
|
||||
{{ formatMessage(analyticsTableMessages.cumulativeCsv) }}
|
||||
</template>
|
||||
<template #grouped-csv>
|
||||
{{ formatMessage(analyticsTableMessages.groupedCsv, { groupBy: groupByLabel }) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-date="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_project="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_country="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_monetization="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_user_agent="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_download_reason="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_version_id="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_loader="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-breakdown_game_version="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-project="{ value }">
|
||||
<span class="text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-views="{ row }">
|
||||
<span>{{ formatInteger(row.views) }}</span>
|
||||
</template>
|
||||
<template #cell-downloads="{ row }">
|
||||
<span>{{ formatInteger(row.downloads) }}</span>
|
||||
</template>
|
||||
<template #cell-revenue="{ row }">
|
||||
<span>{{ formatRevenue(row.revenue) }}</span>
|
||||
</template>
|
||||
<template #cell-playtime="{ row }">
|
||||
<span v-tooltip="formatFullPlaytime(row.playtime)">
|
||||
{{ formatCompactPlaytime(row.playtime) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<div class="flex h-64 items-center justify-center text-secondary">
|
||||
{{ !isDataLoading ? emptyTableMessage : '' }}
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div
|
||||
v-if="filteredRows.length > PAGE_SIZE"
|
||||
class="mt-3 flex flex-wrap items-center justify-between gap-3 px-1 text-sm text-secondary"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
formatMessage(analyticsTableMessages.paginationSummary, {
|
||||
start: visibleRowStart,
|
||||
end: visibleRowEnd,
|
||||
total: filteredRows.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
<div v-if="isDataLoading" class="absolute inset-0 z-10 overflow-hidden rounded-xl">
|
||||
<div class="absolute inset-0 bg-surface-3 opacity-50" />
|
||||
<div class="absolute inset-0 backdrop-blur-[4px]" />
|
||||
<div class="absolute inset-0 flex h-full max-h-[500px] items-center justify-center pt-10">
|
||||
<div class="inline-flex items-center gap-2 text-lg font-semibold text-primary opacity-100">
|
||||
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
Table,
|
||||
useFormatNumber,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import {
|
||||
hasAnalyticsTableSortQuery,
|
||||
hasAnalyticsTableSortRouteChange,
|
||||
readAnalyticsTableSortState,
|
||||
} from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import {
|
||||
doesProjectStatusMatchFilters,
|
||||
injectAnalyticsDashboardContext,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import {
|
||||
isTimeRelevantForGroupBy,
|
||||
isYearRelevantForTimeRange,
|
||||
} from '../analytics-chart/analytics-chart-utils.ts'
|
||||
import {
|
||||
analyticsBreakdownMessages,
|
||||
analyticsMessages,
|
||||
analyticsTableMessages,
|
||||
} from '../analytics-messages.ts'
|
||||
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
|
||||
import {
|
||||
buildAnalyticsTableColumns,
|
||||
getAnalyticsTableBreakdownColumnLabel,
|
||||
} from './analytics-table-columns.ts'
|
||||
import {
|
||||
buildAnalyticsTableCsvContent,
|
||||
downloadAnalyticsTableCsv,
|
||||
getAnalyticsTableCsvFilename,
|
||||
} from './analytics-table-csv-export.ts'
|
||||
import {
|
||||
formatAnalyticsTableCompactPlaytime,
|
||||
formatAnalyticsTableFullPlaytime,
|
||||
formatAnalyticsTableInteger,
|
||||
formatAnalyticsTableRevenue,
|
||||
getAnalyticsTableGroupByLabel,
|
||||
} from './analytics-table-formatting.ts'
|
||||
import { buildAnalyticsTableRows } from './analytics-table-row-builder.ts'
|
||||
import {
|
||||
filterAnalyticsTableRowsBySearch,
|
||||
getAnalyticsTableSearchableColumns,
|
||||
} from './analytics-table-search-filtering.ts'
|
||||
import {
|
||||
areAnalyticsTableSortStatesEqual,
|
||||
buildSyncedAnalyticsTableSortRouteQuery,
|
||||
getRouteAnalyticsTableSortState,
|
||||
toAnalyticsTableSortState,
|
||||
} from './analytics-table-sort-route.ts'
|
||||
import { sortAnalyticsTableRows } from './analytics-table-sorting.ts'
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableMode,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types.ts'
|
||||
import { useAnalyticsTableGraphSelection } from './use-analytics-table-graph-selection.ts'
|
||||
import { useAnalyticsTablePagination } from './use-analytics-table-pagination.ts'
|
||||
import { useAnalyticsTableRowCache } from './use-analytics-table-row-cache.ts'
|
||||
|
||||
const {
|
||||
hasProjectContext,
|
||||
projects,
|
||||
selectedProjectIds: currentSelectedProjectIds,
|
||||
selectedBreakdowns: currentSelectedBreakdowns,
|
||||
displayedSelectedProjectIds: selectedProjectIds,
|
||||
displayedSelectedGroupBy: selectedGroupBy,
|
||||
displayedSelectedBreakdowns: selectedBreakdowns,
|
||||
displayedSelectedFilters: selectedFilters,
|
||||
displayedFetchRequest: fetchRequest,
|
||||
displayedTimeSlices: timeSlices,
|
||||
activeStat,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
selectedGraphDatasetIds,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
isLoading,
|
||||
versionNumbersById,
|
||||
versionProjectNamesById,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
const formatNumber = useFormatNumber()
|
||||
const { formatMessage } = useVIntl()
|
||||
const isDataLoading = computed(() => isLoading.value)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const initialTableSortState = readAnalyticsTableSortState(route.query, {
|
||||
sortColumn: 'date',
|
||||
sortDirection: 'desc',
|
||||
})
|
||||
|
||||
const tableMode = ref<AnalyticsTableMode>('breakdown_only')
|
||||
const sortColumn = ref<AnalyticsTableColumnKey | undefined>(initialTableSortState.sortColumn)
|
||||
const sortDirection = ref<AnalyticsTableSortDirectionValue>(initialTableSortState.sortDirection)
|
||||
const PAGE_SIZE = 500
|
||||
const GRAPH_DATASET_SELECTION_LIMIT = 8
|
||||
const INACTIVE_MODE_WARMUP_POINT_LIMIT = 12000
|
||||
const searchQuery = ref('')
|
||||
const sortCollator = new Intl.Collator(undefined, { sensitivity: 'base' })
|
||||
|
||||
const selectedProjectIdSet = computed(
|
||||
() =>
|
||||
new Set(
|
||||
projects.value
|
||||
.filter(
|
||||
(project) =>
|
||||
selectedProjectIds.value.includes(project.id) &&
|
||||
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
|
||||
)
|
||||
.map((project) => project.id),
|
||||
),
|
||||
)
|
||||
const selectedBreakdownSet = computed(() => new Set(selectedBreakdowns.value))
|
||||
const showBreakdownColumn = computed(() => selectedBreakdowns.value.length > 0)
|
||||
const showGraphDatasetSelection = computed(() =>
|
||||
selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'project'
|
||||
? selectedProjectIdSet.value.size > 1
|
||||
: selectedBreakdowns.value.length > 0,
|
||||
)
|
||||
const showProjectVersionProjectColumn = computed(
|
||||
() =>
|
||||
selectedBreakdownSet.value.has('version_id') &&
|
||||
!selectedBreakdownSet.value.has('project') &&
|
||||
selectedProjectIdSet.value.size > 1,
|
||||
)
|
||||
const includeDateColumn = computed(
|
||||
() =>
|
||||
selectedBreakdowns.value.length === 0 ||
|
||||
(!showGraphDatasetSelection.value && tableMode.value === 'date_breakdown'),
|
||||
)
|
||||
const activeTableMode = computed<AnalyticsTableMode>(() =>
|
||||
selectedBreakdowns.value.length === 0
|
||||
? 'date_breakdown'
|
||||
: showGraphDatasetSelection.value
|
||||
? 'breakdown_only'
|
||||
: tableMode.value,
|
||||
)
|
||||
const displayedIncludeDateColumn = computed(() =>
|
||||
selectedBreakdowns.value.length === 0
|
||||
? true
|
||||
: showGraphDatasetSelection.value
|
||||
? false
|
||||
: displayedTableMode.value === 'date_breakdown',
|
||||
)
|
||||
const groupByLabel = computed(() =>
|
||||
getAnalyticsTableGroupByLabel(selectedGroupBy.value, formatMessage),
|
||||
)
|
||||
const csvExportOptions = computed<OverflowMenuOption[]>(() => {
|
||||
if (showGraphDatasetSelection.value) {
|
||||
return [
|
||||
{
|
||||
id: 'cumulative-csv',
|
||||
action: () => downloadCsv('breakdown_only'),
|
||||
},
|
||||
{
|
||||
id: 'grouped-csv',
|
||||
action: () => downloadCsv('date_breakdown'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const mode = displayedTableMode.value
|
||||
|
||||
return [
|
||||
{
|
||||
id: mode === 'date_breakdown' ? 'grouped-csv' : 'cumulative-csv',
|
||||
action: () => downloadCsv(mode),
|
||||
},
|
||||
]
|
||||
})
|
||||
const projectNamesById = computed(
|
||||
() => new Map(projects.value.map((project) => [project.id, project.name])),
|
||||
)
|
||||
const hasAvailableProjects = computed(() => projects.value.length > 0)
|
||||
const analyticsPointCount = computed(() =>
|
||||
timeSlices.value.reduce((sum, slice) => sum + slice.length, 0),
|
||||
)
|
||||
const emptyTableMessage = computed(() => {
|
||||
if (trimmedSearchQuery.value && sortedRows.value.length > 0) {
|
||||
return formatMessage(analyticsTableMessages.noMatchingRows)
|
||||
}
|
||||
|
||||
if (hasProjectContext.value) {
|
||||
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
|
||||
}
|
||||
|
||||
return hasAvailableProjects.value
|
||||
? formatMessage(analyticsMessages.noDataAvailable)
|
||||
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
|
||||
})
|
||||
|
||||
const breakdownColumnLabel = computed(() =>
|
||||
selectedBreakdowns.value.length === 1
|
||||
? getAnalyticsTableBreakdownColumnLabel(selectedBreakdowns.value[0], formatMessage)
|
||||
: formatMessage(analyticsBreakdownMessages.breakdown),
|
||||
)
|
||||
const relevantStats = computed(
|
||||
() =>
|
||||
new Set(getRelevantAnalyticsDashboardStats(selectedBreakdowns.value, selectedFilters.value)),
|
||||
)
|
||||
|
||||
const showTimeInBucketLabel = computed(() => isTimeRelevantForGroupBy(selectedGroupBy.value))
|
||||
const showYearInBucketLabel = computed(() => {
|
||||
const nextFetchRequest = fetchRequest.value
|
||||
return nextFetchRequest
|
||||
? isYearRelevantForTimeRange(nextFetchRequest.time_range) || selectedGroupBy.value === 'year'
|
||||
: false
|
||||
})
|
||||
|
||||
function buildTableRows(mode: AnalyticsTableMode) {
|
||||
return buildAnalyticsTableRows({
|
||||
mode,
|
||||
fetchRequest: fetchRequest.value,
|
||||
timeSlices: timeSlices.value,
|
||||
selectedBreakdowns: selectedBreakdowns.value,
|
||||
selectedProjectIds: selectedProjectIdSet.value,
|
||||
relevantStats: relevantStats.value,
|
||||
projectNamesById: projectNamesById.value,
|
||||
getVersionDisplayName,
|
||||
getVersionProjectName,
|
||||
showTimeInBucketLabel: showTimeInBucketLabel.value,
|
||||
showYearInBucketLabel: showYearInBucketLabel.value,
|
||||
formatMessage,
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => buildColumns(displayedIncludeDateColumn.value))
|
||||
const activeColumns = computed(() => buildColumns(includeDateColumn.value))
|
||||
|
||||
function buildColumns(includeDate: boolean) {
|
||||
return buildAnalyticsTableColumns({
|
||||
includeDate,
|
||||
selectedBreakdowns: selectedBreakdowns.value,
|
||||
selectedFilters: selectedFilters.value,
|
||||
showBreakdownColumn: showBreakdownColumn.value,
|
||||
showProjectVersionProjectColumn: showProjectVersionProjectColumn.value,
|
||||
formatMessage,
|
||||
getRelevantAnalyticsDashboardStats,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
activeColumns,
|
||||
(nextColumns) => {
|
||||
applyRouteOrDefaultSort(nextColumns)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function sortTableRows(rows: ReturnType<typeof buildTableRows>) {
|
||||
return sortAnalyticsTableRows(rows, sortColumn.value, sortDirection.value, sortCollator)
|
||||
}
|
||||
|
||||
const {
|
||||
displayedTableMode,
|
||||
displayedSortColumn,
|
||||
displayedSortDirection,
|
||||
displayedSortedRows,
|
||||
invalidateTableCaches,
|
||||
invalidateSortedCaches,
|
||||
scheduleRowsForMode,
|
||||
scheduleInactiveModeWarmup,
|
||||
resortDisplayedRowsForCurrentSort,
|
||||
getSortedRowsForMode,
|
||||
} = useAnalyticsTableRowCache({
|
||||
activeTableMode,
|
||||
showBreakdownColumn,
|
||||
analyticsPointCount,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
buildRows: buildTableRows,
|
||||
sortRows: sortTableRows,
|
||||
inactiveModeWarmupPointLimit: INACTIVE_MODE_WARMUP_POINT_LIMIT,
|
||||
})
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
return displayedSortedRows.value
|
||||
})
|
||||
const trimmedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase())
|
||||
const searchableColumns = computed(() => getAnalyticsTableSearchableColumns(columns.value))
|
||||
const filteredRows = computed(() => {
|
||||
if (!trimmedSearchQuery.value) {
|
||||
return sortedRows.value
|
||||
}
|
||||
|
||||
return filterAnalyticsTableRowsBySearch(
|
||||
sortedRows.value,
|
||||
searchableColumns.value,
|
||||
trimmedSearchQuery.value,
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
fetchRequest,
|
||||
timeSlices,
|
||||
selectedProjectIds,
|
||||
selectedGroupBy,
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
projects,
|
||||
versionNumbersById,
|
||||
versionProjectNamesById,
|
||||
],
|
||||
() => {
|
||||
invalidateTableCaches()
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
watch(activeTableMode, () => {
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
(nextQuery) => {
|
||||
const nextSortState = getRouteTableSortState(nextQuery, activeColumns.value)
|
||||
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
|
||||
applyTableSortState(nextSortState)
|
||||
return
|
||||
}
|
||||
|
||||
syncTableSortRouteQuery()
|
||||
},
|
||||
)
|
||||
|
||||
watch([sortColumn, sortDirection], () => {
|
||||
syncTableSortRouteQuery()
|
||||
|
||||
if (resortDisplayedRowsForCurrentSort()) {
|
||||
scheduleInactiveModeWarmup()
|
||||
return
|
||||
}
|
||||
|
||||
invalidateSortedCaches()
|
||||
scheduleRowsForMode(activeTableMode.value)
|
||||
scheduleInactiveModeWarmup()
|
||||
})
|
||||
|
||||
const { filteredSelectableGraphDatasetIds, tableSelectedGraphDatasetIds } =
|
||||
useAnalyticsTableGraphSelection({
|
||||
sortedRows,
|
||||
filteredRows,
|
||||
sortColumn,
|
||||
showGraphDatasetSelection,
|
||||
selectedGraphDatasetIds,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
currentSelectedBreakdowns,
|
||||
currentSelectedProjectIds,
|
||||
activeStat,
|
||||
sortCollator,
|
||||
hasTableSortQuery: () => hasAnalyticsTableSortQuery(route.query),
|
||||
applyActiveStatSort,
|
||||
graphDatasetSelectionLimit: GRAPH_DATASET_SELECTION_LIMIT,
|
||||
})
|
||||
|
||||
const { currentPage, pageCount, visibleRowStart, visibleRowEnd, paginatedRows, switchPage } =
|
||||
useAnalyticsTablePagination({
|
||||
filteredRows,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const revenueFormatter = computed(
|
||||
() =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
function formatInteger(value: number): string {
|
||||
return formatAnalyticsTableInteger(formatNumber, value)
|
||||
}
|
||||
|
||||
function formatRevenue(value: number): string {
|
||||
return formatAnalyticsTableRevenue(revenueFormatter.value, value, formatMessage)
|
||||
}
|
||||
|
||||
function formatCompactPlaytime(value: number): string {
|
||||
return formatAnalyticsTableCompactPlaytime(value, formatMessage)
|
||||
}
|
||||
|
||||
function formatFullPlaytime(value: number): string {
|
||||
return formatAnalyticsTableFullPlaytime(value, formatMessage)
|
||||
}
|
||||
|
||||
function applyRouteOrDefaultSort(nextColumns = activeColumns.value) {
|
||||
const nextSortState = getRouteTableSortState(route.query, nextColumns)
|
||||
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
|
||||
applyTableSortState(nextSortState)
|
||||
}
|
||||
|
||||
syncTableSortRouteQuery()
|
||||
}
|
||||
|
||||
function applyTableSortState(state: {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
}) {
|
||||
sortColumn.value = state.sortColumn
|
||||
sortDirection.value = state.sortDirection
|
||||
}
|
||||
|
||||
function getRouteTableSortState(
|
||||
query: LocationQuery,
|
||||
nextColumns = activeColumns.value,
|
||||
): {
|
||||
sortColumn: AnalyticsTableColumnKey | undefined
|
||||
sortDirection: AnalyticsTableSortDirectionValue
|
||||
} {
|
||||
return getRouteAnalyticsTableSortState(query, nextColumns, getDefaultSortOptions(nextColumns))
|
||||
}
|
||||
|
||||
function getCurrentSortState() {
|
||||
return toAnalyticsTableSortState(sortColumn.value, sortDirection.value)
|
||||
}
|
||||
|
||||
function getDefaultSortOptions(nextColumns = activeColumns.value) {
|
||||
return {
|
||||
columns: nextColumns,
|
||||
showGraphDatasetSelection: showGraphDatasetSelection.value,
|
||||
activeStat: activeStat.value,
|
||||
}
|
||||
}
|
||||
|
||||
function syncTableSortRouteQuery() {
|
||||
if (import.meta.server) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextRouteQuery = buildSyncedAnalyticsTableSortRouteQuery(
|
||||
route.query,
|
||||
getCurrentSortState(),
|
||||
activeColumns.value,
|
||||
getDefaultSortOptions(),
|
||||
)
|
||||
|
||||
if (!hasAnalyticsTableSortRouteChange(route.query, nextRouteQuery)) {
|
||||
return
|
||||
}
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: nextRouteQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function applyActiveStatSort() {
|
||||
const availableColumns = new Set(activeColumns.value.map((column) => column.key))
|
||||
if (!availableColumns.has(activeStat.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
sortColumn.value = activeStat.value
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
|
||||
function applyRequestedSort(column: string, direction: AnalyticsTableSortDirectionValue) {
|
||||
sortColumn.value = column as AnalyticsTableColumnKey
|
||||
sortDirection.value = direction
|
||||
}
|
||||
|
||||
function selectSearchInputText(event: FocusEvent) {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLInputElement) {
|
||||
target.select()
|
||||
}
|
||||
}
|
||||
|
||||
function getCsvRows(mode: AnalyticsTableMode) {
|
||||
const visibleColumns = getCsvColumns(mode)
|
||||
return filterAnalyticsTableRowsBySearch(
|
||||
getSortedRowsForMode(mode),
|
||||
visibleColumns,
|
||||
trimmedSearchQuery.value,
|
||||
)
|
||||
}
|
||||
|
||||
function getCsvColumns(mode: AnalyticsTableMode) {
|
||||
return buildColumns(mode === 'date_breakdown' || !showBreakdownColumn.value)
|
||||
}
|
||||
|
||||
function getCsvFilename(): string {
|
||||
return getAnalyticsTableCsvFilename(breakdownColumnLabel.value, fetchRequest.value, formatMessage)
|
||||
}
|
||||
|
||||
function downloadCsv(mode: AnalyticsTableMode) {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const csvRows = getCsvRows(mode)
|
||||
if (csvRows.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const visibleColumns = getCsvColumns(mode)
|
||||
const csvContent = buildAnalyticsTableCsvContent(csvRows, visibleColumns, formatMessage)
|
||||
downloadAnalyticsTableCsv(getCsvFilename(), csvContent)
|
||||
}
|
||||
</script>
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import type { ComputedRef, Ref, WritableComputedRef } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { areStringArraysEqual } from '~/components/analytics-dashboard/analytics-route-query'
|
||||
import type {
|
||||
AnalyticsDashboardStat,
|
||||
AnalyticsSelectedBreakdowns,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { getAnalyticsTableMetricSortedGraphDatasetIds } from './analytics-table-sorting'
|
||||
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTableGraphSelectionOptions = {
|
||||
sortedRows: ComputedRef<AnalyticsTableRow[]>
|
||||
filteredRows: ComputedRef<AnalyticsTableRow[]>
|
||||
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
|
||||
showGraphDatasetSelection: ComputedRef<boolean>
|
||||
selectedGraphDatasetIds: Ref<string[]>
|
||||
hasExplicitGraphDatasetSelection: Ref<boolean>
|
||||
isGraphDatasetSelectionActive: Ref<boolean>
|
||||
defaultGraphDatasetIds: Ref<string[]>
|
||||
topGraphDatasetIds: Ref<string[]>
|
||||
queryResetToken: Ref<number>
|
||||
currentSelectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
|
||||
currentSelectedProjectIds: Ref<string[]>
|
||||
activeStat: Ref<AnalyticsDashboardStat>
|
||||
sortCollator: Intl.Collator
|
||||
hasTableSortQuery: () => boolean
|
||||
applyActiveStatSort: () => void
|
||||
graphDatasetSelectionLimit: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTableGraphSelection({
|
||||
sortedRows,
|
||||
filteredRows,
|
||||
sortColumn,
|
||||
showGraphDatasetSelection,
|
||||
selectedGraphDatasetIds,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
isGraphDatasetSelectionActive,
|
||||
defaultGraphDatasetIds,
|
||||
topGraphDatasetIds,
|
||||
queryResetToken,
|
||||
currentSelectedBreakdowns,
|
||||
currentSelectedProjectIds,
|
||||
activeStat,
|
||||
sortCollator,
|
||||
hasTableSortQuery,
|
||||
applyActiveStatSort,
|
||||
graphDatasetSelectionLimit,
|
||||
}: UseAnalyticsTableGraphSelectionOptions): {
|
||||
filteredSelectableGraphDatasetIds: ComputedRef<string[]>
|
||||
tableSelectedGraphDatasetIds: WritableComputedRef<unknown[]>
|
||||
} {
|
||||
const selectableGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableSelectableGraphDatasetIds(sortedRows.value),
|
||||
)
|
||||
const filteredSelectableGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableSelectableGraphDatasetIds(filteredRows.value),
|
||||
)
|
||||
const sortedMetricGraphDatasetIds = computed(() =>
|
||||
getAnalyticsTableMetricSortedGraphDatasetIds(sortedRows.value, sortColumn.value, sortCollator),
|
||||
)
|
||||
const defaultSelectedGraphDatasetIds = computed(() => {
|
||||
const sortedMetricIds = sortedMetricGraphDatasetIds.value
|
||||
const defaultIds =
|
||||
sortedMetricIds.length > 0 ? sortedMetricIds : selectableGraphDatasetIds.value
|
||||
return defaultIds.slice(0, graphDatasetSelectionLimit)
|
||||
})
|
||||
const tableSelectedGraphDatasetIds = computed<unknown[]>({
|
||||
get: () => selectedGraphDatasetIds.value,
|
||||
set: (ids) => {
|
||||
const nextGraphDatasetIds = ids.filter((id): id is string => typeof id === 'string')
|
||||
if (showGraphDatasetSelection.value && isDefaultGraphDatasetSelection(nextGraphDatasetIds)) {
|
||||
setSelectedGraphDatasetIds(defaultSelectedGraphDatasetIds.value, false)
|
||||
return
|
||||
}
|
||||
|
||||
selectedGraphDatasetIds.value = nextGraphDatasetIds
|
||||
hasExplicitGraphDatasetSelection.value = showGraphDatasetSelection.value
|
||||
},
|
||||
})
|
||||
|
||||
function setSelectedGraphDatasetIds(ids: string[], explicit: boolean) {
|
||||
selectedGraphDatasetIds.value = ids
|
||||
hasExplicitGraphDatasetSelection.value = explicit
|
||||
}
|
||||
|
||||
function resetGraphDatasetSelection() {
|
||||
setSelectedGraphDatasetIds([], false)
|
||||
}
|
||||
|
||||
function isDefaultGraphDatasetSelection(ids: string[]) {
|
||||
const defaultIds = defaultSelectedGraphDatasetIds.value
|
||||
if (defaultIds.length === 0 || ids.length !== defaultIds.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedIdSet = new Set(ids)
|
||||
return defaultIds.every((id) => selectedIdSet.has(id))
|
||||
}
|
||||
|
||||
watch(
|
||||
[showGraphDatasetSelection, queryResetToken],
|
||||
([nextShowSelection]) => {
|
||||
isGraphDatasetSelectionActive.value = nextShowSelection
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(activeStat, () => {
|
||||
if (!showGraphDatasetSelection.value) {
|
||||
return
|
||||
}
|
||||
if (hasTableSortQuery()) {
|
||||
return
|
||||
}
|
||||
|
||||
applyActiveStatSort()
|
||||
})
|
||||
|
||||
watch(
|
||||
currentSelectedBreakdowns,
|
||||
(nextBreakdowns, previousBreakdowns) => {
|
||||
if (areStringArraysEqual([...nextBreakdowns], [...(previousBreakdowns ?? [])])) {
|
||||
return
|
||||
}
|
||||
|
||||
resetGraphDatasetSelection()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
currentSelectedProjectIds,
|
||||
(nextProjectIds, previousProjectIds) => {
|
||||
if (areStringArraysEqual(nextProjectIds, previousProjectIds ?? [])) {
|
||||
return
|
||||
}
|
||||
|
||||
resetGraphDatasetSelection()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[defaultSelectedGraphDatasetIds, sortedMetricGraphDatasetIds, showGraphDatasetSelection],
|
||||
([nextDefaultGraphDatasetIds, nextTopGraphDatasetIds, nextShowGraphDatasetSelection]) => {
|
||||
defaultGraphDatasetIds.value = nextShowGraphDatasetSelection
|
||||
? [...nextDefaultGraphDatasetIds]
|
||||
: []
|
||||
topGraphDatasetIds.value = nextShowGraphDatasetSelection ? [...nextTopGraphDatasetIds] : []
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
defaultSelectedGraphDatasetIds,
|
||||
showGraphDatasetSelection,
|
||||
hasExplicitGraphDatasetSelection,
|
||||
queryResetToken,
|
||||
],
|
||||
([nextDefaultGraphDatasetIds, nextShowGraphDatasetSelection, nextHasExplicitSelection]) => {
|
||||
if (!nextShowGraphDatasetSelection) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextHasExplicitSelection) {
|
||||
if (isDefaultGraphDatasetSelection(selectedGraphDatasetIds.value)) {
|
||||
setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!areStringArraysEqual(selectedGraphDatasetIds.value, nextDefaultGraphDatasetIds)) {
|
||||
setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function getAnalyticsTableSelectableGraphDatasetIds(rows: AnalyticsTableRow[]): string[] {
|
||||
return Array.from(new Set(rows.map((row) => row.graphDatasetId)))
|
||||
}
|
||||
|
||||
return {
|
||||
filteredSelectableGraphDatasetIds,
|
||||
tableSelectedGraphDatasetIds,
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { AnalyticsTableRow } from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTablePaginationOptions = {
|
||||
filteredRows: ComputedRef<AnalyticsTableRow[]>
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTablePagination({
|
||||
filteredRows,
|
||||
pageSize,
|
||||
}: UseAnalyticsTablePaginationOptions): {
|
||||
currentPage: Ref<number>
|
||||
pageCount: ComputedRef<number>
|
||||
visibleRowStart: ComputedRef<number>
|
||||
visibleRowEnd: ComputedRef<number>
|
||||
paginatedRows: ComputedRef<AnalyticsTableRow[]>
|
||||
switchPage: (page: number) => void
|
||||
} {
|
||||
const currentPage = ref(1)
|
||||
const pageCount = computed(() => Math.max(Math.ceil(filteredRows.value.length / pageSize), 1))
|
||||
const visibleRowStart = computed(() =>
|
||||
filteredRows.value.length === 0 ? 0 : (currentPage.value - 1) * pageSize + 1,
|
||||
)
|
||||
const visibleRowEnd = computed(() =>
|
||||
Math.min(currentPage.value * pageSize, filteredRows.value.length),
|
||||
)
|
||||
const paginatedRows = computed<AnalyticsTableRow[]>(() =>
|
||||
filteredRows.value.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize),
|
||||
)
|
||||
|
||||
watch(filteredRows, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
watch(pageCount, (nextPageCount) => {
|
||||
if (currentPage.value > nextPageCount) {
|
||||
currentPage.value = nextPageCount
|
||||
}
|
||||
})
|
||||
|
||||
function switchPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
pageCount,
|
||||
visibleRowStart,
|
||||
visibleRowEnd,
|
||||
paginatedRows,
|
||||
switchPage,
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import type {
|
||||
AnalyticsTableColumnKey,
|
||||
AnalyticsTableDisplayedRowsCache,
|
||||
AnalyticsTableMode,
|
||||
AnalyticsTableRow,
|
||||
AnalyticsTableSortDirectionValue,
|
||||
} from './analytics-table-types'
|
||||
|
||||
type UseAnalyticsTableRowCacheOptions = {
|
||||
activeTableMode: ComputedRef<AnalyticsTableMode>
|
||||
showBreakdownColumn: ComputedRef<boolean>
|
||||
analyticsPointCount: ComputedRef<number>
|
||||
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
|
||||
sortDirection: Ref<AnalyticsTableSortDirectionValue>
|
||||
buildRows: (mode: AnalyticsTableMode) => AnalyticsTableRow[]
|
||||
sortRows: (rows: AnalyticsTableRow[]) => AnalyticsTableRow[]
|
||||
inactiveModeWarmupPointLimit: number
|
||||
}
|
||||
|
||||
export function useAnalyticsTableRowCache({
|
||||
activeTableMode,
|
||||
showBreakdownColumn,
|
||||
analyticsPointCount,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
buildRows,
|
||||
sortRows,
|
||||
inactiveModeWarmupPointLimit,
|
||||
}: UseAnalyticsTableRowCacheOptions): {
|
||||
displayedTableMode: Ref<AnalyticsTableMode>
|
||||
displayedSortColumn: Ref<AnalyticsTableColumnKey | undefined>
|
||||
displayedSortDirection: Ref<AnalyticsTableSortDirectionValue>
|
||||
displayedSortedRows: ShallowRef<AnalyticsTableRow[]>
|
||||
invalidateTableCaches: () => void
|
||||
invalidateSortedCaches: () => void
|
||||
scheduleRowsForMode: (mode: AnalyticsTableMode) => void
|
||||
scheduleInactiveModeWarmup: () => void
|
||||
resortDisplayedRowsForCurrentSort: () => boolean
|
||||
getSortedRowsForMode: (mode: AnalyticsTableMode) => AnalyticsTableRow[]
|
||||
} {
|
||||
const modeBuildRequestIds: Record<AnalyticsTableMode, number> = {
|
||||
date_breakdown: 0,
|
||||
breakdown_only: 0,
|
||||
}
|
||||
let tableCacheGeneration = 0
|
||||
let displayedSortedRowsGeneration = 0
|
||||
const displayedTableMode = ref<AnalyticsTableMode>('breakdown_only')
|
||||
const displayedSortColumn = ref<AnalyticsTableColumnKey | undefined>(sortColumn.value)
|
||||
const displayedSortDirection = ref<AnalyticsTableSortDirectionValue>(sortDirection.value)
|
||||
const displayedSortedRows = shallowRef<AnalyticsTableRow[]>([])
|
||||
const displayedRowsCache = shallowRef<AnalyticsTableDisplayedRowsCache | null>(null)
|
||||
|
||||
function invalidateTableCaches() {
|
||||
tableCacheGeneration++
|
||||
invalidateSortedCaches()
|
||||
}
|
||||
|
||||
function invalidateSortedCaches() {
|
||||
displayedRowsCache.value = null
|
||||
}
|
||||
|
||||
function hasSortedRowsForMode(mode: AnalyticsTableMode): boolean {
|
||||
const cached = displayedRowsCache.value
|
||||
return (
|
||||
cached !== null &&
|
||||
cached.generation === tableCacheGeneration &&
|
||||
cached.mode === mode &&
|
||||
cached.sortColumn === sortColumn.value &&
|
||||
cached.sortDirection === sortDirection.value
|
||||
)
|
||||
}
|
||||
|
||||
function setDisplayedRowsForMode(
|
||||
mode: AnalyticsTableMode,
|
||||
rows: AnalyticsTableRow[],
|
||||
generation = tableCacheGeneration,
|
||||
) {
|
||||
displayedRowsCache.value = {
|
||||
generation,
|
||||
mode,
|
||||
sortColumn: sortColumn.value,
|
||||
sortDirection: sortDirection.value,
|
||||
rows,
|
||||
}
|
||||
|
||||
if (mode === activeTableMode.value) {
|
||||
displayedSortedRowsGeneration = generation
|
||||
displayedTableMode.value = mode
|
||||
displayedSortColumn.value = sortColumn.value
|
||||
displayedSortDirection.value = sortDirection.value
|
||||
displayedSortedRows.value = rows
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRowsForMode(mode: AnalyticsTableMode) {
|
||||
if (hasSortedRowsForMode(mode)) {
|
||||
if (mode === activeTableMode.value) {
|
||||
displayRowsForMode(mode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++modeBuildRequestIds[mode]
|
||||
const generation = tableCacheGeneration
|
||||
|
||||
void buildRowsForMode(mode, generation, requestId)
|
||||
}
|
||||
|
||||
function displayRowsForMode(mode: AnalyticsTableMode) {
|
||||
const cached = displayedRowsCache.value
|
||||
if (!cached || cached.generation !== tableCacheGeneration || cached.mode !== mode) {
|
||||
return
|
||||
}
|
||||
|
||||
displayedSortedRowsGeneration = cached.generation
|
||||
displayedTableMode.value = mode
|
||||
displayedSortColumn.value = cached.sortColumn
|
||||
displayedSortDirection.value = cached.sortDirection
|
||||
displayedSortedRows.value = cached.rows
|
||||
}
|
||||
|
||||
async function buildRowsForMode(mode: AnalyticsTableMode, generation: number, requestId: number) {
|
||||
await waitForDeferredTableWork()
|
||||
|
||||
if (isStaleBuild(mode, generation, requestId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const rows = sortRows(buildRows(mode))
|
||||
|
||||
if (isStaleBuild(mode, generation, requestId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setDisplayedRowsForMode(mode, rows, generation)
|
||||
}
|
||||
|
||||
function isStaleBuild(mode: AnalyticsTableMode, generation: number, requestId: number): boolean {
|
||||
return tableCacheGeneration !== generation || modeBuildRequestIds[mode] !== requestId
|
||||
}
|
||||
|
||||
function waitForDeferredTableWork(): Promise<void> {
|
||||
if (!import.meta.client) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleInactiveModeWarmup() {
|
||||
if (!showBreakdownColumn.value) {
|
||||
return
|
||||
}
|
||||
if (analyticsPointCount.value > inactiveModeWarmupPointLimit) {
|
||||
return
|
||||
}
|
||||
|
||||
const inactiveMode: AnalyticsTableMode =
|
||||
activeTableMode.value === 'date_breakdown' ? 'breakdown_only' : 'date_breakdown'
|
||||
|
||||
if (hasSortedRowsForMode(inactiveMode)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!import.meta.client) {
|
||||
scheduleRowsForMode(inactiveMode)
|
||||
return
|
||||
}
|
||||
|
||||
const windowWithIdleCallback = window as Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number
|
||||
}
|
||||
|
||||
if (windowWithIdleCallback.requestIdleCallback) {
|
||||
windowWithIdleCallback.requestIdleCallback(() => scheduleRowsForMode(inactiveMode), {
|
||||
timeout: 2000,
|
||||
})
|
||||
} else {
|
||||
window.setTimeout(() => scheduleRowsForMode(inactiveMode), 250)
|
||||
}
|
||||
}
|
||||
|
||||
function resortDisplayedRowsForCurrentSort(): boolean {
|
||||
const mode = activeTableMode.value
|
||||
if (
|
||||
displayedTableMode.value !== mode ||
|
||||
displayedSortedRowsGeneration !== tableCacheGeneration
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
setDisplayedRowsForMode(mode, sortRows(displayedSortedRows.value))
|
||||
return true
|
||||
}
|
||||
|
||||
function getSortedRowsForMode(mode: AnalyticsTableMode): AnalyticsTableRow[] {
|
||||
const cached = displayedRowsCache.value
|
||||
if (
|
||||
cached &&
|
||||
cached.generation === tableCacheGeneration &&
|
||||
cached.mode === mode &&
|
||||
cached.sortColumn === sortColumn.value &&
|
||||
cached.sortDirection === sortDirection.value
|
||||
) {
|
||||
return cached.rows
|
||||
}
|
||||
|
||||
return sortRows(buildRows(mode))
|
||||
}
|
||||
|
||||
return {
|
||||
displayedTableMode,
|
||||
displayedSortColumn,
|
||||
displayedSortDirection,
|
||||
displayedSortedRows,
|
||||
invalidateTableCaches,
|
||||
invalidateSortedCaches,
|
||||
scheduleRowsForMode,
|
||||
scheduleInactiveModeWarmup,
|
||||
resortDisplayedRowsForCurrentSort,
|
||||
getSortedRowsForMode,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user