Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) 13d0c1eeb9 fix: fmt 2026-06-03 13:49:15 +01:00
a8132f6e35 Update apps/labrinth/src/routes/internal/external_notifications.rs
Co-authored-by: Sychic <47618543+Sychic@users.noreply.github.com>
Signed-off-by: Calum H. <hendersoncal117@gmail.com>
2026-06-03 13:47:24 +01:00
Calum H. (IMB11) c555835fc5 fix: notifs not live 2026-06-03 12:24:17 +01:00
ProspectorandGitHub a0c80b13a4 fix: no mention of Australia on hosting landing page (#6288) 2026-06-03 02:25:16 +00:00
Calum H.andGitHub 3c051f5b1d feat: add notifs onto friends ws temporarily (#6290)
* feat: add notifs onto friends ws temporarily

* fix: lint + styling

* fix: regressions
2026-06-02 19:47:37 +00:00
Calum H.andGitHub 940a796ba5 chore: changelog (#6286) 2026-06-02 18:39:29 +02:00
6ee5e4df19 feat: access labrinth backend (#6284)
* feat: redirect `/hosting` to archon

* feat: server invite notification type

* feat: direct email notification endpoint

* feat: revoke notification endpoint

* feat: specify users to remove notifications from

* refactor: insert notifications before sending emails

* refactor: rename endpoint

* refactor: remove archon redirect

* style: mark field unused

* feat: dedup external notifications

* feat: add server invite email templates

* style: remove unnecessary format

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
2026-06-02 16:34:04 +00:00
aecsocketandGitHub d61397097c Tilitfy webhook changes (#6267)
* Tilitfy webhook changes

* add env

* fix
2026-06-02 15:14:02 +02:00
Calum H.andGitHub cfe45b368c fix: memory issues when importing giant mrpack files (#6278)
* feat: dont load mrpacks into memory if they are local imports

* fix: frontend
2026-06-02 12:59:41 +00:00
Calum H.andGitHub 6b0a0c1897 fix: content update modal hiding available updates until reopen (#6277) 2026-06-02 04:23:19 +00:00
Calum H.andGitHub f27691340a fix: path copy issue files tab (#6276) 2026-06-02 04:19:05 +00:00
aecsocketandGitHub c3a58aba9e Fix clickhouse URI, country filtering (#6247)
* Switch to bind for long params

* Country filtering

* prepare

* playtime preservation
2026-06-01 16:11:44 +00:00
ProspectorandGitHub 1550dfb3f0 feat: add toggle for showing play time (#6240)
* feat: add toggle for showing play time

* prepr
2026-06-01 15:52:01 +00:00
Modrinth BotandGitHub 4762a0a725 New translations from Crowdin (main) (#6275) 2026-06-01 14:11:53 +00:00
Prospector cf82943766 fix double changelog 2026-05-31 17:55:20 -07:00
Prospector b527e8f8f2 changelog 2026-05-31 16:28:21 -07:00
Prospector 3c53b5793b fix ping test in app 2026-05-31 16:26:41 -07:00
Prospector e6afc6f4f3 fix some pride links 2026-05-31 16:22:16 -07:00
Prospector 05699a90d6 fix pride badge svg 2026-05-31 15:39:29 -07:00
Prospector 142d560f76 update blog post 2026-05-31 13:43:19 -07:00
Calum H.andGitHub e80e27884e fix: crowdin moment (#6239) 2026-05-31 19:25:20 +00:00
Prospector 75788938b5 update blog post 2026-05-31 10:12:20 -07:00
ProspectorandGitHub 2570cf1bd7 feat: add sydney region (#6258)
* feat: add sydney region

* prepr

* rename
2026-05-31 10:11:58 -07:00
Calum H.andGitHub b0cca873b6 feat: changelog (#6268) 2026-05-31 16:54:33 +00:00
Calum H.andGitHub 325926ad9b feat: pride 2026 frontend (#6205)
* feat: pride 2026 banner app sidebar

* feat: use ProgressBar component

* feat: pride skins

* feat: pride skins

* feat: blog post

* fix: blogpost

* fix: pride skin condition

* fix: types

* fix: show logic

* fix: qa

* fix: lint

* fix: unused var
2026-05-31 16:43:41 +00:00
34b87991bc feat: new user badges, ui consistency pass (#6262)
* feat: new user badges, ui consistency pass

* prepr

* fix: align with backend

* fix: lint

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-05-31 15:25:31 +00:00
aecsocketandGitHub cc8d556448 Expose user campaign donation info (#6266)
expose extra donation info for users
2026-05-31 15:18:40 +00:00
Calum H.andGitHub 71286f7b2b fix: incorrect webhook schema for tiltify (#6260)
fix:
2026-05-31 01:11:22 +02:00
aecsocketandGitHub c29973ec1a Pride 26 campaign backend integration (#6254)
* wip: pride 2026 webhooks and stuff

* setup webhook and link to user

* fix up code

* improve donation resolution

* Pride 26 campaign

* idempotency

* wip: tiltify

* fix

* redis caching

* add num donators

* fix

* Revert openapi

* Prepare

* improve oauth token gen code
2026-05-30 19:21:33 +00:00
Prospector 8c95f0bb81 changelog 2026-05-29 16:10:27 -07:00
Truman GaoandGitHub 627ab6734b fix: revenue not showing for all time query (#6246) 2026-05-29 15:47:17 -07:00
Prospector 36e1cbbf3e changelog 2026-05-29 15:06:42 -07:00
Truman GaoandGitHub 93fe87e57f feat: handle errors in analytics (#6245)
* fix: uri too long error by temporarily batching in frontend

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

* fix: download threshold for projects select

* refactor: pnpm prepr

* feat: handle facets not adding count

* feat: remove getting facets download count field entirely

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

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

* update blog post

* performance: compress mp4s

* date

---------

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

* Filter out invalid loaders, fix query bug

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

* feat: implement dot for showing todays date

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

* feat: if ratio mode, dont show total

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

* refactor: pnpm prepr

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

* feat: implement reset query button

* feat: clear button to clear breakdown

* feat: more aggressively trim allowed minimum group by option

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

* fix: clear selected X above number when appropriate

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

* fix: loading state to include legend in blur

* feat: add project icon to project select

* feat: filter out draft projects from analytics

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

* feat: implement click and drag to select date range

* feat: implement windows history for query builder

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

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

* feat: implement modrinth sided events

* fix: border radius

* feat: implement analytics range highlight

* fix: loading state showing empty state text

* refactor: pnpm prepr

* feat: improve dropdown filter bar and multiselect performance

* fix: multiselect keyboard use

* fix: graph overflow issues

* fix: loading state text on table

* feat: implement tooltip scroll

* fix: adjust charts event tooltip

* feat: shorten time to not repeat am/pm

* feat: implement query params for graph component settings

* fix: qa

* feat: add reset timeframe button

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

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

* fix: change to > 1 day

* fix: custom timeframe picker

* feat: implement big performance improvement for table

* feat: implement hover on legend to highlight graph

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

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

* feat: add tooltip for other item

* feat: improve custom time frame range select

* feat: implement analytics events admin page

* fix: switch column order

* pnpm prepr

* feat: implement mock analytics events

* feat: improve analytics events admin page

* feat: focus title input on analytics create event modal

* fix: remove labels annoying

* feat: hook up analytics events backend

* fix: type error

* feat: reduce combobox padding

* feat: reduce padding on multiselect

* feat: add overlay scrollbar for combobox

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

* feat: MORE PADDING fixes

* feat: use user_agent for download source

* Revert "feat: use user_agent for download source"

This reverts commit d6dc8a99f1.

* fix: query filter project version lag and borked virtualization

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

* feat: implement right side checkmark for multiselect

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

* fix: focus styles

* fix: focus styles pt2

* feat: implement filter by top 8

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

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

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

* feat: change download source to use user_agent

* feat: fix click to cross out in legend

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

* fix: export csv to always be dropdown

* feat: implement breakdown = none

* performance: frontend memory reduction

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

* fix: table checked items not in graph if 0

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

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

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

* feat: add analytics table search

* refactor: pnpm prepr

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

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

* feat: improve table sorting

* feat: sort projects in project dropdown

* fix: getting project name for project versions

* fix: add loading state for filter and parallel fetch

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

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

* fix: custom time range picker being weird

* refactor: pnpm prepr

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

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

* fix: QA polish issues around style and copy

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

* fix: bugs with ratio mode and hiding chart lines

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

* fix: small styles

* fix: polish admin analytic events

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

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

* feat: add unmonetized explaination tooltip

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

* feat: mobile pass

* refactor: pnpm prepr

* add clear button

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

* fix: padding

* feat: implement show prev period toggle

* feat: extract TimeFramePicker to packages/ui

* fix: adjust style

* feat: keep table selected persisted in query parameter

* fix: style on prev item value in legend

* fix: when breakdown switches, reset selected series

* fix: tooltip styles

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

* feat: implement show top 8 button in graph subheading

* fix: rename download type to download reason

* fix: formatting label for table

* feat: persist table sort by and sort direction

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

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

* refactor: pnpm prepr

* fix: remove number if its just top 1

* fix: brief select items empty state when switch breakdown

* feat: implement format table playtime column

* feat: update export csv filename

* feat: change playtime column to display in hours

* refactor: pnpm prepr

* fix: still download type in filter

* feat: update analytics tooltip

* fix: wrong all projects icon

* feat: force legend order and graph colour for monetization

* refactor: pnpm prepr

* fix: multiselect and combobox sizes

* fix: chart icon add hover delay

* feat: (to playtest) implement multiple breakdowns

* fix: couple UX things for multiple breakdown

* fix: cannot unpin on page click

* fix: multiple breakdown legend and tooltip labels

* feat: add right side checkmark for dropdown filtr bar

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

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

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

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

* fix-mobile: fix multiselect scroll on mobile

* feat: consolidate is mobile ref into context

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

* fix-mobile: smaller metric card font

* fix: dropdown filter bar scroll while search

* feat: implement project side events

* feat: implement better mobile view design for query builder

* feat: handle events overflow

* small: add select none

* feat: remove clear project and breakdown

* fix: event icon hover color

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

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

* feat: grey out dimmed lined on legend item hover

* feat-mobile: style fixes

* add close on select prop

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

* feat: date picker default read only

* refactor: pnpm prepr

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

* fix-mobile: improve graph touch interactions

* small: 2 sig figs on playtime

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

* fix: analytics events grouping causing overflow

* feat: improve performance on analytics events grouping

* fix: tooltip expanding page width briefly

* fix: prevent double tap to zoom on inputs

* feat: add click to show chart event for mobile

* fix: toggle not having touch manipulation

* fix: chart tooltip scroll in mobile

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

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

* feat: keep tooltip open after drag in mobile

* fix: using plural instead of single for project breakdown

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

* fix: callback to Organization instead of org id

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

* feat: tap to toggle event tooltip

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

* fix: frontend still filtering after backend already filters

* feat: fix emptys state height content shift

* fix: qa issues

* fix: a number of qa issues

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

* feat: implement graph controls dropdown

* fix date picker typing into time input

* fix: styles in events table

* small: style

* feat: implement using new backend facets route

* feat: implement user get all projects

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

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

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

* refactor: remove chart event height being controlled by parent

* feat: update controls dropdown to have fainter border

* fix: loading bar not fading away

* fix: cannot click in graph

* feat: dont conditionally show multiselect selection actions

* fix: z-index and padding issues

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

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

* fix: playtime y axis labels

* feat: improve y axis formatting for playtime and others

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

* refactor: pnpm prepr

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

* feat: event icon consume scroll for tooltip panel

* feat: remove gap inside chart tooltip

* feat: add gap for date picker 2 calendar view

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

* pnpm prepr

* fix: cant click in gap in toggle

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

* refactor: kabab case

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

* fix: legend is stale after resetting query

* refactor: split up giant analytics provider with utils

* i18n pass

* revert: format number composable change

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

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

* refactor: same rename for analytics table for consistency

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

* refactor: pnpm prepr

* refactor: rename types

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

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

* refactor: pnpm prepr:frontend

* fix: download threshold not width fit

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

* fix: game version dropdown width

---------

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

* fix: pruning

* fix: prepr

* fix: run.mjs

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

* ci

* ci flaky test

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

* fix: changelog

* Update packages/blog/changelog.ts

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

* Update packages/blog/changelog.ts

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

---------

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

* fix: caching structure

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

* fix: doc

* fix: flatten migrations

* feat: remove default cape/cape override concept

* fix: fmt + lint

* feat: remove SelectCapeModal for inline cape list

* feat: qa

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

* fix: lint

* fix: virt bugs + renderer fixes

* fix: qa bugs

* fix: doc

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

* fix: regressions

* devex: split up SkinPreviewrenderer

* fix: lint

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

* feat: qa

* fix: nametag bug + remove minecon skin pack suffix

* feat: pain (literally)

* feat: user agent on moj reqs

* feat: impl per account flush queue for operations

* fix: breadcrumb

* chore: i18n pass

* fix: lint + prep + check

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

* all projects route for user

* analytics facet fetching

* cache download source regexes

* filtering

* prepare

* Split up analytics metrics into separate modules

* prepare

* fix ci
2026-05-27 00:01:13 +00:00
aecsocketandGitHub 2f248027d6 Also run Daedalus manifest uploads when there's a new game version (#6213) 2026-05-26 21:55:54 +00:00
ThatGravyBoatandGitHub f96520638b feat: Add user-images.githubusercontent.com in allowedHostnames (#6210)
feat: Allow user-images.githubusercontent.com in descriptions
2026-05-26 14:52:35 +00:00
Prospector 9e5d29ced6 hotfix 2026-05-24 12:14:13 -07:00
Prospector 3e15d0b287 changelog 2026-05-24 10:46:35 -07:00
ProspectorandGitHub bcce7e28fd fix: guard against non-strings when decorating download urls (#6194) 2026-05-24 10:42:27 -07:00
ProspectorandGitHub 3889d0f5ec fix: simplify preview banner translation key (#6192) 2026-05-24 10:39:39 -07:00
ProspectorandGitHub 6f44c5b039 feat: add 2 second auto close on skipped notifications (#6191) 2026-05-24 10:39:27 -07:00
Modrinth BotandGitHub ea967845d9 New translations from Crowdin (main) (#6193) 2026-05-24 17:25:25 +00:00
Prospector a6967cf9cb changelog 2026-05-24 09:26:36 -07:00
ProspectorandGitHub 5bf92863b0 fix: spanish locale (#6189) 2026-05-24 09:21:14 -07:00
Prospector 71b6ecc10c fix changelog... 2026-05-23 23:26:34 -07:00
Prospector ed28bc7551 remove april fools redirect 2026-05-23 22:03:59 -07:00
Prospector 3e4197db7c changelog 2026-05-23 22:02:49 -07:00
ProspectorandGitHub e12230ff59 feat: update default memory from 2GB to 4GB (#6172)
* feat: update default memory from 2GB to 4GB

* prepr
2026-05-23 21:26:51 -07:00
ProspectorandGitHub aeb9f5a075 fix: invalid auth cookie causing page not to load (#6186) 2026-05-23 21:26:13 -07:00
Calum H.andGitHub 8b17441f40 feat: compact logs if they have logspam to prevent app crashing (#6181)
* feat: compact logs if they have logspam to prevent app crashing

* fix: lint
2026-05-23 18:22:15 +00:00
f9d47e8edc feat: Add new "Enabled" sorting button next to "Disabled" (#6000)
* feat: Added new "Enabled" sorting button next to "Disabled"

* Updated when filter buttons show

- When all mods are disabled, only "Disabled" shows
- When all mods are enabled, only "Enabled" shows
- When there disabled and enabled mods, then only both buttons show.

* Updated when filter buttons show

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-05-23 16:12:14 +00:00
a58bc3dc21 feat: java installation ui improvements (#5731)
* Clean impl of java installation ui improvements

* Migrate composable to ts

* Migrate to ButtonStyled, fix coloring

* Fix lint

* Fix clearing java path not refreshing state

* fix: use Table component + install btn disabled state tooltip

---------

Signed-off-by: Arthur <creeperkatze.dev@gmail.com>
Signed-off-by: Arthur <contact@creeperkatze.dev>
Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-05-23 14:46:12 +00:00
ProspectorandGitHub 1e46444fb0 fix: checklist forcing redirects (#6173) 2026-05-22 15:53:22 -07:00
ProspectorandGitHub 1511e55597 fix: skip reviewed projects in queue (#6171) 2026-05-22 14:54:50 -07:00
aecsocketandGitHub 5727e156ed Fetch project analytics events on analytics get (#6143)
* Fetch project analytics events

* fix

* post-query ua bucketing

* fmt
2026-05-22 18:32:33 +00:00
Calum H.andGitHub 657186398d fix: remove broken roadmap reference (#6165)
Signed-off-by: Calum H. <calum@modrinth.com>
2026-05-22 18:10:01 +00:00
Michael H. 3ab2273782 chore: prepr 2026-05-22 19:58:11 +02:00
ProspectorandGitHub 893ec00fc6 feat: add external dep sorting to moderation queue (#6161)
* feat: add external dep sorting to moderation queue

* prepr
2026-05-21 16:40:13 -07:00
Prospector aa7dd1d210 web 2026-05-21 16:23:30 -07:00
Calum H.andGitHub f8733b0488 feat: changelog 21st may 2026 (#6158)
* feat: changelog 21st may 2026

* fix: changelog
2026-05-21 22:27:14 +00:00
Calum H.andGitHub d077d44540 fix: content tab uniqueness regression (#6156)
* fix: content tab uniqueness regression

Closes: #6154

* fix: further regressions

* fix: lint

* fix: lint
2026-05-21 22:03:35 +00:00
aecsocketandGitHub 4e1a61d8b6 Adjust window occlusion threshold (#6157) 2026-05-21 21:48:39 +00:00
aecsocketandGitHub 71dee4de40 Add modpacks with external files filter to moderation queue (#6155)
* simplify query

* make it a tristate

* external deps count
2026-05-21 20:21:54 +00:00
SychicandGitHub f74fad0cae fix: typo in version changelog (#6153) 2026-05-21 19:38:11 +00:00
Truman GaoandGitHub 07e81ac036 fix: project embeds (#6152)
* fix: project embeds

* remove: params.id
2026-05-21 17:02:53 +00:00
Calum H.andGitHub 6e7835fb35 feat: implement kryos upload sessions (#6145)
* feat: implement upload sessions

* fix: files not scoped

* feat: hide staging files folder and proper cancel feedback

* fix: lint
2026-05-21 16:49:48 +00:00
538 changed files with 52264 additions and 14292 deletions
@@ -9,8 +9,6 @@ body:
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests
required: true
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
required: true
- type: dropdown
id: projects
attributes:
+14
View File
@@ -56,6 +56,17 @@ jobs:
ref: ${{ github.ref }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Install script dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Configure Git author
id: git-author
uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2
@@ -95,6 +106,9 @@ jobs:
shell: bash
run: sudo chown -R $USER:$USER .
- name: Prune stale ICU translations
run: pnpm scripts i18n-icu-contract prune-local
- name: Create Pull Request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
+22 -1
View File
@@ -4,11 +4,14 @@ on:
push:
branches: ['main']
paths:
- '.github/workflows/i18n.push.yml'
- '.github/workflows/i18n-push.yml'
- 'apps/*/src/locales/en-US/**'
- 'apps/*/locales/en-US/**'
- 'packages/*/src/locales/en-US/**'
- 'packages/*/locales/en-US/**'
- 'scripts/i18n-icu-contract.ts'
- 'package.json'
- 'pnpm-lock.yaml'
- 'crowdin.yml'
workflow_dispatch:
@@ -56,6 +59,18 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.ref }}
fetch-depth: 2
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Install script dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Query branch name
id: branch-name
@@ -79,3 +94,9 @@ jobs:
env:
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Clear stale ICU translations in Crowdin
run: pnpm scripts i18n-icu-contract clear-crowdin-changed --base-ref HEAD^ --crowdin-branch "[${{ github.repository_owner }}.${{ github.event.repository.name }}] ${{ steps.branch-name.outputs.safe_branch_name }}"
env:
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+2 -1
View File
@@ -204,4 +204,5 @@ jobs:
- name: Verify intl:extract has been run
run: |
pnpm turbo run intl:extract --force
git diff --exit-code --color */*/src/locales/en-US/index.json
pnpm scripts i18n-icu-contract prune-local --check
git diff --exit-code --color */*/src/locales/*/index.json
+1 -1
View File
@@ -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
View File
@@ -10514,6 +10514,7 @@ dependencies = [
"flate2",
"fs4",
"futures",
"futures-lite 2.6.1",
"heck 0.5.0",
"hickory-resolver 0.25.2",
"indicatif",
+1
View File
@@ -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"
+1
View File
@@ -10,6 +10,7 @@
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend",
"test": "vue-tsc --noEmit"
},
"dependencies": {
+115 -6
View File
@@ -11,6 +11,7 @@ import {
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
CheckIcon,
CompassIcon,
DownloadIcon,
ExternalIcon,
@@ -40,6 +41,7 @@ import {
defineMessages,
I18nDebugPanel,
LoadingBar,
ModrinthHostingLogo,
NewsArticleCard,
NotificationPanel,
OverflowMenu,
@@ -84,6 +86,8 @@ 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 ServerInvitePopupBody from '@/components/ui/notifications/ServerInvitePopupBody.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 +98,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 +139,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 +150,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 +201,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 +244,7 @@ const {
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -330,6 +346,7 @@ async function setupApp() {
locale,
telemetry,
collapsed_navigation,
hide_nametag_skins_page,
advanced_rendering,
onboarded,
default_page,
@@ -360,6 +377,7 @@ async function setupApp() {
themeStore.setThemeState(theme)
themeStore.collapsedNavigation = collapsed_navigation
themeStore.advancedRendering = advanced_rendering
themeStore.hideNametagSkinsPage = hide_nametag_skins_page
themeStore.toggleSidebar = toggle_sidebar
themeStore.devMode = developer_mode
themeStore.featureFlags = feature_flags
@@ -674,12 +692,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(
@@ -739,6 +756,94 @@ 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)
}
}
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: 'Modrinth Hosting',
titleLogo: ModrinthHostingLogo,
bodyComponent: ServerInvitePopupBody,
bodyProps: {
inviterName: invitedBy?.username ?? null,
inviterAvatarUrl: invitedBy?.avatar_url ?? null,
serverName,
},
type: 'info',
buttons: [
{
label: 'Accept',
action: () => acceptServerInviteNotification(notification),
icon: CheckIcon,
color: 'brand',
},
{
label: 'Decline',
action: () => declineServerInviteNotification(notification),
icon: XIcon,
color: 'red',
},
],
autoCloseMs: null,
})
}
async function handleCommand(e) {
if (!e) return
@@ -1227,7 +1332,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<CompassIcon />
</NavButton>
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
<ChangeSkinIcon />
</NavButton>
<NavButton
@@ -1477,6 +1582,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">
@@ -150,7 +150,10 @@ async function refreshValues() {
if (equippedSkin.value) {
try {
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
headUrlCache.value = new Map(headUrlCache.value).set(
equippedSkin.value.texture_key,
headUrl,
)
} catch (error) {
console.warn('Failed to get head render for equipped skin:', error)
}
@@ -160,12 +163,24 @@ async function refreshValues() {
}
}
async function setEquippedSkin(skin: Skin) {
equippedSkin.value = skin
try {
const headUrl = await getPlayerHeadUrl(skin)
headUrlCache.value = new Map(headUrlCache.value).set(skin.texture_key, headUrl)
} catch (error) {
console.warn('Failed to get head render for equipped skin:', error)
}
}
function setLoginDisabled(value: boolean) {
loginDisabled.value = value
}
defineExpose({
refreshValues,
setEquippedSkin,
setLoginDisabled,
loginDisabled,
})
@@ -1,35 +1,33 @@
<template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<div class="auto-detect-modal">
<div class="table">
<div class="table-row table-head">
<div class="table-cell table-text">Version</div>
<div class="table-cell table-text">Path</div>
<div class="table-cell table-text">Actions</div>
</div>
<div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row">
<div class="table-cell table-text">
<span>{{ javaInstall.version }}</span>
</div>
<div v-tooltip="javaInstall.path" class="table-cell table-text">
<span>{{ javaInstall.path }}</span>
</div>
<div class="table-cell table-text manage">
<ButtonStyled v-if="currentSelected.path === javaInstall.path">
<button disabled><CheckIcon /> Selected</button>
<div class="flex flex-col gap-4">
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
<template #cell-version="{ value }">
<span class="font-semibold text-primary">{{ value }}</span>
</template>
<template #cell-path="{ value }">
<span v-tooltip="value" class="block truncate font-mono text-xs">{{ value }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end">
<ButtonStyled v-if="currentSelected.path === row.path">
<button class="!shadow-none" disabled><CheckIcon /> Selected</button>
</ButtonStyled>
<ButtonStyled v-else>
<button @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</button>
<button class="!shadow-none" @click="setJavaInstall(row)"><PlusIcon /> Select</button>
</ButtonStyled>
</div>
</div>
<div v-if="chosenInstallOptions.length === 0" class="table-row entire-row">
<div class="table-cell table-text">No java installations found!</div>
</div>
</div>
<div class="input-group push-right">
</template>
<template #empty-state>
<div class="p-4 text-secondary">No java installations found!</div>
</template>
</Table>
<div class="flex justify-end">
<ButtonStyled type="outlined">
<button @click="$refs.detectJavaModal.hide()">
<button
class="!shadow-none !border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<XIcon />
Cancel
</button>
@@ -40,7 +38,7 @@
</template>
<script setup>
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -52,6 +50,11 @@ const { handleError } = injectNotificationManager()
const chosenInstallOptions = ref([])
const detectJavaModal = ref(null)
const currentSelected = ref({})
const javaInstallColumns = [
{ key: 'version', label: 'Version', width: '9rem' },
{ key: 'path', label: 'Path' },
{ key: 'actions', label: 'Actions', align: 'right', width: '10rem' },
]
defineExpose({
show: async (version, currentSelectedJava) => {
@@ -77,25 +80,3 @@ function setJavaInstall(javaInstall) {
})
}
</script>
<style lang="scss" scoped>
.auto-detect-modal {
.table {
.table-row {
grid-template-columns: 1fr 4fr min-content;
}
span {
display: inherit;
align-items: center;
justify-content: center;
}
padding: 0.5rem;
}
}
.manage {
display: flex;
gap: 0.5rem;
}
</style>
@@ -1,85 +1,104 @@
<template>
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
<div class="toggle-setting" :class="{ compact }">
<StyledInput
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
wrapper-class="installation-input"
@update:model-value="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val,
})
}
"
/>
<div :id="props.id" class="toggle-setting" :class="{ compact }">
<div class="input-with-status">
<StyledInput
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
wrapper-class="installation-input"
@update:model-value="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val,
})
}
"
/>
<ButtonStyled
:color="
!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard'
"
color-fill="text"
>
<button
class="!shadow-none"
:disabled="testingJava || props.disabled"
@click="runTest(props.modelValue?.path)"
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="testingJavaSuccess === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<span class="installation-buttons">
<ButtonStyled v-if="props.version">
<button :disabled="props.disabled || installingJava" @click="reinstallJava">
<button
v-tooltip="testingJavaSuccess === true ? 'Already installed' : undefined"
class="!shadow-none"
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
@click="reinstallJava"
>
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="props.disabled" @click="autoDetect">
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="props.disabled" @click="handleJavaFileInput()">
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
<ButtonStyled v-if="testingJava">
<button disabled>Testing...</button>
</ButtonStyled>
<ButtonStyled v-else-if="testingJavaSuccess === true">
<button disabled>
<CheckIcon />
Success
</button>
</ButtonStyled>
<ButtonStyled v-else-if="testingJavaSuccess === false">
<button disabled>
<XIcon />
Failed
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button :disabled="props.disabled" @click="testJava">
<PlayIcon />
Test
</button>
</ButtonStyled>
</span>
</div>
</template>
<script setup>
import {
CheckIcon,
CheckCircleIcon,
DownloadIcon,
FolderSearchIcon,
PlayIcon,
RefreshCwIcon,
SearchIcon,
XIcon,
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const props = defineProps({
id: {
type: String,
required: false,
default: null,
},
version: {
type: Number,
required: false,
@@ -110,29 +129,36 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const testingJava = ref(false)
const testingJavaSuccess = ref(null)
const {
testingJava,
javaTestResult: testingJavaSuccess,
testJavaInstallationDebounced,
testJavaInstallation,
} = useJavaTest()
const installingJava = ref(false)
const hoveringTest = ref(false)
let hasInitialized = false
async function testJava() {
testingJava.value = true
testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '',
props.version,
)
testingJava.value = false
trackEvent('JavaTest', {
path: props.modelValue ? props.modelValue.path : '',
success: testingJavaSuccess.value,
})
setTimeout(() => {
testingJavaSuccess.value = null
}, 2000)
async function runTest(path) {
await testJavaInstallation(path, props.version, true)
}
watch(
() => props.modelValue?.path,
(newPath) => {
if (newPath) {
if (!hasInitialized) {
testJavaInstallation(newPath, props.version, false)
hasInitialized = true
} else {
testJavaInstallationDebounced(newPath, props.version)
}
}
},
{ immediate: true },
)
async function handleJavaFileInput() {
const filePath = await open()
@@ -142,6 +168,7 @@ async function handleJavaFileInput() {
result = {
path: filePath.path ?? filePath,
version: props.version.toString(),
parsed_version: props.version,
architecture: 'x86',
}
}
@@ -175,6 +202,7 @@ async function reinstallJava() {
result = {
path: path,
version: props.version.toString(),
parsed_version: props.version,
architecture: 'x86',
}
}
@@ -186,13 +214,23 @@ async function reinstallJava() {
emit('update:modelValue', result)
installingJava.value = false
runTest(result.path)
}
</script>
<style lang="scss" scoped>
.input-with-status {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
width: 100%;
min-width: 0;
}
.installation-input {
width: 100% !important;
flex-grow: 1;
flex: 1 1 0;
min-width: 0;
}
.toggle-setting {
@@ -215,12 +253,4 @@ async function reinstallJava() {
gap: 0.5rem;
margin: 0;
}
.test-success {
color: var(--color-green);
}
.test-fail {
color: var(--color-red);
}
</style>
@@ -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,6 +1,15 @@
<script setup lang="ts">
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import {
CheckCircleIcon,
CoffeeIcon,
FolderSearchIcon,
RefreshCwIcon,
SearchIcon,
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
defineMessages,
injectNotificationManager,
@@ -8,9 +17,11 @@ import {
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, readonly, ref, watch } from 'vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
@@ -25,9 +36,54 @@ const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
const overrideJavaInstall = ref(!!instance.value.java_path)
const optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? instance.value.java_path })
const overrideJavaInstall = ref(!!instance.value.java_path)
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
const activePath = computed(() =>
overrideJavaInstall.value ? javaPath.value : (optimalJava?.path ?? ''),
)
watch(overrideJavaInstall, (enabled) => {
if (enabled && !javaPath.value) {
javaPath.value = optimalJava?.path ?? ''
}
})
const { testingJava, javaTestResult, testJavaInstallationDebounced, testJavaInstallation } =
useJavaTest()
const hoveringTest = ref(false)
let hasInitialized = false
watch(
activePath,
(newPath) => {
if (newPath && optimalJava?.parsed_version) {
if (!hasInitialized) {
testJavaInstallation(newPath, optimalJava?.parsed_version, false)
hasInitialized = true
} else {
testJavaInstallationDebounced(newPath, optimalJava?.parsed_version)
}
}
},
{ immediate: true },
)
const javaDetectionModal = ref<{ show: (version: number, current: object) => void } | null>(null)
async function handleBrowseJava() {
const result = await open({ multiple: false })
if (result) {
javaPath.value = result
}
}
function handleDetectJava() {
javaDetectionModal.value?.show(optimalJava?.parsed_version, { path: javaPath.value })
}
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref(
@@ -51,8 +107,8 @@ const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) a
const editProfileObject = computed(() => {
return {
java_path:
overrideJavaInstall.value && javaInstall.value.path !== ''
? javaInstall.value.path.replace('java.exe', 'javaw.exe')
overrideJavaInstall.value && javaPath.value
? javaPath.value.replace('java.exe', 'javaw.exe')
: null,
extra_launch_args: overrideJavaArgs.value
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
@@ -71,7 +127,7 @@ const editProfileObject = computed(() => {
watch(
[
overrideJavaInstall,
javaInstall,
javaPath,
overrideJavaArgs,
javaArgs,
overrideEnvVars,
@@ -90,17 +146,45 @@ const messages = defineMessages({
id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation',
},
customJavaInstallation: {
id: 'instance.settings.tabs.java.custom-java-installation',
defaultMessage: 'Custom Java installation',
},
javaPathPlaceholder: {
id: 'instance.settings.tabs.java.java-path-placeholder',
defaultMessage: '/path/to/java',
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
},
customMemoryAllocation: {
id: 'instance.settings.tabs.java.custom-memory-allocation',
defaultMessage: 'Custom memory allocation',
},
javaArguments: {
id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments',
},
customJavaArguments: {
id: 'instance.settings.tabs.java.custom-java-arguments',
defaultMessage: 'Custom Java arguments',
},
enterJavaArguments: {
id: 'instance.settings.tabs.java.enter-java-arguments',
defaultMessage: 'Enter Java arguments...',
},
javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables',
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
customEnvironmentVariables: {
id: 'instance.settings.tabs.java.custom-environment-variables',
defaultMessage: 'Custom environment variables',
},
enterEnvironmentVariables: {
id: 'instance.settings.tabs.java.enter-environment-variables',
defaultMessage: 'Enter environmental variables...',
},
hooks: {
id: 'instance.settings.tabs.java.hooks',
@@ -111,43 +195,86 @@ const messages = defineMessages({
<template>
<div>
<h2 id="project-name" class="m-0 mb-2.5 text-lg font-semibold text-contrast block">
<JavaDetectionModal ref="javaDetectionModal" @submit="(val) => (javaPath = val.path)" />
<h2 class="m-0 mb-2 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2.5" />
<template v-if="!overrideJavaInstall">
<div class="flex my-2 items-center gap-2 font-semibold">
<template v-if="javaInstall">
<CheckCircleIcon class="text-brand-green h-4 w-4" />
<span>Using default Java {{ optimalJava.major_version }} installation:</span>
</template>
<template v-else-if="optimalJava">
<XCircleIcon class="text-brand-red h-5 w-5" />
<span
>Could not find a default Java {{ optimalJava.major_version }} installation. Please set
one below:</span
<Checkbox
v-model="overrideJavaInstall"
:label="formatMessage(messages.customJavaInstallation)"
class="mb-2"
/>
<div class="flex gap-4 p-4 bg-bg rounded-2xl">
<div class="flex gap-3 items-start flex-1 min-w-0">
<div
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
>
<CoffeeIcon />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<span class="font-semibold leading-none mt-2"
>Java {{ optimalJava?.parsed_version }}</span
>
</template>
<template v-else>
<XCircleIcon class="text-brand-red h-5 w-5" />
<span
>Could not automatically determine a Java installation to use. Please set one
below:</span
>
</template>
<div class="flex gap-2 items-center">
<StyledInput
:model-value="activePath"
:disabled="!overrideJavaInstall"
autocomplete="off"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<ButtonStyled
:color="
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
"
color-fill="text"
>
<button
:disabled="!overrideJavaInstall || testingJava"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<div v-if="overrideJavaInstall" class="flex gap-2">
<ButtonStyled>
<button @click="handleDetectJava">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-if="javaInstall && !overrideJavaInstall"
class="p-4 bg-bg rounded-xl text-xs text-secondary leading-none font-mono"
>
{{ javaInstall.path }}
</div>
</template>
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
</div>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2.5" />
<Checkbox
v-model="overrideMemorySettings"
:label="formatMessage(messages.customMemoryAllocation)"
class="mb-2"
/>
<Slider
id="max-memory"
v-model="memory.maximum"
@@ -159,28 +286,36 @@ const messages = defineMessages({
:snap-range="512"
unit="MB"
/>
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
<Checkbox
v-model="overrideJavaArgs"
:label="formatMessage(messages.customJavaArguments)"
class="my-2"
/>
<StyledInput
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
placeholder="Enter java arguments..."
:placeholder="formatMessage(messages.enterJavaArguments)"
wrapper-class="w-full"
/>
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2.5" />
<Checkbox
v-model="overrideEnvVars"
:label="formatMessage(messages.customEnvironmentVariables)"
class="mb-2"
/>
<StyledInput
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
placeholder="Enter environmental variables..."
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
/>
</div>
@@ -0,0 +1,52 @@
<template>
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1 leading-snug text-primary">
<button
v-if="inviterName"
type="button"
class="inline-flex min-w-0 items-center border-0 bg-transparent p-0 font-semibold text-contrast hover:underline"
@click="openInviterProfile(inviterName)"
>
<Avatar
:src="inviterAvatarUrl"
:alt="inviterName"
circle
size="xxs"
no-shadow
class="mr-1.5 inline-flex"
/>
<span>{{ inviterName }}</span>
</button>
<span>
<span v-if="inviterName" class="whitespace-nowrap">has invited you to manage</span>
<span v-else class="whitespace-nowrap">You have been invited to manage</span>
<span class="font-semibold text-contrast ml-1">{{ serverName }}</span>
<span>.</span>
</span>
</div>
</template>
<script setup>
import { Avatar } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { config } from '@/config'
defineProps({
inviterName: {
type: String,
default: null,
},
inviterAvatarUrl: {
type: String,
default: null,
},
serverName: {
type: String,
required: true,
},
})
function openInviterProfile(username) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`)
}
</script>
@@ -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,16 +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" v-model="settings.hide_nametag_skins_page" />
</div>
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
@@ -183,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">
@@ -217,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
}
"
/>
@@ -236,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,149 +1,242 @@
<template>
<UploadSkinModal ref="uploadModal" />
<ModalWrapper ref="modal" @on-hide="resetState">
<NewModal ref="modal" :on-hide="handleModalHide">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
{{ formatMessage(mode === 'edit' ? messages.editSkinTitle : messages.addSkinTitle) }}
</span>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI / 8"
class="h-full w-full"
/>
</div>
<div class="h-[25rem] w-[16rem] min-w-[16rem] flex-shrink-0 md:self-center">
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture"
framing="modal"
:initial-rotation="Math.PI / 8"
class="h-full w-full"
/>
</div>
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section>
<h2 class="text-base font-semibold mb-2">Texture</h2>
<section v-if="mode === 'edit' && canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
<ButtonStyled>
<button @click="openUploadSkinModal"><UploadIcon /> Replace texture</button>
<button class="!shadow-none" @click="openTextureFileBrowser">
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
</button>
</ButtonStyled>
<input
ref="textureFileInput"
type="file"
accept="image/png"
class="hidden"
@change="onTextureFileInputChange"
/>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Arm style</h2>
<section v-if="canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">
{{ formatMessage(messages.armStyleSection) }}
</h2>
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
<template #default="{ item }">
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
{{
formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
}}
</template>
</RadioButtons>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Cape</h2>
<div class="flex gap-2">
<CapeButton
v-if="defaultCape"
:id="defaultCape.id"
:texture="defaultCape.texture"
:name="undefined"
:selected="!selectedCape"
faded
@select="selectCape(undefined)"
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.capeSection) }}</h2>
<div class="relative w-fit max-w-full">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<span>Use default cape</span>
</CapeButton>
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
<span>Use default cape</span>
</CapeLikeTextButton>
<div
v-if="showCapeTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<CapeButton
v-for="cape in visibleCapeList"
:id="cape.id"
:key="cape.id"
:texture="cape.texture"
:name="cape.name || 'Cape'"
:selected="selectedCape?.id === cape.id"
@select="selectCape(cape)"
/>
<CapeLikeTextButton
v-if="(capes?.length ?? 0) > 2"
tooltip="View more capes"
@mouseup="openSelectCapeModal"
<div
ref="capeListRef"
class="grid grid-cols-[repeat(4,max-content)] auto-rows-max gap-2 overflow-y-auto pr-1"
:style="{ maxHeight: capeListMaxHeight }"
@scroll="checkCapeScrollState"
>
<template #icon><ChevronRightIcon /></template>
<span>More</span>
</CapeLikeTextButton>
<CapeLikeTextButton
:tooltip="formatMessage(messages.noCapeTooltip)"
:highlighted="!selectedCape"
@click="selectCape(undefined)"
>
<template #icon><XIcon /></template>
<span>{{ formatMessage(messages.noneCapeOption) }}</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in sortedCapes"
:id="cape.id"
:key="cape.id"
:texture="cape.texture"
:name="cape.name || formatMessage(messages.capeFallbackName)"
:selected="selectedCape?.id === cape.id"
@select="selectCape(cape)"
/>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showCapeBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</section>
</div>
</div>
<div class="flex gap-2 mt-12">
<ButtonStyled color="brand">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button :disabled="isSaving" @click="hide"><XIcon />Cancel</button>
</ButtonStyled>
</div>
</ModalWrapper>
<SelectCapeModal
ref="selectCapeModal"
:capes="capes || []"
@select="handleCapeSelected"
@cancel="handleCapeCancel"
/>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button :disabled="isSaving" @click="hide">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import {
CheckIcon,
ChevronRightIcon,
SaveIcon,
SpinnerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CapeButton,
CapeLikeTextButton,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
RadioButtons,
SkinPreviewRenderer,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { arrayBufferToBase64 } from '@modrinth/utils'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import {
add_and_equip_custom_skin,
type Cape,
determineModelType,
equip_skin,
get_normalized_skin_texture,
remove_custom_skin,
normalize_skin_texture,
save_custom_skin,
type Skin,
type SkinModel,
type SkinTextureUrl,
unequip_skin,
} from '@/helpers/skins.ts'
const CAPE_LIST_MAX_HEIGHT = 334
const messages = defineMessages({
editSkinTitle: {
id: 'app.skins.modal.edit-title',
defaultMessage: 'Editing skin',
},
addSkinTitle: {
id: 'app.skins.modal.add-title',
defaultMessage: 'Adding a skin',
},
textureSection: {
id: 'app.skins.modal.texture-section',
defaultMessage: 'Texture',
},
replaceTextureButton: {
id: 'app.skins.modal.replace-texture-button',
defaultMessage: 'Replace texture',
},
armStyleSection: {
id: 'app.skins.modal.arm-style-section',
defaultMessage: 'Arm style',
},
wideArmStyle: {
id: 'app.skins.modal.arm-style-wide',
defaultMessage: 'Wide',
},
slimArmStyle: {
id: 'app.skins.modal.arm-style-slim',
defaultMessage: 'Slim',
},
capeSection: {
id: 'app.skins.modal.cape-section',
defaultMessage: 'Cape',
},
noCapeTooltip: {
id: 'app.skins.modal.no-cape-tooltip',
defaultMessage: 'No cape',
},
noneCapeOption: {
id: 'app.skins.modal.none-cape-option',
defaultMessage: 'None',
},
capeFallbackName: {
id: 'app.skins.modal.cape-fallback-name',
defaultMessage: 'Cape',
},
savingTooltip: {
id: 'app.skins.modal.saving-tooltip',
defaultMessage: 'Saving...',
},
uploadSkinFirstTooltip: {
id: 'app.skins.modal.upload-skin-first-tooltip',
defaultMessage: 'Upload a skin first!',
},
makeEditFirstTooltip: {
id: 'app.skins.modal.make-edit-first-tooltip',
defaultMessage: 'Make an edit to the skin first!',
},
addSkinButton: {
id: 'app.skins.modal.add-skin-button',
defaultMessage: 'Add skin',
},
saveSkinButton: {
id: 'app.skins.modal.save-skin-button',
defaultMessage: 'Save skin',
},
})
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = useTemplateRef('modal')
const selectCapeModal = useTemplateRef('selectCapeModal')
const textureFileInput = useTemplateRef<HTMLInputElement>('textureFileInput')
const capeListRef = ref<HTMLElement | null>(null)
const capeListMaxHeight = ref(`${CAPE_LIST_MAX_HEIGHT}px`)
const mode = ref<'new' | 'edit'>('new')
const currentSkin = ref<Skin | null>(null)
const shouldRestoreModal = ref(false)
const isSaving = ref(false)
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
@@ -151,10 +244,49 @@ const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape | undefined>(undefined)
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
const props = defineProps<{ capes?: Cape[] }>()
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
const visibleCapeList = ref<Cape[]>([])
const canEditTextureAndModel = computed(() => currentSkin.value?.source !== 'default')
const {
showTopFade: showCapeTopFade,
showBottomFade: showCapeBottomFade,
checkScrollState: checkCapeScrollState,
forceCheck: forceCapeScrollCheck,
} = useScrollIndicator(capeListRef)
let capeListLayoutFrame: number | null = null
function updateCapeListLayout() {
const capeList = capeListRef.value
const modalContent = capeList?.closest('[data-modal-content]') as HTMLElement | null
if (!capeList || !modalContent) {
capeListMaxHeight.value = `${CAPE_LIST_MAX_HEIGHT}px`
forceCapeScrollCheck()
return
}
const availableHeight =
modalContent.getBoundingClientRect().bottom - capeList.getBoundingClientRect().top
capeListMaxHeight.value = `${Math.min(
CAPE_LIST_MAX_HEIGHT,
Math.max(0, Math.floor(availableHeight)),
)}px`
nextTick(() => forceCapeScrollCheck())
}
function refreshCapeListLayout() {
if (capeListLayoutFrame !== null) {
cancelAnimationFrame(capeListLayoutFrame)
}
capeListLayoutFrame = requestAnimationFrame(() => {
capeListLayoutFrame = null
updateCapeListLayout()
})
}
const sortedCapes = computed(() => {
return [...(props.capes || [])].sort((a, b) => {
@@ -164,32 +296,6 @@ const sortedCapes = computed(() => {
})
})
function initVisibleCapeList() {
if (!props.capes || props.capes.length === 0) {
visibleCapeList.value = []
return
}
if (visibleCapeList.value.length === 0) {
if (selectedCape.value) {
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
} else {
visibleCapeList.value = getSortedCapes(2)
}
}
}
function getSortedCapes(count: number): Cape[] {
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
return sortedCapes.value.slice(0, count)
}
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
return sortedCapes.value.find((cape) => cape.id !== excludeId)
}
async function loadPreviewSkin() {
if (uploadedTextureUrl.value) {
previewSkin.value = uploadedTextureUrl.value.normalized
@@ -221,9 +327,13 @@ const disableSave = computed(
)
const saveTooltip = computed(() => {
if (isSaving.value) return 'Saving...'
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
if (isSaving.value) return formatMessage(messages.savingTooltip)
if (mode.value === 'new' && !uploadedTextureUrl.value) {
return formatMessage(messages.uploadSkinFirstTooltip)
}
if (mode.value === 'edit' && !hasEdits.value) {
return formatMessage(messages.makeEditFirstTooltip)
}
return undefined
})
@@ -234,11 +344,13 @@ function resetState() {
previewSkin.value = ''
variant.value = 'CLASSIC'
selectedCape.value = undefined
visibleCapeList.value = []
shouldRestoreModal.value = false
isSaving.value = false
}
function handleModalHide() {
setTimeout(() => resetState(), 250)
}
async function show(e: MouseEvent, skin?: Skin) {
mode.value = skin ? 'edit' : 'new'
currentSkin.value = skin ?? null
@@ -249,12 +361,11 @@ async function show(e: MouseEvent, skin?: Skin) {
variant.value = 'CLASSIC'
selectedCape.value = undefined
}
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
nextTick(() => refreshCapeListLayout())
}
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
@@ -263,98 +374,54 @@ async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
uploadedTextureUrl.value = skinTextureUrl
variant.value = await determineModelType(skinTextureUrl.original)
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
nextTick(() => refreshCapeListLayout())
}
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
async function setUploadedTexture(skinTextureUrl: SkinTextureUrl) {
uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin()
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
nextTick(() => refreshCapeListLayout())
}
function hide() {
modal.value?.hide()
setTimeout(() => resetState(), 250)
}
function selectCape(cape: Cape | undefined) {
if (cape && selectedCape.value?.id !== cape.id) {
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
if (!isInVisibleList && visibleCapeList.value.length > 0) {
visibleCapeList.value.splice(0, 1, cape)
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
const otherCape = getSortedCapeExcluding(cape.id)
if (otherCape) {
visibleCapeList.value.splice(1, 1, otherCape)
}
}
}
}
selectedCape.value = cape
}
function handleCapeSelected(cape: Cape | undefined) {
selectCape(cape)
function openTextureFileBrowser() {
textureFileInput.value?.click()
}
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
async function onTextureFileInputChange(e: Event) {
const files = (e.target as HTMLInputElement).files
const file = files?.[0]
if (!file) {
return
}
}
function handleCapeCancel() {
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function openSelectCapeModal(e: MouseEvent) {
if (!selectCapeModal.value) return
shouldRestoreModal.value = true
modal.value?.hide()
setTimeout(() => {
selectCapeModal.value?.show(
e,
currentSkin.value?.texture_key,
selectedCape.value,
previewSkin.value,
variant.value,
)
}, 0)
}
function openUploadSkinModal(e: MouseEvent) {
shouldRestoreModal.value = true
modal.value?.hide()
emit('open-upload-modal', e)
}
function restoreModal() {
if (shouldRestoreModal.value) {
setTimeout(() => {
const fakeEvent = new MouseEvent('click')
modal.value?.show(fakeEvent)
shouldRestoreModal.value = false
}, 500)
try {
const originalSkinTexUrl = `data:image/png;base64,${arrayBufferToBase64(
await file.arrayBuffer(),
)}`
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
await setUploadedTexture({
original: originalSkinTexUrl,
normalized: `data:image/png;base64,${arrayBufferToBase64(skinTextureNormalized)}`,
})
} catch (error) {
handleError(error)
} finally {
if (textureFileInput.value) {
textureFileInput.value.value = ''
}
}
}
@@ -370,17 +437,32 @@ async function save() {
textureUrl = currentSkin.value!.texture
}
await unequip_skin()
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
if (mode.value === 'new') {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved')
const addedSkin = await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved', {
applied: true,
skin: addedSkin,
})
} else {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
await remove_custom_skin(currentSkin.value!)
emit('saved')
const updatedSkin = await save_custom_skin(
currentSkin.value!,
bytes,
variant.value,
selectedCape.value,
!!uploadedTextureUrl.value && textureUrl !== currentSkin.value?.texture,
)
if (currentSkin.value?.is_equipped) {
await equip_skin(updatedSkin)
}
emit('saved', {
applied: !!currentSkin.value?.is_equipped,
skin: updatedSkin,
previousSkin: currentSkin.value!,
})
}
hide()
@@ -393,28 +475,53 @@ async function save() {
watch([uploadedTextureUrl, currentSkin], async () => {
await loadPreviewSkin()
refreshCapeListLayout()
})
watch(
() => props.capes,
() => {
initVisibleCapeList()
nextTick(() => refreshCapeListLayout())
},
{ immediate: true },
)
watch(
capeListRef,
(capeList, _, onCleanup) => {
if (!capeList) return
const modalContent = capeList.closest('[data-modal-content]')
const resizeObserver = new ResizeObserver(() => refreshCapeListLayout())
if (modalContent instanceof HTMLElement) {
resizeObserver.observe(modalContent)
}
window.addEventListener('resize', refreshCapeListLayout, { passive: true })
refreshCapeListLayout()
onCleanup(() => {
resizeObserver.disconnect()
window.removeEventListener('resize', refreshCapeListLayout)
if (capeListLayoutFrame !== null) {
cancelAnimationFrame(capeListLayoutFrame)
capeListLayoutFrame = null
}
})
},
{ flush: 'post' },
)
const emit = defineEmits<{
(event: 'saved'): void
(event: 'saved', options: { applied: boolean; skin?: Skin; previousSkin?: Skin }): void
(event: 'deleted', skin: Skin): void
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
}>()
defineExpose({
show,
showNew,
restoreWithNewTexture,
hide,
shouldRestoreModal,
restoreModal,
})
</script>
@@ -1,141 +0,0 @@
<script setup lang="ts">
import { CheckIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CapeButton,
CapeLikeTextButton,
ScrollablePanel,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
const modal = useTemplateRef('modal')
const emit = defineEmits<{
(e: 'select', cape: Cape | undefined): void
(e: 'cancel'): void
}>()
const props = defineProps<{
capes: Cape[]
}>()
const sortedCapes = computed(() => {
return [...props.capes].sort((a, b) => {
const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
const currentSkinId = ref<string | undefined>()
const currentSkinTexture = ref<string | undefined>()
const currentSkinVariant = ref<SkinModel>('CLASSIC')
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
const currentCape = ref<Cape | undefined>()
function show(
e: MouseEvent,
skinId?: string,
selected?: Cape,
skinTexture?: string,
variant?: SkinModel,
) {
currentSkinId.value = skinId
currentSkinTexture.value = skinTexture
currentSkinVariant.value = variant || 'CLASSIC'
currentCape.value = selected
modal.value?.show(e)
}
function select() {
emit('select', currentCape.value)
hide()
}
function hide() {
modal.value?.hide()
emit('cancel')
}
function updateSelectedCape(cape: Cape | undefined) {
currentCape.value = cape
}
function onModalHide() {
emit('cancel')
}
defineExpose({
show,
hide,
})
</script>
<template>
<ModalWrapper ref="modal" @on-hide="onModalHide">
<template #title>
<div class="flex flex-col">
<span class="text-lg font-extrabold text-heading">Change cape</span>
</div>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
v-if="currentSkinTexture"
:cape-src="currentCapeTexture"
:texture-src="currentSkinTexture"
:variant="currentSkinVariant"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI + Math.PI / 8"
class="h-full w-full"
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full my-auto">
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
<CapeLikeTextButton
tooltip="No Cape"
:highlighted="!currentCape"
@click="updateSelectedCape(undefined)"
>
<template #icon>
<XIcon />
</template>
<span>None</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in sortedCapes"
:id="cape.id"
:key="cape.id"
:name="cape.name"
:texture="cape.texture"
:selected="currentCape?.id === cape.id"
@select="updateSelectedCape(cape)"
/>
</div>
</ScrollablePanel>
</div>
</div>
<div class="flex gap-2 items-center">
<ButtonStyled color="brand">
<button @click="select">
<CheckIcon />
Select
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>
@@ -1,141 +0,0 @@
<template>
<ModalWrapper ref="modal" @on-hide="hide(true)">
<template #title>
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
</template>
<div class="relative">
<div
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
@click="triggerFileInput"
>
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
<UploadIcon /> Select skin texture file
</p>
<p class="mx-auto mt-0 text-secondary text-sm text-center">
Drag and drop or click here to browse
</p>
<input
ref="fileInput"
type="file"
accept="image/png"
class="hidden"
@change="handleInputFileChange"
/>
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { onBeforeUnmount, ref, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins'
const { addNotification } = injectNotificationManager()
const modal = ref()
const fileInput = ref<HTMLInputElement>()
const unlisten = ref<() => void>()
const modalVisible = ref(false)
const emit = defineEmits<{
(e: 'uploaded', data: ArrayBuffer): void
(e: 'canceled'): void
}>()
function show(e?: MouseEvent) {
modal.value?.show(e)
modalVisible.value = true
setupDragDropListener()
}
function hide(emitCanceled = false) {
modal.value?.hide()
modalVisible.value = false
cleanupDragDropListener()
resetState()
if (emitCanceled) {
emit('canceled')
}
}
function resetState() {
if (fileInput.value) fileInput.value.value = ''
}
function triggerFileInput() {
fileInput.value?.click()
}
async function handleInputFileChange(e: Event) {
const files = (e.target as HTMLInputElement).files
if (!files || files.length === 0) {
return
}
const file = files[0]
const buffer = await file.arrayBuffer()
await processData(buffer)
}
async function setupDragDropListener() {
try {
if (modalVisible.value) {
await cleanupDragDropListener()
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop') {
return
}
if (!event.payload.paths || event.payload.paths.length === 0) {
return
}
const filePath = event.payload.paths[0]
try {
const data = await get_dragged_skin_data(filePath)
await processData(data.buffer)
} catch (error) {
addNotification({
title: 'Error processing file',
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
type: 'error',
})
}
})
}
} catch (error) {
console.error('Failed to set up drag and drop listener:', error)
}
}
async function cleanupDragDropListener() {
if (unlisten.value) {
unlisten.value()
unlisten.value = undefined
}
}
async function processData(buffer: ArrayBuffer) {
emit('uploaded', buffer)
hide()
}
watch(modalVisible, (isVisible) => {
if (isVisible) {
setupDragDropListener()
} else {
cleanupDragDropListener()
}
})
onBeforeUnmount(() => {
cleanupDragDropListener()
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1,432 @@
<script setup lang="ts">
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
commonMessages,
defineMessages,
SkinButton,
SkinLikeTextButton,
useScrollViewport,
useVIntl,
} from '@modrinth/ui'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins.ts'
type SkinSectionKind = 'saved' | 'default'
type SkinLikeTextButtonExpose = {
getRootElement: () => HTMLElement | null | undefined
}
type AddSkinButtonRef = SkinLikeTextButtonExpose | SkinLikeTextButtonExpose[]
interface DefaultSkinSection {
title: string
infoTooltip?: string
skins: Skin[]
}
interface SkinSection {
key: string
title: string
kind: SkinSectionKind
infoTooltip?: string
skins: Skin[]
}
interface VirtualSkinSection {
section: SkinSection
top: number
index: number
}
const SKIN_CARD_ASPECT_WIDTH = 31
const SKIN_CARD_ASPECT_HEIGHT = 40
const SKIN_GRID_GAP = 12
const SKIN_SECTION_FIRST_SPACING = 4
const SKIN_SECTION_SPACING = 24
const SKIN_SECTION_HEADER_HEIGHT = 28
const SKIN_SECTION_CONTENT_SPACING = 8
const SKIN_SECTION_OVERSCAN = 900
const FALLBACK_CARD_WIDTH = 220
const messages = defineMessages({
savedSkinsSection: {
id: 'app.skins.section.saved-skins',
defaultMessage: 'Saved skins',
},
addSkinButton: {
id: 'app.skins.add-button',
defaultMessage: 'Add skin',
},
dragAndDropSubtitle: {
id: 'app.skins.add-button.drag-and-drop',
defaultMessage: 'Drag and drop',
},
editSkinButton: {
id: 'app.skins.edit-button',
defaultMessage: 'Edit skin',
},
deleteSkinButton: {
id: 'app.skins.delete-button',
defaultMessage: 'Delete skin',
},
})
const props = defineProps<{
savedSkins: Skin[]
defaultSkinSections: DefaultSkinSection[]
getBakedSkinTextures: (skin: Skin) => RenderResult | undefined
isSkinSelected: (skin: Skin) => boolean
isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean
}>()
const emit = defineEmits<{
select: [skin: Skin]
edit: [skin: Skin, event: MouseEvent]
delete: [skin: Skin]
'add-skin': []
'add-skin-dragenter': [event: DragEvent]
'add-skin-dragover': [event: DragEvent]
'add-skin-dragleave': [event: DragEvent]
'add-skin-drop': [event: DragEvent]
}>()
const addSkinButton = useTemplateRef<AddSkinButtonRef>('addSkinButton')
const { formatMessage } = useVIntl()
const { listContainer, relativeScrollTop, scrollContainer, viewportHeight } = useScrollViewport()
const openSectionKeys = ref<Set<string>>(new Set())
const hasSettledInitialLayout = ref(false)
const knownSectionKeys = new Set<string>()
let enableLayoutTransitionsFrame: number | null = null
let isEnableLayoutTransitionsScheduled = false
let isUnmounted = false
const { width: listWidth } = useElementSize(listContainer)
const { width: windowWidth } = useWindowSize()
const columnCount = computed(() => {
if (windowWidth.value >= 2050) {
return 6
}
if (windowWidth.value >= 1750) {
return 5
}
if (windowWidth.value >= 1300) {
return 4
}
return 3
})
const cardWidth = computed(() => {
if (listWidth.value <= 0) {
return FALLBACK_CARD_WIDTH
}
const gapsWidth = (columnCount.value - 1) * SKIN_GRID_GAP
return Math.max(0, (listWidth.value - gapsWidth) / columnCount.value)
})
const cardHeight = computed(
() => (cardWidth.value * SKIN_CARD_ASPECT_HEIGHT) / SKIN_CARD_ASPECT_WIDTH,
)
const sections = computed<SkinSection[]>(() => [
{
key: 'saved-skins',
title: formatMessage(messages.savedSkinsSection),
kind: 'saved',
skins: props.savedSkins,
},
...props.defaultSkinSections.map((section) => ({
key: defaultSkinSectionKey(section.title),
title: section.title,
kind: 'default' as const,
infoTooltip: section.infoTooltip,
skins: section.skins,
})),
])
const sectionLayouts = computed(() => {
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
let top = 0
sections.value.forEach((section, index) => {
const height = getSectionHeightEstimate(section, index)
layouts.push({ section, top, height, index })
top += height
})
return layouts
})
const totalHeight = computed(() => {
const lastSection = sectionLayouts.value[sectionLayouts.value.length - 1]
return lastSection ? lastSection.top + lastSection.height : 0
})
const visibleSections = computed<VirtualSkinSection[]>(() => {
if (!listContainer.value || !scrollContainer.value) {
return sectionLayouts.value.slice(0, 4)
}
const viewportStart = Math.max(0, relativeScrollTop.value - SKIN_SECTION_OVERSCAN)
const viewportEnd = relativeScrollTop.value + viewportHeight.value + SKIN_SECTION_OVERSCAN
return sectionLayouts.value
.filter((layout) => layout.top + layout.height >= viewportStart && layout.top <= viewportEnd)
.map(({ section, top, index }) => ({ section, top, index }))
})
watch(
sections,
(nextSections) => {
const sectionKeys = new Set(nextSections.map((section) => section.key))
const openKeys = new Set(openSectionKeys.value)
for (const section of nextSections) {
if (!knownSectionKeys.has(section.key)) {
knownSectionKeys.add(section.key)
openKeys.add(section.key)
}
}
for (const key of knownSectionKeys) {
if (!sectionKeys.has(key)) {
knownSectionKeys.delete(key)
openKeys.delete(key)
}
}
openSectionKeys.value = openKeys
},
{ immediate: true },
)
watch(
listWidth,
(width) => {
if (
typeof window === 'undefined' ||
width <= 0 ||
hasSettledInitialLayout.value ||
isEnableLayoutTransitionsScheduled
) {
return
}
isEnableLayoutTransitionsScheduled = true
void nextTick(() => {
if (isUnmounted) return
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
if (isUnmounted) return
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
if (isUnmounted) return
hasSettledInitialLayout.value = true
enableLayoutTransitionsFrame = null
isEnableLayoutTransitionsScheduled = false
})
})
})
},
{ immediate: true },
)
onUnmounted(() => {
isUnmounted = true
if (enableLayoutTransitionsFrame !== null) {
window.cancelAnimationFrame(enableLayoutTransitionsFrame)
}
})
function defaultSkinSectionKey(title: string) {
return `default-skins-${title}`
}
function skinKey(skin: Skin, prefix: string) {
return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}`
}
function isSectionOpen(key: string) {
return openSectionKeys.value.has(key)
}
function setSectionOpen(key: string, open: boolean) {
const openKeys = new Set(openSectionKeys.value)
if (open) {
openKeys.add(key)
} else {
openKeys.delete(key)
}
openSectionKeys.value = openKeys
}
function getSectionHeightEstimate(section: SkinSection, index: number) {
const spacing = index === 0 ? SKIN_SECTION_FIRST_SPACING : SKIN_SECTION_SPACING
if (!isSectionOpen(section.key)) {
return spacing + SKIN_SECTION_HEADER_HEIGHT
}
const cardCount = section.kind === 'saved' ? section.skins.length + 1 : section.skins.length
const rowCount = Math.ceil(cardCount / columnCount.value)
const gridHeight = rowCount * cardHeight.value + Math.max(0, rowCount - 1) * SKIN_GRID_GAP
return spacing + SKIN_SECTION_HEADER_HEIGHT + SKIN_SECTION_CONTENT_SPACING + gridHeight
}
function getAddSkinButtonElement() {
const button = Array.isArray(addSkinButton.value)
? addSkinButton.value.find((candidate) => candidate.getRootElement())
: addSkinButton.value
return button?.getRootElement()
}
defineExpose({ getAddSkinButtonElement })
</script>
<template>
<div
ref="listContainer"
class="relative w-full"
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
>
<div
v-for="{ section, top, index } in visibleSections"
:key="section.key"
class="absolute inset-x-0"
:class="[
index === 0 ? 'pt-1' : 'pt-6',
hasSettledInitialLayout
? 'transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none'
: '',
]"
:style="{ transform: `translateY(${top}px)` }"
>
<Accordion
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 border-none cursor-pointer text-left"
content-class="pt-2"
:open-by-default="isSectionOpen(section.key)"
@on-open="setSectionOpen(section.key, true)"
@on-close="setSectionOpen(section.key, false)"
>
<template #title>
{{ section.title }}
</template>
<template #button="{ open }">
<DropdownIcon
class="size-6 shrink-0 text-primary transition-transform duration-300"
:class="{ 'rotate-180': open }"
/>
<span class="min-w-0 text-xl font-semibold leading-7 text-primary">
{{ section.title }}
</span>
<Tooltip
v-if="section.infoTooltip"
theme="dismissable-prompt"
placement="top"
:triggers="['hover', 'focus']"
>
<span
class="inline-flex size-6 shrink-0 items-center justify-center text-secondary transition-colors group-hover:text-primary"
@click.stop
>
<UnknownIcon class="size-5" />
</span>
<template #popper>
<p class="m-0 max-w-96 text-wrap text-sm font-medium leading-tight">
{{ section.infoTooltip }}
</p>
</template>
</Tooltip>
</template>
<div
v-if="section.kind === 'saved'"
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
>
<SkinLikeTextButton
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:drag-active="isAddSkinButtonDragActive"
@click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)"
@dragleave="emit('add-skin-dragleave', $event)"
@drop="emit('add-skin-drop', $event)"
>
<template #icon>
<PlusIcon class="size-8" />
</template>
{{ formatMessage(messages.addSkinButton) }}
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
</SkinLikeTextButton>
<SkinButton
v-for="skin in section.skins"
:key="skinKey(skin, 'saved-skin')"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
@select="emit('select', skin)"
>
<template #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
<div
v-else
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
>
<SkinButton
v-for="skin in section.skins"
:key="skinKey(skin, section.key)"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:tooltip="skin.name"
@select="emit('select', skin)"
/>
</div>
</Accordion>
</div>
</div>
</template>
@@ -244,7 +244,7 @@ const messages = defineMessages({
</TagItem>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
>
<UserIcon
aria-hidden="true"
@@ -8,7 +8,7 @@ interface LogEntry {
filename: string
name?: string
log_type: string
stdout?: string
output?: string | null
age?: number
live?: boolean
}
@@ -50,12 +50,12 @@ async function getHistoricalLogs(profilePathId: string, instancePath: string): P
const entry = getOrCreate(profilePathId)
if (entry.logList) return entry.logList
const logs: LogEntry[] = await get_logs(instancePath, false)
const logs: LogEntry[] = await get_logs(instancePath, true)
entry.logList = logs
for (const log of logs) {
if (log.stdout && log.stdout !== '') {
entry.historicalCache.set(log.filename, log.stdout)
if (log.output) {
entry.historicalCache.set(log.filename, log.output)
}
}
@@ -0,0 +1,52 @@
import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { test_jre } from '@/helpers/jre.js'
export default function useJavaTest() {
const testingJava = ref(false)
const javaTestResult = ref<boolean | null>(null)
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
async function runJavaTest(path: string, version: number, track = true) {
if (testDebounceTimer) {
clearTimeout(testDebounceTimer)
testDebounceTimer = null
}
if (!path) {
javaTestResult.value = null
return
}
testingJava.value = true
try {
javaTestResult.value = await test_jre(path, version)
} catch {
javaTestResult.value = false
}
testingJava.value = false
if (track) {
trackEvent('JavaTest', { path, success: javaTestResult.value })
}
}
function testJavaInstallationDebounced(path: string, version: number, delay = 600) {
if (testDebounceTimer) clearTimeout(testDebounceTimer)
if (!path) {
javaTestResult.value = null
return
}
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
}
async function testJavaInstallation(path: string, version: number, track = false) {
await runJavaTest(path, version, track)
}
return {
testingJava,
javaTestResult,
testJavaInstallationDebounced,
testJavaInstallation,
}
}
+4
View File
@@ -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 {
+2 -3
View File
@@ -6,12 +6,11 @@
import { invoke } from '@tauri-apps/api/core'
/*
A log is a struct containing the filename string, stdout, and stderr, as follows:
A log is a struct containing the filename string and optional output, as follows:
pub struct Logs {
pub filename: String,
pub stdout: String,
pub stderr: String,
pub output: Option<String>,
}
*/
+8 -1
View File
@@ -147,9 +147,16 @@ export async function get_mod_full_path(path: string, projectPath: string): Prom
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
}
export interface JavaVersion {
parsed_version: number
version: string
architecture: string
path: string
}
// Get optimal java version from profile
// Returns a java version
export async function get_optimal_jre_key(path: string): Promise<string | null> {
export async function get_optimal_jre_key(path: string): Promise<JavaVersion | null> {
return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
}
@@ -3,9 +3,8 @@ import {
applyCapeTexture,
createTransparentTexture,
disposeCaches,
loadTexture,
setupSkinModel,
} from '@modrinth/utils'
} from '@modrinth/ui'
import * as THREE from 'three'
import { reactive } from 'vue'
@@ -29,6 +28,7 @@ class BatchSkinRenderer {
private scene: THREE.Scene | null = null
private camera: THREE.PerspectiveCamera | null = null
private currentModel: THREE.Group | null = null
private transparentTexture: THREE.Texture | null = null
private readonly width: number
private readonly height: number
@@ -52,6 +52,7 @@ class BatchSkinRenderer {
})
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.shadowMap.enabled = false
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0)
@@ -62,7 +63,7 @@ class BatchSkinRenderer {
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.castShadow = false
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
@@ -112,9 +113,19 @@ class BatchSkinRenderer {
this.renderer.render(this.scene, this.camera)
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
const response = await fetch(dataUrl)
return await response.blob()
return await new Promise<Blob>((resolve, reject) => {
this.renderer!.domElement.toBlob(
(blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from rendered canvas'))
}
},
'image/webp',
0.9,
)
})
}
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
@@ -122,14 +133,10 @@ class BatchSkinRenderer {
throw new Error('Renderer not initialized')
}
const { model } = await setupSkinModel(modelUrl, textureUrl)
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
if (capeUrl) {
const capeTexture = await loadTexture(capeUrl)
applyCapeTexture(model, capeTexture)
} else {
const transparentTexture = createTransparentTexture()
applyCapeTexture(model, null, transparentTexture)
if (!capeUrl) {
applyCapeTexture(model, null, this.getTransparentTexture())
}
const group = new THREE.Group()
@@ -141,39 +148,38 @@ class BatchSkinRenderer {
this.currentModel = group
}
private clearScene(): void {
if (!this.scene) return
while (this.scene.children.length > 0) {
const child = this.scene.children[0]
this.scene.remove(child)
if (child instanceof THREE.Mesh) {
if (child.geometry) child.geometry.dispose()
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose())
} else {
child.material.dispose()
}
}
}
private getTransparentTexture(): THREE.Texture {
if (!this.transparentTexture) {
this.transparentTexture = createTransparentTexture()
}
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
return this.transparentTexture
}
private clearScene(): void {
if (!this.scene || !this.currentModel) return
this.scene.remove(this.currentModel)
this.currentModel.clear()
this.currentModel = null
}
public dispose(): void {
this.clearScene()
if (this.transparentTexture) {
this.transparentTexture.dispose()
this.transparentTexture = null
}
if (this.renderer) {
this.renderer.dispose()
}
this.renderer = null
this.scene = null
this.camera = null
disposeCaches()
}
}
@@ -194,6 +200,9 @@ export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null
let latestPreviewGeneration = 0
let previewGenerationQueue: Promise<void> = Promise.resolve()
function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer()
@@ -356,7 +365,27 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
return await generateHeadRender(skin)
}
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
export function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const generation = ++latestPreviewGeneration
const skinsSnapshot = [...skins]
const capesSnapshot = [...capes]
const generationPromise = previewGenerationQueue.then(() =>
generateSkinPreviewsForGeneration(skinsSnapshot, capesSnapshot, generation),
)
previewGenerationQueue = generationPromise.catch(() => {})
return generationPromise
}
async function generateSkinPreviewsForGeneration(
skins: Skin[],
capes: Cape[],
generation: number,
): Promise<void> {
const isCurrentGeneration = () => generation === latestPreviewGeneration
try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
@@ -368,6 +397,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
headStorage.batchRetrieve(headKeys),
])
if (!isCurrentGeneration()) return
for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i]
const headKey = headKeys[i]
@@ -388,6 +419,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
}
for (const skin of skins) {
if (!isCurrentGeneration()) return
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (skinBlobUrlMap.has(key)) {
@@ -419,6 +452,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
cape?.texture,
)
if (!isCurrentGeneration()) return
const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards),
backwards: URL.createObjectURL(rawRenderResult.backwards),
@@ -439,9 +474,12 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
}
} finally {
disposeSharedRenderer()
await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
if (isCurrentGeneration()) {
await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
}
}
}
+30 -10
View File
@@ -5,7 +5,6 @@ export interface Cape {
id: string
name: string
texture: string
is_default: boolean
is_equipped: boolean
}
@@ -15,6 +14,7 @@ export type SkinSource = 'default' | 'custom_external' | 'custom'
export interface Skin {
texture_key: string
name?: string
section?: string
variant: SkinModel
cape_id?: string
texture: string
@@ -121,17 +121,11 @@ export async function get_available_skins(): Promise<Skin[]> {
export async function add_and_equip_custom_skin(
textureBlob: Uint8Array,
variant: SkinModel,
capeOverride?: Cape,
): Promise<void> {
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
cape?: Cape,
): Promise<Skin> {
return await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
textureBlob,
variant,
capeOverride,
})
}
export async function set_default_cape(cape?: Cape): Promise<void> {
await invoke('plugin:minecraft-skins|set_default_cape', {
cape,
})
}
@@ -148,6 +142,22 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
})
}
export async function save_custom_skin(
skin: Skin,
textureBlob: Uint8Array,
variant: SkinModel,
cape: Cape | undefined,
replaceTexture: boolean,
): Promise<Skin> {
return await invoke('plugin:minecraft-skins|save_custom_skin', {
skin,
textureBlob,
variant,
cape,
replaceTexture,
})
}
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
const data = await normalize_skin_texture(skin.texture)
const base64 = arrayBufferToBase64(data)
@@ -162,6 +172,16 @@ export async function unequip_skin(): Promise<void> {
await invoke('plugin:minecraft-skins|unequip_skin')
}
export async function flush_pending_skin_change(): Promise<void> {
await invoke('plugin:minecraft-skins|flush_pending_skin_change')
}
export async function flush_pending_skin_change_for_profile(profileId: string): Promise<void> {
await invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', {
profileId,
})
}
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
return new Uint8Array(data)
@@ -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."
},
@@ -302,6 +296,9 @@
"app.project.install-button.already-installed": {
"message": "Tento projekt je již nainstalován"
},
"app.project.install-context.back-to-browse": {
"message": "Zpět na Prozkoumat"
},
"app.project.install-context.install-content-to-instance": {
"message": "Nainstalovat obsah do instnce"
},
@@ -440,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,12 +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..."
},
@@ -113,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"
},
@@ -143,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"
},
@@ -161,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"
},
@@ -197,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."
},
@@ -221,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."
},
@@ -563,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"
},
+163 -10
View File
@@ -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"
},
+162 -9
View File
@@ -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."
},
@@ -332,6 +338,147 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.skins.add-button": {
"message": "Add skin"
},
"app.skins.add-button.drag-and-drop": {
"message": "Drag and drop"
},
"app.skins.apply-button": {
"message": "Apply"
},
"app.skins.delete-button": {
"message": "Delete skin"
},
"app.skins.delete-modal.description": {
"message": "This will permanently delete the selected skin. This action cannot be undone."
},
"app.skins.delete-modal.title": {
"message": "Are you sure you want to delete this skin?"
},
"app.skins.dropped-file-error.text": {
"message": "Failed to read the dropped file."
},
"app.skins.dropped-file-error.title": {
"message": "Error processing file"
},
"app.skins.edit-button": {
"message": "Edit skin"
},
"app.skins.modal.add-skin-button": {
"message": "Add skin"
},
"app.skins.modal.add-title": {
"message": "Adding a skin"
},
"app.skins.modal.arm-style-section": {
"message": "Arm style"
},
"app.skins.modal.arm-style-slim": {
"message": "Slim"
},
"app.skins.modal.arm-style-wide": {
"message": "Wide"
},
"app.skins.modal.cape-fallback-name": {
"message": "Cape"
},
"app.skins.modal.cape-section": {
"message": "Cape"
},
"app.skins.modal.edit-title": {
"message": "Editing skin"
},
"app.skins.modal.make-edit-first-tooltip": {
"message": "Make an edit to the skin first!"
},
"app.skins.modal.no-cape-tooltip": {
"message": "No cape"
},
"app.skins.modal.none-cape-option": {
"message": "None"
},
"app.skins.modal.replace-texture-button": {
"message": "Replace texture"
},
"app.skins.modal.save-skin-button": {
"message": "Save skin"
},
"app.skins.modal.saving-tooltip": {
"message": "Saving..."
},
"app.skins.modal.texture-section": {
"message": "Texture"
},
"app.skins.modal.upload-skin-first-tooltip": {
"message": "Upload a skin first!"
},
"app.skins.preview.edit-button": {
"message": "Edit skin"
},
"app.skins.previewing-badge": {
"message": "Previewing"
},
"app.skins.rate-limit.text": {
"message": "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again."
},
"app.skins.rate-limit.title": {
"message": "Slow down!"
},
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
"app.skins.section.default-skins": {
"message": "Default skins"
},
"app.skins.section.minecon-earth-2017": {
"message": "MINECON Earth 2017"
},
"app.skins.section.modrinth": {
"message": "Modrinth"
},
"app.skins.section.modrinth-pride": {
"message": "Modrinth Pride"
},
"app.skins.section.modrinth-pride.tooltip": {
"message": "You received these skins for donating to a Modrinth Pride fundraiser during Pride Month."
},
"app.skins.section.mounts-of-mayhem": {
"message": "Mounts of Mayhem"
},
"app.skins.section.saved-skins": {
"message": "Saved skins"
},
"app.skins.section.striding-hero": {
"message": "Striding Hero"
},
"app.skins.section.the-copper-age": {
"message": "The Copper Age"
},
"app.skins.section.the-garden-awakens": {
"message": "The Garden Awakens"
},
"app.skins.section.tiny-takeover": {
"message": "Tiny Takeover"
},
"app.skins.sign-in.button": {
"message": "Sign In"
},
"app.skins.sign-in.description": {
"message": "Please sign into your Minecraft account to use the skin management features of the Modrinth app."
},
"app.skins.sign-in.rinthbot-alt": {
"message": "Excited Modrinth Bot"
},
"app.skins.sign-in.title": {
"message": "Please sign in"
},
"app.skins.title": {
"message": "Skin selector"
},
"app.update-popup.body": {
"message": "Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App."
},
@@ -611,6 +758,24 @@
"instance.settings.tabs.java": {
"message": "Java and memory"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Custom environment variables"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Custom Java arguments"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Custom Java installation"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Custom memory allocation"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Enter environmental variables..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Enter Java arguments..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Environment variables"
},
@@ -626,6 +791,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Memory allocated"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
},
"instance.settings.tabs.window": {
"message": "Window"
},
+155 -11
View File
@@ -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."
},
@@ -611,6 +734,24 @@
"instance.settings.tabs.java": {
"message": "Java y memoria"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variables de entorno personalizadas"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Argumentos personalizados de Java"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Instalación personalizada de Java"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Asignación de memoria personalizada"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Introduce las variables de entorno..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Introduce los argumentos de Java..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Variables de entorno"
},
@@ -626,6 +767,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Memoria asignada"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/ruta/de/java"
},
"instance.settings.tabs.window": {
"message": "Ventana"
},
+83 -8
View File
@@ -21,7 +21,7 @@
"message": "Instancia principal"
},
"app.action-bar.show-more-running-instances": {
"message": "Mostrar mas instancias en ejecución"
"message": "Mostrar más instancias en ejecución"
},
"app.action-bar.stop-instance": {
"message": "Finalizar instancia"
@@ -48,7 +48,7 @@
"message": "Tema de color"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Cambia la página en la que el launcher se abre en."
"message": "Cambia la página en la que el lanzador inicia."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Inicio"
@@ -72,13 +72,13 @@
"message": "Vuelve a jugar tus mundos"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Minimiza el launcher cuando un proceso de Minecraft empieza."
"message": "Minimiza el lanzador cuando un proceso de Minecraft empieza."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimiza el launcher"
"message": "Minimiza el lanzador"
},
"app.appearance-settings.native-decorations.description": {
"message": "Usa los sistemas de frame de Windows (se requiere resetear la aplicación)."
"message": "Usa los sistemas de marco de Windows (se requiere resetear la aplicación)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Decoraciones nativas"
@@ -93,10 +93,10 @@
"message": "Alternar barra lateral"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "Si intentas instalar un archivo de paquete de Modrinth (.mrpack) que no esté alojado en Modrinth, te advertiremos de los riesgos antes de instalarlo."
"message": "Si intentas instalar un archivo de paquete de Modrinth (.mrpack) que no esté alojado en Modrinth, debes comprender los riesgos que esto conlleva antes de instalarlo."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Adviérteme antes de instalarme modpacks desconocidos"
"message": "Advertir antes de instalarme paquetes de mods desconocidos"
},
"app.auth-servers.unreachable.body": {
"message": "Los servidores de autenticación de Minecraft pueden no estar funcionando en este momento. Verifica tu conexión a internet e inténtalo de nuevo más tarde."
@@ -104,6 +104,12 @@
"app.auth-servers.unreachable.header": {
"message": "No se puede conectar con los servidores de autenticación"
},
"app.browse.add-servers-to-instance": {
"message": "Añadir servidor a instancia"
},
"app.browse.add-to-an-instance": {
"message": "Añadir a una instancia"
},
"app.browse.add-to-instance": {
"message": "Añadir a la instancia"
},
@@ -116,6 +122,9 @@
"app.browse.already-added": {
"message": "Ya está añadido"
},
"app.browse.back-to-instance": {
"message": "Volver a la instancia"
},
"app.browse.discover-content": {
"message": "Descubrir contenido"
},
@@ -126,7 +135,10 @@
"message": "Ocultar servidores ya añadidos"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
"message": "Paquetes de Mods"
},
"app.browse.server-instance-content-warning": {
"message": "Añadir contenido puede comprometer la compatibilidad a la hora de unirse a un servidor. Cualquier contenido añadido se perderá cuando actualices la instancia del servidor."
},
"app.browse.server.installing": {
"message": "Instalando"
@@ -146,12 +158,18 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos se incluirán en esta instancia"
},
"app.export-modal.version-number-label": {
"message": "Número de versión"
},
@@ -281,6 +299,15 @@
"app.modal.update-to-play.update-required-description": {
"message": "Una actualización es requerida para jugar {name}. Por favor actualízala a la versión más reciente para ejecutar el juego."
},
"app.project.install-button.already-installed": {
"message": "El proyecto ya está instalado"
},
"app.project.install-context.back-to-browse": {
"message": "Volver a descubrir"
},
"app.project.install-context.install-content-to-instance": {
"message": "Instalar contenido"
},
"app.settings.developer-mode-enabled": {
"message": "Modo desarrollador activado."
},
@@ -305,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."
},
@@ -584,6 +620,24 @@
"instance.settings.tabs.java": {
"message": "Java y memoria"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variables de entorno personalizadas"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Argumentos personalizados de Java"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Instalación personalizada de Java"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Asignación de memoria personalizada"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Introduce las variables de entorno..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Introduce los argumentos de Java..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Variables de entorno"
},
@@ -599,6 +653,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Memoria asignada"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/ruta/a/java"
},
"instance.settings.tabs.window": {
"message": "Ventana"
},
@@ -671,6 +728,24 @@
"instance.worlds.world_in_use": {
"message": "El mundo está en uso"
},
"minecraft-account.add-account": {
"message": "Añadir cuenta"
},
"minecraft-account.label": {
"message": "Cuenta de Minecraft"
},
"minecraft-account.not-signed-in": {
"message": "Registrarse"
},
"minecraft-account.remove-account": {
"message": "Eliminar cuenta"
},
"minecraft-account.select-account": {
"message": "Seleccionar cuenta"
},
"minecraft-account.sign-in": {
"message": "Registrarse en Minecraft"
},
"search.filter.locked.instance": {
"message": "Proporcionado por la instancia"
},
@@ -1,10 +1,115 @@
{
"app.action-bar.downloading-java": {
"message": "Ladataan Java-versiota {version}"
},
"app.action-bar.downloads": {
"message": "Lataukset"
},
"app.action-bar.hide-more-running-instances": {
"message": "Piilota enemmän käynnissä olevia instansseja"
},
"app.action-bar.make-primary-instance": {
"message": "Aseta ensisijaiseksi instanssiksi"
},
"app.action-bar.no-instances-running": {
"message": "Ei instansseja käynnissä"
},
"app.action-bar.offline": {
"message": "Offline-tilassa"
},
"app.action-bar.primary-instance": {
"message": "Ensisijainen instanssi"
},
"app.action-bar.show-more-running-instances": {
"message": "Näytä enemmän käynnissä olevia instansseja"
},
"app.action-bar.stop-instance": {
"message": "Pysäytä instanssi"
},
"app.action-bar.view-active-downloads": {
"message": "Näytä aktiiviset lataukset"
},
"app.action-bar.view-instance": {
"message": "Katso intanssia"
},
"app.action-bar.view-logs": {
"message": "Katso lokeja"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Mahdollistaa edistyneen renderöinnin, kuten sumennus efektit, jotka voivat aiheuttaa suorituskykyongelmia ilman laitteistokiihdytettyä renderöintiä."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Edistynyt renderointi"
},
"app.appearance-settings.color-theme.description": {
"message": "Valitse haluamasi väriteema Modrinth Appille."
},
"app.appearance-settings.color-theme.title": {
"message": "Väriteema"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Muuta sivua mille laukaisin avautuu."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Koti"
},
"app.appearance-settings.default-landing-page.library": {
"message": "Kirjasto"
},
"app.appearance-settings.default-landing-page.title": {
"message": "Oletus laskeutumissivu"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Kytkee pois päältä nimikyltin pelaajasi päällä skinit sivulla."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Piilota nimikyltti"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Sisällyttää viimeaikaiset maailmat \"Hyppää takaisin sisään\" osiossa Koti sivulla."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Hyppää takaisin sisään maailmoihin"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Pienennä laukaisin kun Minecraft prosessi käynnistyy."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Piilota käynnistysohjelma"
},
"app.appearance-settings.native-decorations.description": {
"message": "Käytä järjestelmän ikkuna kehystä (Vaatii sovelluksen uudelleen käynnistämisen)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Natiivit koristukset"
},
"app.appearance-settings.select-option": {
"message": "Valitse vaihtoehto"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Laittaa päälle mahdollisuuden kytkeä sivupalkkia."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Kytke sivupalkki"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "Jos yrität asentaa Modrinth-pakettitiedoston (.mrpack), jota ei ole isännöity Modrinthissä, varmistamme, että ymmärrät riskit ennen asennusta."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Varoita minua ennen tuntemattomien modipakettien asentamista"
},
"app.auth-servers.unreachable.body": {
"message": "Minecraftin todennuspalvelimet eivät ehkä ole tällä hetkellä tavoitettavissa. Tarkista internetyhteytesi ja yritä myöhemmin uudelleen."
},
"app.auth-servers.unreachable.header": {
"message": "Todennuspalvelimiin ei saada yhteyttä"
},
"app.browse.add-servers-to-instance": {
"message": "Lisää palvelin instanssiin"
},
"app.browse.add-to-an-instance": {
"message": "Lisää instanssiin"
},
"app.browse.add-to-instance": {
"message": "Lisää instanssiin"
},
@@ -17,12 +122,33 @@
"app.browse.already-added": {
"message": "On jo asennettu"
},
"app.browse.back-to-instance": {
"message": "Takaisin instanssiin"
},
"app.browse.discover-content": {
"message": "Löydä sisältöä"
},
"app.browse.discover-servers": {
"message": "Löydä palvelimia"
},
"app.browse.hide-added-servers": {
"message": "Piilota lisätyt palvelimet"
},
"app.browse.project-type.modpacks": {
"message": "Modipaketit"
},
"app.browse.server-instance-content-warning": {
"message": "Sisällön lisääminen voi rikkoa yhteensopivuuden palvelimelle liittyessä. Kaikki lisätty sisältö katoaa myös kun päivität palvelin instanssin sisällön."
},
"app.browse.server.installing": {
"message": "Asennetaan"
},
"app.creation-modal.installing-modpack.description": {
"message": "{fileName}"
},
"app.creation-modal.installing-modpack.title": {
"message": "Asennetaan modipakettia..."
},
"app.export-modal.description-placeholder": {
"message": "Lisää modipaketin kuvaus..."
},
@@ -32,12 +158,18 @@
"app.export-modal.header": {
"message": "Vie modipaketti"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sisällytä \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modipaketin nimi"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modipaketin nimi"
},
"app.export-modal.select-files-label": {
"message": "Muuta mitkä tiedostot sisältyvät tähän vientiin"
},
"app.export-modal.version-number-label": {
"message": "Versio numero"
},
@@ -80,6 +212,9 @@
"app.instance.mods.share-text": {
"message": "Tsekkaa projektit joita käytän minun modipaketissani!"
},
"app.instance.mods.share-title": {
"message": "Jaetaan modipaketti sisältöä"
},
"app.instance.mods.successfully-uploaded": {
"message": "Onnistuneesti ladattu"
},
@@ -164,6 +299,15 @@
"app.modal.update-to-play.update-required-description": {
"message": "Pävitys vaaditaan pelataksesi {name}. Päivitä viimeisimpään versioon pelataksesi."
},
"app.project.install-button.already-installed": {
"message": "Tämä projekti on jo asennettu"
},
"app.project.install-context.back-to-browse": {
"message": "Takaisin sisällön löytöön"
},
"app.project.install-context.install-content-to-instance": {
"message": "Asenna sisältöä instanssiin"
},
"app.settings.developer-mode-enabled": {
"message": "Kehittäjätila käytössä."
},
@@ -467,6 +611,24 @@
"instance.settings.tabs.java": {
"message": "Java ja muisti"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Mukautetut ympäristö muuttujat"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Mukautetut Java argumentit"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Mukautettu Java asennus"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Mukautettu muistin allokointi"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Syötä ympäristö muuttujat..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Syötä Java argumentit..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Ympäristö muuttujat"
},
@@ -482,6 +644,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Muisti allokoitu"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/javaan/johtava/polku/"
},
"instance.settings.tabs.window": {
"message": "Ikkuna"
},
@@ -554,6 +719,24 @@
"instance.worlds.world_in_use": {
"message": "Maailma on käytössä"
},
"minecraft-account.add-account": {
"message": "Lisää tili"
},
"minecraft-account.label": {
"message": "Minecraft tili"
},
"minecraft-account.not-signed-in": {
"message": "Ei kirjautuneena sisään"
},
"minecraft-account.remove-account": {
"message": "Poista tili"
},
"minecraft-account.select-account": {
"message": "Valitse tili"
},
"minecraft-account.sign-in": {
"message": "Kirjaudu sisään Minecraftiin"
},
"search.filter.locked.instance": {
"message": "Annettu instanssin toimesta"
},
@@ -577,5 +760,26 @@
},
"search.filter.locked.server-loader.title": {
"message": "Modialusta on palvelimen tarjoama"
},
"unknown-pack-warning-modal.body": {
"message": "Tiedosto tarkistetaan vain jos se on ladattu Modrinthiin, riippumatta tiedoston muodosta (mukaanlukien .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Älä näytä tätä varoitusta uudestaan"
},
"unknown-pack-warning-modal.header": {
"message": "Vahvista asennus"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Asenna jokatapauksessa"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Haittaohjelmia levitetään usein modipaketti tiedostojen kautta jakamalla niitä alustoilla kuten Discordissa."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Emme löytäneet tätä tiedostoa Modrinthista. Suosittelemme vahvasti että asennat tiedostoja vain lähteistä joihin luotat."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Tuntematon tiedosto varoitus"
}
}
@@ -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."
},
@@ -729,7 +777,7 @@
"message": "Kumpirmahin ang installation"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "I-install parin"
"message": "I-install pa rin"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Ang malware ay madalas naidadala sa mga modpack files sa pamamagitan ng pag-bigay ng mga ito sa mga platforms kagaya ng Discord."
+162 -12
View File
@@ -224,9 +224,6 @@
"app.instance.worlds.browse-servers": {
"message": "Parcourir des serveurs"
},
"app.instance.worlds.delete-world-description": {
"message": "« {name} » sera supprimé **pour toujours**, et il sera impossible de le récupérer."
},
"app.instance.worlds.delete-world-title": {
"message": "Êtes-vous sûr·e de vouloir supprimer ce monde pour toujours ?"
},
@@ -248,12 +245,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Aucun serveur ou monde ajoutés"
},
"app.instance.worlds.remove-server-description": {
"message": "« {name} » sera retiré de votre liste, y compris en jeu, et il sera impossible de le récupérer."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "« {name} » ({address}) sera retiré de votre liste, y compris en jeu, et il sera impossible de le récupérer."
},
"app.instance.worlds.remove-server-title": {
"message": "Êtes-vous sûr·e de vouloir retirer {name} ?"
},
@@ -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"
},
@@ -611,6 +740,24 @@
"instance.settings.tabs.java": {
"message": "Java et mémoire"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variables d'environnement personnalisé"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Arguments Java personnalisée"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Installation Java personnalisée"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Allocation de mémoire personnalisée"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Entrer une variable d'environnement..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Entrer un argument Java..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Variables d'environnement"
},
@@ -626,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"
},
+12 -3
View File
@@ -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": "שליחת בקשת חברות"
},
+166 -19
View File
@@ -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"
},
+240 -6
View File
@@ -56,21 +56,48 @@
"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"
},
"app.auth-servers.unreachable.body": {
"message": "Server autentikasi Minecraft mungkin sedang tidak tersedia saat ini. Periksa koneksi internet Anda dan coba lagi nanti."
},
@@ -110,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"
},
@@ -242,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"
},
@@ -269,6 +296,15 @@
"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"
},
"app.settings.developer-mode-enabled": {
"message": "Mode pengembang dihidupkan."
},
@@ -293,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."
},
@@ -356,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"
},
@@ -572,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"
},
@@ -587,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"
},
@@ -659,6 +854,24 @@
"instance.worlds.world_in_use": {
"message": "Dunia sedang dimainkan"
},
"minecraft-account.add-account": {
"message": "Tambah akun"
},
"minecraft-account.label": {
"message": "Akun Minecraft"
},
"minecraft-account.not-signed-in": {
"message": "Tidak masuk"
},
"minecraft-account.remove-account": {
"message": "Hapus akun"
},
"minecraft-account.select-account": {
"message": "Pilih akun"
},
"minecraft-account.sign-in": {
"message": "Masuk ke Minecraft"
},
"search.filter.locked.instance": {
"message": "Disediakan oleh instans"
},
@@ -682,5 +895,26 @@
},
"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"
},
"unknown-pack-warning-modal.header": {
"message": "Konfirmasi pemasangan"
},
"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."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Peringatan berkas tidak dikenal"
}
}
+163 -13
View File
@@ -84,7 +84,7 @@
"message": "Decorazioni native"
},
"app.appearance-settings.select-option": {
"message": "Scegli una pagina"
"message": "Seleziona"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Scegli se mostrare o nascondere la barra laterale."
@@ -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."
},
@@ -611,6 +740,24 @@
"instance.settings.tabs.java": {
"message": "Java e memoria"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variabili d'ambiente"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Argomenti JVM"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Eseguibile di Java"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Memoria allocata"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Inserisci le variabili d'ambiente..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Inserisci gli argomenti JVM..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Variabili d'ambiente"
},
@@ -626,6 +773,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Memoria allocata"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/percorso/di/java"
},
"instance.settings.tabs.window": {
"message": "Finestra"
},
@@ -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": "フレンド申請を送信"
},
+153 -6
View File
@@ -24,7 +24,7 @@
"message": "실행 중인 인스턴스 보이기"
},
"app.action-bar.stop-instance": {
"message": "인스턴스 중지"
"message": "인스턴스 멈추기"
},
"app.action-bar.view-active-downloads": {
"message": "다운로드 목록 보기"
@@ -192,10 +192,10 @@
"message": "이 모드팩은 이미 <bold>{instanceName}</bold> 인스턴스에 설치되어 있습니다. 정말로 복제하시겠습니까?"
},
"app.instance.modpack-already-installed.create": {
"message": "생성"
"message": "만들기"
},
"app.instance.modpack-already-installed.header": {
"message": "모드팩 이미 설치"
"message": "이미 설치된 모드팩"
},
"app.instance.modpack-already-installed.instance": {
"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": "친구 요청 보내기"
},
@@ -611,6 +737,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 +770,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "할당된 메모리"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
},
"instance.settings.tabs.window": {
"message": "창"
},
+21 -6
View File
@@ -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"
},
@@ -189,7 +189,7 @@
"message": "Verwijder instantie"
},
"app.instance.modpack-already-installed.body": {
"message": "Deze modpakket is al in de <bold>{instanceName}<bold> instantie geïnstalleerd. Ben je zeker dat je het geen wil dupliceren?"
"message": "Deze modpakket is al in de <bold>{instanceName}</bold> instantie geïnstalleerd. Ben je zeker dat je het geen wil dupliceren?"
},
"app.instance.modpack-already-installed.create": {
"message": "Maak"
@@ -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"
},
+84 -9
View File
@@ -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"
},
+162 -6
View File
@@ -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"
},
@@ -611,6 +746,24 @@
"instance.settings.tabs.java": {
"message": "Java e memória"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Variáveis de ambiente personalizadas"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Argumentos Java personalizados"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Instalação personalizada do Java"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Alocação de memória personalizada"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Insira as variáveis de ambiente..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Insira os argumentos Java..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Variáveis de ambiente"
},
@@ -626,6 +779,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Memória alocada"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/caminho/ao/java"
},
"instance.settings.tabs.window": {
"message": "Janela"
},
@@ -149,9 +149,6 @@
"app.instance.worlds.browse-servers": {
"message": "Procurar servidores"
},
"app.instance.worlds.delete-world-description": {
"message": "\"{name}\" vai ser **apagado permanentemente**, e não haverá maneira de o recuperar."
},
"app.instance.worlds.delete-world-title": {
"message": "Tens a certeza que queres apagar permanentemente este mundo?"
},
@@ -173,12 +170,6 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Sem servidores ou mundos adicionados"
},
"app.instance.worlds.remove-server-description": {
"message": "\"{name}\" vai ser removido da tua lista, incluindo dentro do jogo, e não haverá maneira de o recuperar."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "\"{name}\" ({address}) vai ser removido da tua lista, incluindo dentro do jogo, e não haverá maneira de o recuperar."
},
"app.instance.worlds.remove-server-title": {
"message": "Tens a certeza que queres remover {name}?"
},
+161 -29
View File
@@ -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} готова к установке! Перезапустите приложение, чтобы обновить его, или оно обновится автоматически после закрытия."
},
@@ -360,7 +477,7 @@
"message": "Доступно обновление"
},
"app.update.complete-toast.text": {
"message": "Нажмите здесь, чтобы посмотреть изменения."
"message": "Нажмите, чтобы посмотреть список изменений."
},
"app.update.complete-toast.title": {
"message": "Версия {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": "Сохранить как..."
},
@@ -611,6 +722,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 +755,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Выделенная память"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
},
"instance.settings.tabs.window": {
"message": "Окно"
},
@@ -741,7 +873,7 @@
"message": "Загрузчик управляется сервером"
},
"unknown-pack-warning-modal.body": {
"message": "Файлы любого формата проверены на безопасность, если они загружены на Modrinth (в том числе файлы .mrpack)."
"message": "Файл проверяется только в том случае, если он загружен на Modrinth, независимо от его формата (включая .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Больше не предупреждать"
@@ -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": "ส่งคำขอเป็นเพื่อน"
},
+180 -3
View File
@@ -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 Appi 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"
},
+161 -23
View File
@@ -96,7 +96,7 @@
"message": "Якщо ви спробуєте встановити файл пакета Modrinth (.mrpack), який не розміщено на Modrinth, ми переконаємося, що ви розумієте ризики, перш ніж установлювати його."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Попереджати мене перед установленням невідомих збірок"
"message": "Попередження перед установленням невідомих збірок"
},
"app.auth-servers.unreachable.body": {
"message": "Сервери автентифікації Minecraft можуть зараз не працювати. Перевірте з’єднання з інтернетом та спробуйте пізніше."
@@ -168,7 +168,7 @@
"message": "Назва збірки"
},
"app.export-modal.select-files-label": {
"message": "Змінити які файли додані до експорту"
"message": "Змініть файли, які додані до експорту"
},
"app.export-modal.version-number-label": {
"message": "Номер версії"
@@ -222,10 +222,7 @@
"message": "Додати сервер"
},
"app.instance.worlds.browse-servers": {
"message": "Переглянути сервера"
},
"app.instance.worlds.delete-world-description": {
"message": "«{name}» буде **назавжди видалено** й у вас не буде можливости відновлення."
"message": "Переглянути сервери"
},
"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": "Надіслати запит у друзі"
},
@@ -611,6 +728,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 +761,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Виділена пам’ять"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/шлях/до/java"
},
"instance.settings.tabs.window": {
"message": "Вікно"
},
@@ -702,10 +840,10 @@
"message": "Додати обліковий запис"
},
"minecraft-account.label": {
"message": "Майнкрафт аккаунт"
"message": "Обліковий запис Minecraft"
},
"minecraft-account.not-signed-in": {
"message": "Не ввійдено в систему"
"message": "Не авторизовано"
},
"minecraft-account.remove-account": {
"message": "Видалити обліковий запис"
@@ -714,7 +852,7 @@
"message": "Обрати обліковий запис"
},
"minecraft-account.sign-in": {
"message": "Увійти в Майнкрафт"
"message": "Увійти в Minecraft"
},
"search.filter.locked.instance": {
"message": "Надано профілем"
@@ -747,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."
@@ -1,10 +1,115 @@
{
"app.action-bar.downloading-java": {
"message": "Đang tải xuống Java {version}"
},
"app.action-bar.downloads": {
"message": "Tải xuống"
},
"app.action-bar.hide-more-running-instances": {
"message": "Đóng các phiên bản đang chạy"
},
"app.action-bar.make-primary-instance": {
"message": "Đặt làm bản instance chính"
},
"app.action-bar.no-instances-running": {
"message": "Không có phiên bản nào đang chạy"
},
"app.action-bar.offline": {
"message": "Ngoại tuyến"
},
"app.action-bar.primary-instance": {
"message": "Bản instance chính"
},
"app.action-bar.show-more-running-instances": {
"message": "Hiện thêm các phiên bản đang chạy"
},
"app.action-bar.stop-instance": {
"message": "Dừng instance"
},
"app.action-bar.view-active-downloads": {
"message": "Xem các lượt tải xuống"
},
"app.action-bar.view-instance": {
"message": "Xem phiên bản"
},
"app.action-bar.view-logs": {
"message": "Xem logs"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Kích hoạt các tính năng dựng hình nâng cao như hiệu ứng làm mờ, có thể gây giảm hiệu năng nếu không có chế độ tăng tốc phần cứng."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Dựng hình nâng cao"
},
"app.appearance-settings.color-theme.description": {
"message": "Chọn màu nền ưu thích của bạn cho Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Màu chủ đề"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Thay đổi trang hiển thị khi khởi chạy launcher."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Trang chủ"
},
"app.appearance-settings.default-landing-page.library": {
"message": "Thư viện"
},
"app.appearance-settings.default-landing-page.title": {
"message": "Trang mặc định"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Tắt hiển thị thẻ tên phía trên người chơi ở trang skin."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Ẩn thẻ tên"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Hiển thị các thế giới gần đây trong mục \"Tiếp tục chơi\" ở Trang chủ."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Tiếp tục chơi"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Thu nhỏ launcher khi game Minecraft bắt đầu chạy."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Thu nhỏ launcher"
},
"app.appearance-settings.native-decorations.description": {
"message": "Sử dụng khung cửa sổ hệ thống (yêu cầu khởi động lại ứng dụng)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Giao diện cửa sổ hệ thống"
},
"app.appearance-settings.select-option": {
"message": "Chọn một tuỳ chọn"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Bật tính năng ẩn hiển thị thanh menu bên."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Ẩn/hiện thanh bên"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "Nếu bạn cố gắng cài đặt tệp Modrinth Pack (.mrpack) không được lưu trữ trên máy chủ Modrinth, chúng tôi sẽ đảm bảo bạn hiểu rõ các rủi ro trước khi cài đặt."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Cảnh báo tôi trước khi cài đặt các modpacks không rõ nguồn gốc"
},
"app.auth-servers.unreachable.body": {
"message": "Máy chủ xác thực của Minecraft có thể đang bị sập. Hãy kiểm tra kết nối Internet của bạn và thử lại sau."
},
"app.auth-servers.unreachable.header": {
"message": "Không thể kết nối đến máy chủ xác thực"
},
"app.browse.add-servers-to-instance": {
"message": "Thêm máy chủ vào phiên bản"
},
"app.browse.add-to-an-instance": {
"message": "Thêm vào phiên bản"
},
"app.browse.add-to-instance": {
"message": "Thêm vào hồ sơ"
},
@@ -17,12 +122,33 @@
"app.browse.already-added": {
"message": "Đã được thêm"
},
"app.browse.back-to-instance": {
"message": "Quay lại phiên bản"
},
"app.browse.discover-content": {
"message": "Khám phá nội dung"
},
"app.browse.discover-servers": {
"message": "Khám phá máy chủ"
},
"app.browse.hide-added-servers": {
"message": "Ẩn các server đã thêm"
},
"app.browse.project-type.modpacks": {
"message": "Modpack"
},
"app.browse.server-instance-content-warning": {
"message": "Thêm nội dung có thể gây lỗi tương thích khi tham gia máy chủ. Bất kỳ nội dung nào được thêm vào cũng sẽ bị mất khi bạn cập nhật nội dung của phiên bản máy chủ."
},
"app.browse.server.installing": {
"message": "Đang cài đặt"
},
"app.creation-modal.installing-modpack.description": {
"message": "{fileName}"
},
"app.creation-modal.installing-modpack.title": {
"message": "Đang cài đặt modpack..."
},
"app.export-modal.description-placeholder": {
"message": "Thêm miêu tả cho gói modpack..."
},
@@ -32,12 +158,18 @@
"app.export-modal.header": {
"message": "Xuất modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Bao gồm \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Tên modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Tên modpack"
},
"app.export-modal.select-files-label": {
"message": "Cấu hình các tệp nào được bao gồm trong quá trình xuất phiên bản này"
},
"app.export-modal.version-number-label": {
"message": "Phiên bản"
},
@@ -167,6 +299,15 @@
"app.modal.update-to-play.update-required-description": {
"message": "Bạn cần cập nhật {name} để có thể chơi. Vui lòng cập nhật lên bản mới nhất để khởi chạy trò chơi."
},
"app.project.install-button.already-installed": {
"message": "Dự án này đã được cài đặt"
},
"app.project.install-context.back-to-browse": {
"message": "Quay lại trang Khám Phá"
},
"app.project.install-context.install-content-to-instance": {
"message": "Cài đặt nội dung vào phiên bản"
},
"app.settings.developer-mode-enabled": {
"message": "Chế độ nhà phát triển đã được bật."
},
@@ -191,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."
},
@@ -470,6 +752,24 @@
"instance.settings.tabs.java": {
"message": "Java và bộ nhớ"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Tùy chọn biến môi trường"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Tùy chỉnh tham số Java"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Tùy chỉnh phiên bản cài đặt Java"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Tùy chỉnh phân bổ bộ nhớ"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Nhập biến môi trường..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Nhập tham số Java..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Biến môi trường"
},
@@ -485,6 +785,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Bộ nhớ phân bổ"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/đường_dẫn/tới/java"
},
"instance.settings.tabs.window": {
"message": "Cửa sổ"
},
@@ -557,6 +860,24 @@
"instance.worlds.world_in_use": {
"message": "Thế giới đang mở"
},
"minecraft-account.add-account": {
"message": "Thêm tài khoản"
},
"minecraft-account.label": {
"message": "Tài khoản Minecraft"
},
"minecraft-account.not-signed-in": {
"message": "Chưa đăng nhập"
},
"minecraft-account.remove-account": {
"message": "Xoá tài khoản"
},
"minecraft-account.select-account": {
"message": "Chọn tài khoản"
},
"minecraft-account.sign-in": {
"message": "Đăng nhập vào Minecraft"
},
"search.filter.locked.instance": {
"message": "Được cung cấp bởi phiên bản"
},
@@ -580,5 +901,26 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader được cung cấp bởi máy chủ"
},
"unknown-pack-warning-modal.body": {
"message": "Tệp chỉ được xem xét nếu nó được tải lên Modrinth, bất kể định dạng tệp của nó là gì (bao gồm cả .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Đừng hiển thị cảnh báo này nữa"
},
"unknown-pack-warning-modal.header": {
"message": "Xác nhận cài đặt"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Tiếp tục cài đặt"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Phần mềm độc hại thường được phát tán thông qua các tệp modpack bằng cách chia sẻ chúng trên các nền tảng như Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Chúng tôi không tìm thấy tập tin này trên Modrinth. Chúng tôi đặc biệt khuyên bạn chỉ nên cài đặt các tập tin từ các nguồn đáng tin cậy."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Cảnh báo tệp không xác định"
}
}
+157 -16
View File
@@ -63,7 +63,7 @@
"message": "在皮肤页面中禁用你玩家头顶上的名牌。"
},
"app.appearance-settings.hide-nametag.title": {
"message": "隐藏名"
"message": "隐藏名称标签"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"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": "需求整合包"
},
@@ -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": "游戏窗口"
},
+27 -18
View File
@@ -147,10 +147,10 @@
"message": "{fileName}"
},
"app.creation-modal.installing-modpack.title": {
"message": "正在安裝模組包..."
"message": "正在安裝模組包……"
},
"app.export-modal.description-placeholder": {
"message": "輸入模組包描述..."
"message": "輸入模組包描述……"
},
"app.export-modal.export-button": {
"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,17 +245,11 @@
"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} 個世界..."
"message": "搜尋 {count} 個世界……"
},
"app.instance.worlds.this-server": {
"message": "這個伺服器"
@@ -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": "所需模組包"
},
@@ -302,6 +290,9 @@
"app.project.install-button.already-installed": {
"message": "這個專案已安裝"
},
"app.project.install-context.back-to-browse": {
"message": "返回瀏覽"
},
"app.project.install-context.install-content-to-instance": {
"message": "將內容安裝至實例"
},
@@ -392,9 +383,6 @@
"friends.action.add-friend": {
"message": "新增好友"
},
"friends.action.view-friend-requests": {
"message": "{count} 個好友邀請"
},
"friends.add-friend.submit": {
"message": "送出好友邀請"
},
@@ -608,6 +596,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": "環境變數"
},
@@ -623,6 +629,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "記憶體配置"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
},
"instance.settings.tabs.window": {
"message": "視窗"
},
File diff suppressed because it is too large Load Diff
+12 -8
View File
@@ -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)
@@ -77,7 +77,7 @@ function buildLogList(rawLogs) {
log.filename !== 'latest_stdout.log' &&
log.filename !== 'latest_stdout' &&
log.filename !== 'launcher_log.txt' &&
log.stdout !== '' &&
(log.output == null || log.output !== '') &&
(log.filename.includes('.log') || log.filename.endsWith('.txt')),
)
.map((log) => ({
+77 -35
View File
@@ -230,8 +230,12 @@ function fileNameFromPath(path: string) {
return path.split('/').pop() ?? path
}
function getContentItemId(item: ContentItem | null | undefined) {
return item?.file_path ?? item?.file_name ?? item?.id ?? ''
}
function getContentOperationKeys(item: ContentItem) {
return [item.id, item.file_path, item.file_name, item.project?.id, item.version?.id].filter(
return [getContentItemId(item), item.file_path, item.file_name].filter(
(key): key is string => !!key,
)
}
@@ -293,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({
@@ -478,10 +534,11 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
}
async function handleUpdate(id: string) {
const item = projects.value.find((p) => p.id === id)
const item = projects.value.find((p) => getContentItemId(p) === id)
if (!item?.has_update || !item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
debug('handleUpdate triggered', {
fileName: item.file_name,
@@ -538,15 +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) || updatingProject.value?.id !== item.id) return
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
}
@@ -565,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', {
@@ -595,6 +648,7 @@ async function handleSwitchVersion(item: ContentItem) {
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
updatingModpack.value = false
updatingProject.value = item
@@ -604,22 +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) || updatingProject.value?.id !== item.id) return
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
}
@@ -705,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', {
@@ -752,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) {
@@ -1055,8 +1096,9 @@ provideContentManager({
showContentHint,
dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
id: item.id,
id: getContentItemId(item),
project: item.project ?? {
id: item.file_name,
slug: null,
@@ -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: '',
}
},
})
}
+2 -2
View File
@@ -86,10 +86,10 @@ export default new createRouter({
},
{
path: '/skins',
name: 'Skins',
name: 'Skin selector',
component: Pages.Skins,
meta: {
breadcrumb: [{ name: 'Skins' }],
breadcrumb: [{ name: 'Skin selector' }],
},
},
{
+4
View File
@@ -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
@@ -21,6 +23,7 @@ export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type ThemeStore = {
selectedTheme: ColorTheme
advancedRendering: boolean
hideNametagSkinsPage: boolean
toggleSidebar: boolean
devMode: boolean
@@ -30,6 +33,7 @@ export type ThemeStore = {
export const DEFAULT_THEME_STORE: ThemeStore = {
selectedTheme: 'dark',
advancedRendering: true,
hideNametagSkinsPage: false,
toggleSidebar: false,
devMode: false,
+3
View File
@@ -58,6 +58,9 @@ export default defineConfig({
params: {
overrides: {
removeViewBox: false,
cleanupIds: {
minify: false,
},
},
},
},
+3 -1
View File
@@ -114,10 +114,12 @@ fn main() {
"get_available_capes",
"get_available_skins",
"add_and_equip_custom_skin",
"set_default_cape",
"equip_skin",
"remove_custom_skin",
"save_custom_skin",
"unequip_skin",
"flush_pending_skin_change",
"flush_pending_skin_change_for_profile",
"normalize_skin_texture",
"get_dragged_skin_data",
])
+6
View File
@@ -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/*"
},
+1 -1
View File
@@ -18,7 +18,7 @@ pub struct AdsState {
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
#[cfg(any(windows, target_os = "macos"))]
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 1.0;
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
#[cfg(not(target_os = "linux"))]
const ADS_USER_AGENT: &str = concat!(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ",
+51 -17
View File
@@ -11,10 +11,12 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
get_available_capes,
get_available_skins,
add_and_equip_custom_skin,
set_default_cape,
equip_skin,
remove_custom_skin,
save_custom_skin,
unequip_skin,
flush_pending_skin_change,
flush_pending_skin_change_for_profile,
normalize_skin_texture,
get_dragged_skin_data,
])
@@ -37,29 +39,19 @@ pub async fn get_available_skins() -> Result<Vec<Skin>> {
Ok(minecraft_skins::get_available_skins().await?)
}
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)`
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape)`
///
/// See also: [minecraft_skins::add_and_equip_custom_skin]
#[tauri::command]
pub async fn add_and_equip_custom_skin(
texture_blob: Bytes,
variant: MinecraftSkinVariant,
cape_override: Option<Cape>,
) -> Result<()> {
Ok(minecraft_skins::add_and_equip_custom_skin(
texture_blob,
variant,
cape_override,
cape: Option<Cape>,
) -> Result<Skin> {
Ok(
minecraft_skins::add_and_equip_custom_skin(texture_blob, variant, cape)
.await?,
)
.await?)
}
/// `invoke('plugin:minecraft-skins|set_default_cape', cape)`
///
/// See also: [minecraft_skins::set_default_cape]
#[tauri::command]
pub async fn set_default_cape(cape: Option<Cape>) -> Result<()> {
Ok(minecraft_skins::set_default_cape(cape).await?)
}
/// `invoke('plugin:minecraft-skins|equip_skin', skin)`
@@ -78,6 +70,27 @@ pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
Ok(minecraft_skins::remove_custom_skin(skin).await?)
}
/// `invoke('plugin:minecraft-skins|save_custom_skin', skin, texture_blob, variant, cape, replace_texture)`
///
/// See also: [minecraft_skins::save_custom_skin]
#[tauri::command]
pub async fn save_custom_skin(
skin: Skin,
texture_blob: Bytes,
variant: MinecraftSkinVariant,
cape: Option<Cape>,
replace_texture: bool,
) -> Result<Skin> {
Ok(minecraft_skins::save_custom_skin(
skin,
texture_blob,
variant,
cape,
replace_texture,
)
.await?)
}
/// `invoke('plugin:minecraft-skins|unequip_skin')`
///
/// See also: [minecraft_skins::unequip_skin]
@@ -86,6 +99,27 @@ pub async fn unequip_skin() -> Result<()> {
Ok(minecraft_skins::unequip_skin().await?)
}
/// `invoke('plugin:minecraft-skins|flush_pending_skin_change')`
///
/// See also: [minecraft_skins::flush_pending_skin_change]
#[tauri::command]
pub async fn flush_pending_skin_change() -> Result<()> {
Ok(minecraft_skins::flush_pending_skin_change().await?)
}
/// `invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', profile_id)`
///
/// See also: [minecraft_skins::flush_pending_skin_change_for_profile]
#[tauri::command]
pub async fn flush_pending_skin_change_for_profile(
profile_id: uuid::Uuid,
) -> Result<()> {
Ok(
minecraft_skins::flush_pending_skin_change_for_profile(profile_id)
.await?,
)
}
/// `invoke('plugin:minecraft-skins|normalize_skin_texture')`
///
/// See also: [minecraft_skins::normalize_skin_texture]
+12 -2
View File
@@ -270,10 +270,20 @@ fn main() {
Ok(app) => {
app.run(|app, event| {
#[cfg(not(any(feature = "updater", target_os = "macos")))]
drop((app, event));
let _ = app;
if matches!(&event, tauri::RunEvent::ExitRequested { .. })
&& let Err(error) = tauri::async_runtime::block_on(
theseus::minecraft_skins::flush_pending_skin_change(),
)
{
tracing::warn!(
"Failed to flush pending Minecraft skin change before exit: {error}"
);
}
#[cfg(feature = "updater")]
if matches!(event, tauri::RunEvent::Exit) {
if matches!(&event, tauri::RunEvent::Exit) {
let update_data = app.state::<PendingUpdateData>().inner();
let should_restart = State::get_if_initialized()
.map(|s| {
+53 -36
View File
@@ -65,49 +65,65 @@ async fn fetch(
)
.await?;
// We check Modrinth's fabric version manifest and compare if the fabric version exists in Modrinth's database
// We also check intermediary versions that are newly added to query
let (fetch_fabric_versions, fetch_intermediary_versions) =
if let Some(modrinth_manifest) = modrinth_manifest {
let (mut fetch_versions, mut fetch_intermediary_versions) =
(Vec::new(), Vec::new());
// We check Modrinth's manifest to find newly added loader versions,
// intermediary/mapping artifacts, and game versions.
let (
fetch_fabric_versions,
fetch_intermediary_versions,
has_new_game_versions,
) = if let Some(modrinth_manifest) = modrinth_manifest {
let (mut fetch_versions, mut fetch_intermediary_versions) =
(Vec::new(), Vec::new());
for version in &fabric_manifest.loader {
if !modrinth_manifest
.game_versions
.iter()
.any(|x| x.loaders.iter().any(|x| x.id == version.version))
&& !skip_versions.contains(&&*version.version)
{
fetch_versions.push(version);
}
for version in &fabric_manifest.loader {
if !modrinth_manifest
.game_versions
.iter()
.any(|x| x.loaders.iter().any(|x| x.id == version.version))
&& !skip_versions.contains(&&*version.version)
{
fetch_versions.push(version);
}
}
for version in &fabric_manifest.intermediary {
if !modrinth_manifest
for version in &fabric_manifest.intermediary {
if !modrinth_manifest
.game_versions
.iter()
.any(|x| x.id == version.version)
&& fabric_manifest
.game
.iter()
.any(|x| x.version == version.version)
{
fetch_intermediary_versions.push(version);
}
}
let has_new_game_versions =
fabric_manifest.game.iter().any(|version| {
!modrinth_manifest
.game_versions
.iter()
.any(|x| x.id == version.version)
&& fabric_manifest
.game
.iter()
.any(|x| x.version == version.version)
{
fetch_intermediary_versions.push(version);
}
}
});
(fetch_versions, fetch_intermediary_versions)
} else {
(
fabric_manifest
.loader
.iter()
.filter(|x| !skip_versions.contains(&&*x.version))
.collect(),
fabric_manifest.intermediary.iter().collect(),
)
};
(
fetch_versions,
fetch_intermediary_versions,
has_new_game_versions,
)
} else {
(
fabric_manifest
.loader
.iter()
.filter(|x| !skip_versions.contains(&&*x.version))
.collect(),
fabric_manifest.intermediary.iter().collect(),
true,
)
};
const DUMMY_GAME_VERSION: &str = "1.21";
@@ -216,6 +232,7 @@ async fn fetch(
if !fetch_fabric_versions.is_empty()
|| !fetch_intermediary_versions.is_empty()
|| has_new_game_versions
{
let fabric_manifest_path =
format!("{mod_loader}/v{format_version}/manifest.json",);
+7 -15
View File
@@ -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
+2
View File
@@ -11,6 +11,7 @@
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/{components,composables,layouts,middleware,modules,pages,plugins,utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/frontend",
"cf-deploy": "pnpm run build && wrangler deploy --env staging",
"cf-dev": "pnpm run build && wrangler dev --env staging",
"cf-typegen": "wrangler types"
@@ -54,6 +55,7 @@
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2",
"ansi-to-html": "^0.7.2",
"chart.js": "^4.5.1",
"dayjs": "^1.11.7",
"dompurify": "^3.1.7",
"floating-vue": "^5.2.2",
@@ -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>
@@ -0,0 +1,78 @@
import type { Labrinth } from '@modrinth/api-client'
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
export const ANALYTICS_DASHBOARD_STATS: readonly AnalyticsDashboardStat[] = [
'views',
'downloads',
'revenue',
'playtime',
]
export const TOP_GRAPH_DATASET_LIMIT = 8
export const GRAPH_RENDER_DATASET_LIMIT = 250
export const PREVIOUS_PERIOD_DATASET_ID_PREFIX = 'previous-period:'
export const PREVIOUS_PERIOD_BORDER_DASH = [6, 4]
export const PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000
export const ALL_PROJECTS_DATASET_ID = 'all'
export const PROJECT_EVENT_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
export const MONETIZATION_LEGEND_ENTRY_ORDER = new Map([
['breakdown:monetized', 0],
['breakdown:unmonetized', 1],
])
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES = [
'approved',
'unlisted',
'private',
] as const satisfies readonly Labrinth.Projects.v2.ProjectStatus[]
export type VisibleProjectStatusChangeEventStatus =
(typeof VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)[number]
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET =
new Set<Labrinth.Projects.v2.ProjectStatus>(VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)
export const LIGHT_LEGEND_PALETTE = [
'hsl(152, 100%, 34%)',
'hsl(26, 100%, 42%)',
'hsl(202, 100%, 35%)',
'hsl(327, 45%, 64%)',
'hsl(41, 100%, 45%)',
'hsl(250, 60%, 33%)',
'hsl(170, 43%, 47%)',
'hsl(330, 60%, 33%)',
'hsl(46, 100%, 36%)',
'hsl(167, 100%, 30%)',
'hsl(343, 38%, 45%)',
'hsl(222, 100%, 28%)',
'hsl(270, 62%, 60%)',
'hsl(32, 100%, 37%)',
'hsl(349, 57%, 51%)',
'hsl(191, 43%, 37%)',
]
export const DARK_LEGEND_PALETTE = [
'hsl(145, 78%, 48%)',
'hsl(41, 100%, 50%)',
'hsl(202, 77%, 63%)',
'hsl(323, 66%, 72%)',
'hsl(56, 85%, 60%)',
'hsl(255, 92%, 80%)',
'hsl(12, 100%, 67%)',
'hsl(176, 58%, 56%)',
'hsl(60, 100%, 41%)',
'hsl(165, 80%, 38%)',
'hsl(341, 36%, 56%)',
'hsl(226, 60%, 49%)',
'hsl(252, 53%, 62%)',
'hsl(75, 59%, 50%)',
'hsl(195, 56%, 42%)',
'hsl(30, 59%, 56%)',
]
@@ -0,0 +1,297 @@
<template>
<Menu
theme="analytics-controls-menu"
placement="bottom-end"
:shown="isControlsMenuOpen"
:triggers="[]"
:popper-triggers="[]"
:aria-id="controlsMenuId"
no-auto-focus
@update:shown="isControlsMenuOpen = $event"
>
<button
ref="controlsMenuTrigger"
type="button"
:aria-expanded="isControlsMenuOpen"
:aria-controls="controlsMenuId"
:aria-label="
formatMessage(analyticsChartMessages.controlsAria, {
activeCount: activeControlCountLabel,
})
"
class="btn-dropdown-animation inline-flex min-h-5 cursor-pointer items-center justify-between gap-2 rounded-xl border-0 bg-surface-4 px-3 py-2 text-left text-sm font-semibold text-button-text shadow-none transition-all duration-200 hover:brightness-[115%] focus-visible:brightness-[115%] active:brightness-[115%]"
@click="toggleControlsMenu"
>
<Settings2Icon class="size-4 text-secondary" aria-hidden="true" />
<span class="leading-tight text-primary">
{{ formatMessage(analyticsChartMessages.controlsButton) }}
</span>
<span
v-if="activeControlCount > 0"
class="inline-flex min-w-5 items-center justify-center rounded-full bg-highlight-green px-1.5 text-xs font-semibold leading-5 text-green"
>
{{ activeControlCount }}
</span>
<DropdownIcon class="size-4 text-secondary" aria-hidden="true" />
</button>
<template #popper>
<div
ref="controlsMenuPanel"
role="dialog"
:aria-label="formatMessage(analyticsChartMessages.controlsDialogAria)"
class="mt-1 flex w-[228px] max-w-[calc(100vw_-_2rem)] flex-col overflow-hidden rounded-[14px] border border-solid border-surface-4 bg-surface-3 text-sm shadow-2xl"
>
<div class="flex items-center justify-between gap-3 px-3 py-2.5 text-xs font-medium">
<span class="font-semibold text-primary">{{ activeControlCountLabel }}</span>
<button
type="button"
:disabled="isResetDisabled"
class="border-0 bg-transparent p-0 text-xs font-semibold text-primary transition-all disabled:cursor-not-allowed disabled:opacity-50"
:class="isResetDisabled ? '' : 'hover:text-contrast focus-visible:text-contrast'"
@click="resetControls"
>
{{ formatMessage(analyticsMessages.resetButton) }}
</button>
</div>
<div
v-if="hasDisplayControls"
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
>
<div class="mb-0.5 text-xs font-semibold text-secondary">
{{ formatMessage(analyticsChartMessages.displayControls) }}
</div>
<div v-if="canShowPreviousPeriod" class="flex min-h-7 items-center justify-between">
<label
:for="previousPeriodToggleId"
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
>
<HistoryIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<span class="min-w-0 truncate">
{{ formatMessage(analyticsChartMessages.previousPeriod) }}
</span>
</label>
<Toggle
:id="previousPeriodToggleId"
v-model="showPreviousPeriodModel"
:small="smallToggles"
/>
</div>
<div v-if="canUseRatioMode" class="flex min-h-7 items-center justify-between">
<label
:for="ratioModeToggleId"
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
>
<span
class="inline-flex size-4 shrink-0 items-center justify-center text-sm font-semibold leading-none text-secondary"
aria-hidden="true"
>
%
</span>
<span class="min-w-0 truncate">
{{ formatMessage(analyticsChartMessages.ratio) }}
</span>
</label>
<Toggle :id="ratioModeToggleId" v-model="ratioModeModel" :small="smallToggles" />
</div>
</div>
<div
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
>
<div class="mb-0.5 text-xs font-semibold text-secondary">
{{ formatMessage(analyticsChartMessages.annotations) }}
</div>
<div
v-tooltip="projectEventsDisabledTooltip"
class="justify3 flex min-h-7 items-center"
:aria-disabled="!hasProjectEvents"
>
<label
:for="projectEventsToggleId"
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
:class="hasProjectEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
>
<TagCategoryFlagIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<span class="min-w-0 truncate">
{{ formatMessage(analyticsChartMessages.projectEvents) }}
</span>
</label>
<Toggle
:id="projectEventsToggleId"
v-model="showProjectEventsControlModel"
:small="smallToggles"
:disabled="!hasProjectEvents"
/>
</div>
<div
v-tooltip="modrinthEventsDisabledTooltip"
class="justify3 flex min-h-7 items-center"
:aria-disabled="!hasChartEvents"
>
<label
:for="modrinthEventsToggleId"
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
:class="hasChartEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
>
<InfoIcon class="size-4 shrink-0 text-blue" aria-hidden="true" />
<span class="min-w-0 truncate">
{{ formatMessage(analyticsChartMessages.modrinthEvents) }}
</span>
</label>
<Toggle
:id="modrinthEventsToggleId"
v-model="showChartEventsControlModel"
:small="smallToggles"
:disabled="!hasChartEvents"
/>
</div>
</div>
</div>
</template>
</Menu>
</template>
<script setup lang="ts">
import {
DropdownIcon,
HistoryIcon,
InfoIcon,
Settings2Icon,
TagCategoryFlagIcon,
} from '@modrinth/assets'
import { Toggle, useVIntl } from '@modrinth/ui'
import { Menu } from 'floating-vue'
import { analyticsChartMessages, analyticsMessages } from '../../analytics-messages'
const props = defineProps<{
ratioMode: boolean
showChartEvents: boolean
showProjectEvents: boolean
showPreviousPeriod: boolean
canUseRatioMode: boolean
canShowPreviousPeriod: boolean
hasChartEvents: boolean
hasProjectEvents: boolean
smallToggles: boolean
defaultRatioMode: boolean
defaultShowChartEvents: boolean
defaultShowProjectEvents: boolean
defaultShowPreviousPeriod: boolean
}>()
const emit = defineEmits<{
(
e:
| 'update:ratioMode'
| 'update:showChartEvents'
| 'update:showProjectEvents'
| 'update:showPreviousPeriod',
value: boolean,
): void
}>()
const isControlsMenuOpen = ref(false)
const controlsMenuTrigger = ref<HTMLElement | null>(null)
const controlsMenuPanel = ref<HTMLElement | null>(null)
const controlsMenuId = useId()
const ratioModeToggleId = useId()
const previousPeriodToggleId = useId()
const modrinthEventsToggleId = useId()
const projectEventsToggleId = useId()
const { formatMessage } = useVIntl()
const ratioModeModel = computed({
get: () => props.ratioMode,
set: (value: boolean) => emit('update:ratioMode', value),
})
const showChartEventsModel = computed({
get: () => props.showChartEvents,
set: (value: boolean) => emit('update:showChartEvents', value),
})
const showChartEventsControlModel = computed({
get: () => props.hasChartEvents && props.showChartEvents,
set: (value: boolean) => emit('update:showChartEvents', value),
})
const showProjectEventsModel = computed({
get: () => props.showProjectEvents,
set: (value: boolean) => emit('update:showProjectEvents', value),
})
const showProjectEventsControlModel = computed({
get: () => props.hasProjectEvents && props.showProjectEvents,
set: (value: boolean) => emit('update:showProjectEvents', value),
})
const showPreviousPeriodModel = computed({
get: () => props.showPreviousPeriod,
set: (value: boolean) => emit('update:showPreviousPeriod', value),
})
const hasDisplayControls = computed(() => props.canShowPreviousPeriod || props.canUseRatioMode)
const projectEventsDisabledTooltip = computed(() =>
props.hasProjectEvents ? undefined : formatMessage(analyticsChartMessages.noProjectEvents),
)
const modrinthEventsDisabledTooltip = computed(() =>
props.hasChartEvents ? undefined : formatMessage(analyticsChartMessages.noModrinthEvents),
)
const activeControlCount = computed(() => {
let count = 0
if (props.canShowPreviousPeriod && props.showPreviousPeriod) count += 1
if (props.canUseRatioMode && props.ratioMode) count += 1
if (props.hasProjectEvents && props.showProjectEvents) count += 1
if (props.hasChartEvents && props.showChartEvents) count += 1
return count
})
const activeControlCountLabel = computed(() =>
formatMessage(analyticsChartMessages.activeControlCount, { count: activeControlCount.value }),
)
const isResetDisabled = computed(
() =>
props.showPreviousPeriod === props.defaultShowPreviousPeriod &&
props.ratioMode === props.defaultRatioMode &&
props.showProjectEvents === props.defaultShowProjectEvents &&
props.showChartEvents === props.defaultShowChartEvents,
)
function toggleControlsMenu() {
isControlsMenuOpen.value = !isControlsMenuOpen.value
}
function resetControls() {
if (isResetDisabled.value) return
showPreviousPeriodModel.value = props.defaultShowPreviousPeriod
ratioModeModel.value = props.defaultRatioMode
showProjectEventsModel.value = props.defaultShowProjectEvents
showChartEventsModel.value = props.defaultShowChartEvents
}
function onDocumentPointerDown(event: PointerEvent) {
if (!isControlsMenuOpen.value || !(event.target instanceof Node)) return
if (controlsMenuTrigger.value?.contains(event.target)) return
if (controlsMenuPanel.value?.contains(event.target)) return
isControlsMenuOpen.value = false
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
})
</script>
<style>
.v-popper--theme-analytics-controls-menu .v-popper__inner {
overflow: visible !important;
background: transparent !important;
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
}
.v-popper--theme-analytics-controls-menu .v-popper__arrow-container {
display: none;
}
</style>
@@ -0,0 +1,188 @@
<template>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-5"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-5"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showLegendTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-5 bg-gradient-to-b from-surface-3 to-transparent"
/>
</Transition>
<div
ref="legendContainer"
class="flex max-h-[130px] flex-wrap items-center gap-y-1 overflow-y-auto px-3"
@scroll="checkLegendScrollState"
>
<div
v-for="legendEntry in legendEntries"
:key="legendEntry.id"
class="inline-flex items-center"
>
<button
v-tooltip="legendEntry.projectName ?? ''"
type="button"
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-sm !outline-0 transition-all focus-within:!outline-0 focus:!outline-0 focus-visible:!outline-0"
:class="[
legendEntry.hidden ? 'text-secondary opacity-70' : 'text-primary',
isLegendEntryToggleDisabled(legendEntry) && !isShiftKeyPressed
? 'cursor-default'
: 'cursor-pointer hover:brightness-125',
]"
:aria-pressed="!legendEntry.hidden"
@mouseenter="emit('entry-hover', legendEntry.id)"
@mouseleave="emit('entry-hover-clear', legendEntry.id)"
@focus="emit('entry-hover', legendEntry.id)"
@blur="emit('entry-hover-clear', legendEntry.id)"
@click="emit('entry-click', $event, legendEntry.id)"
>
<span
:class="
legendEntry.isPreviousPeriod
? 'h-0 w-2 rounded-none border-0 border-t-2 border-dashed bg-transparent'
: 'size-2 rounded-full'
"
:style="
legendEntry.isPreviousPeriod
? { borderColor: legendEntry.color }
: { backgroundColor: legendEntry.color }
"
/>
<span
:class="{
'line-through': legendEntry.hidden,
capitalize: shouldCapitalizeDatasetLabels,
}"
>
{{ legendEntry.name }}
</span>
</button>
<Dropdown
v-if="showUnmonetizedInfo && legendEntry.id === 'breakdown:unmonetized'"
theme="analytics-monetization-popover"
:triggers="['hover', 'focus']"
:popper-triggers="['hover', 'focus']"
:delay="{ show: 0, hide: 250 }"
placement="top"
:aria-id="monetizationPopoverId"
no-auto-focus
>
<InfoIcon
class="-ml-1 mt-px inline-flex size-4 items-center justify-center rounded-full border-0 bg-transparent p-0 text-secondary transition-all hover:text-contrast focus-visible:text-contrast"
:aria-label="formatMessage(analyticsChartMessages.viewMonetizedAnalyticsDetails)"
/>
<template #popper>
<div
role="dialog"
:aria-label="formatMessage(analyticsChartMessages.monetizedAnalyticsDetails)"
class="font-base w-[292px] rounded-xl border border-solid border-surface-5 bg-surface-3 p-3 text-sm leading-snug shadow-2xl"
>
{{ formatMessage(analyticsChartMessages.monetizedAnalyticsDetailsDescription) }}
</div>
</template>
</Dropdown>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-5"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-5"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showLegendBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-5 bg-gradient-to-t from-surface-3 to-transparent"
/>
</Transition>
</div>
</template>
<script setup lang="ts">
import { InfoIcon } from '@modrinth/assets'
import { useScrollIndicator, useVIntl } from '@modrinth/ui'
import { Dropdown } from 'floating-vue'
import { analyticsChartMessages } from '../../analytics-messages'
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types'
const props = defineProps<{
legendEntries: AnalyticsChartLegendEntry[]
shouldCapitalizeDatasetLabels: boolean
showUnmonetizedInfo: boolean
}>()
const emit = defineEmits<{
'entry-hover': [datasetId: string]
'entry-hover-clear': [datasetId: string]
'entry-click': [event: MouseEvent, datasetId: string]
}>()
const monetizationPopoverId = useId()
const legendContainer = ref<HTMLElement | null>(null)
const isShiftKeyPressed = ref(false)
const { formatMessage } = useVIntl()
const {
showTopFade: showLegendTopFade,
showBottomFade: showLegendBottomFade,
checkScrollState: checkLegendScrollState,
forceCheck: forceCheckLegendScrollState,
} = useScrollIndicator(legendContainer)
function updateShiftKeyState(event: KeyboardEvent) {
isShiftKeyPressed.value = event.shiftKey
}
function clearShiftKeyState() {
isShiftKeyPressed.value = false
}
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
if (legendEntry.hidden) return false
const visibleCount = props.legendEntries.filter((entry) => !entry.hidden).length
return visibleCount <= 1
}
watch(
() => props.legendEntries,
() => {
nextTick(() => {
forceCheckLegendScrollState()
})
},
{ immediate: true, flush: 'post' },
)
onMounted(() => {
window.addEventListener('keydown', updateShiftKeyState)
window.addEventListener('keyup', updateShiftKeyState)
window.addEventListener('blur', clearShiftKeyState)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', updateShiftKeyState)
window.removeEventListener('keyup', updateShiftKeyState)
window.removeEventListener('blur', clearShiftKeyState)
})
</script>
<style>
.v-popper--theme-analytics-monetization-popover .v-popper__inner {
overflow: visible !important;
background: transparent !important;
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
}
.v-popper--theme-analytics-monetization-popover .v-popper__arrow-container {
display: none;
}
</style>
@@ -0,0 +1,63 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(analyticsChartMessages.renderLimitHeader, { count: tableProjectCount })"
fade="warning"
width="500px"
max-width="calc(100vw - 2rem)"
>
<p class="m-0 max-w-[32rem] text-primary">
{{ formatMessage(analyticsChartMessages.renderLimitDescription) }}
</p>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="transparent">
<button @click="modal?.hide()">
{{ formatMessage(analyticsChartMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button class="!shadow-none" @click="confirm">
{{ formatMessage(analyticsChartMessages.showAll) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
import { analyticsChartMessages } from '../../analytics-messages'
defineProps<{
tableProjectCount: number
}>()
const emit = defineEmits<{
confirm: []
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
function show(event: MouseEvent) {
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
function confirm() {
emit('confirm')
hide()
}
defineExpose({
show,
hide,
})
</script>
@@ -0,0 +1,122 @@
<template>
<div class="flex w-full flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div
class="flex min-h-[84px] w-full flex-col items-start justify-between gap-3 rounded-t-2xl border-0 border-b border-solid border-surface-5 bg-surface-3 p-4 sm:flex-row sm:items-center"
>
<div class="flex flex-col gap-0.5">
<div class="w-max text-xl font-semibold text-contrast">
{{ graphTitle }}
</div>
<div
v-if="showTableSelectionSubheading"
class="m-0 flex w-max flex-wrap items-center gap-2 text-sm text-secondary"
>
<span>{{ tableSelectionSubheading }}</span>
<button
v-if="showGraphRenderLimitButton"
type="button"
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
@click="emit('toggle-graph-render-limit', $event)"
>
{{ graphRenderLimitButtonLabel }}
</button>
<button
v-if="showTopGraphDatasetsButton"
type="button"
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
@click="emit('show-top-graph-datasets')"
>
{{ formatMessage(analyticsChartMessages.showTopEight) }}
</button>
</div>
</div>
<div class="flex grow select-none flex-wrap-reverse items-center justify-end gap-2 gap-y-2">
<AnalyticsChartControls
v-model:ratio-mode="ratioMode"
v-model:show-chart-events="showChartEvents"
v-model:show-project-events="showProjectEvents"
v-model:show-previous-period="showPreviousPeriod"
:can-use-ratio-mode="canUseRatioMode"
:can-show-previous-period="canShowPreviousPeriod"
:has-chart-events="hasChartEvents"
:has-project-events="hasProjectEvents"
:small-toggles="smallToggles"
:default-ratio-mode="DEFAULT_ANALYTICS_GRAPH_RATIO_MODE"
:default-show-chart-events="DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY"
:default-show-project-events="defaultShowProjectEvents"
:default-show-previous-period="DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY"
/>
<Tabs
:value="activeGraphViewMode"
:tabs="viewModeTabs"
@update:value="activeGraphViewMode = $event as AnalyticsGraphViewMode"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ChartAreaIcon, ChartColumnBigIcon, ChartSplineIcon } from '@modrinth/assets'
import { Tabs, type TabsTab, useVIntl } from '@modrinth/ui'
import {
DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
} from '~/components/analytics-dashboard/analytics-route-query'
import type { AnalyticsGraphViewMode } from '~/providers/analytics/analytics'
import { analyticsChartMessages } from '../../analytics-messages.ts'
import AnalyticsChartControls from './AnalyticsChartControls.vue'
const activeGraphViewMode = defineModel<AnalyticsGraphViewMode>('activeGraphViewMode', {
required: true,
})
const ratioMode = defineModel<boolean>('ratioMode', { required: true })
const showChartEvents = defineModel<boolean>('showChartEvents', { required: true })
const showProjectEvents = defineModel<boolean>('showProjectEvents', { required: true })
const showPreviousPeriod = defineModel<boolean>('showPreviousPeriod', { required: true })
const props = defineProps<{
graphTitle: string
showTableSelectionSubheading: boolean
tableSelectionSubheading: string
showGraphRenderLimitButton: boolean
graphRenderLimitButtonLabel: string
showTopGraphDatasetsButton: boolean
canUseRatioMode: boolean
canShowPreviousPeriod: boolean
hasChartEvents: boolean
hasProjectEvents: boolean
smallToggles: boolean
defaultShowProjectEvents: boolean
isMobileLayout: boolean
}>()
const { formatMessage } = useVIntl()
const emit = defineEmits<{
'toggle-graph-render-limit': [event: MouseEvent]
'show-top-graph-datasets': []
}>()
const viewModeTabs = computed<TabsTab[]>(() => [
{
value: 'line',
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.lineView),
icon: ChartSplineIcon,
},
{
value: 'area',
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.areaView),
icon: ChartAreaIcon,
},
{
value: 'bar',
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.barView),
icon: ChartColumnBigIcon,
},
])
</script>
@@ -0,0 +1,450 @@
import { useVIntl } from '@modrinth/ui'
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardProject,
} from '~/providers/analytics/analytics'
import { analyticsChartMessages } from '../../analytics-messages.ts'
import { COMBINED_BREAKDOWN_DATASET_ID_PREFIX } from '../../breakdown.ts'
import {
ALL_PROJECTS_DATASET_ID,
MONETIZATION_LEGEND_ENTRY_ORDER,
PREVIOUS_PERIOD_BORDER_DASH,
} from '../analytics-chart-constants.ts'
import type { AnalyticsChartEvent } from '../analytics-chart-plot/AnalyticsChartEvents.vue'
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types.ts'
import {
areStringArraysEqual,
type ChartDataset,
decodeBreakdownDatasetValue,
getChartDatasetTotal,
getPreviousPeriodDatasetId,
} from '../analytics-chart-utils.ts'
export function useAnalyticsChartLegend({
selectableChartDatasets,
allChartDatasets,
previousChartDatasets,
shouldShowPreviousPeriod,
isRatioMode,
hiddenGraphDatasetIds,
selectedBreakdowns,
isGraphDatasetSelectionActive,
selectedProjects,
selectedProjectIdSet,
selectedProjectEventIdSet,
}: {
selectableChartDatasets: ComputedRef<ChartDataset[]>
allChartDatasets: ComputedRef<ChartDataset[]>
previousChartDatasets: ComputedRef<ChartDataset[]>
shouldShowPreviousPeriod: ComputedRef<boolean>
isRatioMode: Ref<boolean>
hiddenGraphDatasetIds: Ref<string[]>
selectedBreakdowns: Ref<readonly AnalyticsBreakdownPreset[]>
isGraphDatasetSelectionActive: Ref<boolean>
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>
selectedProjectIdSet: ComputedRef<Set<string>>
selectedProjectEventIdSet: ComputedRef<Set<string>>
}) {
const { formatMessage } = useVIntl()
const hoveredLegendEntryId = ref<string | null>(null)
const hiddenDatasetIds = computed(() => new Set(hiddenGraphDatasetIds.value))
const previousChartDatasetByOriginalId = computed(() => {
const datasets = new Map<string, ChartDataset>()
for (const dataset of previousChartDatasets.value) {
datasets.set(dataset.projectId, dataset)
}
return datasets
})
const currentLegendEntries = computed<AnalyticsChartLegendEntry[]>(() =>
selectableChartDatasets.value
.map((dataset) => ({
id: dataset.projectId,
name: dataset.label,
projectName: dataset.projectName,
color: dataset.borderColor,
totalValue: getChartDatasetTotal(dataset),
hidden: hiddenDatasetIds.value.has(dataset.projectId),
}))
.sort(compareLegendEntries),
)
const visibleProjectEventIdSet = computed(() => {
if (!selectedBreakdowns.value.includes('project')) {
return selectedProjectEventIdSet.value
}
const visibleProjectIds = new Set<string>()
const projectIdsWithLegendEntries = new Set<string>()
for (const legendEntry of currentLegendEntries.value) {
const projectId = getLegendEntryProjectId(legendEntry)
if (!projectId) {
continue
}
projectIdsWithLegendEntries.add(projectId)
if (!legendEntry.hidden) {
visibleProjectIds.add(projectId)
}
}
if (isGraphDatasetSelectionActive.value) {
return visibleProjectIds
}
if (projectIdsWithLegendEntries.size === 0) {
return selectedProjectEventIdSet.value
}
const eventProjectIds = new Set<string>()
for (const projectId of selectedProjectEventIdSet.value) {
if (!projectIdsWithLegendEntries.has(projectId) || visibleProjectIds.has(projectId)) {
eventProjectIds.add(projectId)
}
}
return eventProjectIds
})
const legendEntries = computed<AnalyticsChartLegendEntry[]>(() => {
if (!shouldShowPreviousPeriod.value) {
return currentLegendEntries.value
}
return currentLegendEntries.value.flatMap((entry) => {
const previousDataset = previousChartDatasetByOriginalId.value.get(entry.id)
const previousEntry: AnalyticsChartLegendEntry = {
id: getPreviousPeriodDatasetId(entry.id),
name: formatMessage(analyticsChartMessages.previousPeriodSuffix, { name: entry.name }),
projectName: entry.projectName,
color: entry.color,
totalValue: previousDataset ? getChartDatasetTotal(previousDataset) : 0,
hidden: hiddenDatasetIds.value.has(getPreviousPeriodDatasetId(entry.id)),
isPreviousPeriod: true,
}
return [entry, previousEntry]
})
})
const hiddenCurrentLegendEntryIds = computed(() =>
currentLegendEntries.value.filter((entry) => entry.hidden).map((entry) => entry.id),
)
const hiddenCurrentLegendEntryIdsKey = computed(() =>
hiddenCurrentLegendEntryIds.value.join('\u0000'),
)
const chartDatasetById = computed(() => {
const datasets = new Map<string, ChartDataset>()
for (const dataset of selectableChartDatasets.value) {
datasets.set(dataset.projectId, dataset)
if (!shouldShowPreviousPeriod.value) {
continue
}
const previousDataset = previousChartDatasetByOriginalId.value.get(dataset.projectId)
const previousData = Array.from(
{ length: dataset.data.length },
(_, index) => previousDataset?.data[index] ?? 0,
)
datasets.set(getPreviousPeriodDatasetId(dataset.projectId), {
projectId: getPreviousPeriodDatasetId(dataset.projectId),
label: formatMessage(analyticsChartMessages.previousPeriodSuffix, {
name: dataset.label,
}),
projectName: dataset.projectName,
data: previousData,
borderColor: dataset.borderColor,
backgroundColor: dataset.backgroundColor,
borderDash: PREVIOUS_PERIOD_BORDER_DASH,
})
}
return datasets
})
const hoverRatioSliceTotals = computed(() => {
const sliceLength = selectableChartDatasets.value.reduce(
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
0,
)
const totals = new Array<number>(sliceLength).fill(0)
for (const legendEntry of legendEntries.value) {
if (legendEntry.hidden) continue
const dataset = chartDatasetById.value.get(legendEntry.id)
if (!dataset) continue
for (let i = 0; i < sliceLength; i++) {
totals[i] += dataset.data[i] ?? 0
}
}
return totals
})
const baseVisibleChartDatasets = computed(() =>
legendEntries.value
.filter((legendEntry) => !legendEntry.hidden)
.map((legendEntry) => {
const dataset = chartDatasetById.value.get(legendEntry.id)
if (!dataset) return null
return {
...dataset,
borderColor: legendEntry.color,
backgroundColor: legendEntry.color,
}
})
.filter((dataset): dataset is ChartDataset => Boolean(dataset)),
)
const visibleChartDatasets = computed<ChartDataset[]>(() => {
const datasets = baseVisibleChartDatasets.value
if (!isRatioMode.value || datasets.length === 0) return datasets
const sliceLength = datasets.reduce(
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
0,
)
const totals = new Array<number>(sliceLength).fill(0)
for (const dataset of datasets) {
for (let i = 0; i < sliceLength; i++) {
totals[i] += dataset.data[i] ?? 0
}
}
return datasets.map((dataset) => ({
...dataset,
data: dataset.data.map((value, i) => (totals[i] === 0 ? 0 : (value / totals[i]) * 100)),
}))
})
const visibleChartDatasetById = computed(() => {
const datasets = new Map<string, ChartDataset>()
for (const dataset of visibleChartDatasets.value) {
datasets.set(dataset.projectId, dataset)
}
return datasets
})
const highlightedChartDatasetId = computed(() => {
const datasetId = hoveredLegendEntryId.value
if (!datasetId || !visibleChartDatasetById.value.has(datasetId)) return null
return datasetId
})
function compareLegendEntries(a: AnalyticsChartLegendEntry, b: AnalyticsChartLegendEntry) {
if (selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization') {
const aOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(a.id)
const bOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(b.id)
if (aOrder !== undefined || bOrder !== undefined) {
return (aOrder ?? Number.MAX_SAFE_INTEGER) - (bOrder ?? Number.MAX_SAFE_INTEGER)
}
}
return b.totalValue - a.totalValue || a.name.localeCompare(b.name)
}
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
}
function getLegendEntryProjectId(legendEntry: AnalyticsChartLegendEntry) {
const projectBreakdownIndex = selectedBreakdowns.value.findIndex(
(breakdown) => breakdown === 'project',
)
if (projectBreakdownIndex === -1) {
if (selectedProjects.value.length === 1 && legendEntry.id === ALL_PROJECTS_DATASET_ID) {
return selectedProjects.value[0]?.id ?? null
}
return null
}
if (selectedBreakdowns.value.length === 1) {
return selectedProjectIdSet.value.has(legendEntry.id) ? legendEntry.id : null
}
if (!legendEntry.id.startsWith(COMBINED_BREAKDOWN_DATASET_ID_PREFIX)) {
return null
}
const values = legendEntry.id
.slice(COMBINED_BREAKDOWN_DATASET_ID_PREFIX.length)
.split('+')
.map(decodeBreakdownDatasetValue)
const projectId = values[projectBreakdownIndex]
return projectId && selectedProjectIdSet.value.has(projectId) ? projectId : null
}
function hidePreviousPeriodEntriesForHiddenCurrentEntries() {
if (hiddenCurrentLegendEntryIds.value.length === 0) return
const nextHiddenDatasetIds = new Set(hiddenGraphDatasetIds.value)
for (const datasetId of hiddenCurrentLegendEntryIds.value) {
nextHiddenDatasetIds.add(getPreviousPeriodDatasetId(datasetId))
}
const nextHiddenDatasetIdList = Array.from(nextHiddenDatasetIds)
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIdList)) {
hiddenGraphDatasetIds.value = nextHiddenDatasetIdList
}
}
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
if (legendEntry.hidden) return false
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
return visibleCount <= 1
}
function getLegendEntryTooltip(legendEntry: AnalyticsChartLegendEntry) {
return legendEntry.projectName ?? ''
}
function isUnmonetizedLegendEntry(legendEntry: AnalyticsChartLegendEntry) {
return (
selectedBreakdowns.value.length === 1 &&
selectedBreakdowns.value[0] === 'monetization' &&
legendEntry.id === 'breakdown:unmonetized'
)
}
function setHoveredLegendEntryId(datasetId: string) {
hoveredLegendEntryId.value = datasetId
}
function clearHoveredLegendEntryId(datasetId: string) {
if (hoveredLegendEntryId.value === datasetId) {
hoveredLegendEntryId.value = null
}
}
function clearLegendHoverState() {
hoveredLegendEntryId.value = null
}
function toggleLegendEntryVisibility(datasetId: string) {
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
if (nextHiddenDatasetIds.has(datasetId)) {
nextHiddenDatasetIds.delete(datasetId)
} else {
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
if (visibleCount <= 1) return
nextHiddenDatasetIds.add(datasetId)
}
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
}
function soloLegendEntry(datasetId: string) {
const currentLegendEntryIds = new Set(legendEntries.value.map((entry) => entry.id))
const otherIds = legendEntries.value.map((entry) => entry.id).filter((id) => id !== datasetId)
const isAlreadySolo =
!hiddenDatasetIds.value.has(datasetId) &&
otherIds.every((id) => hiddenDatasetIds.value.has(id))
if (isAlreadySolo) {
hiddenGraphDatasetIds.value = hiddenGraphDatasetIds.value.filter(
(hiddenDatasetId) => !currentLegendEntryIds.has(hiddenDatasetId),
)
return
}
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
for (const legendEntry of legendEntries.value) {
if (legendEntry.id === datasetId) {
nextHiddenDatasetIds.delete(legendEntry.id)
} else {
nextHiddenDatasetIds.add(legendEntry.id)
}
}
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
}
function onLegendEntryClick(event: MouseEvent, datasetId: string) {
if (event.shiftKey) {
soloLegendEntry(datasetId)
clearLegendHoverState()
return
}
toggleLegendEntryVisibility(datasetId)
clearLegendHoverState()
}
function onTooltipEntryClick(datasetId: string, shiftKey: boolean) {
if (!chartDatasetById.value.has(datasetId)) return
if (shiftKey) {
soloLegendEntry(datasetId)
clearLegendHoverState()
return
}
toggleLegendEntryVisibility(datasetId)
clearLegendHoverState()
}
watch(
[shouldShowPreviousPeriod, hiddenCurrentLegendEntryIdsKey],
([showPreviousPeriod]) => {
if (!showPreviousPeriod) return
hidePreviousPeriodEntriesForHiddenCurrentEntries()
},
{ immediate: true },
)
watch(
[allChartDatasets, legendEntries],
([datasets]) => {
if (datasets.length === 0) return
const availableDatasetIds = new Set(legendEntries.value.map((entry) => entry.id))
const nextHiddenDatasetIds = hiddenGraphDatasetIds.value.filter((datasetId) =>
availableDatasetIds.has(datasetId),
)
if (
legendEntries.value.length > 0 &&
legendEntries.value.every((entry) => nextHiddenDatasetIds.includes(entry.id))
) {
const firstLegendEntry = legendEntries.value[0]
if (firstLegendEntry) {
const firstLegendEntryIndex = nextHiddenDatasetIds.indexOf(firstLegendEntry.id)
if (firstLegendEntryIndex !== -1) {
nextHiddenDatasetIds.splice(firstLegendEntryIndex, 1)
}
}
}
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIds)) {
hiddenGraphDatasetIds.value = nextHiddenDatasetIds
}
},
{ immediate: true },
)
return {
hoveredLegendEntryId,
hiddenDatasetIds,
previousChartDatasetByOriginalId,
currentLegendEntries,
visibleProjectEventIdSet,
legendEntries,
hiddenCurrentLegendEntryIds,
hiddenCurrentLegendEntryIdsKey,
chartDatasetById,
hoverRatioSliceTotals,
baseVisibleChartDatasets,
visibleChartDatasets,
visibleChartDatasetById,
highlightedChartDatasetId,
isProjectChartEventVisibleForLegend,
getLegendEntryProjectId,
hidePreviousPeriodEntriesForHiddenCurrentEntries,
isLegendEntryToggleDisabled,
getLegendEntryTooltip,
isUnmonetizedLegendEntry,
setHoveredLegendEntryId,
clearHoveredLegendEntryId,
clearLegendHoverState,
toggleLegendEntryVisibility,
soloLegendEntry,
onLegendEntryClick,
onTooltipEntryClick,
}
}
@@ -0,0 +1,487 @@
<template>
<div
v-show="visible"
ref="tooltipElement"
class="analytics-chart-tooltip absolute left-0 top-0 z-10 flex max-h-[356px] flex-col overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-3 py-2 text-sm shadow-lg"
:class="pinned ? '' : 'pointer-events-none'"
:style="positionStyle"
@wheel.stop
@click.stop
>
<div
class="mb-1.5 flex shrink-0 items-start justify-between gap-2 border-0 border-b border-solid border-surface-5 px-3 pb-1.5 font-medium text-contrast"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="min-w-0 truncate">
{{ rangeLabel }}
<span v-if="durationLabel" class="text-xs font-normal text-secondary">
({{ durationLabel }})
</span>
</span>
<span v-if="previousRangeLabel" class="min-w-0 truncate text-xs text-primary">
<span class="font-medium">{{ previousRangeLabel }}</span>
<span class="font-normal text-secondary">
{{ formatMessage(analyticsChartMessages.previousPeriodShort) }}
</span>
</span>
</div>
<PinIcon
v-if="pinned"
v-tooltip="formatMessage(analyticsChartMessages.tooltipPinned)"
class="pointer-events-none size-4 shrink-0 font-normal text-contrast"
:aria-label="formatMessage(analyticsChartMessages.pinned)"
/>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showEntriesTopFade"
class="analytics-chart-tooltip-entries-fade-top pointer-events-none absolute left-0 right-0 z-10 -mt-1 h-6 bg-gradient-to-b from-surface-3 to-transparent"
/>
</Transition>
<div
ref="entriesElement"
class="analytics-chart-tooltip-entries flex min-h-0 flex-col overflow-y-auto overscroll-contain px-3"
@scroll="checkEntriesScrollState"
@touchstart="onEntriesTouchStart"
@touchmove="onEntriesTouchMove"
@touchend="clearEntriesTouchScroll"
@touchcancel="clearEntriesTouchScroll"
>
<div v-if="!ratioMode" class="flex shrink-0 items-center justify-between gap-4">
<span class="font-medium text-primary">
{{ formatMessage(analyticsChartMessages.total) }}
</span>
<span class="font-semibold text-contrast">{{ formattedTotal }}</span>
</div>
<div
v-for="entry in entries"
:key="entry.projectId"
class="flex w-full min-w-0 items-center justify-between gap-4 text-primary"
>
<button
type="button"
class="inline-flex min-w-0 items-center gap-1.5 border-0 bg-transparent p-0 py-0.5 text-left focus-visible:!outline-none"
:class="
entry.toggleDisabled && !shiftKeyPressed
? 'cursor-default'
: entry.hidden
? 'cursor-pointer text-secondary opacity-70'
: 'cursor-pointer text-primary transition-all hover:brightness-125'
"
:aria-label="getEntryAriaLabel(entry)"
@mouseenter="emit('entry-hover', entry.projectId)"
@mouseleave="emit('entry-hover-clear', entry.projectId)"
@focus="emit('entry-hover', entry.projectId)"
@blur="emit('entry-hover-clear', entry.projectId)"
@click="onEntryClick($event, entry)"
>
<span
:class="
entry.isPreviousPeriod
? 'h-0 w-2 rounded-none border-0 border-t-2 border-dashed bg-transparent'
: 'size-2 rounded-full'
"
class="shrink-0"
:style="
entry.isPreviousPeriod
? { borderColor: entry.color }
: { backgroundColor: entry.color }
"
/>
<span
v-tooltip="entry.projectName ?? ''"
class="min-w-0 truncate"
:class="{
'line-through': entry.hidden,
capitalize: capitalizeLabels,
}"
>
{{ entry.name }}
</span>
</button>
<span
:class="[
'shrink-0',
entry.isPreviousPeriod ? 'font-medium text-secondary' : 'font-semibold',
entry.hidden ? 'text-primary line-through opacity-70' : 'text-contrast',
]"
>
{{ entry.formattedValue }}
</span>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showEntriesBottomFade"
class="analytics-chart-tooltip-entries-fade-bottom pointer-events-none absolute left-0 right-0 z-10 -mb-1 h-6 bg-gradient-to-t from-surface-3 to-transparent"
/>
</Transition>
</div>
</template>
<script setup lang="ts">
import { PinIcon } from '@modrinth/assets'
import { useScrollIndicator, useVIntl } from '@modrinth/ui'
import { analyticsChartMessages } from '../../analytics-messages'
export type AnalyticsChartTooltipEntry = {
projectId: string
name: string
projectName?: string
color: string
formattedValue: string
hidden: boolean
toggleDisabled: boolean
isPreviousPeriod?: boolean
}
const props = defineProps<{
visible: boolean
x: number
y: number
start: Date | null
end: Date | null
previousStart: Date | null
previousEnd: Date | null
chartStart: Date | null
chartEnd: Date | null
formattedTotal: string
entries: AnalyticsChartTooltipEntry[]
containerWidth: number
containerHeight: number
pinned: boolean
ratioMode: boolean
capitalizeLabels: boolean
shiftKeyPressed: boolean
}>()
const emit = defineEmits<{
'entry-click': [projectId: string, shiftKey: boolean]
'entry-hover': [projectId: string]
'entry-hover-clear': [projectId: string]
}>()
const { formatMessage } = useVIntl()
function onEntryClick(event: MouseEvent, entry: AnalyticsChartTooltipEntry) {
if (entry.toggleDisabled && !event.shiftKey) return
emit('entry-click', entry.projectId, event.shiftKey)
}
function getEntryAriaLabel(entry: AnalyticsChartTooltipEntry) {
return formatMessage(
entry.hidden
? analyticsChartMessages.showEntryInGraph
: analyticsChartMessages.hideEntryInGraph,
{ name: entry.name },
)
}
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const ONE_HOUR_MS = 60 * 60 * 1000
const ONE_MINUTE_MS = 60 * 1000
function formatRangeLabel(
start: Date,
end: Date,
chartStart: Date | null,
chartEnd: Date | null,
): string {
const includeTime = end.getTime() - start.getTime() < ONE_DAY_MS
const yearsDiffer = start.getFullYear() !== end.getFullYear()
const chartYearsDiffer =
chartStart !== null && chartEnd !== null && chartStart.getFullYear() !== chartEnd.getFullYear()
const rangeYearDiffersFromChart =
chartStart !== null && start.getFullYear() !== chartStart.getFullYear()
const showTrailingYear = !yearsDiffer && (chartYearsDiffer || rangeYearDiffersFromChart)
const monthsDiffer = yearsDiffer || start.getMonth() !== end.getMonth()
const timeOptions: Intl.DateTimeFormatOptions = includeTime
? { hour: 'numeric', minute: '2-digit', hour12: true }
: {}
const startOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
...(yearsDiffer ? { year: 'numeric' } : {}),
...timeOptions,
}
if (includeTime) {
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(undefined, timeOptions).format(end)
const range = `${startLabel}${endLabel}`
if (!showTrailingYear) return range
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
return `${range}, ${yearLabel}`
}
let endOptions: Intl.DateTimeFormatOptions
if (yearsDiffer) {
endOptions = { month: 'short', day: 'numeric', year: 'numeric' }
} else if (monthsDiffer) {
endOptions = { month: 'short', day: 'numeric' }
} else {
endOptions = { day: 'numeric' }
}
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(undefined, endOptions).format(end)
const range = `${startLabel}${endLabel}`
if (!showTrailingYear) return range
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
return `${range}, ${yearLabel}`
}
function formatDurationLabel(start: Date, end: Date): string {
const durationMs = end.getTime() - start.getTime()
if (!Number.isFinite(durationMs) || durationMs <= 0) return ''
if (durationMs >= ONE_DAY_MS) {
const days = Math.round(durationMs / ONE_DAY_MS)
return formatMessage(analyticsChartMessages.durationDays, { count: days })
}
if (durationMs >= ONE_HOUR_MS) {
const hours = Math.round(durationMs / ONE_HOUR_MS)
return formatMessage(analyticsChartMessages.durationHours, { count: hours })
}
const minutes = Math.max(1, Math.round(durationMs / ONE_MINUTE_MS))
return formatMessage(analyticsChartMessages.durationMinutes, { count: minutes })
}
const rangeLabel = computed(() =>
props.start && props.end
? formatRangeLabel(props.start, props.end, props.chartStart, props.chartEnd)
: '',
)
const durationLabel = computed(() =>
props.start && props.end ? formatDurationLabel(props.start, props.end) : '',
)
const previousRangeLabel = computed(() =>
props.previousStart && props.previousEnd
? formatRangeLabel(props.previousStart, props.previousEnd, props.chartStart, props.chartEnd)
: '',
)
const tooltipElement = ref<HTMLDivElement | null>(null)
const entriesElement = ref<HTMLDivElement | null>(null)
const {
showTopFade: showEntriesTopFade,
showBottomFade: showEntriesBottomFade,
checkScrollState: checkEntriesScrollState,
forceCheck: forceCheckEntriesScrollState,
} = useScrollIndicator(entriesElement)
const tooltipWidth = ref(0)
const tooltipHeight = ref(0)
const entriesTopOffset = ref(0)
const entriesBottomOffset = ref(0)
const tooltipOffsetParentLeft = ref(0)
const viewportWidth = ref(0)
let entriesTouchStartY = 0
let entriesTouchStartScrollTop = 0
const CURSOR_OFFSET = 12
const EDGE_PADDING = 8
const TOOLTIP_MAX_WIDTH = 26 * 16
const WHEEL_DELTA_LINE = 1
const WHEEL_DELTA_PAGE = 2
const WHEEL_LINE_HEIGHT = 16
function getTooltipFallbackWidth() {
const availableWidth = (viewportWidth.value || props.containerWidth) - EDGE_PADDING * 2
if (availableWidth <= 0) return TOOLTIP_MAX_WIDTH
return Math.min(TOOLTIP_MAX_WIDTH, availableWidth)
}
function updateTooltipMeasurements() {
nextTick(() => {
const element = tooltipElement.value
if (!element) return
tooltipWidth.value = element.offsetWidth
tooltipHeight.value = element.offsetHeight
const entries = entriesElement.value
if (entries) {
entriesTopOffset.value = entries.offsetTop
entriesBottomOffset.value = Math.max(
0,
element.offsetHeight - entries.offsetTop - entries.offsetHeight,
)
}
const offsetParent =
element.offsetParent instanceof HTMLElement ? element.offsetParent : element.parentElement
tooltipOffsetParentLeft.value = offsetParent?.getBoundingClientRect().left ?? 0
viewportWidth.value =
document.documentElement.clientWidth || window.innerWidth || props.containerWidth
forceCheckEntriesScrollState()
})
}
watch(
() => [
props.visible,
props.entries,
rangeLabel.value,
durationLabel.value,
previousRangeLabel.value,
props.pinned,
props.containerWidth,
props.containerHeight,
],
updateTooltipMeasurements,
{ deep: true, immediate: true },
)
onMounted(() => {
updateTooltipMeasurements()
window.addEventListener('resize', updateTooltipMeasurements)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateTooltipMeasurements)
})
function getNormalizedWheelDeltaY(event: WheelEvent, element: HTMLElement) {
if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * element.clientHeight
if (event.deltaMode === WHEEL_DELTA_LINE) return event.deltaY * WHEEL_LINE_HEIGHT
return event.deltaY
}
function getMaxScrollTop(element: HTMLElement) {
return Math.max(0, element.scrollHeight - element.clientHeight)
}
function consumeWheel(event: WheelEvent): boolean {
const element = entriesElement.value
if (!props.visible || !element) return false
const maxScrollTop = getMaxScrollTop(element)
if (maxScrollTop <= 0) return false
const deltaY = getNormalizedWheelDeltaY(event, element)
if (deltaY === 0) return false
const scrollTop = element.scrollTop
element.scrollTop = Math.min(maxScrollTop, Math.max(0, scrollTop + deltaY))
event.preventDefault()
return true
}
function onEntriesTouchStart(event: TouchEvent) {
const element = entriesElement.value
const touch = event.touches[0]
if (!element || !touch) return
entriesTouchStartY = touch.clientY
entriesTouchStartScrollTop = element.scrollTop
}
function onEntriesTouchMove(event: TouchEvent) {
const element = entriesElement.value
const touch = event.touches[0]
if (!props.visible || !element || !touch) return
const maxScrollTop = getMaxScrollTop(element)
if (maxScrollTop <= 0) return
const nextScrollTop = Math.min(
maxScrollTop,
Math.max(0, entriesTouchStartScrollTop + entriesTouchStartY - touch.clientY),
)
element.scrollTop = nextScrollTop
event.preventDefault()
event.stopPropagation()
}
function clearEntriesTouchScroll() {
entriesTouchStartY = 0
entriesTouchStartScrollTop = 0
}
defineExpose({
consumeWheel,
})
const positionStyle = computed(() => {
const tooltipMaxWidth = getTooltipFallbackWidth()
const tooltipWidthForPosition = tooltipWidth.value || tooltipMaxWidth
const desiredLeft = props.x + CURSOR_OFFSET
const viewportRight = viewportWidth.value || tooltipOffsetParentLeft.value + props.containerWidth
const desiredViewportRight = tooltipOffsetParentLeft.value + desiredLeft + tooltipWidthForPosition
const shouldPlaceLeft =
props.x <= props.containerWidth / 4 || desiredViewportRight > viewportRight - EDGE_PADDING
const candidateLeft = shouldPlaceLeft
? props.x - tooltipWidthForPosition - CURSOR_OFFSET
: desiredLeft
const minLeft = EDGE_PADDING - tooltipOffsetParentLeft.value
const maxLeft = Math.max(
minLeft,
viewportRight - tooltipOffsetParentLeft.value - tooltipWidthForPosition - EDGE_PADDING,
)
const clampedLeft = Math.min(maxLeft, Math.max(minLeft, candidateLeft))
const desiredTop = props.y - tooltipHeight.value / 2
const maxTop = Math.max(EDGE_PADDING, props.containerHeight - tooltipHeight.value - EDGE_PADDING)
const clampedTop = Math.min(maxTop, Math.max(EDGE_PADDING, desiredTop))
return {
'--analytics-chart-tooltip-max-width': `${tooltipMaxWidth}px`,
'--analytics-chart-tooltip-entries-top': `${entriesTopOffset.value}px`,
'--analytics-chart-tooltip-entries-bottom': `${entriesBottomOffset.value}px`,
transform: `translate3d(${clampedLeft}px, ${clampedTop}px, 0)`,
}
})
</script>
<style scoped>
.analytics-chart-tooltip {
min-width: min(14rem, var(--analytics-chart-tooltip-max-width, calc(100vw - 1rem)));
max-width: var(--analytics-chart-tooltip-max-width, min(26rem, calc(100vw - 1rem)));
transition: transform 750ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.analytics-chart-tooltip-entries {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
}
.analytics-chart-tooltip-entries-fade-top {
top: var(--analytics-chart-tooltip-entries-top, 0rem);
}
.analytics-chart-tooltip-entries-fade-bottom {
bottom: var(--analytics-chart-tooltip-entries-bottom, 0rem);
}
@media (pointer: coarse) {
.analytics-chart-tooltip {
pointer-events: auto;
}
}
</style>
@@ -0,0 +1,219 @@
<template>
<div
ref="chartContainer"
class="relative -ml-4 h-[460px] select-none"
@click="onChartClick"
@wheel.capture="onChartWheel"
>
<div :class="['h-full']">
<div v-if="showEmptyChartState" class="flex h-full items-center justify-center rounded-xl">
<div v-if="!isDataLoading" class="relative bottom-6 text-base font-normal text-secondary">
{{ emptyChartMessage }}
</div>
</div>
<template v-else>
<ClientOnly>
<AnalyticsChartClient
:type="chartType"
:fill="isArea"
:stacked="isStacked"
:ratio-mode="isRatioMode"
:datasets="visibleChartDatasets"
:labels="chartLabels"
:x-axis-tick-limit="xAxisTickLimit"
:active-stat="activeStat"
:pinned-slice-index="pinnedSliceIndex"
:highlighted-dataset-id="highlightedChartDatasetId"
@hover="onChartHover"
@geometry="onChartGeometry"
@pinned-drag="onPinnedDrag"
@range-select="onRangeSelect"
@touch-drag="onTouchDragEnd"
/>
</ClientOnly>
<AnalyticsChartEvents
v-if="hasVisibleTimelineEvents"
:events="visibleTimelineEvents"
:active-stat="activeStat"
:group-by="selectedGroupBy"
:chart-start="chartRangeBounds?.start ?? null"
:chart-end="chartRangeBounds?.end ?? null"
:geometry="chartGeometry"
/>
<div
v-if="showHoverGuide"
aria-hidden="true"
class="pointer-events-none absolute bottom-0 left-0 top-0 z-10 mb-[1.8rem] mt-2.5 border-0 border-l border-solid border-contrast opacity-25"
:style="{ transform: `translate(${hoverState.x}px, 0)` }"
/>
<div
v-if="showPinnedGuide"
aria-hidden="true"
class="pointer-events-none absolute bottom-0 left-0 top-0 z-10 mb-[1.8rem] mt-2.5 border-0 border-l border-dashed border-green opacity-75"
:style="{ transform: `translate(${hoverState.x}px, 0)` }"
/>
<AnalyticsChartTooltip
ref="chartTooltip"
:visible="hoverState.visible"
:x="hoverState.x"
:y="hoverState.y"
:start="hoverBucketRange?.start ?? null"
:end="hoverBucketRange?.end ?? null"
:previous-start="previousHoverBucketRange?.start ?? null"
:previous-end="previousHoverBucketRange?.end ?? null"
:chart-start="chartRangeBounds?.start ?? null"
:chart-end="chartRangeBounds?.end ?? null"
:formatted-total="hoverFormattedTotal"
:entries="hoverEntries"
:container-width="containerSize.width"
:container-height="containerSize.height"
:pinned="isHoverPinned"
:ratio-mode="isRatioMode"
:capitalize-labels="shouldCapitalizeDatasetLabels"
:shift-key-pressed="isShiftKeyPressed"
@entry-click="(datasetId, shiftKey) => emit('entry-click', datasetId, shiftKey)"
@entry-hover="emit('entry-hover', $event)"
@entry-hover-clear="emit('entry-hover-clear', $event)"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { useFormatNumber, useVIntl } from '@modrinth/ui'
import type {
AnalyticsDashboardStat,
AnalyticsGroupByPreset,
} from '~/providers/analytics/analytics'
import type {
AnalyticsChartLegendEntry,
AnalyticsChartRangeBounds,
} from '../analytics-chart-types.ts'
import type { ChartDataset } from '../analytics-chart-utils.ts'
import { formatMetricValue } from '../analytics-chart-utils.ts'
import AnalyticsChartClient from '../AnalyticsChart.client.vue'
import AnalyticsChartEvents, { type AnalyticsChartEvent } from './AnalyticsChartEvents.vue'
import AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
import { useAnalyticsChartInteractions } from './use-analytics-chart-interactions.ts'
const props = defineProps<{
chartType: 'line' | 'bar'
isArea: boolean
isStacked: boolean
isRatioMode: boolean
isDataLoading: boolean
showEmptyChartState: boolean
emptyChartMessage: string
visibleChartDatasets: ChartDataset[]
chartLabels: string[]
xAxisTickLimit?: number
activeStat: AnalyticsDashboardStat
highlightedChartDatasetId: string | null
hasVisibleTimelineEvents: boolean
visibleTimelineEvents: AnalyticsChartEvent[]
selectedGroupBy: AnalyticsGroupByPreset
chartRangeBounds: AnalyticsChartRangeBounds | null
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null
sliceCount: number
shouldShowPreviousPeriod: boolean
allChartDatasets: ChartDataset[]
currentLegendEntries: AnalyticsChartLegendEntry[]
legendEntries: AnalyticsChartLegendEntry[]
chartDatasetById: Map<string, ChartDataset>
hoverRatioSliceTotals: number[]
shouldCapitalizeDatasetLabels: boolean
}>()
const emit = defineEmits<{
'range-select': [start: Date, end: Date, groupBy: AnalyticsGroupByPreset]
'entry-click': [datasetId: string, shiftKey: boolean]
'entry-hover': [datasetId: string]
'entry-hover-clear': [datasetId: string]
}>()
const formatNumber = useFormatNumber()
const { formatMessage } = useVIntl()
const {
chartContainer,
chartTooltip,
chartGeometry,
containerSize,
hoverState,
isHoverPinned,
isShiftKeyPressed,
onChartHover,
onPinnedDrag,
onTouchDragEnd,
onChartGeometry,
onRangeSelect,
onChartClick,
onChartWheel,
pinnedSliceIndex,
showHoverGuide,
showPinnedGuide,
hoverBucketRange,
previousHoverBucketRange,
} = useAnalyticsChartInteractions({
isDataLoading: computed(() => props.isDataLoading),
fetchRequest: computed(() => props.fetchRequest),
sliceCount: computed(() => props.sliceCount),
chartLabels: computed(() => props.chartLabels),
allChartDatasets: computed(() => props.allChartDatasets),
chartRangeBounds: computed(() => props.chartRangeBounds),
shouldShowPreviousPeriod: computed(() => props.shouldShowPreviousPeriod),
onRangeSelected: (start, end, groupBy) => emit('range-select', start, end, groupBy),
})
const hoverTotalValue = computed(() => {
if (hoverState.sliceIndex === null) return 0
const sliceIndex = hoverState.sliceIndex
if (props.isRatioMode) return props.hoverRatioSliceTotals[sliceIndex] ?? 0
return props.currentLegendEntries.reduce((sum, legendEntry) => {
if (legendEntry.hidden) return sum
const dataset = props.chartDatasetById.get(legendEntry.id)
return sum + (dataset?.data[sliceIndex] ?? 0)
}, 0)
})
const hoverFormattedTotal = computed(() => {
if (props.isRatioMode) {
return hoverTotalValue.value > 0 ? '100%' : '0%'
}
return formatMetricValue(hoverTotalValue.value, props.activeStat, formatNumber, formatMessage)
})
const hoverEntries = computed(() => {
if (hoverState.sliceIndex === null) return []
const sliceIndex = hoverState.sliceIndex
const totalValue = hoverTotalValue.value
return props.legendEntries.map((legendEntry) => {
const dataset = props.chartDatasetById.get(legendEntry.id)
const value = dataset?.data[sliceIndex] ?? 0
const ratioValue = legendEntry.hidden || totalValue === 0 ? 0 : (value / totalValue) * 100
return {
projectId: legendEntry.id,
name: legendEntry.name,
projectName: legendEntry.projectName,
color: legendEntry.color,
formattedValue: props.isRatioMode
? `${ratioValue.toFixed(1)}%`
: formatMetricValue(value, props.activeStat, formatNumber, formatMessage),
hidden: legendEntry.hidden,
toggleDisabled: !legendEntry.hidden && isLegendEntryToggleDisabled(legendEntry),
isPreviousPeriod: legendEntry.isPreviousPeriod,
}
})
})
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
if (legendEntry.hidden) return false
const visibleCount = props.legendEntries.filter((entry) => !entry.hidden).length
return visibleCount <= 1
}
</script>
@@ -0,0 +1,232 @@
import type { Labrinth } from '@modrinth/api-client'
import { injectModrinthClient, useVIntl } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, type ComputedRef } from 'vue'
import type { AnalyticsDashboardContextValue } from '~/providers/analytics/analytics'
import { analyticsProjectEventMessages, type FormatMessage } from '../../analytics-messages.ts'
import {
PROJECT_EVENT_DATE_FORMATTER,
PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS,
VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET,
type VisibleProjectStatusChangeEventStatus,
} from '../analytics-chart-constants.ts'
import type { AnalyticsChartRangeBounds } from '../analytics-chart-types.ts'
import type { AnalyticsChartEvent } from './AnalyticsChartEvents.vue'
const analyticsEventsQueryKey = ['analytics-events'] as const
export function useAnalyticsChartEvents(
context: Pick<
AnalyticsDashboardContextValue,
| 'activeStat'
| 'showChartEvents'
| 'showProjectEvents'
| 'displayedProjectEvents'
| 'hasCompletedAnalyticsLoading'
>,
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>,
selectedProjectNameById: ComputedRef<Map<string, string>>,
selectedProjectEventIdSet: ComputedRef<Set<string>>,
visibleProjectEventIdSet: ComputedRef<Set<string>>,
) {
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
const { data: analyticsEvents } = useQuery({
queryKey: analyticsEventsQueryKey,
queryFn: () => client.labrinth.analytics_v3.getEvents(),
enabled: computed(() => context.hasCompletedAnalyticsLoading.value),
placeholderData: [],
refetchOnMount: 'always',
retry: false,
staleTime: 0,
})
const localAnalyticsChartEvents = computed(() => analyticsEvents.value ?? [])
const hasChartEvents = computed(() =>
localAnalyticsChartEvents.value.some(isTimelineEventVisibleInCurrentGraph),
)
const visibleModrinthChartEvents = computed<AnalyticsChartEvent[]>(() =>
context.showChartEvents.value
? localAnalyticsChartEvents.value.map((event) => ({
...event,
markerIcon: 'info' as const,
groupKey: 'modrinth',
}))
: [],
)
const localProjectChartEvents = computed<AnalyticsChartEvent[]>(() =>
dedupeProjectVersionUploadEvents(
context.displayedProjectEvents.value.filter(
(event) =>
selectedProjectEventIdSet.value.has(event.project_id) && shouldShowProjectEvent(event),
),
).map((event) => ({
title: getProjectEventTitle(event, formatMessage),
starts: event.timestamp,
ends: event.timestamp,
projectId: event.project_id,
projectName: selectedProjectNameById.value.get(event.project_id),
subtitle: formatProjectEventDate(event.timestamp),
markerIcon: 'flag' as const,
groupKey: 'project',
})),
)
const hasProjectEvents = computed(() =>
localProjectChartEvents.value.some(
(event) =>
isProjectChartEventVisibleForLegend(event) && isTimelineEventVisibleInCurrentGraph(event),
),
)
const visibleProjectChartEvents = computed(() =>
context.showProjectEvents.value
? localProjectChartEvents.value.filter(isProjectChartEventVisibleForLegend)
: [],
)
const visibleTimelineEvents = computed(() => [
...visibleModrinthChartEvents.value,
...visibleProjectChartEvents.value,
])
const hasVisibleTimelineEvents = computed(
() => visibleModrinthChartEvents.value.length > 0 || visibleProjectChartEvents.value.length > 0,
)
function isTimelineEventVisibleInCurrentGraph(event: AnalyticsChartEvent) {
const rangeBounds = chartRangeBounds.value
if (!rangeBounds) return false
if (!doesTimelineEventMatchActiveStat(event)) return false
const eventStartMs = new Date(event.starts).getTime()
const eventEndMs = new Date(event.ends).getTime()
if (!Number.isFinite(eventStartMs) || !Number.isFinite(eventEndMs)) return false
if (eventEndMs < eventStartMs) return false
return eventEndMs >= rangeBounds.start.getTime() && eventStartMs <= rangeBounds.end.getTime()
}
function doesTimelineEventMatchActiveStat(event: AnalyticsChartEvent) {
if (!event.for_metric_kind?.length) return true
return event.for_metric_kind.some((metricKind) => metricKind === context.activeStat.value)
}
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
}
return {
localAnalyticsChartEvents,
hasChartEvents,
visibleModrinthChartEvents,
localProjectChartEvents,
hasProjectEvents,
visibleProjectChartEvents,
visibleTimelineEvents,
hasVisibleTimelineEvents,
isTimelineEventVisibleInCurrentGraph,
isProjectChartEventVisibleForLegend,
}
}
function getProjectEventTitle(
event: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
formatMessage: FormatMessage,
) {
if (event.kind === 'version_uploaded') {
const versionNumber = event.version_number.trim()
return versionNumber
? formatMessage(analyticsProjectEventMessages.versionReleased, { version: versionNumber })
: formatMessage(analyticsProjectEventMessages.versionUploaded)
}
if (isVisibleProjectStatusChangeEventStatus(event.status_to)) {
return getProjectStatusEventTitle(event.status_to, formatMessage)
}
return formatMessage(analyticsProjectEventMessages.projectStatusChanged)
}
function getProjectStatusEventTitle(
status: VisibleProjectStatusChangeEventStatus,
formatMessage: FormatMessage,
) {
switch (status) {
case 'approved':
return formatMessage(analyticsProjectEventMessages.projectApproved)
case 'unlisted':
return formatMessage(analyticsProjectEventMessages.projectUnlisted)
case 'private':
return formatMessage(analyticsProjectEventMessages.projectPrivate)
}
}
function shouldShowProjectEvent(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) {
if (event.kind !== 'status_changed') {
return true
}
return isVisibleProjectStatusChangeEventStatus(event.status_to)
}
function isVisibleProjectStatusChangeEventStatus(
status: Labrinth.Projects.v2.ProjectStatus,
): status is VisibleProjectStatusChangeEventStatus {
return VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET.has(status)
}
function dedupeProjectVersionUploadEvents(events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]) {
const keptEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[] = []
const keptVersionUploadEventsByKey = new Map<
string,
Labrinth.Analytics.v3.ProjectAnalyticsEvent[]
>()
for (const event of events) {
const key = getProjectVersionUploadDedupeKey(event)
if (!key) {
keptEvents.push(event)
continue
}
const matchingEvents = keptVersionUploadEventsByKey.get(key) ?? []
if (
matchingEvents.some((matchingEvent) =>
areProjectEventsWithinDedupeWindow(event, matchingEvent),
)
) {
continue
}
keptEvents.push(event)
matchingEvents.push(event)
keptVersionUploadEventsByKey.set(key, matchingEvents)
}
return keptEvents
}
function getProjectVersionUploadDedupeKey(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) {
if (event.kind !== 'version_uploaded') return null
const versionNumber = event.version_number.trim()
if (versionNumber.length === 0) return null
return `${event.project_id}:${versionNumber}`
}
function areProjectEventsWithinDedupeWindow(
left: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
right: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
) {
const leftTimestamp = new Date(left.timestamp).getTime()
const rightTimestamp = new Date(right.timestamp).getTime()
if (!Number.isFinite(leftTimestamp) || !Number.isFinite(rightTimestamp)) return false
return Math.abs(leftTimestamp - rightTimestamp) <= PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS
}
function formatProjectEventDate(timestamp: string) {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return timestamp
return PROJECT_EVENT_DATE_FORMATTER.format(date)
}
@@ -0,0 +1,303 @@
import type { Labrinth } from '@modrinth/api-client'
import { computed, type ComputedRef, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
import {
ensureMinimumTimeRange,
getDefaultAnalyticsGroupByForDurationMinutes,
} from '../../query-builder/timeframe.ts'
import type {
AnalyticsChartHoverState,
AnalyticsChartRangeBounds,
} from '../analytics-chart-types.ts'
import type { ChartDataset } from '../analytics-chart-utils.ts'
import { getSliceBucketRange } from '../analytics-chart-utils.ts'
import type {
AnalyticsChartGeometryPayload,
AnalyticsChartRangeSelectPayload,
} from '../AnalyticsChart.client.vue'
import type AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
export function useAnalyticsChartInteractions({
isDataLoading,
fetchRequest,
sliceCount,
chartLabels,
allChartDatasets,
chartRangeBounds,
shouldShowPreviousPeriod,
onRangeSelected,
}: {
isDataLoading: ComputedRef<boolean>
fetchRequest: ComputedRef<Labrinth.Analytics.v3.FetchRequest | null>
sliceCount: ComputedRef<number>
chartLabels: ComputedRef<string[]>
allChartDatasets: ComputedRef<ChartDataset[]>
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>
shouldShowPreviousPeriod: ComputedRef<boolean>
onRangeSelected: (start: Date, end: Date, groupBy: AnalyticsGroupByPreset) => void
}) {
const chartContainer = ref<HTMLElement | null>(null)
const chartTooltip = ref<InstanceType<typeof AnalyticsChartTooltip> | null>(null)
const chartGeometry = ref<AnalyticsChartGeometryPayload | null>(null)
const containerSize = reactive({ width: 0, height: 0 })
const hoverState = reactive<AnalyticsChartHoverState>({
visible: false,
x: 0,
y: 0,
sliceIndex: null,
})
const isHoverPinned = ref(false)
const ignoreNextChartClick = ref(false)
const isShiftKeyPressed = ref(false)
let resizeObserver: ResizeObserver | null = null
let clearIgnoredChartClickTimeout: ReturnType<typeof setTimeout> | null = null
function setHoverState(payload: AnalyticsChartHoverState) {
hoverState.visible = payload.visible
hoverState.x = payload.x
hoverState.y = payload.y
hoverState.sliceIndex = payload.sliceIndex
}
function clearHoverState() {
hoverState.visible = false
hoverState.sliceIndex = null
}
function unpinHoverState() {
isHoverPinned.value = false
clearHoverState()
}
function updateShiftKeyState(event: KeyboardEvent) {
isShiftKeyPressed.value = event.shiftKey
}
function clearShiftKeyState() {
isShiftKeyPressed.value = false
}
function onDocumentClick(event: MouseEvent) {
if (!isHoverPinned.value) return
if (event.target instanceof Node && chartContainer.value?.contains(event.target)) return
unpinHoverState()
}
function onChartHover(payload: AnalyticsChartHoverState) {
if (isDataLoading.value) return
if (isHoverPinned.value) return
setHoverState(payload)
}
function ignoreUpcomingChartClick() {
ignoreNextChartClick.value = true
if (clearIgnoredChartClickTimeout) {
clearTimeout(clearIgnoredChartClickTimeout)
}
clearIgnoredChartClickTimeout = setTimeout(() => {
ignoreNextChartClick.value = false
clearIgnoredChartClickTimeout = null
}, 350)
}
function onPinnedDrag(payload: AnalyticsChartHoverState) {
if (isDataLoading.value || !isHoverPinned.value) return
ignoreUpcomingChartClick()
setHoverState(payload)
}
function onTouchDragEnd() {
ignoreUpcomingChartClick()
}
function onChartGeometry(payload: AnalyticsChartGeometryPayload) {
chartGeometry.value = payload
}
function getDefaultGroupByForRange(start: Date, end: Date) {
const ensuredRange = ensureMinimumTimeRange(start, end)
const durationMinutes = Math.max(
1,
Math.floor((ensuredRange.end.getTime() - ensuredRange.start.getTime()) / 60000),
)
return getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes)
}
function onRangeSelect(payload: AnalyticsChartRangeSelectPayload) {
if (isDataLoading.value) return
const nextFetchRequest = fetchRequest.value
if (!nextFetchRequest) return
if (payload.startSliceIndex === payload.endSliceIndex) {
ignoreUpcomingChartClick()
return
}
const startSliceIndex = Math.min(payload.startSliceIndex, payload.endSliceIndex)
const endSliceIndex = Math.max(payload.startSliceIndex, payload.endSliceIndex)
const startBucketRange = getSliceBucketRange(
nextFetchRequest.time_range,
sliceCount.value,
startSliceIndex,
)
const endBucketRange = getSliceBucketRange(
nextFetchRequest.time_range,
sliceCount.value,
endSliceIndex,
)
const start = startBucketRange.start
const end = endBucketRange.end
if (
!Number.isFinite(start.getTime()) ||
!Number.isFinite(end.getTime()) ||
end.getTime() <= start.getTime()
) {
return
}
ignoreUpcomingChartClick()
unpinHoverState()
onRangeSelected(start, end, getDefaultGroupByForRange(start, end))
}
function onChartClick() {
if (isDataLoading.value) return
if (ignoreNextChartClick.value) {
ignoreNextChartClick.value = false
return
}
if (!hoverState.visible || hoverState.sliceIndex === null) {
if (isHoverPinned.value) {
unpinHoverState()
}
return
}
if (isHoverPinned.value) {
unpinHoverState()
return
}
isHoverPinned.value = true
}
function onChartWheel(event: WheelEvent) {
if (isAnalyticsEventTooltipTrigger(event.target)) return
if (!hoverState.visible) return
chartTooltip.value?.consumeWheel(event)
}
function isAnalyticsEventTooltipTrigger(target: EventTarget | null) {
return (
target instanceof Element && target.closest('[data-analytics-event-tooltip-trigger]') !== null
)
}
const pinnedSliceIndex = computed(() => (isHoverPinned.value ? hoverState.sliceIndex : null))
const showHoverGuide = computed(
() =>
!isDataLoading.value &&
!isHoverPinned.value &&
hoverState.visible &&
hoverState.sliceIndex !== null,
)
const showPinnedGuide = computed(
() =>
!isDataLoading.value &&
isHoverPinned.value &&
hoverState.visible &&
hoverState.sliceIndex !== null,
)
const hoverBucketRange = computed(() => {
const nextFetchRequest = fetchRequest.value
if (!nextFetchRequest || hoverState.sliceIndex === null) return null
return getSliceBucketRange(nextFetchRequest.time_range, sliceCount.value, hoverState.sliceIndex)
})
const previousHoverBucketRange = computed(() => {
if (!shouldShowPreviousPeriod.value) return null
const bucketRange = hoverBucketRange.value
const rangeBounds = chartRangeBounds.value
if (!bucketRange || !rangeBounds) return null
const periodMs = rangeBounds.end.getTime() - rangeBounds.start.getTime()
if (!Number.isFinite(periodMs) || periodMs <= 0) return null
return {
start: new Date(bucketRange.start.getTime() - periodMs),
end: new Date(bucketRange.end.getTime() - periodMs),
}
})
onMounted(() => {
if (chartContainer.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
containerSize.width = entry.contentRect.width
containerSize.height = entry.contentRect.height
})
resizeObserver.observe(chartContainer.value)
}
window.addEventListener('keydown', updateShiftKeyState)
window.addEventListener('keyup', updateShiftKeyState)
window.addEventListener('blur', clearShiftKeyState)
document.addEventListener('click', onDocumentClick, true)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
resizeObserver = null
window.removeEventListener('keydown', updateShiftKeyState)
window.removeEventListener('keyup', updateShiftKeyState)
window.removeEventListener('blur', clearShiftKeyState)
document.removeEventListener('click', onDocumentClick, true)
if (clearIgnoredChartClickTimeout) {
clearTimeout(clearIgnoredChartClickTimeout)
clearIgnoredChartClickTimeout = null
}
})
watch([chartLabels, allChartDatasets], () => {
isHoverPinned.value = false
clearHoverState()
})
watch(isDataLoading, (loading) => {
if (!loading) return
isHoverPinned.value = false
clearHoverState()
})
return {
chartContainer,
chartTooltip,
chartGeometry,
containerSize,
hoverState,
isHoverPinned,
isShiftKeyPressed,
setHoverState,
clearHoverState,
unpinHoverState,
onChartHover,
onPinnedDrag,
onTouchDragEnd,
onChartGeometry,
onRangeSelect,
onChartClick,
onChartWheel,
pinnedSliceIndex,
showHoverGuide,
showPinnedGuide,
hoverBucketRange,
previousHoverBucketRange,
}
}
@@ -0,0 +1,50 @@
import { computed, type ComputedRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
export function useAnalyticsChartLayout(showEmptyChartState: ComputedRef<boolean>) {
const graphSection = ref<HTMLElement | null>(null)
const rememberedGraphSectionHeight = ref(0)
const graphSectionStyle = computed(() =>
showEmptyChartState.value && rememberedGraphSectionHeight.value > 0
? { height: `${rememberedGraphSectionHeight.value}px` }
: undefined,
)
let graphSectionResizeObserver: ResizeObserver | null = null
function rememberGraphSectionHeight() {
if (!graphSection.value) return
const height = graphSection.value.getBoundingClientRect().height
if (height > 0) {
rememberedGraphSectionHeight.value = height
}
}
onMounted(() => {
if (graphSection.value && typeof ResizeObserver !== 'undefined') {
graphSectionResizeObserver = new ResizeObserver(() => {
if (showEmptyChartState.value) return
rememberGraphSectionHeight()
})
graphSectionResizeObserver.observe(graphSection.value)
}
})
onBeforeUnmount(() => {
graphSectionResizeObserver?.disconnect()
graphSectionResizeObserver = null
})
watch(showEmptyChartState, (showEmpty) => {
if (showEmpty) {
rememberGraphSectionHeight()
} else {
nextTick(rememberGraphSectionHeight)
}
})
return {
graphSection,
graphSectionStyle,
rememberGraphSectionHeight,
}
}
@@ -0,0 +1,21 @@
export type AnalyticsChartRangeBounds = {
start: Date
end: Date
}
export type AnalyticsChartHoverState = {
visible: boolean
x: number
y: number
sliceIndex: number | null
}
export type AnalyticsChartLegendEntry = {
id: string
name: string
projectName?: string
color: string
totalValue: number
hidden: boolean
isPreviousPeriod?: boolean
}
@@ -0,0 +1,862 @@
import type { Labrinth } from '@modrinth/api-client'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardProject,
AnalyticsDashboardStat,
AnalyticsGroupByPreset,
} from '~/providers/analytics/analytics'
import {
analyticsChartMessages,
analyticsMessages,
analyticsStatCardMessages,
formatAnalyticsDownloadReasonLabel,
formatAnalyticsLoaderLabel,
formatAnalyticsMonetizationLabel,
type FormatMessage,
} from '../analytics-messages'
import {
ALL_BREAKDOWN_VALUE,
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
getAnalyticsBreakdownDatasetId,
getAnalyticsBreakdownKey,
getAnalyticsBreakdownValues,
UNKNOWN_BREAKDOWN_VALUE,
} from '../breakdown'
import { PREVIOUS_PERIOD_DATASET_ID_PREFIX } from './analytics-chart-constants'
export type ChartDataset = {
projectId: string
label: string
projectName?: string
data: number[]
borderColor: string
backgroundColor: string
borderDash?: number[]
}
export function getChartDatasetTotal(dataset: ChartDataset) {
return dataset.data.reduce((sum, value) => sum + value, 0)
}
export function getPreviousPeriodDatasetId(datasetId: string) {
return `${PREVIOUS_PERIOD_DATASET_ID_PREFIX}${datasetId}`
}
export function decodeBreakdownDatasetValue(value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
export function areStringArraysEqual(left: string[], right: string[]) {
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false
}
return true
}
const LOADER_CHART_COLORS: Record<string, string> = {
fabric: 'var(--color-platform-fabric)',
'legacy-fabric': 'var(--color-platform-fabric)',
quilt: 'var(--color-platform-quilt)',
forge: 'var(--color-platform-forge)',
neoforge: 'var(--color-platform-neoforge)',
neo_forge: 'var(--color-platform-neoforge)',
liteloader: 'var(--color-platform-liteloader)',
bukkit: 'var(--color-platform-bukkit)',
bungeecord: 'var(--color-platform-bungeecord)',
folia: 'var(--color-platform-folia)',
paper: 'var(--color-platform-paper)',
purpur: 'var(--color-platform-purpur)',
spigot: 'var(--color-platform-spigot)',
velocity: 'var(--color-platform-velocity)',
waterfall: 'var(--color-platform-waterfall)',
sponge: 'var(--color-platform-sponge)',
ornithe: 'var(--color-platform-ornithe)',
'bta-babric': 'var(--color-platform-bta-babric)',
nilloader: 'var(--color-platform-nilloader)',
}
const REGION_CODE_PATTERN = /^[a-z]{2}$/i
const OTHER_COUNTRY_CODE = 'XX'
const ALL_PROJECTS_DATASET_ID = 'all'
const MONETIZATION_CHART_COLOR_INDEX: Record<string, number> = {
monetized: 0,
unmonetized: 1,
}
const regionDisplayNamesByLocale = new Map<string, Intl.DisplayNames | null>()
function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
if (regionDisplayNamesByLocale.has(locale)) {
return regionDisplayNamesByLocale.get(locale) ?? null
}
try {
const displayNames = new Intl.DisplayNames(locale, { type: 'region' })
regionDisplayNamesByLocale.set(locale, displayNames)
return displayNames
} catch {
regionDisplayNamesByLocale.set(locale, null)
return null
}
}
function formatCountryCode(countryCode: string, formatMessage: FormatMessage): string {
const normalized = countryCode.trim().toUpperCase()
if (normalized === OTHER_COUNTRY_CODE) {
return formatMessage(analyticsMessages.unknown)
}
if (!REGION_CODE_PATTERN.test(normalized)) {
return countryCode
}
const locale = new Intl.DateTimeFormat().resolvedOptions().locale || 'en'
const localizedDisplayNames = getRegionDisplayNames(locale)
const localizedValue = localizedDisplayNames?.of(normalized)
if (localizedValue && localizedValue !== normalized) {
return localizedValue
}
const englishDisplayNames = getRegionDisplayNames('en')
const englishValue = englishDisplayNames?.of(normalized)
if (englishValue && englishValue !== normalized) {
return englishValue
}
return countryCode
}
export function formatBreakdownLabel(
breakdownValue: string,
selectedBreakdown: AnalyticsBreakdownPreset,
getVersionDisplayName: ((versionId: string) => string) | undefined,
formatMessage: FormatMessage,
): string {
const normalizedValue = breakdownValue.trim()
const normalizedLowercaseValue = normalizedValue.toLowerCase()
if (
normalizedValue === UNKNOWN_BREAKDOWN_VALUE ||
normalizedLowercaseValue === 'other' ||
normalizedLowercaseValue === 'unknown'
) {
return formatMessage(analyticsMessages.unknown)
}
if (selectedBreakdown === 'country') {
return formatCountryCode(breakdownValue, formatMessage)
}
if (selectedBreakdown === 'monetization') {
return formatAnalyticsMonetizationLabel(normalizedLowercaseValue, formatMessage)
}
if (selectedBreakdown === 'download_reason') {
return formatAnalyticsDownloadReasonLabel(normalizedLowercaseValue, formatMessage)
}
if (selectedBreakdown === 'version_id') {
return getVersionDisplayName?.(breakdownValue) ?? breakdownValue
}
if (selectedBreakdown === 'loader') {
return formatAnalyticsLoaderLabel(normalizedValue, formatMessage)
}
return breakdownValue
}
export function formatBreakdownLabels(
breakdownValues: readonly string[],
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
getVersionDisplayName: ((versionId: string) => string) | undefined,
formatMessage: FormatMessage,
): string {
return collapseRepeatedUnknownBreakdownLabels(
selectedBreakdowns
.filter((breakdown) => breakdown !== 'none')
.map((breakdown, index) =>
formatBreakdownLabel(
breakdownValues[index] ?? '',
breakdown,
getVersionDisplayName,
formatMessage,
),
),
formatMessage,
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
}
function collapseRepeatedUnknownBreakdownLabels(
labels: string[],
formatMessage: FormatMessage,
): string[] {
let hasUnknownLabel = false
const collapsedLabels: string[] = []
const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown)
for (const label of labels) {
if (label === unknownBreakdownLabel) {
if (hasUnknownLabel) {
continue
}
hasUnknownLabel = true
}
collapsedLabels.push(label)
}
return collapsedLabels
}
export function shouldCapitalizeBreakdownLabel(
selectedBreakdown: AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[],
): boolean {
const selectedBreakdowns = Array.isArray(selectedBreakdown)
? selectedBreakdown
: [selectedBreakdown]
return (
selectedBreakdowns.length > 0 &&
selectedBreakdowns.every(
(breakdown) =>
breakdown === 'download_reason' ||
breakdown === 'monetization' ||
breakdown === 'loader' ||
breakdown === 'country',
)
)
}
function getBreakdownColor(
breakdownValue: string,
selectedBreakdown: AnalyticsBreakdownPreset,
fallbackColor: string,
palette: string[],
): string {
if (selectedBreakdown === 'monetization') {
const colorIndex = MONETIZATION_CHART_COLOR_INDEX[breakdownValue]
if (colorIndex !== undefined) {
return getPaletteColorForIndex(colorIndex, palette)
}
}
if (selectedBreakdown !== 'loader') {
return fallbackColor
}
const normalizedLoader = breakdownValue.trim().toLowerCase()
return LOADER_CHART_COLORS[normalizedLoader] ?? fallbackColor
}
type PaletteRankEntry = {
key: string
label: string
total: number
}
function getPaletteColorForIndex(index: number, palette: string[]): string {
if (palette.length === 0) return ''
return palette[index % palette.length]
}
function buildPaletteColorsByDownloadRank(
entries: PaletteRankEntry[],
palette: string[],
): Map<string, string> {
const colorsByKey = new Map<string, string>()
if (palette.length === 0) return colorsByKey
const sortedEntries = [...entries].sort(
(a, b) => b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
)
sortedEntries.forEach((entry, index) => {
colorsByKey.set(entry.key, getPaletteColorForIndex(index, palette))
})
return colorsByKey
}
export function getMetricValue(
point: Labrinth.Analytics.v3.ProjectAnalytics,
activeStat: AnalyticsDashboardStat,
): number {
switch (activeStat) {
case 'views':
return point.metric_kind === 'views' ? point.views : 0
case 'downloads':
return point.metric_kind === 'downloads' ? point.downloads : 0
case 'playtime':
return point.metric_kind === 'playtime' ? point.seconds : 0
case 'revenue': {
if (point.metric_kind !== 'revenue') return 0
const value = Number.parseFloat(point.revenue)
return Number.isFinite(value) ? value : 0
}
}
}
function isMetricKindForStat(
point: Labrinth.Analytics.v3.ProjectAnalytics,
activeStat: AnalyticsDashboardStat,
): boolean {
return point.metric_kind === activeStat
}
function isProjectAnalyticsPointInSelectedProjects(
point: Labrinth.Analytics.v3.AnalyticsData,
selectedProjectIds: Set<string>,
): point is Labrinth.Analytics.v3.ProjectAnalytics {
return 'source_project' in point && selectedProjectIds.has(point.source_project)
}
export function buildChartDatasets(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
selectedProjects: AnalyticsDashboardProject[],
activeStat: AnalyticsDashboardStat,
palette: string[],
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
getVersionDisplayName: ((versionId: string) => string) | undefined,
getVersionProjectName: ((versionId: string) => string | undefined) | undefined,
formatMessage: FormatMessage,
sliceCount: number = timeSlices.length,
): ChartDataset[] {
const selectedProjectIds = new Set(selectedProjects.map((project) => project.id))
if (selectedProjectIds.size === 0) {
return []
}
const dataLength = Math.max(sliceCount, timeSlices.length)
const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none')
const projectNamesById = new Map(selectedProjects.map((project) => [project.id, project.name]))
function formatChartBreakdownLabels(breakdownValues: readonly string[]): string {
return collapseRepeatedUnknownBreakdownLabels(
normalizedBreakdowns.map((breakdown, index) => {
const breakdownValue = breakdownValues[index] ?? ''
if (breakdown === 'project') {
return projectNamesById.get(breakdownValue) ?? breakdownValue
}
return formatBreakdownLabel(breakdownValue, breakdown, getVersionDisplayName, formatMessage)
}),
formatMessage,
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
}
if (
normalizedBreakdowns.length > 0 &&
!(normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'project')
) {
const dataByBreakdown = new Map<string, number[]>()
const breakdownValuesByKey = new Map<string, string[]>()
const downloadTotalsByBreakdown = new Map<string, number>()
timeSlices.forEach((slice, sliceIndex) => {
for (const point of slice) {
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
const breakdownValues = getAnalyticsBreakdownValues(
point,
normalizedBreakdowns,
formatMessage,
)
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
continue
}
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
if (!dataByBreakdown.has(breakdownKey)) {
dataByBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
breakdownValuesByKey.set(breakdownKey, breakdownValues)
}
if (point.metric_kind === 'downloads') {
downloadTotalsByBreakdown.set(
breakdownKey,
(downloadTotalsByBreakdown.get(breakdownKey) ?? 0) + getMetricValue(point, 'downloads'),
)
}
if (!isMetricKindForStat(point, activeStat)) continue
const breakdownData = dataByBreakdown.get(breakdownKey)
if (!breakdownData) continue
breakdownData[sliceIndex] += getMetricValue(point, activeStat)
}
})
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
Array.from(dataByBreakdown.keys()).map((breakdownKey) => ({
key: breakdownKey,
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
total: downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
})),
palette,
)
return Array.from(dataByBreakdown.entries()).map(([breakdownKey, data]) => {
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
const color =
normalizedBreakdowns.length === 1
? getBreakdownColor(
breakdownValues[0] ?? '',
normalizedBreakdowns[0],
fallbackColor,
palette,
)
: fallbackColor
return {
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
label: formatChartBreakdownLabels(breakdownValues),
projectName:
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
? getVersionProjectName?.(breakdownValues[0] ?? '')
: undefined,
data,
borderColor: color,
backgroundColor: color,
}
})
}
if (normalizedBreakdowns.length === 0) {
const data = new Array(dataLength).fill(0)
let downloadTotal = 0
timeSlices.forEach((slice, sliceIndex) => {
for (const point of slice) {
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
if (point.metric_kind === 'downloads') {
downloadTotal += getMetricValue(point, 'downloads')
}
if (!isMetricKindForStat(point, activeStat)) continue
data[sliceIndex] += getMetricValue(point, activeStat)
}
})
const color =
buildPaletteColorsByDownloadRank(
[
{
key: ALL_PROJECTS_DATASET_ID,
label: formatMessage(analyticsMessages.allProjects),
total: downloadTotal,
},
],
palette,
).get(ALL_PROJECTS_DATASET_ID) ?? ''
const selectedProject = selectedProjects.length === 1 ? selectedProjects[0] : undefined
return [
{
projectId: ALL_PROJECTS_DATASET_ID,
label: selectedProject?.name ?? formatMessage(analyticsMessages.allProjects),
data,
borderColor: color,
backgroundColor: color,
},
]
}
const dataByProjectBreakdown = new Map<string, number[]>()
const breakdownValuesByKey = new Map<string, string[]>()
const downloadTotalsByProjectBreakdown = new Map<string, number>()
for (const project of selectedProjects) {
const breakdownValues = [project.id]
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
breakdownValuesByKey.set(breakdownKey, breakdownValues)
downloadTotalsByProjectBreakdown.set(breakdownKey, 0)
}
timeSlices.forEach((slice, sliceIndex) => {
for (const point of slice) {
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
const breakdownValues = getAnalyticsBreakdownValues(
point,
normalizedBreakdowns,
formatMessage,
)
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
continue
}
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
if (!dataByProjectBreakdown.has(breakdownKey)) {
dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
breakdownValuesByKey.set(breakdownKey, breakdownValues)
downloadTotalsByProjectBreakdown.set(breakdownKey, 0)
}
if (point.metric_kind === 'downloads') {
downloadTotalsByProjectBreakdown.set(
breakdownKey,
(downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0) +
getMetricValue(point, 'downloads'),
)
}
if (!isMetricKindForStat(point, activeStat)) continue
const projectData = dataByProjectBreakdown.get(breakdownKey)
if (!projectData) continue
projectData[sliceIndex] += getMetricValue(point, activeStat)
}
})
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) => ({
key: breakdownKey,
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
total: downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
})),
palette,
)
return Array.from(dataByProjectBreakdown.entries()).map(([breakdownKey, data]) => {
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
const color =
normalizedBreakdowns.length === 1
? getBreakdownColor(
breakdownValues[0] ?? '',
normalizedBreakdowns[0],
fallbackColor,
palette,
)
: fallbackColor
return {
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
label: formatChartBreakdownLabels(breakdownValues),
projectName:
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
? getVersionProjectName?.(breakdownValues[0] ?? '')
: undefined,
data,
borderColor: color,
backgroundColor: color,
}
})
}
export function getSliceCount(
timeRange: Labrinth.Analytics.v3.TimeRange,
fallback: number,
): number {
if ('slices' in timeRange.resolution) {
return Math.max(1, timeRange.resolution.slices)
}
if ('minutes' in timeRange.resolution) {
const duration = new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime()
const bucketMs = timeRange.resolution.minutes * 60 * 1000
if (bucketMs > 0 && duration > 0) {
return Math.max(1, Math.ceil(duration / bucketMs))
}
}
return Math.max(1, fallback)
}
export function getSliceBucketRange(
timeRange: Labrinth.Analytics.v3.TimeRange,
sliceCount: number,
index: number,
): { start: Date; end: Date } {
const startMs = new Date(timeRange.start).getTime()
const endMs = new Date(timeRange.end).getTime()
const bucketMs = sliceCount > 0 ? (endMs - startMs) / sliceCount : 0
return {
start: new Date(startMs + index * bucketMs),
end: new Date(startMs + (index + 1) * bucketMs),
}
}
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const ONE_MINUTE_MS = 60 * 1000
const YEAR_LABEL_TIME_RANGE_YEARS = 2
const COMPACT_AXIS_THRESHOLD = 5
const SHORT_HOURLY_TIME_LABEL_DURATION_MS = 6 * ONE_DAY_MS
export const DEFAULT_X_AXIS_TICK_LIMIT = 12
export const SHORT_HOURLY_AXIS_TICK_LIMIT = 8
export function buildTimeAxisLabels(
timeRange: Labrinth.Analytics.v3.TimeRange,
sliceCount: number,
groupBy: AnalyticsGroupByPreset,
): string[] {
const startMs = new Date(timeRange.start).getTime()
const endMs = new Date(timeRange.end).getTime()
const totalMs = endMs - startMs
const bucketMs = sliceCount > 0 ? totalMs / sliceCount : 0
const includeTime = shouldShowTimeForHourlyAxis(timeRange, groupBy)
const includeYear = isYearRelevantForTimeRange(timeRange) || groupBy === 'year'
const dates: Date[] = []
const dateKeys: string[] = []
for (let i = 0; i < sliceCount; i++) {
const date = new Date(startMs + (i + 1) * bucketMs)
dates.push(date)
dateKeys.push(`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`)
}
const dateFormatter = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
})
if (!includeTime) {
return dates.map((date) => dateFormatter.format(date))
}
const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric' })
const uniqueDateCount = new Set(dateKeys).size
if (uniqueDateCount <= 1 || isSingleFullDayTimeRange(new Date(startMs), new Date(endMs))) {
return dates.map((date) => timeFormatter.format(date))
}
if (includeTime || sliceCount <= COMPACT_AXIS_THRESHOLD) {
const dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
})
return dates.map((date) => dateAndTimeFormatter.format(date))
}
return dates.map((date) => dateFormatter.format(date))
}
export function isTimeRelevantForGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
return groupBy === '1h' || groupBy === '6h'
}
export function shouldUseShortHourlyAxis(
timeRange: Labrinth.Analytics.v3.TimeRange,
groupBy: AnalyticsGroupByPreset,
): boolean {
if (!isTimeRelevantForGroupBy(groupBy)) {
return false
}
const durationMs = getTimeRangeDurationMs(timeRange)
return (
Number.isFinite(durationMs) &&
durationMs > 0 &&
durationMs <= DEFAULT_X_AXIS_TICK_LIMIT * ONE_DAY_MS
)
}
export function getShortHourlyAxisTickLimit(
timeRange: Labrinth.Analytics.v3.TimeRange,
groupBy: AnalyticsGroupByPreset,
): number | undefined {
if (!shouldUseShortHourlyAxis(timeRange, groupBy)) {
return undefined
}
const durationMs = getTimeRangeDurationMs(timeRange)
if (durationMs > SHORT_HOURLY_TIME_LABEL_DURATION_MS) {
return Math.min(DEFAULT_X_AXIS_TICK_LIMIT, Math.ceil(durationMs / ONE_DAY_MS))
}
return SHORT_HOURLY_AXIS_TICK_LIMIT
}
function shouldShowTimeForHourlyAxis(
timeRange: Labrinth.Analytics.v3.TimeRange,
groupBy: AnalyticsGroupByPreset,
): boolean {
const durationMs = getTimeRangeDurationMs(timeRange)
return (
isTimeRelevantForGroupBy(groupBy) &&
Number.isFinite(durationMs) &&
durationMs > 0 &&
durationMs <= SHORT_HOURLY_TIME_LABEL_DURATION_MS
)
}
function getTimeRangeDurationMs(timeRange: Labrinth.Analytics.v3.TimeRange): number {
return new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime()
}
export function isYearRelevantForTimeRange(timeRange: Labrinth.Analytics.v3.TimeRange): boolean {
const start = new Date(timeRange.start)
const end = new Date(timeRange.end)
const yearLabelThreshold = new Date(start)
yearLabelThreshold.setFullYear(start.getFullYear() + YEAR_LABEL_TIME_RANGE_YEARS)
return (
Number.isFinite(start.getTime()) &&
Number.isFinite(end.getTime()) &&
end.getTime() > yearLabelThreshold.getTime()
)
}
export function formatBucketEndLabel(end: Date, includeTime: boolean, includeYear = false): string {
if (includeTime) {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
hour: 'numeric',
minute: '2-digit',
}).format(end)
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
}).format(end)
}
function isStartOfDay(date: Date): boolean {
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
)
}
function isSingleFullDayTimeRange(start: Date, end: Date): boolean {
const durationMs = end.getTime() - start.getTime()
return (
Math.abs(durationMs - ONE_DAY_MS) < ONE_MINUTE_MS && isStartOfDay(start) && isStartOfDay(end)
)
}
export function formatMetricValue(
value: number,
activeStat: AnalyticsDashboardStat,
formatNumber: (value: number) => string,
formatMessage: FormatMessage,
): string {
switch (activeStat) {
case 'revenue': {
const amount = Math.round(value * 100) / 100
return formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatNumber(amount),
})
}
case 'playtime': {
const hours = value / 3600
return formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: hours.toFixed(1),
})
}
case 'views':
case 'downloads':
default:
return formatNumber(Math.round(value))
}
}
function formatSmallAxisNumber(value: number): string {
const rounded = Math.round(value)
if (Math.abs(value - rounded) < 0.0000001) {
return String(rounded)
}
const formattedValue = Math.abs(value) < 1 ? value.toFixed(2) : value.toFixed(1)
return formattedValue.replace(/\.?0+$/, '')
}
const COMPACT_AXIS_UNITS = [
{ threshold: 1_000_000, divisor: 1_000_000, suffix: 'M' },
{ threshold: 1_000, divisor: 1_000, suffix: 'K' },
] as const
const MAX_COMPACT_AXIS_DIGITS = 3
function getCompactAxisUnit(values: readonly number[]) {
let maxAbsoluteValue = 0
for (const value of values) {
if (Number.isFinite(value)) {
maxAbsoluteValue = Math.max(maxAbsoluteValue, Math.abs(value))
}
}
return COMPACT_AXIS_UNITS.find((unit) => maxAbsoluteValue >= unit.threshold) ?? null
}
function formatCompactAxisNumber(value: number, axisValues: readonly number[]): string | null {
if (Math.abs(value) === 0) return '0'
const unit = getCompactAxisUnit(axisValues)
if (!unit) return null
return `${formatCompactAxisValue(value / unit.divisor)}${unit.suffix}`
}
function formatCompactAxisValue(value: number): string {
const absoluteValue = Math.abs(value)
if (absoluteValue === 0) return '0'
const integerDigitCount = absoluteValue < 1 ? 1 : Math.floor(absoluteValue).toString().length
const fractionDigitCount = Math.max(0, MAX_COMPACT_AXIS_DIGITS - integerDigitCount)
const roundedValue = Number(value.toFixed(fractionDigitCount))
const roundedIntegerDigitCount =
Math.abs(roundedValue) < 1 ? 1 : Math.floor(Math.abs(roundedValue)).toString().length
if (roundedIntegerDigitCount > MAX_COMPACT_AXIS_DIGITS) {
const truncatedValue = Math.sign(value) * (10 ** MAX_COMPACT_AXIS_DIGITS - 1)
return String(truncatedValue)
}
return roundedValue.toFixed(fractionDigitCount).replace(/\.?0+$/, '')
}
export function formatAxisValue(
value: number,
activeStat: AnalyticsDashboardStat,
formatCompact: (value: number) => string,
formatMessage: FormatMessage,
axisValues: readonly number[] = [value],
): string {
switch (activeStat) {
case 'revenue': {
const amount = Math.round(value * 100) / 100
const axisAmounts = axisValues.map((axisValue) => Math.round(axisValue * 100) / 100)
return formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatCompactAxisNumber(amount, axisAmounts) ?? formatCompact(amount),
})
}
case 'playtime': {
const formattedHours = formatCompactAxisNumber(value, axisValues)
if (formattedHours) {
return formatMessage(analyticsChartMessages.playtimeAxisHours, { hours: formattedHours })
}
if (Math.abs(value) < 10) {
return formatMessage(analyticsChartMessages.playtimeAxisHours, {
hours: formatSmallAxisNumber(value),
})
}
return formatMessage(analyticsChartMessages.playtimeAxisHours, {
hours: formatCompact(Math.round(value)),
})
}
case 'views':
case 'downloads':
default: {
const roundedValue = Math.round(value)
const roundedAxisValues = axisValues.map((axisValue) => Math.round(axisValue))
const formattedValue = formatCompactAxisNumber(roundedValue, roundedAxisValues)
if (formattedValue) return formattedValue
if (Math.abs(value) < 10) {
return formatSmallAxisNumber(value)
}
return formatCompact(roundedValue)
}
}
}
@@ -0,0 +1,252 @@
<template>
<AnalyticsChartRenderLimitModal
ref="showAllSelectedGraphDatasetsModal"
:table-project-count="tableProjectCount"
@confirm="confirmShowAllSelectedGraphDatasets"
/>
<section
ref="graphSection"
class="relative flex flex-col rounded-2xl border border-solid border-surface-5 bg-surface-3"
:style="graphSectionStyle"
>
<AnalyticsChartHeader
v-model:active-graph-view-mode="activeGraphViewMode"
v-model:ratio-mode="isRatioMode"
v-model:show-chart-events="showChartEvents"
v-model:show-project-events="showProjectEvents"
v-model:show-previous-period="showPreviousPeriod"
:graph-title="graphTitle"
:show-table-selection-subheading="showTableSelectionSubheading"
:table-selection-subheading="tableSelectionSubheading"
:show-graph-render-limit-button="showGraphRenderLimitButton"
:graph-render-limit-button-label="graphRenderLimitButtonLabel"
:show-top-graph-datasets-button="showTopGraphDatasetsButton"
:can-use-ratio-mode="canUseRatioMode"
:can-show-previous-period="canShowPreviousPeriodToggle"
:has-chart-events="hasChartEvents"
:has-project-events="hasProjectEvents"
:small-toggles="!isMobileLayout"
:default-show-project-events="defaultShowProjectEvents"
:is-mobile-layout="isMobileLayout"
@toggle-graph-render-limit="toggleGraphRenderLimit"
@show-top-graph-datasets="showTopGraphDatasets"
/>
<div
class="flex flex-col gap-6 px-4 pb-6 pt-5"
:class="['transition-opacity', isDataLoading ? 'pointer-events-none opacity-75' : '']"
>
<AnalyticsChartLegend
:legend-entries="legendEntries"
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
:show-unmonetized-info="showUnmonetizedInfo"
@entry-hover="setHoveredLegendEntryId"
@entry-hover-clear="clearHoveredLegendEntryId"
@entry-click="onLegendEntryClick"
/>
<AnalyticsChartPlot
:chart-type="chartType"
:is-area="isArea"
:is-stacked="isStacked"
:is-ratio-mode="isRatioMode"
:is-data-loading="isDataLoading"
:show-empty-chart-state="showEmptyChartState"
:empty-chart-message="emptyChartMessage"
:visible-chart-datasets="visibleChartDatasets"
:chart-labels="chartLabels"
:x-axis-tick-limit="xAxisTickLimit"
:active-stat="activeStat"
:highlighted-chart-dataset-id="highlightedChartDatasetId"
:has-visible-timeline-events="hasVisibleTimelineEvents"
:visible-timeline-events="visibleTimelineEvents"
:selected-group-by="selectedGroupBy"
:chart-range-bounds="chartRangeBounds"
:fetch-request="fetchRequest"
:slice-count="sliceCount"
:should-show-previous-period="shouldShowPreviousPeriod"
:all-chart-datasets="allChartDatasets"
:current-legend-entries="currentLegendEntries"
:legend-entries="legendEntries"
:chart-dataset-by-id="chartDatasetById"
:hover-ratio-slice-totals="hoverRatioSliceTotals"
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
@range-select="onRangeSelect"
@entry-click="onTooltipEntryClick"
@entry-hover="setHoveredLegendEntryId"
@entry-hover-clear="clearHoveredLegendEntryId"
/>
</div>
<div class="pointer-events-none absolute inset-0 z-[20] overflow-hidden rounded-xl">
<AnalyticsLoadingBar :loading="isDataLoading" />
</div>
<div v-if="isDataLoading" class="absolute inset-0 z-[19] overflow-hidden rounded-xl">
<div class="absolute inset-0 bg-surface-3 opacity-50" />
<div class="absolute inset-0 backdrop-blur-[3px]" />
<div class="absolute inset-0 flex items-center justify-center">
<div
class="relative bottom-6 inline-flex items-center gap-2 text-lg font-semibold text-primary"
>
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { useVIntl } from '@modrinth/ui'
import { getDefaultAnalyticsGraphProjectEventsVisibility } from '~/components/analytics-dashboard/analytics-route-query'
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
import { injectAnalyticsDashboardContext } from '~/providers/analytics/analytics'
import { analyticsMessages } from '../analytics-messages.ts'
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
import AnalyticsChartLegend from './analytics-chart-header/AnalyticsChartLegend.vue'
import AnalyticsChartRenderLimitModal from './analytics-chart-header/AnalyticsChartRenderLimitModal.vue'
import AnalyticsChartHeader from './analytics-chart-header/index.vue'
import { useAnalyticsChartLegend } from './analytics-chart-header/use-analytics-chart-legend.ts'
import AnalyticsChartPlot from './analytics-chart-plot/index.vue'
import { useAnalyticsChartEvents } from './analytics-chart-plot/use-analytics-chart-events.ts'
import { useAnalyticsChartLayout } from './analytics-chart-plot/use-analytics-chart-layout.ts'
import { useAnalyticsChartDatasets } from './use-analytics-chart-datasets.ts'
import { useAnalyticsChartProjects } from './use-analytics-chart-projects.ts'
const dashboardContext = injectAnalyticsDashboardContext()
const { formatMessage } = useVIntl()
const {
activeStat,
activeGraphViewMode,
isRatioMode,
showChartEvents,
showProjectEvents,
showPreviousPeriod,
isMobileLayout,
hiddenGraphDatasetIds,
isGraphDatasetSelectionActive,
selectedProjectIds: currentSelectedProjectIds,
selectedTimeframeMode,
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
selectedGroupBy: selectedDashboardGroupBy,
displayedFetchRequest: fetchRequest,
displayedSelectedGroupBy: selectedGroupBy,
displayedSelectedBreakdowns: selectedBreakdowns,
isLoading,
} = dashboardContext
const isDataLoading = computed(() => isLoading.value)
const defaultShowProjectEvents = computed(() =>
getDefaultAnalyticsGraphProjectEventsVisibility(currentSelectedProjectIds.value),
)
const {
selectedProjectIdSet,
hasAvailableProjects,
selectedProjects,
selectedProjectNameById,
selectedProjectEventIdSet,
} = useAnalyticsChartProjects(dashboardContext)
const {
showAllSelectedGraphDatasets,
chartRangeBounds,
tableProjectCount,
showEmptyChartState,
emptyChartMessage,
graphTitle,
showTableSelectionSubheading,
shouldCapitalizeDatasetLabels,
chartType,
canShowPreviousPeriodToggle,
shouldShowPreviousPeriod,
isArea,
isStacked,
sliceCount,
chartLabels,
xAxisTickLimit,
allChartDatasets,
previousChartDatasets,
tableSelectionSubheading,
showGraphRenderLimitButton,
graphRenderLimitButtonLabel,
showTopGraphDatasetsButton,
selectableChartDatasets,
showTopGraphDatasets,
} = useAnalyticsChartDatasets(dashboardContext, selectedProjects, hasAvailableProjects)
const {
currentLegendEntries,
visibleProjectEventIdSet,
legendEntries,
chartDatasetById,
hoverRatioSliceTotals,
visibleChartDatasets,
highlightedChartDatasetId,
setHoveredLegendEntryId,
clearHoveredLegendEntryId,
onLegendEntryClick,
onTooltipEntryClick,
} = useAnalyticsChartLegend({
selectableChartDatasets,
allChartDatasets,
previousChartDatasets,
shouldShowPreviousPeriod,
isRatioMode,
hiddenGraphDatasetIds,
selectedBreakdowns,
isGraphDatasetSelectionActive,
selectedProjects,
selectedProjectIdSet,
selectedProjectEventIdSet,
})
const { hasChartEvents, hasProjectEvents, visibleTimelineEvents, hasVisibleTimelineEvents } =
useAnalyticsChartEvents(
dashboardContext,
chartRangeBounds,
selectedProjectNameById,
selectedProjectEventIdSet,
visibleProjectEventIdSet,
)
const { graphSection, graphSectionStyle } = useAnalyticsChartLayout(showEmptyChartState)
const showAllSelectedGraphDatasetsModal = ref<InstanceType<
typeof AnalyticsChartRenderLimitModal
> | null>(null)
const canUseRatioMode = computed(
() =>
(activeGraphViewMode.value === 'area' || activeGraphViewMode.value === 'bar') &&
legendEntries.value.length > 1,
)
const showUnmonetizedInfo = computed(
() => selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization',
)
function toggleGraphRenderLimit(event: MouseEvent) {
if (showAllSelectedGraphDatasets.value) {
showAllSelectedGraphDatasets.value = false
return
}
showAllSelectedGraphDatasetsModal.value?.show(event)
}
function confirmShowAllSelectedGraphDatasets() {
showAllSelectedGraphDatasets.value = true
}
function onRangeSelect(start: Date, end: Date, groupBy: AnalyticsGroupByPreset) {
selectedTimeframeMode.value = 'custom_datetime_range'
selectedCustomTimeframeStartDate.value = start.toISOString()
selectedCustomTimeframeEndDate.value = end.toISOString()
selectedDashboardGroupBy.value = groupBy
}
watch(canUseRatioMode, (canUse) => {
if (!canUse) {
isRatioMode.value = false
}
})
</script>
@@ -0,0 +1,395 @@
import { useVIntl } from '@modrinth/ui'
import { computed, type ComputedRef, ref, watch } from 'vue'
import { useTheme } from '~/composables/nuxt-accessors'
import { isDarkTheme } from '~/plugins/theme/index.ts'
import type {
AnalyticsDashboardContextValue,
AnalyticsDashboardProject,
AnalyticsDashboardStat,
} from '~/providers/analytics/analytics'
import {
analyticsChartMessages,
analyticsMessages,
formatAnalyticsGraphTitle,
type FormatMessage,
getAnalyticsBreakdownItemType,
} from '../analytics-messages'
import {
ANALYTICS_DASHBOARD_STATS,
DARK_LEGEND_PALETTE,
GRAPH_RENDER_DATASET_LIMIT,
LIGHT_LEGEND_PALETTE,
TOP_GRAPH_DATASET_LIMIT,
} from './analytics-chart-constants'
import {
buildChartDatasets,
buildTimeAxisLabels,
type ChartDataset,
getChartDatasetTotal,
getShortHourlyAxisTickLimit,
getSliceCount,
shouldCapitalizeBreakdownLabel,
} from './analytics-chart-utils'
export function useAnalyticsChartDatasets(
context: Pick<
AnalyticsDashboardContextValue,
| 'activeStat'
| 'activeGraphViewMode'
| 'isRatioMode'
| 'showPreviousPeriod'
| 'hasPreviousPeriodComparison'
| 'hasProjectContext'
| 'displayedFetchRequest'
| 'displayedTimeSlices'
| 'displayedPreviousTimeSlices'
| 'displayedSelectedGroupBy'
| 'displayedSelectedBreakdowns'
| 'hiddenGraphDatasetIds'
| 'hasExplicitGraphDatasetSelection'
| 'isGraphDatasetSelectionActive'
| 'selectedGraphDatasetIds'
| 'defaultGraphDatasetIds'
| 'topGraphDatasetIds'
| 'getVersionDisplayName'
| 'getVersionProjectName'
>,
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>,
hasAvailableProjects: ComputedRef<boolean>,
) {
const theme = useTheme()
const { formatMessage } = useVIntl()
const showAllSelectedGraphDatasets = ref(false)
const chartRangeBounds = computed(() => {
const nextFetchRequest = context.displayedFetchRequest.value
if (!nextFetchRequest) return null
return {
start: new Date(nextFetchRequest.time_range.start),
end: new Date(nextFetchRequest.time_range.end),
}
})
const showProjectVersionNames = computed(
() =>
context.displayedSelectedBreakdowns.value.includes('version_id') &&
selectedProjects.value.length > 1,
)
const tableProjectCount = computed(() => context.selectedGraphDatasetIds.value.length)
const isTableGraphSelectionEmpty = computed(
() =>
context.isGraphDatasetSelectionActive.value &&
context.hasExplicitGraphDatasetSelection.value &&
tableProjectCount.value === 0,
)
const showEmptyChartState = computed(
() => selectedProjects.value.length === 0 || isTableGraphSelectionEmpty.value,
)
const emptyChartMessage = computed(() => {
if (isTableGraphSelectionEmpty.value) {
return formatMessage(analyticsChartMessages.selectTableItemsEmpty)
}
if (context.hasProjectContext.value) {
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
}
return hasAvailableProjects.value
? formatMessage(analyticsMessages.noDataAvailable)
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
})
const legendPalette = computed(() =>
isDarkTheme(theme.active) ? DARK_LEGEND_PALETTE : LIGHT_LEGEND_PALETTE,
)
const graphTitle = computed(() =>
formatAnalyticsGraphTitle(context.activeStat.value, formatMessage),
)
const showTableSelectionSubheading = computed(
() => context.isGraphDatasetSelectionActive.value && tableProjectCount.value > 0,
)
const tableBreakdownItemType = computed(() =>
getAnalyticsBreakdownItemType(context.displayedSelectedBreakdowns.value),
)
const shouldCapitalizeDatasetLabels = computed(() =>
shouldCapitalizeBreakdownLabel(context.displayedSelectedBreakdowns.value),
)
const chartType = computed<'line' | 'bar'>(() =>
context.activeGraphViewMode.value === 'bar' ? 'bar' : 'line',
)
const canShowPreviousPeriodToggle = computed(
() => context.activeGraphViewMode.value === 'line' && context.hasPreviousPeriodComparison.value,
)
const shouldShowPreviousPeriod = computed(
() => canShowPreviousPeriodToggle.value && context.showPreviousPeriod.value,
)
const isArea = computed(() => context.activeGraphViewMode.value === 'area')
const isStacked = computed(
() =>
context.isRatioMode.value ||
context.activeGraphViewMode.value === 'area' ||
context.activeGraphViewMode.value === 'bar',
)
const sliceCount = computed(() => {
const nextFetchRequest = context.displayedFetchRequest.value
const fallback = context.displayedTimeSlices.value.length
if (!nextFetchRequest) return Math.max(1, fallback)
return getSliceCount(nextFetchRequest.time_range, fallback)
})
const chartLabels = computed(() => {
const nextFetchRequest = context.displayedFetchRequest.value
if (!nextFetchRequest) return []
return buildTimeAxisLabels(
nextFetchRequest.time_range,
sliceCount.value,
context.displayedSelectedGroupBy.value,
)
})
const xAxisTickLimit = computed(() => {
const nextFetchRequest = context.displayedFetchRequest.value
return nextFetchRequest
? getShortHourlyAxisTickLimit(
nextFetchRequest.time_range,
context.displayedSelectedGroupBy.value,
)
: undefined
})
const chartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
buildDatasetsByStat(
context.displayedTimeSlices.value,
selectedProjects.value,
legendPalette.value,
context.displayedSelectedBreakdowns.value,
context.getVersionDisplayName,
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
formatMessage,
sliceCount.value,
),
)
const previousChartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
buildDatasetsByStat(
context.displayedPreviousTimeSlices.value,
selectedProjects.value,
legendPalette.value,
context.displayedSelectedBreakdowns.value,
context.getVersionDisplayName,
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
formatMessage,
sliceCount.value,
),
)
const allChartDatasets = computed(() => chartDatasetsByStat.value[context.activeStat.value])
const previousChartDatasets = computed(
() => previousChartDatasetsByStat.value[context.activeStat.value],
)
const sortedChartDatasetIds = computed(() => sortDatasetsByTotal(allChartDatasets.value))
const chartTopGraphDatasetIds = computed(() =>
sortedChartDatasetIds.value.slice(0, TOP_GRAPH_DATASET_LIMIT),
)
const fallbackDefaultGraphDatasetIds = computed(() =>
context.defaultGraphDatasetIds.value.length > 0
? context.defaultGraphDatasetIds.value
: chartTopGraphDatasetIds.value,
)
const isShowingAllTableItems = computed(() => {
if (context.selectedGraphDatasetIds.value.length !== sortedChartDatasetIds.value.length) {
return false
}
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
return sortedChartDatasetIds.value.every((datasetId) => selectedDatasetIds.has(datasetId))
})
const isShowingTopGraphDatasets = computed(() => {
if (
context.selectedGraphDatasetIds.value.length !== fallbackDefaultGraphDatasetIds.value.length
) {
return false
}
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
return fallbackDefaultGraphDatasetIds.value.every((datasetId) =>
selectedDatasetIds.has(datasetId),
)
})
const isShowingTopTableItems = computed(() => {
const topDatasetIds = new Set(
context.topGraphDatasetIds.value.slice(0, context.selectedGraphDatasetIds.value.length),
)
return context.selectedGraphDatasetIds.value.every((datasetId) => topDatasetIds.has(datasetId))
})
const isGraphRenderDatasetOverLimit = computed(
() =>
context.isGraphDatasetSelectionActive.value &&
selectedChartDatasets.value.length > GRAPH_RENDER_DATASET_LIMIT,
)
const isGraphRenderDatasetLimitActive = computed(
() => isGraphRenderDatasetOverLimit.value && !showAllSelectedGraphDatasets.value,
)
const tableSelectionSubheading = computed(() => {
if (isGraphRenderDatasetLimitActive.value) {
return formatMessage(analyticsChartMessages.tableSelectionLimited, {
limit: GRAPH_RENDER_DATASET_LIMIT,
itemType: tableBreakdownItemType.value,
})
}
if (isShowingAllTableItems.value) {
return formatMessage(analyticsChartMessages.tableSelectionAll, {
count: tableProjectCount.value,
itemType: tableBreakdownItemType.value,
})
}
if (isShowingTopTableItems.value) {
return formatMessage(analyticsChartMessages.tableSelectionTop, {
count: tableProjectCount.value,
itemType: tableBreakdownItemType.value,
})
}
return formatMessage(analyticsChartMessages.tableSelectionCount, {
count: tableProjectCount.value,
itemType: tableBreakdownItemType.value,
})
})
const shouldUseDefaultGraphDatasetSelection = computed(
() =>
context.isGraphDatasetSelectionActive.value &&
!context.hasExplicitGraphDatasetSelection.value &&
context.selectedGraphDatasetIds.value.length === 0,
)
const selectedGraphDatasetIdSet = computed(() => {
if (shouldUseDefaultGraphDatasetSelection.value) {
return new Set(fallbackDefaultGraphDatasetIds.value)
}
return new Set(context.selectedGraphDatasetIds.value)
})
const selectedChartDatasets = computed(() => {
if (!context.isGraphDatasetSelectionActive.value) {
return allChartDatasets.value
}
return allChartDatasets.value.filter((dataset) =>
selectedGraphDatasetIdSet.value.has(dataset.projectId),
)
})
const sortedSelectedChartDatasetIds = computed(() =>
sortDatasetsByTotal(selectedChartDatasets.value),
)
const showGraphRenderLimitButton = computed(() => isGraphRenderDatasetOverLimit.value)
const graphRenderLimitButtonLabel = computed(() =>
showAllSelectedGraphDatasets.value
? formatMessage(analyticsChartMessages.showLimited)
: formatMessage(analyticsChartMessages.showAll),
)
const showTopGraphDatasetsButton = computed(
() =>
context.isGraphDatasetSelectionActive.value &&
context.topGraphDatasetIds.value.length > 0 &&
!isShowingTopGraphDatasets.value,
)
const limitedGraphDatasetIds = computed(
() => new Set(sortedSelectedChartDatasetIds.value.slice(0, GRAPH_RENDER_DATASET_LIMIT)),
)
const selectableChartDatasets = computed(() => {
if (!isGraphRenderDatasetLimitActive.value) {
return selectedChartDatasets.value
}
return selectedChartDatasets.value.filter((dataset) =>
limitedGraphDatasetIds.value.has(dataset.projectId),
)
})
function showTopGraphDatasets() {
context.selectedGraphDatasetIds.value = []
context.hasExplicitGraphDatasetSelection.value = false
showAllSelectedGraphDatasets.value = false
}
watch([() => context.selectedGraphDatasetIds.value.join('\u0000'), allChartDatasets], () => {
showAllSelectedGraphDatasets.value = false
})
return {
showAllSelectedGraphDatasets,
chartRangeBounds,
showProjectVersionNames,
tableProjectCount,
isTableGraphSelectionEmpty,
showEmptyChartState,
emptyChartMessage,
legendPalette,
graphTitle,
showTableSelectionSubheading,
shouldCapitalizeDatasetLabels,
chartType,
canShowPreviousPeriodToggle,
shouldShowPreviousPeriod,
isArea,
isStacked,
sliceCount,
chartLabels,
xAxisTickLimit,
chartDatasetsByStat,
previousChartDatasetsByStat,
allChartDatasets,
previousChartDatasets,
sortedChartDatasetIds,
chartTopGraphDatasetIds,
fallbackDefaultGraphDatasetIds,
isShowingAllTableItems,
isShowingTopGraphDatasets,
isShowingTopTableItems,
tableSelectionSubheading,
shouldUseDefaultGraphDatasetSelection,
selectedGraphDatasetIdSet,
selectedChartDatasets,
sortedSelectedChartDatasetIds,
isGraphRenderDatasetOverLimit,
showGraphRenderLimitButton,
graphRenderLimitButtonLabel,
showTopGraphDatasetsButton,
isGraphRenderDatasetLimitActive,
limitedGraphDatasetIds,
selectableChartDatasets,
showTopGraphDatasets,
}
}
function buildDatasetsByStat(
timeSlices: Parameters<typeof buildChartDatasets>[0],
selectedProjects: AnalyticsDashboardProject[],
palette: string[],
selectedBreakdowns: Parameters<typeof buildChartDatasets>[4],
getVersionDisplayName: Parameters<typeof buildChartDatasets>[5],
getVersionProjectName: Parameters<typeof buildChartDatasets>[6],
formatMessage: FormatMessage,
sliceCount: number,
) {
const datasetsByStat = {} as Record<AnalyticsDashboardStat, ChartDataset[]>
for (const stat of ANALYTICS_DASHBOARD_STATS) {
datasetsByStat[stat] = buildChartDatasets(
timeSlices,
selectedProjects,
stat,
palette,
selectedBreakdowns,
getVersionDisplayName,
getVersionProjectName,
formatMessage,
sliceCount,
)
}
return datasetsByStat
}
function sortDatasetsByTotal(datasets: ChartDataset[]) {
return [...datasets]
.sort((a, b) => {
const totalDifference = getChartDatasetTotal(b) - getChartDatasetTotal(a)
return (
totalDifference || a.label.localeCompare(b.label) || a.projectId.localeCompare(b.projectId)
)
})
.map((dataset) => dataset.projectId)
}
@@ -0,0 +1,38 @@
import { computed } from 'vue'
import {
type AnalyticsDashboardContextValue,
doesProjectStatusMatchFilters,
} from '~/providers/analytics/analytics'
export function useAnalyticsChartProjects(
context: Pick<
AnalyticsDashboardContextValue,
'displayedSelectedProjectIds' | 'projects' | 'displayedSelectedFilters'
>,
) {
const selectedProjectIdSet = computed(() => new Set(context.displayedSelectedProjectIds.value))
const hasAvailableProjects = computed(() => context.projects.value.length > 0)
const selectedProjects = computed(() =>
context.projects.value.filter(
(project) =>
selectedProjectIdSet.value.has(project.id) &&
doesProjectStatusMatchFilters(project.status, context.displayedSelectedFilters.value),
),
)
const selectedProjectNameById = computed(
() => new Map(selectedProjects.value.map((project) => [project.id, project.name])),
)
const selectedProjectEventIdSet = computed(
() => new Set(selectedProjects.value.map((project) => project.id)),
)
return {
selectedProjectIdSet,
hasAvailableProjects,
selectedProjects,
selectedProjectNameById,
selectedProjectEventIdSet,
}
}
@@ -0,0 +1,897 @@
import { defineMessages, getLoaderMessage, type VIntlFormatters } from '@modrinth/ui'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsGroupByPreset,
} from '~/providers/analytics/analytics'
export type FormatMessage = VIntlFormatters['formatMessage']
export type AnalyticsBreakdownItemType =
| 'country'
| 'downloadReason'
| 'downloadSource'
| 'gameVersion'
| 'loader'
| 'monetization'
| 'project'
| 'projectVersion'
| 'other'
export const analyticsMessages = defineMessages({
title: {
id: 'analytics.title',
defaultMessage: 'Analytics',
},
resetButton: {
id: 'analytics.action.reset',
defaultMessage: 'Reset',
},
refreshButton: {
id: 'analytics.action.refresh',
defaultMessage: 'Refresh',
},
fetchingResults: {
id: 'analytics.loading.fetching-results',
defaultMessage: 'Fetching results...',
},
allProjects: {
id: 'analytics.project.all',
defaultMessage: 'All projects',
},
selectProjects: {
id: 'analytics.project.select',
defaultMessage: 'Select projects',
},
projectCount: {
id: 'analytics.project.count',
defaultMessage: '{count, plural, one {# project} other {# projects}}',
},
projectIconAlt: {
id: 'analytics.project.icon-alt',
defaultMessage: '{name} Icon',
},
noDataAvailable: {
id: 'analytics.empty.no-data',
defaultMessage: 'No data available',
},
noDataAvailableForAnalytics: {
id: 'analytics.empty.no-data-for-analytics',
defaultMessage: 'No data available for analytics',
},
noProjectsAvailable: {
id: 'analytics.empty.no-projects',
defaultMessage: 'No projects available',
},
noProjectsAvailableForAnalytics: {
id: 'analytics.empty.no-projects-for-analytics',
defaultMessage: 'No projects available for analytics',
},
selectAtLeastOneProject: {
id: 'analytics.empty.select-project',
defaultMessage: 'Select at least one project to view data',
},
unknown: {
id: 'analytics.value.unknown',
defaultMessage: 'Unknown',
},
other: {
id: 'analytics.value.other',
defaultMessage: 'Other',
},
none: {
id: 'analytics.value.none',
defaultMessage: 'None',
},
noBreakdown: {
id: 'analytics.breakdown.none.selected',
defaultMessage: 'No breakdown',
},
breakdownBy: {
id: 'analytics.breakdown.selected',
defaultMessage: 'Breakdown by {breakdown}',
},
projectLabel: {
id: 'analytics.query.label.project',
defaultMessage: 'Project:',
},
timeframeLabel: {
id: 'analytics.query.label.timeframe',
defaultMessage: 'Timeframe:',
},
groupedByLabel: {
id: 'analytics.query.label.grouped-by',
defaultMessage: 'Grouped by',
},
breakdownLabel: {
id: 'analytics.query.label.breakdown',
defaultMessage: 'Breakdown:',
},
addFilterButton: {
id: 'analytics.query.filter.add',
defaultMessage: 'Add filter',
},
addButton: {
id: 'analytics.action.add',
defaultMessage: 'Add',
},
downloadsSuffix: {
id: 'analytics.downloads.suffix',
defaultMessage: 'downloads',
},
projectsAbove: {
id: 'analytics.threshold.projects-above',
defaultMessage: 'Projects above',
},
countriesAbove: {
id: 'analytics.threshold.countries-above',
defaultMessage: 'Countries above',
},
projectVersionsAbove: {
id: 'analytics.threshold.project-versions-above',
defaultMessage: 'Project versions above',
},
gameVersionsAbove: {
id: 'analytics.threshold.game-versions-above',
defaultMessage: 'Game versions above',
},
projectDownloadsThresholdAria: {
id: 'analytics.threshold.project-downloads-aria',
defaultMessage: 'Project downloads threshold',
},
countryDownloadsThresholdAria: {
id: 'analytics.threshold.country-downloads-aria',
defaultMessage: 'Country downloads threshold',
},
projectVersionDownloadsThresholdAria: {
id: 'analytics.threshold.project-version-downloads-aria',
defaultMessage: 'Project version downloads threshold',
},
gameVersionDownloadsThresholdAria: {
id: 'analytics.threshold.game-version-downloads-aria',
defaultMessage: 'Game version downloads threshold',
},
loadingOptions: {
id: 'analytics.options.loading',
defaultMessage: 'Loading...',
},
searchCountriesPlaceholder: {
id: 'analytics.filter.search.countries',
defaultMessage: 'Search countries...',
},
searchDownloadSourcesPlaceholder: {
id: 'analytics.filter.search.download-sources',
defaultMessage: 'Search download sources...',
},
searchProjectVersionsPlaceholder: {
id: 'analytics.filter.search.project-versions',
defaultMessage: 'Search project versions...',
},
searchVersionsPlaceholder: {
id: 'analytics.filter.search.versions',
defaultMessage: 'Search versions...',
},
gameVersionTypeAria: {
id: 'analytics.filter.game-version-type',
defaultMessage: 'Game version type',
},
releaseTab: {
id: 'analytics.filter.game-version-type.release',
defaultMessage: 'Release',
},
allTab: {
id: 'analytics.filter.game-version-type.all',
defaultMessage: 'All',
},
})
export const analyticsStatMessages = defineMessages({
views: {
id: 'analytics.stat.views',
defaultMessage: 'Views',
},
downloads: {
id: 'analytics.stat.downloads',
defaultMessage: 'Downloads',
},
revenue: {
id: 'analytics.stat.revenue',
defaultMessage: 'Revenue',
},
playtime: {
id: 'analytics.stat.playtime',
defaultMessage: 'Playtime',
},
})
export const analyticsGraphTitleMessages = defineMessages({
views: {
id: 'analytics.graph.title.views',
defaultMessage: 'Views Over Time',
},
downloads: {
id: 'analytics.graph.title.downloads',
defaultMessage: 'Downloads Over Time',
},
revenue: {
id: 'analytics.graph.title.revenue',
defaultMessage: 'Revenue Over Time',
},
playtime: {
id: 'analytics.graph.title.playtime',
defaultMessage: 'Playtime Over Time',
},
})
export const analyticsStatCardMessages = defineMessages({
revenueValue: {
id: 'analytics.stat.revenue-value',
defaultMessage: '${value}',
},
playtimeHours: {
id: 'analytics.stat.playtime-hours',
defaultMessage: '{hours} hrs',
},
unavailableTooltip: {
id: 'analytics.stat.unavailable-tooltip',
defaultMessage: 'Stat unavailable for current query',
},
unavailableLabel: {
id: 'analytics.stat.unavailable',
defaultMessage: 'N/A',
},
previousPeriodComparison: {
id: 'analytics.stat.previous-period-comparison',
defaultMessage: 'vs prev. period',
},
previousPeriodComparisonShort: {
id: 'analytics.stat.previous-period-comparison-short',
defaultMessage: 'vs prev.',
},
})
export const analyticsGroupByMessages = defineMessages({
oneHour: {
id: 'analytics.group-by.1h',
defaultMessage: '1h',
},
sixHours: {
id: 'analytics.group-by.6h',
defaultMessage: '6h',
},
day: {
id: 'analytics.group-by.day',
defaultMessage: 'Day',
},
week: {
id: 'analytics.group-by.week',
defaultMessage: 'Week',
},
month: {
id: 'analytics.group-by.month',
defaultMessage: 'Month',
},
year: {
id: 'analytics.group-by.year',
defaultMessage: 'Year',
},
date: {
id: 'analytics.group-by.date',
defaultMessage: 'Date',
},
groupByHour: {
id: 'analytics.group-by.selected.hour',
defaultMessage: 'Group by hour',
},
groupBySixHours: {
id: 'analytics.group-by.selected.six-hours',
defaultMessage: 'Group by 6 hours',
},
groupByDay: {
id: 'analytics.group-by.selected.day',
defaultMessage: 'Group by day',
},
groupByWeek: {
id: 'analytics.group-by.selected.week',
defaultMessage: 'Group by week',
},
groupByMonth: {
id: 'analytics.group-by.selected.month',
defaultMessage: 'Group by month',
},
groupByYear: {
id: 'analytics.group-by.selected.year',
defaultMessage: 'Group by year',
},
})
export const analyticsBreakdownMessages = defineMessages({
breakdown: {
id: 'analytics.breakdown.generic',
defaultMessage: 'Breakdown',
},
project: {
id: 'analytics.breakdown.project',
defaultMessage: 'Project',
},
country: {
id: 'analytics.breakdown.country',
defaultMessage: 'Country',
},
monetization: {
id: 'analytics.breakdown.monetization',
defaultMessage: 'Monetization',
},
userAgent: {
id: 'analytics.breakdown.download-source',
defaultMessage: 'Download source',
},
downloadReason: {
id: 'analytics.breakdown.download-reason',
defaultMessage: 'Download reason',
},
versionId: {
id: 'analytics.breakdown.project-version',
defaultMessage: 'Project version',
},
loader: {
id: 'analytics.breakdown.loader',
defaultMessage: 'Loader',
},
gameVersion: {
id: 'analytics.breakdown.game-version',
defaultMessage: 'Game version',
},
projectStatus: {
id: 'analytics.breakdown.project-status',
defaultMessage: 'Project status',
},
})
export const analyticsMonetizationMessages = defineMessages({
monetized: {
id: 'analytics.value.monetized',
defaultMessage: 'Monetized',
},
unmonetized: {
id: 'analytics.value.unmonetized',
defaultMessage: 'Unmonetized',
},
})
export const analyticsDownloadReasonMessages = defineMessages({
standalone: {
id: 'analytics.download-reason.standalone',
defaultMessage: 'Standalone',
},
dependency: {
id: 'analytics.download-reason.dependency',
defaultMessage: 'Dependency',
},
modpack: {
id: 'analytics.download-reason.modpack',
defaultMessage: 'Modpack',
},
update: {
id: 'analytics.download-reason.update',
defaultMessage: 'Update',
},
})
export const analyticsDownloadSourceMessages = defineMessages({
website: {
id: 'analytics.download-source.website',
defaultMessage: 'Modrinth Website',
},
app: {
id: 'analytics.download-source.app',
defaultMessage: 'Modrinth App',
},
})
export const analyticsProjectStatusMessages = defineMessages({
approved: {
id: 'analytics.project-status.approved',
defaultMessage: 'Approved',
},
archived: {
id: 'analytics.project-status.archived',
defaultMessage: 'Archived',
},
rejected: {
id: 'analytics.project-status.rejected',
defaultMessage: 'Rejected',
},
draft: {
id: 'analytics.project-status.draft',
defaultMessage: 'Draft',
},
unlisted: {
id: 'analytics.project-status.unlisted',
defaultMessage: 'Unlisted',
},
withheld: {
id: 'analytics.project-status.withheld',
defaultMessage: 'Withheld',
},
private: {
id: 'analytics.project-status.private',
defaultMessage: 'Private',
},
other: {
id: 'analytics.project-status.other',
defaultMessage: 'Other',
},
})
export const analyticsTableMessages = defineMessages({
searchPlaceholder: {
id: 'analytics.table.search.placeholder',
defaultMessage: 'Search...',
},
exportCsvButton: {
id: 'analytics.table.export-csv',
defaultMessage: 'Export CSV',
},
cumulativeCsv: {
id: 'analytics.table.export.cumulative',
defaultMessage: 'Cumulative',
},
groupedCsv: {
id: 'analytics.table.export.grouped',
defaultMessage: 'Grouped by {groupBy}',
},
noMatchingRows: {
id: 'analytics.table.empty.no-matching-rows',
defaultMessage: 'No matching analytics rows',
},
paginationSummary: {
id: 'analytics.table.pagination.summary',
defaultMessage: 'Showing {start} to {end} of {total}',
},
playtimeSecondsHeader: {
id: 'analytics.table.csv.header.playtime-seconds',
defaultMessage: 'Playtime (seconds)',
},
csvSelectedRange: {
id: 'analytics.table.csv.selected-range',
defaultMessage: 'Selected Range',
},
csvDateRange: {
id: 'analytics.table.csv.date-range',
defaultMessage: '{start} to {end}',
},
csvFilename: {
id: 'analytics.table.csv.filename',
defaultMessage: 'Modrinth Analytics {breakdown} Breakdown - {dateRange}',
},
durationDays: {
id: 'analytics.table.duration.days',
defaultMessage: '{count, plural, one {# day} other {# days}}',
},
durationHours: {
id: 'analytics.table.duration.hours',
defaultMessage: '{count, plural, one {# hour} other {# hours}}',
},
durationMinutes: {
id: 'analytics.table.duration.minutes',
defaultMessage: '{count, plural, one {# minute} other {# minutes}}',
},
})
export const analyticsChartMessages = defineMessages({
selectTableItemsEmpty: {
id: 'analytics.chart.empty.select-table-items',
defaultMessage: 'Select items from table below to visualize your data.',
},
showLimited: {
id: 'analytics.chart.action.show-limited',
defaultMessage: 'Show limited',
},
showAll: {
id: 'analytics.chart.action.show-all',
defaultMessage: 'Show all',
},
showTopEight: {
id: 'analytics.chart.action.show-top-eight',
defaultMessage: 'Show top 8',
},
tableSelectionLimited: {
id: 'analytics.chart.table-selection.limited',
defaultMessage:
'Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table',
},
tableSelectionAll: {
id: 'analytics.chart.table-selection.all',
defaultMessage:
'Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
},
tableSelectionTop: {
id: 'analytics.chart.table-selection.top',
defaultMessage:
'Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
},
tableSelectionCount: {
id: 'analytics.chart.table-selection.count',
defaultMessage:
'Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
},
lineView: {
id: 'analytics.chart.view.line',
defaultMessage: 'Line',
},
areaView: {
id: 'analytics.chart.view.area',
defaultMessage: 'Area',
},
barView: {
id: 'analytics.chart.view.bar',
defaultMessage: 'Bar',
},
controlsButton: {
id: 'analytics.chart.controls.button',
defaultMessage: 'Controls',
},
controlsAria: {
id: 'analytics.chart.controls.aria',
defaultMessage: 'Analytics graph controls, {activeCount}',
},
controlsDialogAria: {
id: 'analytics.chart.controls.dialog-aria',
defaultMessage: 'Analytics graph controls',
},
activeControlCount: {
id: 'analytics.chart.controls.active-count',
defaultMessage: '{count} active',
},
displayControls: {
id: 'analytics.chart.controls.display',
defaultMessage: 'Display',
},
previousPeriod: {
id: 'analytics.chart.controls.previous-period',
defaultMessage: 'Previous period',
},
ratio: {
id: 'analytics.chart.controls.ratio',
defaultMessage: 'Ratio',
},
annotations: {
id: 'analytics.chart.controls.annotations',
defaultMessage: 'Annotations',
},
projectEvents: {
id: 'analytics.chart.controls.project-events',
defaultMessage: 'Project events',
},
modrinthEvents: {
id: 'analytics.chart.controls.modrinth-events',
defaultMessage: 'Modrinth events',
},
noProjectEvents: {
id: 'analytics.chart.controls.no-project-events',
defaultMessage: 'No project events in graph.',
},
noModrinthEvents: {
id: 'analytics.chart.controls.no-modrinth-events',
defaultMessage: 'No Modrinth events in graph.',
},
viewMonetizedAnalyticsDetails: {
id: 'analytics.chart.legend.monetization-details.aria',
defaultMessage: 'View monetized analytics details',
},
monetizedAnalyticsDetails: {
id: 'analytics.chart.legend.monetization-details.title',
defaultMessage: 'Monetized analytics details',
},
monetizedAnalyticsDetailsDescription: {
id: 'analytics.chart.legend.monetization-details.description',
defaultMessage:
'Only views and downloads made through Modrinth count toward monetization, and downloads require users to be logged in.',
},
previousPeriodSuffix: {
id: 'analytics.chart.legend.previous-period-suffix',
defaultMessage: '{name} (Prev.)',
},
previousPeriodShort: {
id: 'analytics.chart.tooltip.previous-period-short',
defaultMessage: '(prev.)',
},
tooltipPinned: {
id: 'analytics.chart.tooltip.pinned',
defaultMessage: 'Chart tooltip pinned',
},
pinned: {
id: 'analytics.chart.tooltip.pinned-aria',
defaultMessage: 'Pinned',
},
total: {
id: 'analytics.chart.tooltip.total',
defaultMessage: 'Total',
},
showEntryInGraph: {
id: 'analytics.chart.tooltip.show-entry',
defaultMessage: 'Show {name} in graph',
},
hideEntryInGraph: {
id: 'analytics.chart.tooltip.hide-entry',
defaultMessage: 'Hide {name} in graph',
},
durationDays: {
id: 'analytics.chart.tooltip.duration.days',
defaultMessage: '{count, plural, one {# day} other {# days}}',
},
durationHours: {
id: 'analytics.chart.tooltip.duration.hours',
defaultMessage: '{count, plural, one {# hour} other {# hours}}',
},
durationMinutes: {
id: 'analytics.chart.tooltip.duration.minutes',
defaultMessage: '{count, plural, one {# minute} other {# minutes}}',
},
playtimeAxisHours: {
id: 'analytics.chart.axis.playtime-hours',
defaultMessage: '{hours} h',
},
renderLimitHeader: {
id: 'analytics.chart.render-limit.header',
defaultMessage: 'Show all {count} lines in graph?',
},
renderLimitDescription: {
id: 'analytics.chart.render-limit.description',
defaultMessage: 'Showing all selected lines from table may degrade page performance.',
},
cancelButton: {
id: 'analytics.action.cancel',
defaultMessage: 'Cancel',
},
analyticsEventsCount: {
id: 'analytics.chart.events.count-aria',
defaultMessage: '{count, plural, one {# analytics event} other {# analytics events}}',
},
seeAnnouncement: {
id: 'analytics.chart.events.see-announcement',
defaultMessage: 'See announcement',
},
projectEventTitle: {
id: 'analytics.chart.events.project-title',
defaultMessage: '<project>{projectName}</project>: {title}',
},
})
export const analyticsProjectEventMessages = defineMessages({
versionReleased: {
id: 'analytics.project-event.version-released',
defaultMessage: '{version} released',
},
versionUploaded: {
id: 'analytics.project-event.version-uploaded',
defaultMessage: 'Version uploaded',
},
projectApproved: {
id: 'analytics.project-event.project-approved',
defaultMessage: 'Project approved',
},
projectUnlisted: {
id: 'analytics.project-event.project-unlisted',
defaultMessage: 'Project unlisted',
},
projectPrivate: {
id: 'analytics.project-event.project-private',
defaultMessage: 'Project set to private',
},
projectStatusChanged: {
id: 'analytics.project-event.project-status-changed',
defaultMessage: 'Project status changed',
},
})
export function formatAnalyticsStatLabel(
stat: AnalyticsDashboardStat,
formatMessage: FormatMessage,
): string {
return formatMessage(analyticsStatMessages[stat])
}
export function formatAnalyticsGraphTitle(
stat: AnalyticsDashboardStat,
formatMessage: FormatMessage,
): string {
return formatMessage(analyticsGraphTitleMessages[stat])
}
export function formatAnalyticsGroupByLabel(
groupBy: AnalyticsGroupByPreset,
formatMessage: FormatMessage,
): string {
switch (groupBy) {
case '1h':
return formatMessage(analyticsGroupByMessages.oneHour)
case '6h':
return formatMessage(analyticsGroupByMessages.sixHours)
case 'day':
return formatMessage(analyticsGroupByMessages.day)
case 'week':
return formatMessage(analyticsGroupByMessages.week)
case 'month':
return formatMessage(analyticsGroupByMessages.month)
case 'year':
return formatMessage(analyticsGroupByMessages.year)
default:
return formatMessage(analyticsGroupByMessages.date)
}
}
export function formatAnalyticsGroupBySelectedLabel(
groupBy: AnalyticsGroupByPreset,
formatMessage: FormatMessage,
): string {
switch (groupBy) {
case '1h':
return formatMessage(analyticsGroupByMessages.groupByHour)
case '6h':
return formatMessage(analyticsGroupByMessages.groupBySixHours)
case 'day':
return formatMessage(analyticsGroupByMessages.groupByDay)
case 'week':
return formatMessage(analyticsGroupByMessages.groupByWeek)
case 'month':
return formatMessage(analyticsGroupByMessages.groupByMonth)
case 'year':
return formatMessage(analyticsGroupByMessages.groupByYear)
default:
return formatMessage(analyticsGroupByMessages.groupByDay)
}
}
export function formatAnalyticsBreakdownLabel(
breakdown: AnalyticsBreakdownPreset,
formatMessage: FormatMessage,
): string {
switch (breakdown) {
case 'none':
case 'project':
return formatMessage(analyticsBreakdownMessages.project)
case 'country':
return formatMessage(analyticsBreakdownMessages.country)
case 'monetization':
return formatMessage(analyticsBreakdownMessages.monetization)
case 'user_agent':
return formatMessage(analyticsBreakdownMessages.userAgent)
case 'download_reason':
return formatMessage(analyticsBreakdownMessages.downloadReason)
case 'version_id':
return formatMessage(analyticsBreakdownMessages.versionId)
case 'loader':
return formatMessage(analyticsBreakdownMessages.loader)
case 'game_version':
return formatMessage(analyticsBreakdownMessages.gameVersion)
default:
return formatMessage(analyticsBreakdownMessages.breakdown)
}
}
export function getAnalyticsBreakdownItemType(
breakdowns: readonly AnalyticsBreakdownPreset[],
): AnalyticsBreakdownItemType {
if (breakdowns.length !== 1) {
return 'other'
}
switch (breakdowns[0]) {
case 'project':
return 'project'
case 'country':
return 'country'
case 'monetization':
return 'monetization'
case 'user_agent':
return 'downloadSource'
case 'download_reason':
return 'downloadReason'
case 'version_id':
return 'projectVersion'
case 'loader':
return 'loader'
case 'game_version':
return 'gameVersion'
default:
return 'other'
}
}
export function formatAnalyticsMonetizationLabel(
value: string,
formatMessage: FormatMessage,
): string {
switch (value.trim().toLowerCase()) {
case 'monetized':
return formatMessage(analyticsMonetizationMessages.monetized)
case 'unmonetized':
return formatMessage(analyticsMonetizationMessages.unmonetized)
default:
return value
}
}
export function formatAnalyticsDownloadReasonLabel(
reason: string,
formatMessage: FormatMessage,
): string {
switch (reason.trim().toLowerCase()) {
case 'standalone':
return formatMessage(analyticsDownloadReasonMessages.standalone)
case 'dependency':
return formatMessage(analyticsDownloadReasonMessages.dependency)
case 'modpack':
return formatMessage(analyticsDownloadReasonMessages.modpack)
case 'update':
return formatMessage(analyticsDownloadReasonMessages.update)
default:
return reason
}
}
export function formatAnalyticsDownloadSourceLabel(
source: string,
formatMessage: FormatMessage,
): string {
const normalized = source.trim()
const normalizedLowercase = normalized.toLowerCase()
if (normalizedLowercase === 'website') {
return formatMessage(analyticsDownloadSourceMessages.website)
}
if (normalizedLowercase === 'modrinth_app') {
return formatMessage(analyticsDownloadSourceMessages.app)
}
if (!normalized.includes('_')) {
return normalized
}
return normalizedLowercase
.split('_')
.filter((part) => part.length > 0)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(' ')
}
export function formatAnalyticsProjectStatusLabel(
status: string,
formatMessage: FormatMessage,
): string {
switch (status.trim().toLowerCase()) {
case 'approved':
return formatMessage(analyticsProjectStatusMessages.approved)
case 'archived':
return formatMessage(analyticsProjectStatusMessages.archived)
case 'rejected':
return formatMessage(analyticsProjectStatusMessages.rejected)
case 'draft':
return formatMessage(analyticsProjectStatusMessages.draft)
case 'unlisted':
return formatMessage(analyticsProjectStatusMessages.unlisted)
case 'withheld':
return formatMessage(analyticsProjectStatusMessages.withheld)
case 'private':
return formatMessage(analyticsProjectStatusMessages.private)
case 'other':
return formatMessage(analyticsProjectStatusMessages.other)
default:
return capitalizeAnalyticsValue(status)
}
}
export function formatAnalyticsLoaderLabel(loader: string, formatMessage: FormatMessage): string {
const normalizedLoader = loader.trim()
const loaderMessage = getLoaderMessage(normalizedLoader)
return loaderMessage ? formatMessage(loaderMessage) : capitalizeAnalyticsValue(normalizedLoader)
}
function capitalizeAnalyticsValue(value: string): string {
const normalizedValue = value.trim()
if (normalizedValue.length === 0) {
return value
}
return `${normalizedValue.charAt(0).toUpperCase()}${normalizedValue.slice(1)}`
}
@@ -0,0 +1,908 @@
import type { LocationQuery, LocationQueryValue, LocationQueryValueRaw } from 'vue-router'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsGraphState,
AnalyticsGraphViewMode,
AnalyticsGroupByPreset,
AnalyticsLastTimeframeUnit,
AnalyticsQueryBuilderState,
AnalyticsQueryFilterCategory,
AnalyticsSelectedBreakdowns,
AnalyticsSelectedFilters,
AnalyticsTableSortColumn,
AnalyticsTableSortDirection,
AnalyticsTableSortState,
AnalyticsTimeframeMode,
AnalyticsTimeframePreset,
MutableRouteQuery,
} from '~/providers/analytics/analytics-types'
export const DEFAULT_TIMEFRAME_PRESET: AnalyticsTimeframePreset = 'last_30_days'
export const DEFAULT_TIMEFRAME_MODE: AnalyticsTimeframeMode = 'preset'
export const DEFAULT_LAST_TIMEFRAME_AMOUNT = 1
export const DEFAULT_LAST_TIMEFRAME_UNIT: AnalyticsLastTimeframeUnit = 'days'
export const DEFAULT_GROUP_BY_PRESET: AnalyticsGroupByPreset = 'day'
export const DEFAULT_BREAKDOWN_PRESET: AnalyticsBreakdownPreset = 'none'
export const DEFAULT_ANALYTICS_DASHBOARD_STAT: AnalyticsDashboardStat = 'views'
export const DEFAULT_ANALYTICS_GRAPH_VIEW_MODE: AnalyticsGraphViewMode = 'line'
export const DEFAULT_ANALYTICS_GRAPH_RATIO_MODE = false
export const DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY = true
export const DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY = false
export const MAX_ANALYTICS_BREAKDOWN_PRESETS = 2
const TIMEFRAME_PRESET_VALUES: AnalyticsTimeframePreset[] = [
'today',
'yesterday',
'last_7_days',
'last_14_days',
'last_30_days',
'last_90_days',
'last_180_days',
'year_to_date',
'all_time',
]
const TIMEFRAME_MODE_VALUES: AnalyticsTimeframeMode[] = [
'preset',
'last',
'custom_range',
'custom_datetime_range',
]
const LAST_TIMEFRAME_UNIT_VALUES: AnalyticsLastTimeframeUnit[] = [
'hours',
'days',
'weeks',
'months',
]
const GROUP_BY_PRESET_VALUES: AnalyticsGroupByPreset[] = [
'1h',
'6h',
'day',
'week',
'month',
'year',
]
const BREAKDOWN_PRESET_VALUES: AnalyticsBreakdownPreset[] = [
'none',
'project',
'country',
'monetization',
'user_agent',
'download_reason',
'version_id',
'loader',
'game_version',
]
const ANALYTICS_DASHBOARD_STAT_VALUES: AnalyticsDashboardStat[] = [
'views',
'downloads',
'revenue',
'playtime',
]
const ANALYTICS_GRAPH_VIEW_MODE_VALUES: AnalyticsGraphViewMode[] = ['line', 'area', 'bar']
const ANALYTICS_TABLE_SORT_COLUMN_VALUES: AnalyticsTableSortColumn[] = [
'date',
'project',
'breakdown',
'breakdown_project',
'breakdown_country',
'breakdown_monetization',
'breakdown_user_agent',
'breakdown_download_reason',
'breakdown_version_id',
'breakdown_loader',
'breakdown_game_version',
'views',
'downloads',
'revenue',
'playtime',
]
const ANALYTICS_TABLE_SORT_DIRECTION_VALUES: AnalyticsTableSortDirection[] = ['asc', 'desc']
const PROJECT_STATUS_FILTER_VALUES = [
'approved',
'archived',
'rejected',
'draft',
'unlisted',
'withheld',
'private',
'other',
]
const QUERY_KEY_PROJECT_IDS = 'a_projects'
const QUERY_KEY_TIMEFRAME_MODE = 'a_timeframe_mode'
const QUERY_KEY_TIMEFRAME = 'a_timeframe'
const QUERY_KEY_TIMEFRAME_LAST_AMOUNT = 'a_timeframe_last_amount'
const QUERY_KEY_TIMEFRAME_LAST_UNIT = 'a_timeframe_last_unit'
const QUERY_KEY_TIMEFRAME_START = 'a_timeframe_start'
const QUERY_KEY_TIMEFRAME_END = 'a_timeframe_end'
const QUERY_KEY_GROUP_BY = 'a_group_by'
const QUERY_KEY_BREAKDOWN = 'a_breakdown'
const QUERY_KEY_FILTER_PROJECT_STATUS = 'a_project_status'
const QUERY_KEY_FILTER_COUNTRY = 'a_country'
const QUERY_KEY_FILTER_MONETIZATION = 'a_monetization'
const QUERY_KEY_FILTER_USER_AGENT = 'a_user_agent'
const QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE = 'a_download_source'
const QUERY_KEY_FILTER_DOWNLOAD_REASON = 'a_download_reason'
const QUERY_KEY_FILTER_VERSION_ID = 'a_version_id'
const QUERY_KEY_FILTER_GAME_VERSION = 'a_game_version'
const QUERY_KEY_FILTER_LOADER_TYPE = 'a_loader_type'
const QUERY_KEY_STAT = 'a_stat'
const QUERY_KEY_GRAPH_VIEW_MODE = 'a_chart'
const QUERY_KEY_GRAPH_RATIO_MODE = 'a_ratio'
const QUERY_KEY_GRAPH_EVENTS_VISIBILITY = 'a_events'
const QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY = 'a_project_events'
const QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY = 'a_prev_period'
const QUERY_KEY_GRAPH_HIDDEN_SERIES = 'a_hidden_series'
const QUERY_KEY_GRAPH_SELECTED_SERIES = 'a_selected_series'
const QUERY_KEY_TABLE_SORT = 'a_table_sort'
const QUERY_KEY_TABLE_SORT_DIRECTION = 'a_table_sort_direction'
const QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER = 'a_top_breakdown'
const QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION = 'a_legend_expanded'
const URL_FILTER_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
'project_status',
'country',
'monetization',
'user_agent',
'download_reason',
'version_id',
'game_version',
'loader_type',
]
const FILTER_QUERY_KEY_BY_CATEGORY: Record<
Exclude<AnalyticsQueryFilterCategory, 'project'>,
string
> = {
project_status: QUERY_KEY_FILTER_PROJECT_STATUS,
country: QUERY_KEY_FILTER_COUNTRY,
monetization: QUERY_KEY_FILTER_MONETIZATION,
user_agent: QUERY_KEY_FILTER_USER_AGENT,
download_reason: QUERY_KEY_FILTER_DOWNLOAD_REASON,
version_id: QUERY_KEY_FILTER_VERSION_ID,
game_version: QUERY_KEY_FILTER_GAME_VERSION,
loader_type: QUERY_KEY_FILTER_LOADER_TYPE,
}
const ANALYTICS_QUERY_KEYS = [
QUERY_KEY_PROJECT_IDS,
QUERY_KEY_TIMEFRAME_MODE,
QUERY_KEY_TIMEFRAME,
QUERY_KEY_TIMEFRAME_LAST_AMOUNT,
QUERY_KEY_TIMEFRAME_LAST_UNIT,
QUERY_KEY_TIMEFRAME_START,
QUERY_KEY_TIMEFRAME_END,
QUERY_KEY_GROUP_BY,
QUERY_KEY_BREAKDOWN,
QUERY_KEY_FILTER_PROJECT_STATUS,
QUERY_KEY_FILTER_COUNTRY,
QUERY_KEY_FILTER_MONETIZATION,
QUERY_KEY_FILTER_USER_AGENT,
QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE,
QUERY_KEY_FILTER_DOWNLOAD_REASON,
QUERY_KEY_FILTER_VERSION_ID,
QUERY_KEY_FILTER_GAME_VERSION,
QUERY_KEY_FILTER_LOADER_TYPE,
QUERY_KEY_STAT,
QUERY_KEY_GRAPH_VIEW_MODE,
QUERY_KEY_GRAPH_RATIO_MODE,
QUERY_KEY_GRAPH_EVENTS_VISIBILITY,
QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY,
QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
QUERY_KEY_GRAPH_HIDDEN_SERIES,
QUERY_KEY_GRAPH_SELECTED_SERIES,
QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER,
QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION,
]
export function buildEmptySelectedFilters(): AnalyticsSelectedFilters {
return {
project: [],
project_status: [],
country: [],
monetization: [],
user_agent: [],
download_reason: [],
version_id: [],
game_version: [],
loader_type: [],
}
}
function parseListQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
): string[] {
if (value === undefined) return []
const values = Array.isArray(value) ? value : [value]
const parsedValues: string[] = []
for (const item of values) {
if (!item) continue
const parts = item.split(',')
for (const part of parts) {
const trimmed = part.trim()
if (trimmed.length > 0) {
parsedValues.push(trimmed)
}
}
}
return Array.from(new Set(parsedValues))
}
function parseSelectedSeriesQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
): string[] {
return parseListQueryValue(value).filter((item) => item.toLowerCase() !== 'null')
}
function normalizeFilterQueryValues(
category: Exclude<AnalyticsQueryFilterCategory, 'project'>,
values: string[],
): string[] {
if (category === 'project_status') {
return values
.map((value) => value.trim().toLowerCase())
.filter((value) => PROJECT_STATUS_FILTER_VALUES.includes(value))
}
if (category !== 'loader_type') {
return values
}
return Array.from(
new Set(values.map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)),
)
}
function parsePresetQueryValue<T extends string>(
value: LocationQueryValue | LocationQueryValue[] | undefined,
allowedValues: readonly T[],
fallbackValue: T,
): T {
const rawValue = Array.isArray(value) ? value[0] : value
if (!rawValue) return fallbackValue
if (!allowedValues.includes(rawValue as T)) return fallbackValue
return rawValue as T
}
function parseAnalyticsBreakdownsQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
fallbackValues: AnalyticsSelectedBreakdowns,
): AnalyticsBreakdownPreset[] {
const rawValues = parseListQueryValue(value)
if (rawValues.length === 0) {
return [...fallbackValues]
}
const parsedBreakdowns: AnalyticsBreakdownPreset[] = []
for (const rawValue of rawValues) {
const normalizedValue = rawValue === 'download_source' ? 'user_agent' : rawValue
if (BREAKDOWN_PRESET_VALUES.includes(normalizedValue as AnalyticsBreakdownPreset)) {
parsedBreakdowns.push(normalizedValue as AnalyticsBreakdownPreset)
}
}
return parsedBreakdowns
}
function parsePositiveIntegerQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
fallbackValue: number,
): number {
const rawValue = Array.isArray(value) ? value[0] : value
if (!rawValue) return fallbackValue
const parsedValue = Number.parseInt(rawValue, 10)
if (!Number.isFinite(parsedValue) || parsedValue < 1) return fallbackValue
return parsedValue
}
function parseEnabledQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
): boolean {
const rawValue = Array.isArray(value) ? value[0] : value
return rawValue === '1'
}
function parseVisibleQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
fallbackValue: boolean,
): boolean {
const rawValue = Array.isArray(value) ? value[0] : value
if (rawValue === undefined) return fallbackValue
return rawValue !== '0'
}
function getLocalDateQueryValue(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getDefaultCustomStartDate(): string {
const date = new Date()
date.setDate(date.getDate() - 1)
return getLocalDateQueryValue(date)
}
function getDefaultCustomEndDate(): string {
return getLocalDateQueryValue(new Date())
}
function getDefaultCustomDateTimeValue(value: string): string {
return new Date(`${value}T00:00:00`).toISOString()
}
function parseDateQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
fallbackValue: string,
): string {
const rawValue = Array.isArray(value) ? value[0] : value
if (!rawValue || !/^\d{4}-\d{2}-\d{2}$/.test(rawValue)) return fallbackValue
const date = new Date(`${rawValue}T00:00:00`)
if (Number.isNaN(date.getTime())) return fallbackValue
if (getLocalDateQueryValue(date) !== rawValue) return fallbackValue
return rawValue
}
function parseDateTimeQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
fallbackValue: string,
): string {
const rawValue = Array.isArray(value) ? value[0] : value
if (!rawValue || !/^\d{4}-\d{2}-\d{2}T/.test(rawValue)) return fallbackValue
const date = new Date(rawValue)
if (Number.isNaN(date.getTime())) return fallbackValue
return date.toISOString()
}
function isTimeframeRangeEndBeforeStart(
mode: AnalyticsTimeframeMode,
startValue: string,
endValue: string,
): boolean {
if (mode === 'custom_datetime_range') {
return new Date(endValue).getTime() < new Date(startValue).getTime()
}
return endValue < startValue
}
export function getDefaultAnalyticsGraphProjectEventsVisibility(
selectedProjectIds: readonly string[] = [],
): boolean {
return selectedProjectIds.length <= 1
}
export function buildDefaultAnalyticsGraphState(
selectedProjectIds: readonly string[] = [],
): AnalyticsGraphState {
return {
activeStat: DEFAULT_ANALYTICS_DASHBOARD_STAT,
activeGraphViewMode: DEFAULT_ANALYTICS_GRAPH_VIEW_MODE,
isRatioMode: DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
showChartEvents: DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
showProjectEvents: getDefaultAnalyticsGraphProjectEventsVisibility(selectedProjectIds),
showPreviousPeriod: DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
hiddenGraphDatasetIds: [],
selectedGraphDatasetIds: null,
}
}
export function buildDefaultAnalyticsQueryBuilderState(
availableProjectIds: string[],
): AnalyticsQueryBuilderState {
return {
selectedProjectIds: [...availableProjectIds],
selectedTimeframeMode: DEFAULT_TIMEFRAME_MODE,
selectedTimeframe: DEFAULT_TIMEFRAME_PRESET,
selectedLastTimeframeAmount: DEFAULT_LAST_TIMEFRAME_AMOUNT,
selectedLastTimeframeUnit: DEFAULT_LAST_TIMEFRAME_UNIT,
selectedCustomTimeframeStartDate: getDefaultCustomStartDate(),
selectedCustomTimeframeEndDate: getDefaultCustomEndDate(),
selectedGroupBy: DEFAULT_GROUP_BY_PRESET,
selectedBreakdowns: getDefaultAnalyticsBreakdownPresets(availableProjectIds),
selectedFilters: buildEmptySelectedFilters(),
}
}
export function getDefaultAnalyticsBreakdownPresets(
selectedProjectIds: readonly string[],
): AnalyticsSelectedBreakdowns {
return selectedProjectIds.length > 1 ? ['project'] : []
}
export function getDefaultAnalyticsBreakdownPreset(
selectedProjectIds: readonly string[],
): AnalyticsBreakdownPreset {
return selectedProjectIds.length > 1 ? 'project' : DEFAULT_BREAKDOWN_PRESET
}
export function getAnalyticsBreakdownPresetsForProjectSelection(
breakdowns: readonly AnalyticsBreakdownPreset[],
selectedProjectIds: readonly string[],
): AnalyticsSelectedBreakdowns {
const normalizedBreakdowns: AnalyticsSelectedBreakdowns = []
const canBreakDownByProject = selectedProjectIds.length > 1
for (const breakdown of breakdowns) {
if (breakdown === 'none') {
continue
}
if (breakdown === 'project' && !canBreakDownByProject) {
continue
}
if (!normalizedBreakdowns.includes(breakdown)) {
normalizedBreakdowns.push(breakdown)
}
if (normalizedBreakdowns.length >= MAX_ANALYTICS_BREAKDOWN_PRESETS) {
break
}
}
return normalizedBreakdowns
}
export function getAnalyticsBreakdownPresetForProjectSelection(
breakdown: AnalyticsBreakdownPreset,
selectedProjectIds: readonly string[],
): AnalyticsBreakdownPreset {
const defaultBreakdown = getDefaultAnalyticsBreakdownPreset(selectedProjectIds)
if (
(breakdown === 'none' && defaultBreakdown === 'project') ||
(breakdown === 'project' && defaultBreakdown === 'none')
) {
return defaultBreakdown
}
return breakdown
}
export function isAnalyticsQueryBuilderStateDefault(
state: AnalyticsQueryBuilderState,
availableProjectIds: string[],
): boolean {
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
const areDefaultProjectsSelected =
availableProjectIds.length === 0
? state.selectedProjectIds.length === 0
: areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
return (
areDefaultProjectsSelected &&
state.selectedTimeframeMode === defaultState.selectedTimeframeMode &&
state.selectedTimeframe === defaultState.selectedTimeframe &&
state.selectedLastTimeframeAmount === defaultState.selectedLastTimeframeAmount &&
state.selectedLastTimeframeUnit === defaultState.selectedLastTimeframeUnit &&
state.selectedCustomTimeframeStartDate === defaultState.selectedCustomTimeframeStartDate &&
state.selectedCustomTimeframeEndDate === defaultState.selectedCustomTimeframeEndDate &&
state.selectedGroupBy === defaultState.selectedGroupBy &&
areStringArraysEqual(
state.selectedBreakdowns,
getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds),
) &&
areSelectedFiltersEqual(state.selectedFilters, defaultState.selectedFilters)
)
}
export function isAnalyticsGraphStateDefault(
state: AnalyticsGraphState,
selectedProjectIds: readonly string[] = [],
): boolean {
const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds)
return (
state.activeStat === defaultState.activeStat &&
state.activeGraphViewMode === defaultState.activeGraphViewMode &&
state.isRatioMode === defaultState.isRatioMode &&
state.showChartEvents === defaultState.showChartEvents &&
state.showProjectEvents === defaultState.showProjectEvents &&
state.showPreviousPeriod === defaultState.showPreviousPeriod &&
areStringArraysEqual(state.hiddenGraphDatasetIds, defaultState.hiddenGraphDatasetIds) &&
state.selectedGraphDatasetIds === defaultState.selectedGraphDatasetIds
)
}
function serializeListQueryValue(values: string[]): string | undefined {
if (values.length === 0) return undefined
return values.join(',')
}
function serializeExplicitListQueryValue(values: string[]): string {
return values.join(',')
}
function serializeVisibleQueryValue(value: boolean, defaultValue: boolean): string | undefined {
if (value === defaultValue) return undefined
return value ? '1' : '0'
}
function normalizeQueryValue(
value:
| LocationQueryValue
| LocationQueryValue[]
| LocationQueryValueRaw
| LocationQueryValueRaw[]
| undefined,
): string[] {
if (value === undefined || value === null) return []
if (Array.isArray(value)) {
return value
.filter(
(item): item is LocationQueryValue | LocationQueryValueRaw =>
item !== undefined && item !== null,
)
.map((item) => String(item))
}
return [String(value)]
}
function areQueryValuesEqual(
left:
| LocationQueryValue
| LocationQueryValue[]
| LocationQueryValueRaw
| LocationQueryValueRaw[]
| undefined,
right:
| LocationQueryValue
| LocationQueryValue[]
| LocationQueryValueRaw
| LocationQueryValueRaw[]
| undefined,
): boolean {
const leftValues = normalizeQueryValue(left)
const rightValues = normalizeQueryValue(right)
if (leftValues.length !== rightValues.length) return false
for (let index = 0; index < leftValues.length; index += 1) {
if (leftValues[index] !== rightValues[index]) return false
}
return true
}
export function areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false
}
return true
}
export function areSelectedFiltersEqual(
left: AnalyticsSelectedFilters,
right: AnalyticsSelectedFilters,
): boolean {
if (!areStringArraysEqual(left.project, right.project)) return false
for (const category of URL_FILTER_CATEGORIES) {
if (!areStringArraysEqual(left[category], right[category])) return false
}
return true
}
function areAllProjectsSelected(selectedProjectIds: string[], allProjectIds: string[]): boolean {
if (allProjectIds.length === 0 || selectedProjectIds.length !== allProjectIds.length) {
return false
}
const allProjectIdSet = new Set(allProjectIds)
return selectedProjectIds.every((projectId) => allProjectIdSet.has(projectId))
}
export function readAnalyticsGraphState(
query: LocationQuery,
selectedProjectIds: readonly string[] = [],
): AnalyticsGraphState {
const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds)
return {
activeStat: parsePresetQueryValue(
query[QUERY_KEY_STAT],
ANALYTICS_DASHBOARD_STAT_VALUES,
defaultState.activeStat,
),
activeGraphViewMode: parsePresetQueryValue(
query[QUERY_KEY_GRAPH_VIEW_MODE],
ANALYTICS_GRAPH_VIEW_MODE_VALUES,
defaultState.activeGraphViewMode,
),
isRatioMode: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_RATIO_MODE]),
showChartEvents: parseVisibleQueryValue(
query[QUERY_KEY_GRAPH_EVENTS_VISIBILITY],
defaultState.showChartEvents,
),
showProjectEvents: parseVisibleQueryValue(
query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY],
defaultState.showProjectEvents,
),
showPreviousPeriod: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY]),
hiddenGraphDatasetIds: parseListQueryValue(query[QUERY_KEY_GRAPH_HIDDEN_SERIES]),
selectedGraphDatasetIds:
query[QUERY_KEY_GRAPH_SELECTED_SERIES] === undefined
? null
: parseSelectedSeriesQueryValue(query[QUERY_KEY_GRAPH_SELECTED_SERIES]),
}
}
export function readAnalyticsTableSortState(
query: LocationQuery,
defaultState: AnalyticsTableSortState,
): AnalyticsTableSortState {
const rawSortColumn = Array.isArray(query[QUERY_KEY_TABLE_SORT])
? query[QUERY_KEY_TABLE_SORT][0]
: query[QUERY_KEY_TABLE_SORT]
const rawSortDirection = Array.isArray(query[QUERY_KEY_TABLE_SORT_DIRECTION])
? query[QUERY_KEY_TABLE_SORT_DIRECTION][0]
: query[QUERY_KEY_TABLE_SORT_DIRECTION]
if (
!rawSortColumn ||
!rawSortDirection ||
!ANALYTICS_TABLE_SORT_COLUMN_VALUES.includes(rawSortColumn as AnalyticsTableSortColumn) ||
!ANALYTICS_TABLE_SORT_DIRECTION_VALUES.includes(rawSortDirection as AnalyticsTableSortDirection)
) {
return defaultState
}
return {
sortColumn: rawSortColumn as AnalyticsTableSortColumn,
sortDirection: rawSortDirection as AnalyticsTableSortDirection,
}
}
export function readAnalyticsQueryBuilderState(
query: LocationQuery,
availableProjectIds: string[],
): AnalyticsQueryBuilderState {
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
const selectedProjectIdsFromQuery = parseListQueryValue(query[QUERY_KEY_PROJECT_IDS])
const selectedProjectIds =
selectedProjectIdsFromQuery.length > 0
? selectedProjectIdsFromQuery
: defaultState.selectedProjectIds
const selectedFilters = buildEmptySelectedFilters()
for (const category of URL_FILTER_CATEGORIES) {
const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category]
const rawQueryValue =
category === 'user_agent' && query[categoryQueryKey] === undefined
? query[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE]
: query[categoryQueryKey]
selectedFilters[category] = normalizeFilterQueryValues(
category,
parseListQueryValue(rawQueryValue),
)
}
const selectedTimeframeMode = parsePresetQueryValue(
query[QUERY_KEY_TIMEFRAME_MODE],
TIMEFRAME_MODE_VALUES,
defaultState.selectedTimeframeMode,
)
const isCustomDateTimeRange = selectedTimeframeMode === 'custom_datetime_range'
const parseTimeframeRangeQueryValue = isCustomDateTimeRange
? parseDateTimeQueryValue
: parseDateQueryValue
const customTimeframeStartFallback = isCustomDateTimeRange
? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeStartDate)
: defaultState.selectedCustomTimeframeStartDate
const customTimeframeEndFallback = isCustomDateTimeRange
? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeEndDate)
: defaultState.selectedCustomTimeframeEndDate
const selectedCustomTimeframeStartDate = parseTimeframeRangeQueryValue(
query[QUERY_KEY_TIMEFRAME_START],
customTimeframeStartFallback,
)
const rawCustomTimeframeEndDate = parseTimeframeRangeQueryValue(
query[QUERY_KEY_TIMEFRAME_END],
customTimeframeEndFallback,
)
const selectedCustomTimeframeEndDate = isTimeframeRangeEndBeforeStart(
selectedTimeframeMode,
selectedCustomTimeframeStartDate,
rawCustomTimeframeEndDate,
)
? selectedCustomTimeframeStartDate
: rawCustomTimeframeEndDate
const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
parseAnalyticsBreakdownsQueryValue(
query[QUERY_KEY_BREAKDOWN],
getDefaultAnalyticsBreakdownPresets(selectedProjectIds),
),
selectedProjectIds,
)
return {
selectedProjectIds,
selectedTimeframeMode,
selectedTimeframe: parsePresetQueryValue(
query[QUERY_KEY_TIMEFRAME],
TIMEFRAME_PRESET_VALUES,
defaultState.selectedTimeframe,
),
selectedLastTimeframeAmount: parsePositiveIntegerQueryValue(
query[QUERY_KEY_TIMEFRAME_LAST_AMOUNT],
defaultState.selectedLastTimeframeAmount,
),
selectedLastTimeframeUnit: parsePresetQueryValue(
query[QUERY_KEY_TIMEFRAME_LAST_UNIT],
LAST_TIMEFRAME_UNIT_VALUES,
defaultState.selectedLastTimeframeUnit,
),
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
selectedGroupBy: parsePresetQueryValue(
query[QUERY_KEY_GROUP_BY],
GROUP_BY_PRESET_VALUES,
defaultState.selectedGroupBy,
),
selectedBreakdowns,
selectedFilters,
}
}
export function hasAnalyticsBreakdownQuery(query: LocationQuery): boolean {
return parseListQueryValue(query[QUERY_KEY_BREAKDOWN]).length > 0
}
export function hasAnalyticsProjectSelectionQuery(query: LocationQuery): boolean {
return parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]).length > 0
}
export function hasAnalyticsGraphProjectEventsVisibilityQuery(query: LocationQuery): boolean {
return query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] !== undefined
}
export function hasAnalyticsTableSortQuery(query: LocationQuery): boolean {
return (
query[QUERY_KEY_TABLE_SORT] !== undefined || query[QUERY_KEY_TABLE_SORT_DIRECTION] !== undefined
)
}
export function buildAnalyticsQueryBuilderRouteQuery(
currentRouteQuery: LocationQuery,
state: AnalyticsQueryBuilderState,
availableProjectIds: string[],
graphState?: AnalyticsGraphState,
): MutableRouteQuery {
const nextRouteQuery = {
...currentRouteQuery,
} as MutableRouteQuery
const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
? undefined
: serializeListQueryValue(state.selectedProjectIds)
const isCustomTimeframeMode =
state.selectedTimeframeMode === 'custom_range' ||
state.selectedTimeframeMode === 'custom_datetime_range'
nextRouteQuery[QUERY_KEY_PROJECT_IDS] = projectIdsQueryValue
nextRouteQuery[QUERY_KEY_TIMEFRAME_MODE] =
state.selectedTimeframeMode !== DEFAULT_TIMEFRAME_MODE ? state.selectedTimeframeMode : undefined
nextRouteQuery[QUERY_KEY_TIMEFRAME] =
state.selectedTimeframeMode === 'preset' && state.selectedTimeframe !== DEFAULT_TIMEFRAME_PRESET
? state.selectedTimeframe
: undefined
nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_AMOUNT] =
state.selectedTimeframeMode === 'last' ? String(state.selectedLastTimeframeAmount) : undefined
nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_UNIT] =
state.selectedTimeframeMode === 'last' ? state.selectedLastTimeframeUnit : undefined
nextRouteQuery[QUERY_KEY_TIMEFRAME_START] = isCustomTimeframeMode
? state.selectedCustomTimeframeStartDate
: undefined
nextRouteQuery[QUERY_KEY_TIMEFRAME_END] = isCustomTimeframeMode
? state.selectedCustomTimeframeEndDate
: undefined
nextRouteQuery[QUERY_KEY_GROUP_BY] =
state.selectedGroupBy !== DEFAULT_GROUP_BY_PRESET ? state.selectedGroupBy : undefined
const defaultBreakdowns = getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds)
const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
state.selectedBreakdowns,
state.selectedProjectIds,
)
nextRouteQuery[QUERY_KEY_BREAKDOWN] = areStringArraysEqual(selectedBreakdowns, defaultBreakdowns)
? undefined
: selectedBreakdowns.length === 0
? 'none'
: serializeListQueryValue(selectedBreakdowns)
for (const category of URL_FILTER_CATEGORIES) {
const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category]
nextRouteQuery[categoryQueryKey] = serializeListQueryValue(state.selectedFilters[category])
}
nextRouteQuery[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE] = undefined
if (graphState) {
const defaultGraphState = buildDefaultAnalyticsGraphState(state.selectedProjectIds)
nextRouteQuery[QUERY_KEY_STAT] =
graphState.activeStat !== DEFAULT_ANALYTICS_DASHBOARD_STAT ? graphState.activeStat : undefined
nextRouteQuery[QUERY_KEY_GRAPH_VIEW_MODE] =
graphState.activeGraphViewMode !== DEFAULT_ANALYTICS_GRAPH_VIEW_MODE
? graphState.activeGraphViewMode
: undefined
nextRouteQuery[QUERY_KEY_GRAPH_RATIO_MODE] = graphState.isRatioMode ? '1' : undefined
nextRouteQuery[QUERY_KEY_GRAPH_EVENTS_VISIBILITY] = serializeVisibleQueryValue(
graphState.showChartEvents,
defaultGraphState.showChartEvents,
)
nextRouteQuery[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] = serializeVisibleQueryValue(
graphState.showProjectEvents,
defaultGraphState.showProjectEvents,
)
nextRouteQuery[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY] = graphState.showPreviousPeriod
? '1'
: undefined
nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER] = undefined
nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION] = undefined
nextRouteQuery[QUERY_KEY_GRAPH_HIDDEN_SERIES] = serializeListQueryValue(
[...graphState.hiddenGraphDatasetIds].sort((left, right) => left.localeCompare(right)),
)
nextRouteQuery[QUERY_KEY_GRAPH_SELECTED_SERIES] =
graphState.selectedGraphDatasetIds === null
? undefined
: serializeExplicitListQueryValue(graphState.selectedGraphDatasetIds)
}
return nextRouteQuery
}
export function buildAnalyticsTableSortRouteQuery(
currentRouteQuery: LocationQuery,
state: AnalyticsTableSortState,
defaultState: AnalyticsTableSortState,
): MutableRouteQuery {
const nextRouteQuery = {
...currentRouteQuery,
} as MutableRouteQuery
const isDefaultSort =
state.sortColumn === defaultState.sortColumn &&
state.sortDirection === defaultState.sortDirection
nextRouteQuery[QUERY_KEY_TABLE_SORT] =
isDefaultSort || state.sortColumn === undefined ? undefined : state.sortColumn
nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION] =
isDefaultSort || state.sortColumn === undefined ? undefined : state.sortDirection
return nextRouteQuery
}
export function hasAnalyticsQueryBuilderRouteChange(
currentRouteQuery: LocationQuery,
nextRouteQuery: MutableRouteQuery,
): boolean {
return ANALYTICS_QUERY_KEYS.some(
(key) => !areQueryValuesEqual(currentRouteQuery[key], nextRouteQuery[key]),
)
}
export function hasAnalyticsTableSortRouteChange(
currentRouteQuery: LocationQuery,
nextRouteQuery: MutableRouteQuery,
): boolean {
return (
!areQueryValuesEqual(
currentRouteQuery[QUERY_KEY_TABLE_SORT],
nextRouteQuery[QUERY_KEY_TABLE_SORT],
) ||
!areQueryValuesEqual(
currentRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION],
nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION],
)
)
}

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