Compare commits

..
Author SHA1 Message Date
tdgao 7eebd690d0 fix: game version dropdown width 2026-05-29 13:22:47 -06:00
tdgao 3631e1c9af fix: no option to see release/all game versions in selected filter dropdown 2026-05-29 13:20:50 -06:00
tdgao 2b175beef6 fix: download threshold not width fit 2026-05-29 13:17:13 -06:00
tdgao 7057966c75 refactor: pnpm prepr:frontend 2026-05-29 11:38:41 -06:00
tdgao 54223be674 refactor: colocate query builder url with analytics-dashboard component 2026-05-29 11:37:05 -06:00
tdgao 8ea5420818 refactor: move query builder types into types file and move components into components/analytics-dashboard 2026-05-29 11:28:04 -06:00
tdgao cdb19ee189 refactor: rename types 2026-05-29 11:19:05 -06:00
tdgao d4a9c1f4e4 refactor: pnpm prepr 2026-05-29 11:15:38 -06:00
tdgao d2d3de9646 refactor: name main components to index.vue and keep folder name as component name 2026-05-29 11:10:02 -06:00
tdgao 78851f3140 refactor: same rename for analytics table for consistency 2026-05-29 10:44:16 -06:00
tdgao fb2d894b24 refactor: rename folder that with components to match main component name 2026-05-29 10:42:04 -06:00
tdgao 089cb080c5 fix: playtime was choosing y axis ticks in seconds instead of hours 2026-05-29 10:29:44 -06:00
tdgao f70aa9c999 revert: format number composable change 2026-05-29 10:21:31 -06:00
tdgao 1192f6ba1a Merge branch 'main' into truman/analytics 2026-05-29 10:13:57 -06:00
tdgao a9cb04a542 i18n pass 2026-05-29 10:13:25 -06:00
tdgao 6bb9f73e9b refactor: split up giant analytics provider with utils 2026-05-28 22:27:24 -06:00
tdgao bde052c83c fix: legend is stale after resetting query 2026-05-28 21:14:26 -06:00
tdgao 57faade025 refactor: split up large analytics chart and table component files into smaller components and ts modules 2026-05-28 21:03:18 -06:00
tdgao 8bb030eb60 refactor: kabab case 2026-05-28 20:23:08 -06:00
tdgao 246dbde369 fix: bugs around selected series from table not persisting with timeframe or filter changes 2026-05-28 20:19:41 -06:00
tdgao 8609dea0c4 fix: cant click in gap in toggle 2026-05-28 20:02:02 -06:00
tdgao 1af1426385 pnpm prepr 2026-05-28 19:59:02 -06:00
tdgao 64d641ff2d feat: improve analytics events grouping logic for modrinth events to be close to target 2026-05-28 19:57:07 -06:00
tdgao f3d03dcb58 feat: add gap for date picker 2 calendar view 2026-05-28 19:35:24 -06:00
tdgao e79dc7fba8 feat: remove gap inside chart tooltip 2026-05-28 19:27:34 -06:00
tdgao 3c2dcfa31c feat: event icon consume scroll for tooltip panel 2026-05-28 19:25:10 -06:00
tdgao a50c2ee4b9 feat: change hidden legend items to not contribute to ratio percentages 2026-05-28 19:24:59 -06:00
tdgao ef51b74a51 refactor: pnpm prepr 2026-05-28 19:20:19 -06:00
tdgao e790b95c5b feat: use tabs for game version select, and remove prev period when change breakdown or project selection 2026-05-28 19:18:07 -06:00
tdgao 598190ffaa feat: improve y axis formatting for playtime and others 2026-05-28 19:11:09 -06:00
tdgao 898c7d0ff1 fix: playtime y axis labels 2026-05-28 18:43:20 -06:00
tdgao fd4f95e105 feat: remove show more and show less in legend, always show all 2026-05-28 18:24:03 -06:00
tdgao ac636a617c fix: project events incorrectly toggling on for first page load 2026-05-28 17:59:39 -06:00
tdgao b53429c21c fix: z-index and padding issues 2026-05-28 16:52:40 -06:00
tdgao 16ed0c7355 feat: dont conditionally show multiselect selection actions 2026-05-28 16:34:47 -06:00
tdgao 3d4ed94cc6 fix: cannot click in graph 2026-05-28 16:34:32 -06:00
tdgao db2cff2596 fix: loading bar not fading away 2026-05-28 16:20:13 -06:00
tdgao 64bbeb7f5a feat: update controls dropdown to have fainter border 2026-05-28 16:02:39 -06:00
tdgao 09ae1eb3a8 refactor: remove chart event height being controlled by parent 2026-05-28 16:01:01 -06:00
tdgao 2e5c880a39 Merge branch 'main' into truman/analytics 2026-05-28 15:13:06 -06:00
tdgao d11eaef7ed performance: cache project version options to fix lag on open sub menu 2026-05-28 15:01:58 -06:00
tdgao 600dc9be2b fix: refreshing causes multiple projects to do breakdown=none 2026-05-28 14:19:46 -06:00
tdgao 88e467bbe8 performance: deter non-critical fetches to after analytics is in 2026-05-28 14:13:02 -06:00
tdgao 6f436df6bb feat: implement user get all projects 2026-05-28 13:51:20 -06:00
tdgao 384b890dc8 feat: implement using new backend facets route 2026-05-28 13:18:41 -06:00
tdgao 6e2450e025 small: style 2026-05-28 13:17:18 -06:00
tdgao 8c74aefd36 fix: styles in events table 2026-05-28 13:08:38 -06:00
tdgao b4e423890f fix date picker typing into time input 2026-05-28 13:08:29 -06:00
tdgao 7142232251 feat: implement graph controls dropdown 2026-05-28 12:47:48 -06:00
tdgao b51e11e32e 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
2026-05-28 12:14:06 -06:00
tdgao 10e326cdd7 fix: qa issues 2026-05-28 10:53:16 -06:00
tdgao da6c86f3c0 feat: fix emptys state height content shift 2026-05-28 10:48:27 -06:00
tdgao f96ce42fb8 fix: frontend still filtering after backend already filters 2026-05-28 10:20:24 -06:00
tdgao 37f4475abe fix: add user select none on graph and fix zoom into download threshold input 2026-05-27 16:23:34 -06:00
tdgao 69dcbb9524 feat: tap to toggle event tooltip 2026-05-27 16:23:01 -06:00
tdgao 74b9af8230 feat: improve chart tooltip date range label formatter to be much more consistent 2026-05-27 16:08:10 -06:00
tdgao a2cef735dc fix: callback to Organization instead of org id 2026-05-27 16:04:19 -06:00
tdgao 8691a28ca4 fix: date picker scrolling page after picking date in mobile by suppressing focus 2026-05-27 16:02:37 -06:00
tdgao c288151b6d fix: using plural instead of single for project breakdown 2026-05-27 15:58:53 -06:00
tdgao f0b15aaa31 feat: keep tooltip open after drag in mobile 2026-05-27 15:38:13 -06:00
tdgao 09310915ea fix: dropdown filter bar briefly empty when switching pages in mobile 2026-05-27 15:37:56 -06:00
tdgao d5b2a71688 fix: remove project breakdownoption as it is default breakdown when none are selected 2026-05-27 15:35:30 -06:00
tdgao 87a7c79ca7 fix: chart tooltip scroll in mobile 2026-05-27 15:28:10 -06:00
tdgao 4156077b60 fix: toggle not having touch manipulation 2026-05-27 15:10:45 -06:00
tdgao 9f3a8878e5 feat: add click to show chart event for mobile 2026-05-27 15:10:38 -06:00
tdgao 7d1d4f412f fix: prevent double tap to zoom on inputs 2026-05-27 15:01:18 -06:00
tdgao 8d197666f6 fix: tooltip expanding page width briefly 2026-05-27 14:57:57 -06:00
tdgao b299460e75 feat: improve performance on analytics events grouping 2026-05-27 14:23:17 -06:00
tdgao 6b2564b49f fix: analytics events grouping causing overflow 2026-05-27 14:16:44 -06:00
tdgao 172bc6f126 feat: deduplicate version uploads that have same version number and are uploaded on same day 2026-05-27 13:45:05 -06:00
tdgao 3ec41cddc5 small: 2 sig figs on playtime 2026-05-27 13:30:47 -06:00
tdgao 55f2f40648 fix-mobile: improve graph touch interactions 2026-05-27 13:23:13 -06:00
tdgao 67e7ff45f9 feat: default to projects breakdown instead of no breakdown with multiple projects 2026-05-27 13:01:18 -06:00
tdgao dc27bc044c refactor: pnpm prepr 2026-05-27 12:56:30 -06:00
tdgao ce20e90eaf feat: date picker default read only 2026-05-27 12:41:49 -06:00
tdgao 8908ffc0f6 feat: add close on select for time frame picker mobile 2026-05-27 12:39:17 -06:00
tdgao 2f227f91a4 add close on select prop 2026-05-27 12:39:03 -06:00
tdgao 1e345cd7ef feat-mobile: style fixes 2026-05-27 12:34:54 -06:00
tdgao e42b060290 feat: grey out dimmed lined on legend item hover 2026-05-27 12:03:14 -06:00
tdgao b3cc052bac feat: implement analytics performance updates, including facets, and v3 user projects 2026-05-27 12:01:55 -06:00
tdgao 0a232b6855 feat: default hide project events if there are multiple projects, and default show if only 1 project 2026-05-27 11:27:29 -06:00
tdgao f885ac43c1 fix: event icon hover color 2026-05-27 11:13:01 -06:00
tdgao c8d5fad6fa Merge branch 'main' into truman/analytics 2026-05-27 09:54:40 -06:00
tdgao 10a880ebe2 feat: remove clear project and breakdown 2026-05-27 09:46:30 -06:00
tdgao b8cdd2520b small: add select none 2026-05-27 09:41:04 -06:00
tdgao a00b47afca feat: handle events overflow 2026-05-27 09:40:23 -06:00
tdgao 124d271128 feat: implement better mobile view design for query builder 2026-05-26 20:16:29 -06:00
tdgao ec08069c87 feat: implement project side events 2026-05-26 18:15:57 -06:00
tdgao d50d79b40b fix: dropdown filter bar scroll while search 2026-05-26 17:09:32 -06:00
tdgao 3512eeaa18 fix-mobile: smaller metric card font 2026-05-26 17:00:01 -06:00
tdgao 5cf169aae4 Merge branch 'main' into truman/analytics 2026-05-26 16:44:15 -06:00
tdgao be89aee53c 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 2026-05-26 16:37:27 -06:00
tdgao 6a1f510442 feat: consolidate is mobile ref into context 2026-05-26 16:04:05 -06:00
tdgao b778e40b96 fix-mobile: fix multiselect scroll on mobile 2026-05-26 13:53:20 -06:00
tdgao b3e0a90a1f feat-mobile: time frame picker to use different start and end date pickers for mobile 2026-05-26 13:52:56 -06:00
tdgao eb8650391b feat-mobile: dropdown filter to replace dropdown for submenus on small screen 2026-05-26 13:49:27 -06:00
tdgao 73944fb54b feat-mobile: remove drag to select time frame in graph 2026-05-26 13:48:46 -06:00
tdgao e7271bbe35 feat: enabling prev period will cross out prev for current ones already crossed out 2026-05-26 12:55:19 -06:00
tdgao e377b27e10 feat: add right side checkmark for dropdown filtr bar 2026-05-25 13:07:35 -06:00
tdgao e88b02415d fix: multiple breakdown legend and tooltip labels 2026-05-25 12:59:40 -06:00
tdgao 4dc980c3a1 fix: cannot unpin on page click 2026-05-25 12:42:25 -06:00
tdgao 4a5a2d9407 fix: couple UX things for multiple breakdown 2026-05-25 12:12:12 -06:00
tdgao 8d336728fd feat: (to playtest) implement multiple breakdowns 2026-05-25 11:51:58 -06:00
tdgao 1e443b1e9b fix: chart icon add hover delay 2026-05-25 11:16:36 -06:00
tdgao ccbc448f0b fix: multiselect and combobox sizes 2026-05-25 11:14:05 -06:00
tdgao 88913219a3 refactor: pnpm prepr 2026-05-25 09:36:01 -06:00
tdgao 27514c3350 Merge branch 'main' into truman/analytics 2026-05-25 09:35:35 -06:00
tdgao bb2d006d0f feat: force legend order and graph colour for monetization 2026-05-25 09:34:03 -06:00
tdgao 64d549d93c fix: wrong all projects icon 2026-05-25 09:27:38 -06:00
tdgao 47f3ebced3 feat: update analytics tooltip 2026-05-25 09:23:32 -06:00
tdgao f0c8e2ea1f fix: still download type in filter 2026-05-25 09:22:19 -06:00
tdgao 9ab31afd08 refactor: pnpm prepr 2026-05-23 10:58:05 -06:00
tdgao 47a95282d3 feat: change playtime column to display in hours 2026-05-23 10:56:03 -06:00
tdgao b10307a6b1 feat: update export csv filename 2026-05-23 10:53:00 -06:00
tdgao 1859e52eae feat: implement format table playtime column 2026-05-23 10:47:48 -06:00
tdgao 57ee2a290f fix: brief select items empty state when switch breakdown 2026-05-23 10:37:48 -06:00
tdgao a2a87a7546 fix: remove number if its just top 1 2026-05-23 10:29:49 -06:00
tdgao 576603a6ab refactor: pnpm prepr 2026-05-23 10:26:14 -06:00
tdgao 5020dd2756 feat: implement prev period analytics fetch into the same current period fetch by shifting start date 2026-05-23 10:26:06 -06:00
tdgao ccbfd2fd71 fix: show top 8 button in graph not defaulting to top 8 for other metrics 2026-05-23 10:17:28 -06:00
tdgao aaa249e2c3 feat: persist table sort by and sort direction 2026-05-23 10:05:28 -06:00
tdgao bdd8e8f05f fix: formatting label for table 2026-05-23 09:52:41 -06:00
tdgao 12a8faa307 fix: rename download type to download reason 2026-05-23 09:36:51 -06:00
tdgao 080c65c624 feat: implement show top 8 button in graph subheading 2026-05-22 12:43:48 -06:00
tdgao 65bf029f36 feat: change project selection to reset to show top 8 only if reconciled down to 0 items 2026-05-22 12:14:07 -06:00
tdgao e74bde4ecc fix: tooltip styles 2026-05-22 11:59:32 -06:00
tdgao 30d5284280 fix: when breakdown switches, reset selected series 2026-05-22 11:57:34 -06:00
tdgao 23092f0e46 fix: style on prev item value in legend 2026-05-22 11:28:39 -06:00
tdgao a9e8389c3f feat: keep table selected persisted in query parameter 2026-05-22 11:26:31 -06:00
tdgao b2f420c6d5 Merge branch 'truman/analytics' of github.com:modrinth/code into truman/analytics 2026-05-22 11:12:37 -06:00
tdgao 7e7673c9a5 fix: adjust style 2026-05-22 11:12:35 -06:00
Calum H. (IMB11) 21443756d5 feat: extract TimeFramePicker to packages/ui 2026-05-22 13:10:00 +01:00
tdgao 9363a5c337 feat: implement show prev period toggle 2026-05-21 22:54:49 -06:00
tdgao b39c6898e4 fix: padding 2026-05-21 22:54:28 -06:00
tdgao 3090e69419 feat: add time in analytics event and normalize date/time so its correct to timezones 2026-05-21 22:25:18 -06:00
tdgao ad1f2d8f28 add clear button 2026-05-21 22:15:54 -06:00
tdgao a4903cb700 refactor: pnpm prepr 2026-05-21 22:01:37 -06:00
tdgao 5203518ead feat: mobile pass 2026-05-21 21:59:24 -06:00
tdgao 0cec8fab7a performance: implement limit on how many lines can be shown in graph 2026-05-21 21:50:23 -06:00
tdgao 300e598bec feat: add unmonetized explaination tooltip 2026-05-21 21:33:01 -06:00
tdgao ffff579eea feat: add subheading in graph for showing N items from table 2026-05-21 20:41:17 -06:00
tdgao 5175f3981e fix: keep scroll position with selection action row appearing when selecting one 2026-05-21 20:41:02 -06:00
tdgao 901a5ef779 fix: polish admin analytic events 2026-05-21 17:08:28 -06:00
tdgao 83e0c8dabe fix: small styles 2026-05-21 16:47:16 -06:00
tdgao 9a96d78b98 fix: adjust padding in combobox and multiselect and fix not unfocusing when deselect 2026-05-21 16:40:59 -06:00
tdgao f248a08417 fix: bugs with ratio mode and hiding chart lines 2026-05-21 16:39:59 -06:00
tdgao 36043c1d0b feat: dont show select all when its just one item in section 2026-05-21 16:25:42 -06:00
tdgao 7ab65aa769 fix: QA polish issues around style and copy 2026-05-21 16:16:24 -06:00
tdgao 55306e36d6 Merge branch 'main' into truman/analytics 2026-05-21 15:41:31 -06:00
tdgao 7816fb7473 performance: only do queries to populate graph first before other analytics queries 2026-05-20 23:02:41 -06:00
tdgao 5b98442aba Merge branch 'main' into truman/analytics 2026-05-20 16:44:49 -06:00
tdgao aa39bab614 fix: add back in batch with 300ms interval for projects to prevent backend rate limiting 2026-05-20 16:30:47 -06:00
tdgao f6643640fd refactor: pnpm prepr 2026-05-20 16:25:08 -06:00
tdgao 0baf998218 fix: custom time range picker being weird 2026-05-20 16:24:00 -06:00
tdgao d58fb917d0 feat: dropdown filter always open on one side and improve styles 2026-05-20 16:13:41 -06:00
tdgao eff50b60f1 performance: use precomputed map for project version options to remove first hover lag 2026-05-20 15:56:55 -06:00
tdgao b89862e7dd fix: add loading state for filter and parallel fetch 2026-05-20 15:48:57 -06:00
tdgao 09bdf35fbf fix: getting project name for project versions 2026-05-20 15:38:59 -06:00
tdgao a9d03a3c6d feat: sort projects in project dropdown 2026-05-20 15:29:08 -06:00
tdgao 067350a5d9 feat: improve table sorting 2026-05-20 15:28:50 -06:00
tdgao 3058c9a2c1 feat: remove breakdown = none when there are multiple projects 2026-05-20 15:20:54 -06:00
tdgao 58ec28297b fix: query filter options not coming in from analytics fetch 2026-05-20 15:15:06 -06:00
tdgao 02f2163158 refactor: pnpm prepr 2026-05-19 15:03:27 -06:00
tdgao 683476a90e Merge branch 'main' into truman/analytics 2026-05-19 15:02:12 -06:00
tdgao a9526b8021 feat: add analytics table search 2026-05-19 14:49:26 -06:00
tdgao bfc1520d2d performance: batch analytics requests by 15 project ids, with 150 ms delay between, so backend is happy 2026-05-19 14:39:34 -06:00
tdgao 175da90290 performance: add caching for metric types so switching between them is snappy 2026-05-19 14:21:33 -06:00
tdgao a999795125 feat: add shift click to select a range in table 2026-05-19 13:52:37 -06:00
tdgao fb3497e51f fix: table checked items not in graph if 0 2026-05-19 13:42:58 -06:00
tdgao 0aa16341ad performance: reduce memory usage from project versions query by keeping only whats necessary 2026-05-19 13:39:30 -06:00
tdgao a414e2face performance: frontend memory reduction 2026-05-19 13:16:43 -06:00
tdgao 724570be8a feat: implement breakdown = none 2026-05-19 13:08:42 -06:00
tdgao 28f59d3127 fix: export csv to always be dropdown 2026-05-19 12:45:56 -06:00
tdgao 06e2bcfa58 feat: add hover legend item to highlight line in tooltip 2026-05-19 12:42:31 -06:00
tdgao 428056ee52 feat: fix click to cross out in legend 2026-05-19 12:41:21 -06:00
tdgao bcad49a9a3 feat: change download source to use user_agent 2026-05-19 11:54:49 -06:00
tdgao 96ff380985 feat (playtest): table component controls what graph shows 2026-05-18 21:32:38 -06:00
tdgao 78b5c8307f feat (playtest): cross out legend items in tooltip and allow hide/show in tooltip 2026-05-18 21:08:46 -06:00
tdgao 1a626830bc fix: preview is incorrect when selecting same date in range date picker 2026-05-18 20:43:43 -06:00
Truman GaoandGitHub 94ee519ced Merge branch 'main' into truman/analytics
Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
2026-05-18 20:00:41 -06:00
tdgao 5f6cfaf2bd feat: implement filter by top 8 2026-05-18 19:49:00 -06:00
tdgao 7e928cf35f fix: focus styles pt2 2026-05-18 19:26:50 -06:00
tdgao 77f7ac9c6a fix: focus styles 2026-05-18 19:25:04 -06:00
tdgao d07d184d8a feat: keep crossed out legend items still shown in tooltip but also crossed out 2026-05-18 19:02:35 -06:00
tdgao f509967107 feat: implement right side checkmark for multiselect 2026-05-18 18:54:49 -06:00
tdgao 30ac9374d0 feat: rename breakdown "none" to "project" 2026-05-18 18:40:33 -06:00
tdgao 7da4730e91 fix: query filter project version lag and borked virtualization 2026-05-18 16:31:50 -06:00
tdgao 9c66aa5105 Revert "feat: use user_agent for download source"
This reverts commit d6dc8a99f1.
2026-05-18 16:22:52 -06:00
tdgao d6dc8a99f1 feat: use user_agent for download source 2026-05-18 16:03:24 -06:00
tdgao c1afb22e66 feat: MORE PADDING fixes 2026-05-18 15:50:03 -06:00
tdgao 5dcae6ba28 feat: a bunch of style fixes to combobox, multiselect and dropdown filter bar 2026-05-18 15:39:34 -06:00
tdgao 51a1daaaf7 feat: add overlay scrollbar for combobox 2026-05-18 14:48:58 -06:00
tdgao aa493a3272 feat: reduce padding on multiselect 2026-05-18 14:42:31 -06:00
tdgao ba6c8be1fb feat: reduce combobox padding 2026-05-18 12:56:47 -06:00
tdgao 29ea3ffe5e fix: type error 2026-05-18 12:46:34 -06:00
tdgao 4b2d1b672d feat: hook up analytics events backend 2026-05-16 10:21:41 -06:00
tdgao ec2c1bd017 fix: remove labels annoying 2026-05-15 17:54:59 -06:00
tdgao f3911a4235 feat: focus title input on analytics create event modal 2026-05-15 17:46:28 -06:00
tdgao 79a5ef49e0 feat: improve analytics events admin page 2026-05-15 17:40:44 -06:00
tdgao 42ec8a8573 feat: implement mock analytics events 2026-05-15 17:33:41 -06:00
tdgao 46d66c97b8 pnpm prepr 2026-05-15 17:18:09 -06:00
tdgao 72940f3f49 fix: switch column order 2026-05-15 17:14:37 -06:00
tdgao 8d06d46dd9 feat: implement analytics events admin page 2026-05-15 17:04:48 -06:00
tdgao 8bf965847b feat: improve custom time frame range select 2026-05-15 16:10:41 -06:00
tdgao c5c199aed0 feat: add tooltip for other item 2026-05-15 15:18:55 -06:00
tdgao 0e4b384790 feat: more performance optimization to analytics dashboard state, chart, and table 2026-05-15 15:14:13 -06:00
tdgao 60f4ca117c fix: defer commit in query builder/filter and style fixes 2026-05-15 14:53:39 -06:00
tdgao 5c9b9f8638 feat: implement hover on legend to highlight graph 2026-05-15 13:39:44 -06:00
tdgao b93aa1df37 feat: implement big performance improvement for table 2026-05-15 13:00:00 -06:00
tdgao 1786b37e22 fix: custom timeframe picker 2026-05-15 11:56:26 -06:00
tdgao 10f2c7f8a0 fix: change to > 1 day 2026-05-15 11:34:34 -06:00
tdgao a4172cd028 feat: implement auto switching temporarily to group by day for renvenue metric and disable revenue metric for time range < 2 days 2026-05-15 11:32:43 -06:00
tdgao 9664eb104a fix: legend colors moving between metric by determining color based on only downloads metric index 2026-05-15 11:13:04 -06:00
tdgao ccc2ff89e4 feat: add reset timeframe button 2026-05-15 10:48:12 -06:00
tdgao ebebec7522 fix: qa 2026-05-15 10:37:56 -06:00
tdgao 4f607d8a11 feat: implement query params for graph component settings 2026-05-15 10:17:31 -06:00
tdgao 04110fc810 feat: shorten time to not repeat am/pm 2026-05-14 21:06:48 -06:00
tdgao b15afcb42c fix: adjust charts event tooltip 2026-05-14 14:06:47 -06:00
tdgao 38664c191a feat: implement tooltip scroll 2026-05-14 13:38:00 -06:00
tdgao 309f0fcfbb fix: loading state text on table 2026-05-14 12:33:38 -06:00
tdgao 99a8a6773b fix: graph overflow issues 2026-05-14 12:31:47 -06:00
tdgao 4b289613a7 fix: multiselect keyboard use 2026-05-14 12:15:13 -06:00
tdgao 816349c1c3 feat: improve dropdown filter bar and multiselect performance 2026-05-14 12:03:58 -06:00
tdgao 2c5d4781ac refactor: pnpm prepr 2026-05-13 14:00:42 -06:00
tdgao a74316cf45 fix: loading state showing empty state text 2026-05-13 14:00:14 -06:00
tdgao 228bf74fa8 feat: implement analytics range highlight 2026-05-13 13:51:12 -06:00
tdgao dda69609fb fix: border radius 2026-05-13 13:06:14 -06:00
tdgao 626d55ced8 Merge branch 'main' into truman/analytics 2026-05-13 12:59:00 -06:00
tdgao 3714cb3fda feat: implement modrinth sided events 2026-05-13 12:54:17 -06:00
tdgao 48c957dc54 feat: implement showing project for project version breakdown/filter when there are multiple projects 2026-05-13 12:10:18 -06:00
tdgao 077cc60023 revert: no longer switch breakdown/filter option if same category 2026-05-13 11:31:28 -06:00
tdgao 5a7a6e66d3 feat: implement windows history for query builder 2026-05-13 11:16:46 -06:00
tdgao 5fc9f47c0c feat: implement click and drag to select date range 2026-05-13 11:08:34 -06:00
tdgao 8261b8bc4e feat: implement multiselect sections headers, project select org sections, and project options icons 2026-05-12 23:44:08 -06:00
tdgao 4788369c41 feat: filter out draft projects from analytics 2026-05-12 22:27:34 -06:00
tdgao 02aa76a3d0 feat: add project icon to project select 2026-05-12 22:22:23 -06:00
tdgao ae0cea0003 fix: loading state to include legend in blur 2026-05-12 18:52:41 -06:00
tdgao 635ae777d8 feat: graph style updates and dont show year in x axis unless more than 2 year timeframe 2026-05-12 18:07:02 -06:00
tdgao c9397010b9 fix: clear selected X above number when appropriate 2026-05-12 17:30:31 -06:00
tdgao 28d56e8f3a fix: dont show project status filter when from project settings/analytics 2026-05-12 17:07:27 -06:00
tdgao 3c406c82d6 feat: more aggressively trim allowed minimum group by option 2026-05-12 17:02:31 -06:00
tdgao e38d7af02c feat: clear button to clear breakdown 2026-05-12 16:57:57 -06:00
tdgao 92ad6e2258 feat: implement reset query button 2026-05-12 16:47:40 -06:00
tdgao cba07e4062 feat: add pick and plop for date range start/end dates 2026-05-11 16:54:30 -06:00
tdgao aad7b13edf refactor: pnpm prepr 2026-05-08 15:29:46 -06:00
tdgao 843529bbfa feat: implement show more batching excess lines into "Other" bucket 2026-05-08 15:27:02 -06:00
tdgao df196cc2be feat: if ratio mode, dont show total 2026-05-08 15:10:20 -06:00
tdgao 37f9564cdd feat: add max date to be today and show todays date 2026-05-08 14:52:53 -06:00
tdgao 2e3d6a035e feat: implement dot for showing todays date 2026-05-08 14:52:46 -06:00
tdgao 5d20585bed feat: implement cancel/apply for custom timeframe range picker 2026-05-08 14:47:59 -06:00
tdgao d8c311e33b feat: implement ratio mode 2026-05-08 14:17:52 -06:00
tdgao eac9daa0ca feat: implement filter by project status 2026-05-08 14:00:04 -06:00
tdgao 8b5cf0489c feat: implement better no projects empty state 2026-05-08 13:09:29 -06:00
tdgao 1c5b37b3b0 feat: update date/no date sorting 2026-05-08 11:58:00 -06:00
tdgao 8181b05890 feat: implement virtualized table 2026-05-08 11:29:10 -06:00
tdgao 089b37d0f6 fix: graph colors to be unique 2026-05-08 11:01:38 -06:00
tdgao e4799173ff feat: dont show prev period for all time/ when no prev period 2026-05-08 01:15:10 -06:00
tdgao c7e141c177 fix: switch breakdown/filter selection if one already exists 2026-05-08 01:04:52 -06:00
tdgao 9fea83b338 fix: graph and table seeding 0s from filter options instead of backend results 2026-05-08 00:54:56 -06:00
tdgao b78f16e20c refactor: pnpm prepr 2026-05-08 00:32:55 -06:00
tdgao 40cfcaf44d feat: disable toggle line if only one exists, and show graph as soon as loaded 2026-05-08 00:29:20 -06:00
tdgao 5392577ea3 feat: add loader colors for graph 2026-05-08 00:07:27 -06:00
tdgao 174915823d fix: stat card overflow 2026-05-08 00:06:59 -06:00
tdgao 0c5a3faf63 feat: implement better x-axis and tooltip showing timeframe 2026-05-08 00:00:02 -06:00
tdgao 8fb19616dd feat: implement hover guide on graph 2026-05-07 12:51:13 -06:00
tdgao 45c8af9fbc feat: default 30 days by day, and shift click to in legend to show all but clicked one 2026-05-07 12:44:55 -06:00
tdgao f77788f8b7 fix: exclude draft projects from analytics 2026-05-07 12:42:36 -06:00
tdgao 6ad653ab0f feat: implement above num downloads in preview dropdown 2026-05-07 12:39:05 -06:00
tdgao 722feb9baf feat: add country downloads above... filter 2026-05-07 11:57:25 -06:00
tdgao 6114d175c8 feat: add total to tooltip and handle overflow 2026-05-07 11:43:26 -06:00
tdgao 3ef95b6faf fix: graph offset 2026-05-07 11:34:33 -06:00
tdgao 067d307992 fix: remove spinner 2026-05-07 11:31:55 -06:00
tdgao 4fdf7d7b32 fix: QA issues - show year if relevant, press enter to run query, hide project column when not relevant, and reasonable group by selection 2026-05-07 11:29:57 -06:00
tdgao 6a71e77b33 feat: implement calendar only date picker and custom range timeframe for with date picker 2026-05-07 10:47:11 -06:00
tdgao 7e324ae7a8 Merge branch 'main' into truman/analytics 2026-05-07 09:47:32 -06:00
tdgao 2482ea5e5d feat: implement generic DropdownFilterBar component 2026-05-06 10:30:01 -06:00
tdgao dbcf26790e feat: improve filter bar styles 2026-05-06 09:35:08 -06:00
tdgao 8aba8bdc7e feat: implement pretty loading state 2026-05-04 17:35:31 -06:00
tdgao b2067bc693 fix: back fill 0 when there is no data at a point in time 2026-05-04 16:29:05 -06:00
tdgao 662075cc86 fix: small style update 2026-05-04 16:06:40 -06:00
tdgao 45d697efda fix: dropdown cancel button 2026-05-04 16:04:44 -06:00
tdgao 5be9d2482d feat: wire up downloads.monetized 2026-05-04 15:50:05 -06:00
tdgao 1e41ae1082 feat: improve graph UI 2026-05-04 15:43:08 -06:00
tdgao cfb1fbfa82 feat: hook up backend for new metrics 2026-05-04 15:35:55 -06:00
tdgao b63db68eed Merge branch 'main' into truman/analytics 2026-05-04 14:58:32 -06:00
tdgao bcb8566848 feat: improve how time frame picker opens for custom timeframe 2026-05-04 14:45:14 -06:00
tdgao b1e3d09587 feat: only displays loaders and game versions relevant to project + qa style fixes 2026-05-04 14:27:44 -06:00
tdgao ab1183cefe feat: update analytics query to match new format 2026-05-04 14:05:14 -06:00
tdgao d7cbe1798b feat: all switch between seeing only release or all game versions + QA style fixes 2026-05-04 13:37:48 -06:00
tdgao 27b3cc1b48 fix: qa issues 2026-05-04 12:51:55 -06:00
tdgao 1ddb8b29cc feat: implement independant all projects select and num selected row 2026-05-04 12:23:45 -06:00
tdgao 47b41e9e80 refactor: pnpm prepr 2026-04-30 21:39:12 -06:00
tdgao 646f803a86 feat; change how the query filter options are fetched 2026-04-30 21:34:32 -06:00
tdgao 3a55afb5cd fix: include date disabled in analytics table 2026-04-30 21:07:08 -06:00
tdgao 872c9de392 fix: analytics query bugs 2026-04-30 21:02:43 -06:00
tdgao de192d6fc5 feat: add pinned icon in chart tooltip 2026-04-30 20:34:03 -06:00
tdgao 48c652df46 feat: more concise axis labels 2026-04-30 20:33:50 -06:00
tdgao e1d3b6ff76 feat: update stat card component styles 2026-04-30 20:13:36 -06:00
tdgao a3b28367cb refactor: pnpm prepr 2026-04-30 20:02:58 -06:00
tdgao 3b3fcc10e5 fix: table breakdown column not actual breakdown name 2026-04-30 19:58:18 -06:00
tdgao dcab62d165 feat: implement include date 2026-04-30 19:54:04 -06:00
tdgao e43286195c refactor: use chips and button component in table 2026-04-30 19:42:03 -06:00
tdgao aa868e3255 fix: graph loading state on refresh 2026-04-30 19:35:19 -06:00
tdgao 12c18d6d93 feat: use tabs component in graph 2026-04-30 19:26:01 -06:00
tdgao f8c93ec074 feat: implement tabs component 2026-04-30 19:25:54 -06:00
tdgao c7908fb70d fix: spacing 2026-04-30 19:13:24 -06:00
tdgao 424bf6b19c fix: empty state y axis 2026-04-30 19:06:53 -06:00
tdgao 1c2d8c34c6 feat: implement filter by game version downloads above value 2026-04-30 17:29:47 -06:00
tdgao 0b4ac38b76 feat: hide filters as needed based on breakdown 2026-04-30 17:14:24 -06:00
tdgao 109cb0508d feat: add query filter sub menu empty state 2026-04-30 16:44:07 -06:00
tdgao 0ec0714d57 feat: add category filter sorting and get download source filter options 2026-04-30 16:34:11 -06:00
tdgao 99e2d657ef fix: filter sub menu first opens badly 2026-04-30 16:29:32 -06:00
tdgao 88c2a30109 feat: implement loader type options and remove "All" option 2026-04-30 16:28:11 -06:00
tdgao fdf4ac6410 feat: small style improvements 2026-04-30 14:29:11 -06:00
tdgao 4a61322617 feat: implement version number display and also searchable category filters 2026-04-30 13:55:42 -06:00
tdgao 560a4423a1 refactor: move query filter components into co-located folder 2026-04-30 13:41:05 -06:00
tdgao 3c366d135c fix: custom timeframe UX and styles 2026-04-30 13:38:59 -06:00
tdgao 7d0c04ac4d refactor: separate out query builder timeframe inputs 2026-04-30 13:27:26 -06:00
tdgao 56876cc130 feat: implement custom timeframe pickers and projects above num downloads 2026-04-30 13:17:36 -06:00
tdgao 31476c2150 feat: implement country filter 2026-04-30 12:49:01 -06:00
tdgao 6f2117c7a5 feat: implement bottom slot in combobox and multiselect dropdowns 2026-04-30 12:37:38 -06:00
tdgao b3eea2c38c feat: add anchored line to analytics chart and remove darkening points 2026-04-29 13:48:36 -06:00
tdgao c8ad19cb7e fix: version_id breakdown incorrectly named 2026-04-29 13:37:14 -06:00
tdgao 05cd3002c3 feat: update combobox active item to green 2026-04-29 13:28:02 -06:00
tdgao 4b31e465cb fix: remove projects from filters 2026-04-29 12:31:38 -06:00
tdgao 2eee097870 feat: improve query filter and how it commits changes 2026-04-29 12:25:37 -06:00
tdgao a0144f5c3c feat: small style improvements 2026-04-29 12:15:53 -06:00
tdgao 4414ea8f46 feat: implement better chart tooltip menu anchoring and dragging on mobile 2026-04-29 11:09:25 -06:00
tdgao c6a47493c0 feat: improve query filter menu styles and switch out of Menu 2026-04-29 10:52:57 -06:00
tdgao 0c991708cb fix: QA Issues 2026-04-29 10:13:35 -06:00
tdgao 0cedf43604 feat: add dropdown width and min dropdown width on combobox and multiselect 2026-04-29 10:13:25 -06:00
tdgao 628712e580 refactor: pnpm prepr 2026-04-27 20:53:41 -06:00
tdgao 53a04cf941 feat: update columns widths 2026-04-27 20:52:00 -06:00
tdgao 849edf0740 fix: queries not caching properly 2026-04-27 20:49:53 -06:00
tdgao df8aff28ca feat: hide/show specific series in graph, formatted legend labels, loading states 2026-04-27 20:30:20 -06:00
tdgao 34b736fd06 feat: implement adding project id analytics 2026-04-27 19:54:37 -06:00
tdgao 8c7e485e8f fix: broken lock file 2026-04-27 18:45:01 -06:00
tdgao 8694971610 refactor: pnpm prepr 2026-04-27 18:43:15 -06:00
tdgao 40d8007655 Merge branch 'main' into truman/analytics 2026-04-27 18:40:12 -06:00
tdgao 329cc3bf7d feat: use mutliselect for active filtered by options and use table header 2026-04-27 18:33:02 -06:00
tdgao 5cf6941fa8 feat: implement multiselect input content slow 2026-04-27 18:32:20 -06:00
tdgao ff0968b074 implement header slot for table 2026-04-27 18:31:17 -06:00
tdgao 43abf27985 fix: triangle safe area for query filter sub menus 2026-04-27 18:14:36 -06:00
tdgao 34b392bb26 fix: type 2026-04-27 17:38:44 -06:00
tdgao 4231250fa2 feat: separate out query filter component 2026-04-27 17:05:34 -06:00
tdgao ec723c7247 feat: style improvements to project select 2026-04-27 16:20:02 -06:00
tdgao f17b97847a feat: filter server projects 2026-04-24 13:31:17 -06:00
tdgao bfdb386f52 refactor: object destructure for context 2026-04-23 13:13:17 -06:00
tdgao d5239bd520 feat: implement disabled statcard for non-applicable ones 2026-04-23 12:53:56 -06:00
tdgao b126bc475f feat: add table empty state 2026-04-23 12:25:02 -06:00
tdgao 3daa527595 feat: improve query builder styles and timeframes 2026-04-23 12:03:22 -06:00
tdgao 8840a1315a refactor: pnpm prepr 2026-04-23 11:48:58 -06:00
tdgao fd07f8e218 fix: type error 2026-04-23 11:47:45 -06:00
tdgao 270e73fd04 remove: events toggle button for now since it does nothing 2026-04-23 11:43:04 -06:00
tdgao 6ce8be3b57 feat: style improvements 2026-04-23 11:25:20 -06:00
tdgao fbc996b7b2 fix: query builder disable group-by options if not relavant 2026-04-23 11:19:38 -06:00
tdgao 5297b173dc fix: date display to show time conditionally 2026-04-23 11:15:57 -06:00
tdgao 97fde95a03 feat: implement analytics table and breakdown 2026-04-22 18:29:08 -06:00
tdgao e52d6420de feat: add query to url params for query builder 2026-04-22 17:57:33 -06:00
tdgao 31f13a20ae feat: improve query builder styles 2026-04-22 17:40:55 -06:00
tdgao 5234270a04 feat: implement analytics chart 2026-04-22 17:33:52 -06:00
tdgao 661765d902 feat: style consistency updates 2026-04-22 14:37:09 -06:00
tdgao 1c368d8755 feat: initial implementation of analytics DI, query builder component, and stat cards component 2026-04-22 14:14:19 -06:00
tdgao e09845b0b8 feat: wire up shared analytics dashboard page 2026-04-21 22:22:55 -06:00
tdgao 1db663ba77 remove: delete current analytics implementation 2026-04-21 21:28:02 -06:00
tdgao 0301f4a8be feat: implement analytics route in api client 2026-04-21 11:46:15 -06:00
141 changed files with 933 additions and 3216 deletions
+4 -19
View File
@@ -84,7 +84,6 @@ import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
@@ -102,7 +101,6 @@ import { list } from '@/helpers/profile.js'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
import {
areUpdatesEnabled,
enqueueUpdateForInstallation,
@@ -136,7 +134,6 @@ const route = useRoute()
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref()
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
@@ -147,9 +144,6 @@ const forceSidebar = computed(
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const prideFundraiserEnabled = computed(
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
)
const hostingIntercomIdentityKey = computed(() => {
const rawServerId = route.params.id
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
@@ -198,12 +192,6 @@ const tauriApiClient = new TauriModrinthClient({
],
})
provideModrinthClient(tauriApiClient)
const { data: authenticatedModrinthUser } = useQuery({
queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]),
queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(),
enabled: () => !!credentials.value?.session,
retry: false,
})
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
@@ -688,11 +676,12 @@ async function logOut() {
await fetchCredentials()
}
const MIDAS_BITFLAG = 1 << 0
const hasPlus = computed(
() =>
!!credentials.value?.user &&
(hasMidasBadge(credentials.value.user) ||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
credentials.value &&
credentials.value.user &&
(credentials.value.user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG,
)
const showAd = computed(
@@ -1490,10 +1479,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense>
</div>
<PrideFundraiserBanner
v-if="prideFundraiserEnabled"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
/>
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
<div class="space-y-4 flex flex-col items-center w-full">
@@ -1,123 +0,0 @@
<script setup lang="ts">
import { CalendarIcon, UsersIcon, XIcon } from '@modrinth/assets'
import { injectModrinthClient, ProgressBar } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref } from 'vue'
const DISMISSED_STORAGE_KEY = 'pride-fundraiser-2026-dismissed'
const client = injectModrinthClient()
const dismissed = ref(localStorage.getItem(DISMISSED_STORAGE_KEY) === 'true')
const { data: campaignInfo } = useQuery({
queryKey: ['campaign', 'pride-26'],
queryFn: () => client.labrinth.campaign_internal.getPride26(),
enabled: () => !dismissed.value,
staleTime: 15 * 60 * 1000,
refetchInterval: 15 * 60 * 1000,
retry: false,
})
const shouldShowBanner = computed(
() => !dismissed.value && Number(campaignInfo.value?.target_usd) > 0,
)
async function openPrideFundraiser() {
await openUrl('https://modrinth.com/pride?from=app')
}
function dismissBanner() {
dismissed.value = true
localStorage.setItem(DISMISSED_STORAGE_KEY, 'true')
}
function formatUsd(amount: string | number) {
return Number(amount).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
})
}
function daysLeft() {
return Math.max(
0,
Math.ceil((new Date('2026-07-01T00:00:00Z').getTime() - Date.now()) / (24 * 60 * 60 * 1000)),
)
}
</script>
<template>
<div v-if="shouldShowBanner && campaignInfo">
<section
role="link"
tabindex="0"
class="flex w-full cursor-pointer flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-button-bg p-3 text-primary transition-[border-color,filter] hover:border-surface-6 hover:brightness-125 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
aria-label="Open Pride fundraiser"
@click="openPrideFundraiser"
@keydown.enter="openPrideFundraiser"
@keydown.space.prevent="openPrideFundraiser"
>
<div class="flex w-full items-center justify-between gap-2">
<h2 class="m-0 min-w-0 truncate text-base font-semibold text-contrast">
Pride Fundraiser 2026
</h2>
<button
type="button"
class="m-0 flex size-5 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-primary transition-colors hover:text-contrast focus-visible:text-contrast"
aria-label="Dismiss Pride fundraiser"
@click.stop="dismissBanner"
@keydown.stop
>
<XIcon aria-hidden="true" class="size-5" />
</button>
</div>
<div class="h-px w-full bg-surface-5" />
<div class="flex w-full flex-col gap-2.5">
<div class="flex items-end gap-1 whitespace-nowrap">
<span class="text-base font-semibold leading-5 text-contrast">
{{ formatUsd(campaignInfo.total_donations_usd) }}
</span>
<span class="text-xs font-medium leading-4">
of {{ formatUsd(campaignInfo.target_usd) }}
</span>
</div>
<ProgressBar
class="pride-fundraiser-banner__progress"
:progress="Number(campaignInfo.total_donations_usd)"
:max="Number(campaignInfo.target_usd)"
color="purple"
full-width
:gradient-border="false"
:aria-label="`${formatUsd(campaignInfo.total_donations_usd)} of ${formatUsd(
campaignInfo.target_usd,
)} raised`"
/>
<div class="flex flex-wrap items-center gap-2 text-xs font-medium leading-4">
<span class="flex items-center gap-1">
<UsersIcon aria-hidden="true" class="size-4 shrink-0" />
{{ campaignInfo.num_donators.toLocaleString('en-US') }}
{{ campaignInfo.num_donators === 1 ? 'supporter' : 'supporters' }}
</span>
<span class="flex items-center gap-1">
<CalendarIcon aria-hidden="true" class="size-4 shrink-0" />
{{ daysLeft() }} {{ daysLeft() === 1 ? 'day left' : 'days left' }}
</span>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.pride-fundraiser-banner__progress :deep(.progress-bar) {
background: linear-gradient(
90deg,
var(--color-red) 0%,
var(--color-orange) 20%,
var(--color-green) 50%,
var(--color-blue) 75%,
var(--color-purple) 100%
);
}
</style>
@@ -393,6 +393,7 @@ const messages = defineMessages({
<FriendsSection
v-if="pendingFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="pendingFriends"
:heading="formatMessage(messages.pending)"
:remove-friend="removeFriend"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
@@ -11,7 +11,6 @@ import {
useVIntl,
} from '@modrinth/ui'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
@@ -25,7 +24,6 @@ type AddSkinButtonRef = SkinLikeTextButtonExpose | SkinLikeTextButtonExpose[]
interface DefaultSkinSection {
title: string
infoTooltip?: string
skins: Skin[]
}
@@ -33,7 +31,6 @@ interface SkinSection {
key: string
title: string
kind: SkinSectionKind
infoTooltip?: string
skins: Skin[]
}
@@ -148,7 +145,6 @@ const sections = computed<SkinSection[]>(() => [
key: defaultSkinSectionKey(section.title),
title: section.title,
kind: 'default' as const,
infoTooltip: section.infoTooltip,
skins: section.skins,
})),
])
@@ -334,24 +330,6 @@ defineExpose({ getAddSkinButtonElement })
<span class="min-w-0 text-xl font-semibold leading-7 text-primary">
{{ section.title }}
</span>
<Tooltip
v-if="section.infoTooltip"
theme="dismissable-prompt"
placement="top"
:triggers="['hover', 'focus']"
>
<span
class="inline-flex size-6 shrink-0 items-center justify-center text-secondary transition-colors group-hover:text-primary"
@click.stop
>
<UnknownIcon class="size-5" />
</span>
<template #popper>
<p class="m-0 max-w-96 text-wrap text-sm font-medium leading-tight">
{{ section.infoTooltip }}
</p>
</template>
</Tooltip>
</template>
<div
@@ -1,23 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
const MIDAS_BITFLAG = 1 << 0
const PRIDE_26_MIDAS_DURATION_MS = 30 * 24 * 60 * 60 * 1000
type Pride26Campaign = Labrinth.Users.v3.Pride26CampaignDonation | null | undefined
export function hasMidasBadge(user?: { badges?: number } | null) {
return !!user?.badges && (user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG
}
export function hasPride26Badge(campaign: Pride26Campaign) {
return campaign?.has_badge === true
}
export function hasActivePride26Midas(campaign: Pride26Campaign, now = Date.now()) {
if (campaign?.has_midas !== true) {
return false
}
const donatedAt = new Date(campaign.last_donated_at).getTime()
return Number.isFinite(donatedAt) && donatedAt + PRIDE_26_MIDAS_DURATION_MS > now
}
@@ -431,15 +431,6 @@
"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"
},
+5 -63
View File
@@ -13,14 +13,11 @@ import {
commonMessages,
ConfirmModal,
defineMessages,
injectAuth,
injectModrinthClient,
injectNotificationManager,
SkinPreviewRenderer,
useVIntl,
} from '@modrinth/ui'
import { arrayBufferToBase64 } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { type DragDropEvent, getCurrentWebview } from '@tauri-apps/api/webview'
import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
@@ -47,7 +44,6 @@ import {
normalize_skin_texture,
remove_custom_skin,
} from '@/helpers/skins.ts'
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/state'
@@ -57,25 +53,11 @@ type VirtualSkinSectionListExpose = {
}
const PENDING_SKIN_REFRESH_DELAY_MS = 11_000
const DEFAULT_SKIN_SECTION_SORT_ORDER = ['Default skins', 'Modrinth Pride']
const messages = defineMessages({
skinSelectorTitle: {
id: 'app.skins.title',
defaultMessage: 'Skin selector',
},
modrinthPrideSection: {
id: 'app.skins.section.modrinth-pride',
defaultMessage: 'Modrinth Pride',
},
modrinthPrideTooltip: {
id: 'app.skins.section.modrinth-pride.tooltip',
defaultMessage:
'You received these skins for donating to a Modrinth Pride fundraiser during Pride Month.',
},
modrinthSection: {
id: 'app.skins.section.modrinth',
defaultMessage: 'Modrinth',
},
defaultSkinsSection: {
id: 'app.skins.section.default-skins',
defaultMessage: 'Default skins',
@@ -175,8 +157,6 @@ const skinSectionList = useTemplateRef<VirtualSkinSectionListExpose>('skinSectio
const { formatMessage } = useVIntl()
const notifications = injectNotificationManager()
const { addNotification, handleError } = notifications
const auth = injectAuth()
const client = injectModrinthClient()
const themeStore = useTheming()
const skins = ref<Skin[]>([])
@@ -200,42 +180,22 @@ const savedSkins = computed(() => {
return []
}
})
const { data: modrinthUser } = useQuery({
queryKey: computed(() => ['authenticated-user', 'campaigns', auth.user.value?.id]),
queryFn: () => client.labrinth.users_v3.getAuthenticated(),
enabled: () => !!auth.session_token.value,
retry: false,
})
const hasModrinthPrideCampaign = computed(
() => !!auth.session_token.value && hasPride26Badge(modrinthUser.value?.campaigns?.pride_26),
)
const defaultSkins = computed(() =>
filterDefaultSkins(skins.value).filter(
(skin) => skin.section !== 'Modrinth Pride' || hasModrinthPrideCampaign.value,
),
)
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
const defaultSkinSections = computed(() => {
const sections = new Map<string, Skin[]>()
for (const skin of defaultSkins.value) {
const section = skin.section ?? 'Default skins'
const sectionSkins = sections.get(section)
const sectionTitle = getDefaultSkinSectionTitle(skin.section)
const sectionSkins = sections.get(sectionTitle)
if (sectionSkins) {
sectionSkins.push(skin)
} else {
sections.set(section, [skin])
sections.set(sectionTitle, [skin])
}
}
return Array.from(sections, ([section, skins]) => ({
section,
title: getDefaultSkinSectionTitle(section),
infoTooltip: getDefaultSkinSectionInfoTooltip(section),
skins,
})).sort(
(a, b) => getDefaultSkinSectionSortIndex(a.section) - getDefaultSkinSectionSortIndex(b.section),
)
return Array.from(sections, ([title, skins]) => ({ title, skins }))
})
const currentCape = computed(() => {
@@ -343,10 +303,6 @@ function isMinecraftSkinRateLimitError(error: unknown) {
function getDefaultSkinSectionTitle(section?: string) {
switch (section) {
case 'Modrinth Pride':
return formatMessage(messages.modrinthPrideSection)
case 'Modrinth':
return formatMessage(messages.modrinthSection)
case 'MINECON Earth 2017':
return formatMessage(messages.mineconEarth2017Section)
case 'Builders & Biomes':
@@ -370,20 +326,6 @@ function getDefaultSkinSectionTitle(section?: string) {
}
}
function getDefaultSkinSectionInfoTooltip(section: string) {
switch (section) {
case 'Modrinth Pride':
return formatMessage(messages.modrinthPrideTooltip)
default:
return undefined
}
}
function getDefaultSkinSectionSortIndex(section: string) {
const index = DEFAULT_SKIN_SECTION_SORT_ORDER.indexOf(section)
return index === -1 ? DEFAULT_SKIN_SECTION_SORT_ORDER.length : index
}
function changeSkin(newSkin: Skin) {
selectedSkin.value = newSkin
}
+30 -63
View File
@@ -297,58 +297,6 @@ function isActiveUpdateRequest(requestId: number) {
return activeUpdateRequestId.value === requestId
}
function sortVersionsByPublishedDate(versions: Labrinth.Versions.v2.Version[]) {
return [...versions].sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
}
function mergeVersionIntoList(
versions: Labrinth.Versions.v2.Version[],
version: Labrinth.Versions.v2.Version,
) {
const existingIndex = versions.findIndex((v) => v.id === version.id)
if (existingIndex === -1) {
return sortVersionsByPublishedDate([version, ...versions])
}
const mergedVersions = [...versions]
mergedVersions[existingIndex] = version
return sortVersionsByPublishedDate(mergedVersions)
}
async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: string) {
let fetchError: unknown = null
let versions = (await get_project_versions(projectId, 'bypass').catch((err) => {
fetchError = err
return null
})) as Labrinth.Versions.v2.Version[] | null
if (!versions) {
versions = (await get_project_versions(projectId).catch(() => null)) as
| Labrinth.Versions.v2.Version[]
| null
}
if (!versions && fetchError) {
handleError(fetchError as Error)
}
let mergedVersions = sortVersionsByPublishedDate(versions ?? [])
if (pinnedVersionId && !mergedVersions.some((version) => version.id === pinnedVersionId)) {
const pinnedVersion = (await get_version(pinnedVersionId, 'bypass').catch(
() => null,
)) as Labrinth.Versions.v2.Version | null
if (pinnedVersion) {
mergedVersions = mergeVersionIntoList(mergedVersions, pinnedVersion)
}
}
return mergedVersions
}
async function handleBrowseContent() {
if (!props.instance) return
await router.push({
@@ -595,14 +543,16 @@ async function handleUpdate(id: string) {
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = await getUpdaterProjectVersions(item.project.id, initialVersionId)
const versions = (await get_project_versions(item.project.id).catch((e) => {
return handleError(e)
})) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
return
loadingVersions.value = false
if (versions.length === 0) {
if (!versions) {
debug('handleUpdate: no versions returned', { projectId: item.project.id })
return
}
@@ -621,6 +571,9 @@ async function handleUpdate(id: string) {
updateVersionInList: versions.some((v) => v.id === item.update_version_id),
})
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
const preselectedVersion =
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
debug('handleUpdate: resolved content updater preselection', {
@@ -658,16 +611,23 @@ async function handleSwitchVersion(item: ContentItem) {
await nextTick()
const initialVersionId = item.version.id
contentUpdaterModal.value?.show(initialVersionId, { switchMode: true })
contentUpdaterModal.value?.show(item.version.id, { switchMode: true })
const versions = await getUpdaterProjectVersions(item.project.id, initialVersionId)
const versions = (await get_project_versions(item.project.id).catch((e) => {
return handleError(e)
})) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
return
loadingVersions.value = false
if (!versions) return
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = versions
}
@@ -753,17 +713,19 @@ async function handleModpackUpdate() {
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = await getUpdaterProjectVersions(
props.instance.linked_data.project_id,
initialVersionId,
)
const versions = (await get_project_versions(props.instance.linked_data.project_id).catch(
handleError,
)) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
loadingVersions.value = false
if (versions.length === 0) return
if (!versions) return
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
const preselectedVersion =
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
debug('handleModpackUpdate: resolved modpack updater preselection', {
@@ -798,7 +760,12 @@ async function fetchAndSpliceVersion(
)) as Labrinth.Versions.v2.Version | null
if (!isActiveUpdateRequest(requestId)) return
if (!fullVersion) return
updatingProjectVersions.value = mergeVersionIntoList(updatingProjectVersions.value, fullVersion)
const index = updatingProjectVersions.value.findIndex((v) => v.id === versionId)
if (index !== -1) {
const newVersions = [...updatingProjectVersions.value]
newVersions[index] = fullVersion
updatingProjectVersions.value = newVersions
}
}
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
@@ -1,5 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import { type AuthProvider, provideAuth } from '@modrinth/ui'
import { computed, type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
@@ -12,7 +12,7 @@ export function setupAuthProvider(
requestSignIn: (redirectPath: string) => void | Promise<void>,
) {
const sessionToken = ref<string | null>(null)
const user = ref<AuthUser | null>(null)
const user = ref<Labrinth.Users.v2.User | null>(null)
const isReady = computed(() => credentials.value !== undefined)
const authProvider: AuthProvider = {
-1
View File
@@ -9,7 +9,6 @@ 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,
}
-3
View File
@@ -58,9 +58,6 @@ export default defineConfig({
params: {
overrides: {
removeViewBox: false,
cleanupIds: {
minify: false,
},
},
},
},
+15 -7
View File
@@ -75,10 +75,10 @@ export default defineNuxtConfig({
},
ssr: {
// https://github.com/Akryum/floating-vue/issues/809#issuecomment-1002996240
noExternal: ['floating-vue', '@floating-ui/core', '@floating-ui/dom'],
},
optimizeDeps: {
include: ['vue-router', 'floating-vue', '@floating-ui/dom'],
noExternal: ['v-tooltip'],
optimizeDeps: {
include: ['vue-router'],
},
},
define: {
global: {},
@@ -104,9 +104,6 @@ export default defineNuxtConfig({
params: {
overrides: {
removeViewBox: false,
cleanupIds: {
minify: false,
},
},
},
},
@@ -128,6 +125,8 @@ export default defineNuxtConfig({
const docTemplates = Object.keys(
await import('./src/templates/docs/index.ts').then((m) => m.default),
)
const blogArticles = await import('@modrinth/blog').then((m) => m.articles)
const { getChangelog } = await import('@modrinth/blog')
nitroConfig.prerender = nitroConfig.prerender || {}
nitroConfig.prerender.routes = nitroConfig.prerender.routes || []
@@ -137,6 +136,15 @@ export default defineNuxtConfig({
for (const template of docTemplates) {
nitroConfig.prerender.routes.push(`/_internal/templates/doc/${template}`)
}
nitroConfig.prerender.routes.push('/news')
for (const article of blogArticles) {
nitroConfig.prerender.routes.push(`/news/article/${article.slug}`)
}
nitroConfig.prerender.routes.push('/news/changelog')
for (const entry of getChangelog()) {
const id = entry.version ?? entry.date.unix()
nitroConfig.prerender.routes.push(`/news/changelog/${entry.product}/${id}`)
}
},
async 'build:before'() {
// 30 minutes
@@ -1,18 +1,18 @@
/*
Cards and body styling
*/
// CARDS
.base-card {
padding: 1rem;
padding: var(--spacing-card-lg);
position: relative;
min-height: var(--font-size-2xl);
background-color: var(--surface-3);
border-radius: var(--radius-lg);
border: 1px solid var(--surface-4);
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-card);
margin-bottom: var(--gap-md);
margin-bottom: var(--spacing-card-md);
outline: 2px solid transparent;
outline-offset: -2px;
box-shadow: var(--shadow-card);
.card__overlay {
position: absolute;
@@ -25,17 +25,6 @@
z-index: 2;
}
&:where(&.warning, &.information) {
padding: 1.5rem;
line-height: 1.5;
min-height: 0;
a {
color: var(--color-blue);
text-decoration: underline;
}
}
&.moderation-card {
background-color: var(--color-warning-banner-bg);
}
@@ -96,7 +96,7 @@ export function useAnalyticsChartDatasets(
}
return hasAvailableProjects.value
? formatMessage(analyticsMessages.noDataAvailable)
? formatMessage(analyticsMessages.selectAtLeastOneProject)
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
})
const legendPalette = computed(() =>
@@ -232,13 +232,7 @@ import {
type AnalyticsFilterValueCategory = Exclude<AnalyticsQueryFilterCategory, 'project'>
type GameVersionType = 'release' | 'all'
type SetDropdownFilterValues = (values: string[]) => void
type DownloadsThresholdSelection = {
categoryKey: DownloadsThresholdFilterCategory
selectedValues: string[]
}
type ApplyDownloadsThreshold = (
setSelectedValues: SetDropdownFilterValues,
) => DownloadsThresholdSelection | null
type ApplyDownloadsThreshold = (setSelectedValues: SetDropdownFilterValues) => void
type CloseDownloadsThresholdMenu = (event?: Event) => void
const props = withDefaults(
@@ -769,46 +763,48 @@ function compareOptionalDateStringsDescending(
function applyGameVersionDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
const threshold = gameVersionDownloadsThreshold.value
if (threshold === null) {
return null
return
}
const selectedValues = gameVersionFilterOptions.value
.filter((gameVersion) => {
return (gameVersionDownloadsByVersion.value.get(gameVersion.value) ?? 0) > threshold
return (gameVersionDownloadsByVersion.value.get(gameVersion.value) ?? 0) >= threshold
})
.map((gameVersion) => gameVersion.value)
return setDownloadsThresholdSelectedValues('game_version', selectedValues, setSelectedValues)
setDownloadsThresholdSelectedValues('game_version', selectedValues, setSelectedValues)
}
function applyCountryDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
const threshold = countryDownloadsThreshold.value
if (threshold === null) {
return null
return
}
const selectedValues = countryFilterOptions.value
.filter((country) => {
return (countryDownloadsByCode.value.get(country.value.trim().toUpperCase()) ?? 0) > threshold
return (
(countryDownloadsByCode.value.get(country.value.trim().toUpperCase()) ?? 0) >= threshold
)
})
.map((country) => country.value)
return setDownloadsThresholdSelectedValues('country', selectedValues, setSelectedValues)
setDownloadsThresholdSelectedValues('country', selectedValues, setSelectedValues)
}
function applyProjectVersionDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
const threshold = projectVersionDownloadsThreshold.value
if (threshold === null) {
return null
return
}
const selectedValues = projectVersionFilterOptions.value
.filter((version) => {
return (projectVersionDownloadsById.value.get(version.value) ?? 0) > threshold
return (projectVersionDownloadsById.value.get(version.value) ?? 0) >= threshold
})
.map((version) => version.value)
return setDownloadsThresholdSelectedValues('version_id', selectedValues, setSelectedValues)
setDownloadsThresholdSelectedValues('version_id', selectedValues, setSelectedValues)
}
function setCountryDownloadsThreshold(
@@ -880,18 +876,12 @@ function setDownloadsThresholdSelectedValues(
categoryKey: DownloadsThresholdFilterCategory,
selectedValues: string[],
setSelectedValues: SetDropdownFilterValues,
): DownloadsThresholdSelection {
const normalizedSelectedValues = normalizeSelectedFilterValues(categoryKey, selectedValues, [])
) {
downloadsThresholdSelections.value = {
...downloadsThresholdSelections.value,
[categoryKey]: normalizedSelectedValues,
[categoryKey]: normalizeSelectedFilterValues(categoryKey, selectedValues, []),
}
setSelectedValues(selectedValues)
return {
categoryKey,
selectedValues: normalizedSelectedValues,
}
}
function clearDownloadsThreshold(categoryKey: DownloadsThresholdFilterCategory) {
@@ -933,13 +923,8 @@ async function runDownloadsThresholdQuery(
closeMenu: CloseDownloadsThresholdMenu,
event?: KeyboardEvent,
) {
const selection = applyDownloadsThreshold(setSelectedValues)
applyDownloadsThreshold(setSelectedValues)
closeMenu(event)
if (selection) {
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
nextFilters[selection.categoryKey] = selection.selectedValues
draftSelectedFilters.value = nextFilters
}
await scheduleSelectedFiltersCommit()
await refreshAnalyticsQuery()
}
@@ -753,7 +753,7 @@ function applyProjectDownloadsThreshold(threshold: number | null) {
}
const projectIds = projects.value
.filter((project) => (projectDownloadsById.value.get(project.id) ?? 0) > threshold)
.filter((project) => (projectDownloadsById.value.get(project.id) ?? 0) >= threshold)
.map((project) => project.id)
projectDownloadsThresholdProjectIds.value = projectIds
@@ -3,7 +3,7 @@
<AutoLink
:to="currentAd.link"
:aria-label="currentAd.description"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit] border border-solid border-surface-4 bg-surface-3"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit] bg-bg-raised"
>
<img
:src="currentAd.light"
@@ -19,7 +19,7 @@
/>
</AutoLink>
<div
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl border border-solid border-surface-4 bg-surface-3"
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
>
<div id="modrinth-rail-1" />
</div>
+1 -2
View File
@@ -1,8 +1,7 @@
<template>
<nav :aria-label="ariaLabel" class="w-full">
<ul
class="card-shadow m-0 flex list-none flex-col items-start gap-1.5 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
:class="{ 'pt-3': filteredItems?.[0]?.type === 'heading' }"
class="card-shadow m-0 flex list-none flex-col items-start gap-1.5 rounded-2xl bg-bg-raised p-4"
>
<slot v-if="hasSlotContent" />
-2
View File
@@ -75,7 +75,6 @@ export const initAuth = async (oldToken = null) => {
auth.user = await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
},
@@ -106,7 +105,6 @@ export const initAuth = async (oldToken = null) => {
auth.user = await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
},
+1 -2
View File
@@ -793,7 +793,6 @@ import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { errors as generatedStateErrors } from '~/generated/state.json'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
import { hasActiveMidas } from '~/utils/user-membership.ts'
const generatedState = useGeneratedState()
@@ -1097,7 +1096,7 @@ const userMenuOptions = computed(() => {
id: 'plus',
link: '/plus',
color: 'purple',
shown: !flags.value.hidePlusPromoInUserMenu && !hasActiveMidas(user),
shown: !flags.value.hidePlusPromoInUserMenu && !isPermission(user.badges, 1 << 0),
},
{
id: 'servers',
+3 -5
View File
@@ -2,21 +2,19 @@ import { useAppQueryClient } from '~/composables/query-client'
import { useServerModrinthClient } from '~/server/utils/api-client'
export default defineNuxtRouteMiddleware(async (to) => {
const userParam = to.params.user ?? to.params.id
const userId = Array.isArray(userParam) ? userParam[0] : userParam
if (!to.path.startsWith('/user/') || !userId) {
if (!to.path.startsWith('/user/') || !to.params.id) {
return
}
const queryClient = useAppQueryClient()
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const userId = to.params.id as string
try {
const user = await queryClient.fetchQuery({
queryKey: ['user', userId],
queryFn: () => client.labrinth.users_v3.get(userId),
queryFn: () => client.labrinth.users_v2.get(userId),
})
if (!user) return
+6 -2
View File
@@ -38,7 +38,11 @@
{{ calculateSavings(price.prices.intervals.monthly, price.prices.intervals.yearly) }}% with
annual billing!
</p>
<ButtonStyled v-if="auth.user && hasActiveMidas(auth.user)" color="purple" size="large">
<ButtonStyled
v-if="auth.user && isPermission(auth.user.badges, 1 << 0)"
color="purple"
size="large"
>
<nuxt-link to="/settings/billing">
<SettingsIcon aria-hidden="true" />
Manage subscription
@@ -91,8 +95,8 @@ import {
import { calculateSavings, getCurrency } from '@modrinth/utils'
import { useBaseFetch } from '@/composables/fetch.js'
import { isPermission } from '@/utils/permissions.ts'
import { products } from '~/generated/state.json'
import { hasActiveMidas } from '~/utils/user-membership.ts'
const { addNotification } = injectNotificationManager()
const formatPrice = useFormatPrice()
+78 -31
View File
@@ -177,9 +177,9 @@
{{
user.bio
? user.bio
: projects?.length > 0
? formatMessage(messages.bioFallbackCreator)
: formatMessage(messages.bioFallbackUser)
: projects.length === 0
? formatMessage(messages.bioFallbackUser)
: formatMessage(messages.bioFallbackCreator)
}}
</template>
<template v-if="!isModrinthUser" #stats>
@@ -460,11 +460,8 @@
</div>
</div>
<div class="normal-page__sidebar">
<div
v-if="organizations?.length > 0"
class="mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4 pt-3"
>
<h2 class="m-0 mb-2 text-lg text-contrast">
<div v-if="organizations?.length > 0" class="card flex-card">
<h2 class="text-lg text-contrast">
{{ formatMessage(messages.profileOrganizations) }}
</h2>
<div class="flex flex-wrap gap-2">
@@ -479,16 +476,24 @@
</nuxt-link>
</div>
</div>
<UserBadges
:downloads="sumDownloads"
:join-date="joinDate"
:role="user.role"
:badges="user.badges"
:has-midas="hasActiveMidas(user)"
:has-pride="hasPride26Badge(user)"
:earliest-project-by-type="earliestProjectByType"
class="mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4 pt-3"
/>
<div v-if="badges.length > 0" class="card flex-card">
<h2 class="text-lg text-contrast">
{{ formatMessage(messages.profileBadges) }}
</h2>
<div class="flex flex-wrap gap-2">
<div v-for="badge in badges" :key="badge">
<StaffBadge v-if="badge === 'staff'" class="h-14 w-14" />
<ModBadge v-else-if="badge === 'mod'" class="h-14 w-14" />
<nuxt-link v-else-if="badge === 'plus'" to="/plus">
<PlusBadge class="h-14 w-14" />
</nuxt-link>
<TenMClubBadge v-else-if="badge === '10m-club'" class="h-14 w-14" />
<EarlyAdopterBadge v-else-if="badge === 'early-adopter'" class="h-14 w-14" />
<AlphaTesterBadge v-else-if="badge === 'alpha-tester'" class="h-14 w-14" />
<BetaTesterBadge v-else-if="badge === 'beta-tester'" class="h-14 w-14" />
</div>
</div>
</div>
<AdPlaceholder v-if="!auth.user" />
</div>
</div>
@@ -535,7 +540,6 @@ import {
useCompactNumber,
useFormatDateTime,
useFormatNumber,
UserBadges,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
@@ -543,13 +547,19 @@ import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { onServerPrefetch } from 'vue'
import TenMClubBadge from '~/assets/images/badges/10m-club.svg?component'
import AlphaTesterBadge from '~/assets/images/badges/alpha-tester.svg?component'
import BetaTesterBadge from '~/assets/images/badges/beta-tester.svg?component'
import EarlyAdopterBadge from '~/assets/images/badges/early-adopter.svg?component'
import ModBadge from '~/assets/images/badges/mod.svg?component'
import PlusBadge from '~/assets/images/badges/plus.svg?component'
import StaffBadge from '~/assets/images/badges/staff.svg?component'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { reportUser } from '~/utils/report-helpers.ts'
import { hasActiveMidas, hasPride26Badge } from '~/utils/user-membership.ts'
const data = useNuxtApp()
const route = useNativeRoute()
@@ -733,7 +743,7 @@ const {
suspense: userSuspense,
} = useQuery({
queryKey: computed(() => ['user', userId]),
queryFn: () => client.labrinth.users_v3.get(userId),
queryFn: () => client.labrinth.users_v2.get(userId),
})
watch(
@@ -849,21 +859,57 @@ const sumDownloads = computed(() => {
})
const joinDate = computed(() => new Date(user.value.created))
const MODRINTH_BETA_END_DATE = new Date('2022-02-27T08:00:00.000Z')
const MODRINTH_ALPHA_END_DATE = new Date('2020-11-30T08:00:00.000Z')
const badges = computed(() => {
const badges = []
if (user.value.role === 'admin') {
badges.push('staff')
}
if (user.value.role === 'moderator') {
badges.push('mod')
}
if (isPermission(user.value.badges, 1 << 0)) {
badges.push('plus')
}
if (sumDownloads.value > 10000000) {
badges.push('10m-club')
}
if (
isPermission(user.value.badges, 1 << 1) ||
isPermission(user.value.badges, 1 << 2) ||
isPermission(user.value.badges, 1 << 3)
) {
badges.push('early-adopter')
}
if (isPermission(user.value.badges, 1 << 4) || joinDate.value < MODRINTH_ALPHA_END_DATE) {
badges.push('alpha-tester')
} else if (isPermission(user.value.badges, 1 << 4) || joinDate.value < MODRINTH_BETA_END_DATE) {
badges.push('beta-tester')
}
if (isPermission(user.value.badges, 1 << 5)) {
badges.push('contributor')
}
if (isPermission(user.value.badges, 1 << 6)) {
badges.push('translator')
}
return badges
})
async function copyId() {
await navigator.clipboard.writeText(user.value.id)
}
const earliestProjectByType = computed(() => {
const obj = {}
for (const project of projects.value ?? []) {
obj[project.project_type] = new Date(project.published)
}
return obj
})
async function copyPermalink() {
await navigator.clipboard.writeText(`${config.public.siteUrl}/user/${user.value.id}`)
}
@@ -966,6 +1012,7 @@ export default defineNuxtComponent({
}
.description {
// Grow to take up remaining space
flex-grow: 1;
color: var(--color-text);
@@ -21,7 +21,7 @@ const ANALYTICS_START_TIME = new Date(ANALYTICS_START_TIMESTAMP).getTime()
export const REVENUE_MIN_TIMEFRAME_MS = 1 * 24 * 60 * 60 * 1000 // need at least 1 day in timeframe range to show revenue
const ANALYTICS_DAY_MS = 24 * 60 * 60 * 1000
const ANALYTICS_MAX_TIME_SLICES = 256 // controls granularity allowed in "group by" for timeframe ranges
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE = 40
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE = 2000
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_DELAY_MS = 300
function isProjectAnalyticsPoint(
@@ -369,10 +369,8 @@ export function getAnalyticsTimeframeDurationMs({
yearStart.setHours(0, 0, 0, 0)
return now.getTime() - yearStart.getTime()
}
case 'all_time': {
const allTimeDurationMs = nowTimestamp - ANALYTICS_START_TIME
return Math.max(0, allTimeDurationMs)
}
case 'all_time':
return REVENUE_MIN_TIMEFRAME_MS
}
}
@@ -478,85 +476,6 @@ export function computeTotals(
return totals
}
export function getProjectDownloadsByIdFromTimeSlices(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
): Map<string, number> {
const projectDownloadsById = new Map<string, number>()
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint) || dataPoint.metric_kind !== 'downloads') {
continue
}
projectDownloadsById.set(
dataPoint.source_project,
(projectDownloadsById.get(dataPoint.source_project) ?? 0) + dataPoint.downloads,
)
}
}
return projectDownloadsById
}
function getDownloadFieldCountsFromTimeSlices(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
getKey: (
dataPoint: Extract<Labrinth.Analytics.v3.ProjectMetrics, { metric_kind: 'downloads' }>,
) => string | null | undefined,
): Map<string, number> {
const downloadsByValue = new Map<string, number>()
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint) || dataPoint.metric_kind !== 'downloads') {
continue
}
const key = getKey(dataPoint)?.trim()
if (!key) {
continue
}
downloadsByValue.set(key, (downloadsByValue.get(key) ?? 0) + dataPoint.downloads)
}
}
return downloadsByValue
}
export function getProjectVersionDownloadsByIdFromTimeSlices(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
): Map<string, number> {
return getDownloadFieldCountsFromTimeSlices(timeSlices, (dataPoint) => dataPoint.version_id)
}
export function getGameVersionDownloadsByVersionFromTimeSlices(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
): Map<string, number> {
return getDownloadFieldCountsFromTimeSlices(timeSlices, (dataPoint) => dataPoint.game_version)
}
export function getCountryDownloadsByCodeFromTimeSlices(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
): Map<string, number> {
const countryDownloadsByCode = new Map<string, number>()
const downloadsByCountry = getDownloadFieldCountsFromTimeSlices(
timeSlices,
(dataPoint) => dataPoint.country,
)
for (const [country, downloads] of downloadsByCountry.entries()) {
const countryCode = country.toUpperCase()
countryDownloadsByCode.set(
countryCode,
(countryDownloadsByCode.get(countryCode) ?? 0) + downloads,
)
}
return countryDownloadsByCode
}
export function cloneAnalyticsFetchRequest(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): Labrinth.Analytics.v3.FetchRequest | null {
@@ -174,8 +174,28 @@ function getEmptyAnalyticsFacetsFilterOptionSummary(): AnalyticsFacetsFilterOpti
}
}
function getAnalyticsFacetValues<T>(facets: T[] | null | undefined): T[] {
return facets ? [...facets] : []
function getAnalyticsFacetValues<T>(
facets: Labrinth.Analytics.v3.AnalyticsFacet<T>[] | null | undefined,
): T[] {
return facets?.map((facet) => facet.value) ?? []
}
function getAnalyticsFacetDownloadsByValue<T>(
facets: Labrinth.Analytics.v3.AnalyticsFacet<T>[] | null | undefined,
getKey: (value: T) => string,
): Map<string, number> {
const downloadsByValue = new Map<string, number>()
for (const facet of facets ?? []) {
const key = getKey(facet.value)
if (key.length === 0) {
continue
}
const downloads = Number.isFinite(facet.downloads) ? facet.downloads : 0
downloadsByValue.set(key, (downloadsByValue.get(key) ?? 0) + downloads)
}
return downloadsByValue
}
export function getAnalyticsFacetsFilterOptionSummary(
@@ -185,18 +205,15 @@ export function getAnalyticsFacetsFilterOptionSummary(
return getEmptyAnalyticsFacetsFilterOptionSummary()
}
const projectDownloadFacets = facets.project_downloads
const projectViewFacets = facets.project_views
const projectPlaytimeFacets = facets.project_playtime
const downloadCountries = getAnalyticsFacetValues(projectDownloadFacets?.country)
const downloadGameVersions = getAnalyticsFacetValues(projectDownloadFacets?.game_version)
const downloadLoaders = getAnalyticsFacetValues(projectDownloadFacets?.loader)
const downloadVersionIds = getAnalyticsFacetValues(projectDownloadFacets?.version_id)
const viewCountries = getAnalyticsFacetValues(projectViewFacets?.country)
const playtimeCountries = getAnalyticsFacetValues(projectPlaytimeFacets?.country)
const playtimeGameVersions = getAnalyticsFacetValues(projectPlaytimeFacets?.game_version)
const playtimeLoaders = getAnalyticsFacetValues(projectPlaytimeFacets?.loader)
const playtimeVersionIds = getAnalyticsFacetValues(projectPlaytimeFacets?.version_id)
const downloadCountries = getAnalyticsFacetValues(facets.project_downloads.country)
const downloadGameVersions = getAnalyticsFacetValues(facets.project_downloads.game_version)
const downloadLoaders = getAnalyticsFacetValues(facets.project_downloads.loader)
const downloadVersionIds = getAnalyticsFacetValues(facets.project_downloads.version_id)
const viewCountries = getAnalyticsFacetValues(facets.project_views.country)
const playtimeCountries = getAnalyticsFacetValues(facets.project_playtime.country)
const playtimeGameVersions = getAnalyticsFacetValues(facets.project_playtime.game_version)
const playtimeLoaders = getAnalyticsFacetValues(facets.project_playtime.loader)
const playtimeVersionIds = getAnalyticsFacetValues(facets.project_playtime.version_id)
const countries = new Set([...viewCountries, ...downloadCountries, ...playtimeCountries])
const gameVersions = new Set([...downloadGameVersions, ...playtimeGameVersions])
const loaderTypes = new Set<string>()
@@ -213,8 +230,8 @@ export function getAnalyticsFacetsFilterOptionSummary(
.map((country) => country.trim().toUpperCase())
.filter((country) => country.length > 0),
),
downloadSources: sortStringValues(getAnalyticsFacetValues(projectDownloadFacets?.user_agent)),
downloadReasons: sortStringValues(getAnalyticsFacetValues(projectDownloadFacets?.reason)),
downloadSources: sortStringValues(getAnalyticsFacetValues(facets.project_downloads.user_agent)),
downloadReasons: sortStringValues(getAnalyticsFacetValues(facets.project_downloads.reason)),
gameVersions: sortStringValues(
[...gameVersions]
.map((gameVersion) => gameVersion.trim())
@@ -222,10 +239,22 @@ export function getAnalyticsFacetsFilterOptionSummary(
),
loaderTypes: sortStringValues([...loaderTypes]),
versionIds: sortStringValues([...new Set([...downloadVersionIds, ...playtimeVersionIds])]),
projectDownloadsById: new Map(),
projectVersionDownloadsById: new Map(),
gameVersionDownloadsByVersion: new Map(),
countryDownloadsByCode: new Map(),
projectDownloadsById: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.project_id,
(projectId) => projectId.trim(),
),
projectVersionDownloadsById: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.version_id,
(versionId) => versionId.trim(),
),
gameVersionDownloadsByVersion: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.game_version,
(gameVersion) => gameVersion.trim(),
),
countryDownloadsByCode: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.country,
(country) => country.trim().toUpperCase(),
),
}
}
@@ -1,10 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import {
createContext,
injectModrinthClient,
injectNotificationManager,
type ProjectPageContext,
} from '@modrinth/ui'
import { createContext, injectModrinthClient, type ProjectPageContext } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import type { ComputedRef, Ref } from 'vue'
@@ -47,11 +42,7 @@ import {
fetchAnalyticsTimeSlices,
getAnalyticsProjectEventsInTimeRange,
getAnalyticsTimeframeDurationMs,
getCountryDownloadsByCodeFromTimeSlices,
getGameVersionDownloadsByVersionFromTimeSlices,
getPercentChange,
getProjectDownloadsByIdFromTimeSlices,
getProjectVersionDownloadsByIdFromTimeSlices,
isAnalyticsFetchRequestReady,
isRevenueHourlyGroupBy,
REVENUE_MIN_TIMEFRAME_MS,
@@ -132,26 +123,6 @@ const ANALYTICS_PREFETCH_GC_TIME_MS = 15 * 1000
const ANALYTICS_FILTER_OPTIONS_GC_TIME_MS = 60 * 1000
const ANALYTICS_MOBILE_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
function getAnalyticsFetchErrorMessage(error: unknown): string {
if (error && typeof error === 'object') {
const dataDescription = (error as { data?: { description?: unknown } }).data?.description
if (typeof dataDescription === 'string' && dataDescription.length > 0) {
return dataDescription
}
const message = (error as { message?: unknown }).message
if (typeof message === 'string' && message.length > 0) {
return message
}
}
if (typeof error === 'string' && error.length > 0) {
return error
}
return 'Please try refreshing the page or changing your query.'
}
export interface AnalyticsDashboardContextValue {
hasProjectContext: ComputedRef<boolean>
projectGroups: ComputedRef<AnalyticsDashboardProjectGroup[]>
@@ -246,7 +217,6 @@ export function createAnalyticsDashboardContext(
options: CreateAnalyticsDashboardContextOptions,
): AnalyticsDashboardContextValue {
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
const route = useRoute()
const initialQueryState = readAnalyticsQueryBuilderState(route.query, [])
@@ -877,7 +847,6 @@ export function createAnalyticsDashboardContext(
data: currentAnalyticsData,
isPending: currentTimeSlicePending,
isFetching: currentFetching,
error: currentAnalyticsError,
refetch: refetchCurrentTimeSlices,
} = useQuery({
queryKey: computed(() =>
@@ -903,17 +872,6 @@ export function createAnalyticsDashboardContext(
enabled: computed(() => isAnalyticsFetchRequestReady(analyticsTimeSlicesFetchRequest.value)),
gcTime: ANALYTICS_TIME_SLICES_GC_TIME_MS,
})
watch(currentAnalyticsError, (error) => {
if (!error) {
return
}
addNotification({
title: 'Analytics failed to load',
text: getAnalyticsFetchErrorMessage(error),
type: 'error',
})
})
const isCurrentTimeSliceLoading = computed(
() =>
isAnalyticsFetchRequestReady(analyticsTimeSlicesFetchRequest.value) &&
@@ -1059,35 +1017,6 @@ export function createAnalyticsDashboardContext(
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
})
const { data: analyticsDownloadCountTimeSlices } = useQuery({
queryKey: computed(() => [
'analytics',
'dashboard',
analyticsQueryUserId.value,
'filter-options',
'download-counts-fallback',
analyticsFacetsRequest.value,
queryRefreshTimestamp.value,
]),
queryFn: () => {
const nextRequest = analyticsFacetsRequest.value
if (!isAnalyticsFetchRequestReady(nextRequest)) {
return []
}
return fetchAnalyticsTimeSlices(nextRequest, (request) =>
client.labrinth.analytics_v3.fetch(request),
)
},
enabled: computed(
() =>
hasFetchedAnalyticsFilterOptions.value &&
isAnalyticsFetchRequestReady(analyticsFacetsRequest.value),
),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
})
const { data: filterOptionProjectVersions, isFetched: hasFetchedFilterOptionProjectVersions } =
useQuery({
queryKey: computed(() => [
@@ -1316,21 +1245,17 @@ export function createAnalyticsDashboardContext(
}
return versionProjectIconUrls
})
const downloadCountTimeSlices = computed(() => {
const countTimeSlices = analyticsDownloadCountTimeSlices.value ?? []
return countTimeSlices.length > 0 ? countTimeSlices : timeSlices.value
})
const projectDownloadsById = computed(() =>
getProjectDownloadsByIdFromTimeSlices(downloadCountTimeSlices.value),
const projectDownloadsById = computed(
() => analyticsFacetsFilterOptionSummary.value.projectDownloadsById,
)
const projectVersionDownloadsById = computed(() =>
getProjectVersionDownloadsByIdFromTimeSlices(downloadCountTimeSlices.value),
const projectVersionDownloadsById = computed(
() => analyticsFacetsFilterOptionSummary.value.projectVersionDownloadsById,
)
const countryDownloadsByCode = computed(() =>
getCountryDownloadsByCodeFromTimeSlices(downloadCountTimeSlices.value),
const countryDownloadsByCode = computed(
() => analyticsFacetsFilterOptionSummary.value.countryDownloadsByCode,
)
const gameVersionDownloadsByVersion = computed(() =>
getGameVersionDownloadsByVersionFromTimeSlices(downloadCountTimeSlices.value),
const gameVersionDownloadsByVersion = computed(
() => analyticsFacetsFilterOptionSummary.value.gameVersionDownloadsByVersion,
)
const selectedProjectIdSet = computed(() => new Set(selectedProjectIds.value))
+3 -3
View File
@@ -1,5 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import { type AuthProvider, provideAuth } from '@modrinth/ui'
import { ref, watchEffect } from 'vue'
import { getSignInRedirectPath } from '~/composables/auth.js'
@@ -8,7 +8,7 @@ export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
const router = useRouter()
const route = useRoute()
const sessionToken = ref<string | null>(null)
const user = ref<AuthUser | null>(null)
const user = ref<Labrinth.Users.v2.User | null>(null)
const authProvider: AuthProvider = {
session_token: sessionToken,
@@ -26,7 +26,7 @@ export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
watchEffect(() => {
sessionToken.value = auth.value.token || null
user.value = (auth.value.user as Labrinth.Users.v3.User | null) ?? null
user.value = (auth.value.user as Labrinth.Users.v2.User | null) ?? null
})
provideAuth(authProvider)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 891 KiB

@@ -1,19 +1,5 @@
{
"articles": [
{
"title": "Pride 2026 Fundraiser: Matching up to $10,000",
"summary": "Celebrating our community and working together to make a difference.",
"thumbnail": "https://modrinth.com/news/article/pride-campaign-2026/thumbnail.webp",
"date": "2026-06-01T16:00:00.000Z",
"link": "https://modrinth.com/news/article/pride-campaign-2026"
},
{
"title": "Project Analytics are good now",
"summary": "Get deeper insights into how people are using your projects with breakdowns, filtering, and more!",
"thumbnail": "https://modrinth.com/news/article/analytics-overhaul/thumbnail.webp",
"date": "2026-05-29T20:00:00.000Z",
"link": "https://modrinth.com/news/article/analytics-overhaul"
},
{
"title": "Modrinth Hosting in the App",
"summary": "Buy, setup, and manage your server all in the Modrinth App.",
File diff suppressed because one or more lines are too long
@@ -1,40 +0,0 @@
import { UserBadge } from '@modrinth/utils'
const PRIDE_26_MIDAS_DURATION_MS = 30 * 24 * 60 * 60 * 1000
type Pride26Campaign = {
last_donated_at?: string | null
has_badge?: boolean | null
has_midas?: boolean | null
}
type UserWithMembership = {
badges?: number | null
campaigns?: {
pride_26?: Pride26Campaign | null
} | null
}
export function hasPride26Badge(user?: UserWithMembership | null) {
return user?.campaigns?.pride_26?.has_badge === true
}
export function hasActivePride26Midas(user?: UserWithMembership | null, now = Date.now()) {
const pride26Campaign = user?.campaigns?.pride_26
if (!pride26Campaign?.has_midas || !pride26Campaign.last_donated_at) {
return false
}
const lastDonatedAt = Date.parse(pride26Campaign.last_donated_at)
if (!Number.isFinite(lastDonatedAt)) {
return false
}
return lastDonatedAt + PRIDE_26_MIDAS_DURATION_MS > now
}
export function hasActiveMidas(user?: UserWithMembership | null, now = Date.now()) {
return Boolean((user?.badges ?? 0) & UserBadge.MIDAS) || hasActivePride26Midas(user, now)
}
-3
View File
@@ -102,9 +102,6 @@ TREMENDOUS_API_URL=https://testflight.tremendous.com/api/v2/
TREMENDOUS_API_KEY=none
TREMENDOUS_PRIVATE_KEY=none
TREMENDOUS_CAMPAIGN_ID=none
TILTIFY_CLIENT_ID=
TILTIFY_CLIENT_SECRET=
TILTIFY_PRIDE_26_CAMPAIGN_ID=
HCAPTCHA_SECRET=none
-3
View File
@@ -123,9 +123,6 @@ TREMENDOUS_API_URL=https://testflight.tremendous.com/api/v2/
TREMENDOUS_API_KEY=none
TREMENDOUS_PRIVATE_KEY=none
TREMENDOUS_CAMPAIGN_ID=none
TILTIFY_CLIENT_ID=
TILTIFY_CLIENT_SECRET=
TILTIFY_PRIDE_26_CAMPAIGN_ID=
HCAPTCHA_SECRET=none
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM versions\n WHERE mod_id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "12fa322e09465aab925ac33f8b2a1371fb63be744f1d87c68229342ffadbe998"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM campaign_donations WHERE id=$1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "2248f0698a4195e8f1309d89d9ea78aeb06db699c469cdef0719486d9b938647"
}
@@ -1,27 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n insert into campaign_donations (id, tiltify_event_id, raw_data, donated_at, amount_usd, user_id)\n values ($1, $2::text::uuid, $3, $4, $5, $6)\n on conflict (tiltify_event_id) do nothing\n returning id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Text",
"Jsonb",
"Timestamptz",
"Numeric",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "2f883a2641f3276be6e57f7fcce0f1c7cc2ba03e09094750dc501df45d8421d5"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start\n FROM payouts_values\n WHERE mod_id = ANY($1) AND created >= $2 AND created < $3\n GROUP by mod_id, interval_start ORDER BY interval_start\n ",
"query": "\n SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start\n FROM payouts_values\n WHERE mod_id = ANY($1) AND created BETWEEN $2 AND $3\n GROUP by mod_id, interval_start ORDER BY interval_start\n ",
"describe": {
"columns": [
{
@@ -33,5 +33,5 @@
null
]
},
"hash": "41da353ec0bfb3209f095e1e4f932beedc4b3b1c6de8365b7b523b9d917977b2"
"hash": "4198ea701f956dd65cab1a8e60b5b67df45f8c07bb70e3c4f090d943feafdaf3"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_last_donated_at,\n (\n SELECT SUM(campaign_donations.amount_usd)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_total_amount_donated_usd,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
"describe": {
"columns": [
{
@@ -50,91 +50,81 @@
},
{
"ordinal": 9,
"name": "campaign_pride_26_last_donated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "campaign_pride_26_total_amount_donated_usd",
"type_info": "Numeric"
},
{
"ordinal": 11,
"name": "github_id",
"type_info": "Int8"
},
{
"ordinal": 12,
"ordinal": 10,
"name": "discord_id",
"type_info": "Int8"
},
{
"ordinal": 13,
"ordinal": 11,
"name": "gitlab_id",
"type_info": "Int8"
},
{
"ordinal": 14,
"ordinal": 12,
"name": "google_id",
"type_info": "Varchar"
},
{
"ordinal": 15,
"ordinal": 13,
"name": "steam_id",
"type_info": "Int8"
},
{
"ordinal": 16,
"ordinal": 14,
"name": "microsoft_id",
"type_info": "Varchar"
},
{
"ordinal": 17,
"ordinal": 15,
"name": "email_verified",
"type_info": "Bool"
},
{
"ordinal": 18,
"ordinal": 16,
"name": "password",
"type_info": "Text"
},
{
"ordinal": 19,
"ordinal": 17,
"name": "totp_secret",
"type_info": "Varchar"
},
{
"ordinal": 20,
"ordinal": 18,
"name": "paypal_id",
"type_info": "Text"
},
{
"ordinal": 21,
"ordinal": 19,
"name": "paypal_country",
"type_info": "Text"
},
{
"ordinal": 22,
"ordinal": 20,
"name": "paypal_email",
"type_info": "Text"
},
{
"ordinal": 23,
"ordinal": 21,
"name": "venmo_handle",
"type_info": "Text"
},
{
"ordinal": 24,
"ordinal": 22,
"name": "stripe_customer_id",
"type_info": "Text"
},
{
"ordinal": 25,
"ordinal": 23,
"name": "allow_friend_requests",
"type_info": "Bool"
},
{
"ordinal": 26,
"ordinal": 24,
"name": "is_subscribed_to_newsletter",
"type_info": "Bool"
}
@@ -155,8 +145,6 @@
false,
false,
false,
null,
null,
true,
true,
true,
@@ -175,5 +163,5 @@
false
]
},
"hash": "e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5"
"hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n t.mod_id AS \"project_id!\",\n tm.created,\n tm.body AS \"body: sqlx::types::Json<MessageBody>\"\n FROM threads_messages tm\n INNER JOIN threads t ON t.id = tm.thread_id\n WHERE\n t.mod_id = ANY($1)\n AND tm.body->>'type' = 'status_change'\n AND tm.created >= $2\n AND tm.created < $3\n ",
"query": "\n SELECT\n t.mod_id AS \"project_id!\",\n tm.created,\n tm.body AS \"body: sqlx::types::Json<MessageBody>\"\n FROM threads_messages tm\n INNER JOIN threads t ON t.id = tm.thread_id\n WHERE\n t.mod_id = ANY($1)\n AND tm.body->>'type' = 'status_change'\n AND tm.created BETWEEN $2 AND $3\n ",
"describe": {
"columns": [
{
@@ -32,5 +32,5 @@
false
]
},
"hash": "550dfe07e122a331b92e931ec2b4e4a3a77e8bacdd5a7e2e13b06aa6b55fceb3"
"hash": "a5141c0435441f062231c842cb5db5e0c78f8f3896c1e9f2b2b56cce41fa1591"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start\n FROM payouts_values\n WHERE user_id = $1 AND created >= $2 AND created < $3\n GROUP by mod_id, interval_start ORDER BY interval_start\n ",
"query": "\n SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start\n FROM payouts_values\n WHERE user_id = $1 AND created BETWEEN $2 AND $3\n GROUP by mod_id, interval_start ORDER BY interval_start\n ",
"describe": {
"columns": [
{
@@ -33,5 +33,5 @@
null
]
},
"hash": "33181ac6fe503d4ca6b396d6ddd7070d2abac466048c870b2f23497848cf55b3"
"hash": "dfb4bd3db0d1cc2b2f811c267547a224ee4710e202cf1c8f3f35e49b54d6f2f9"
}
@@ -1,10 +0,0 @@
create table campaign_donations (
id bigint primary key,
tiltify_event_id uuid not null unique,
raw_data jsonb not null,
donated_at timestamptz not null,
amount_usd numeric(96, 48),
user_id bigint references users(id)
);
create index campaign_donations_user_amount_donated_at_idx
on campaign_donations (user_id, amount_usd, donated_at);
+6 -10
View File
@@ -1,12 +1,12 @@
use super::DatabaseError;
use crate::database::PgTransaction;
use crate::models::ids::{
AffiliateCodeId, AnalyticsEventId, CampaignDonationId, ChargeId,
CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId,
OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId,
OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId,
ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId,
TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId,
AffiliateCodeId, AnalyticsEventId, ChargeId, CollectionId, FileId, ImageId,
NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId,
OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId,
ProductId, ProductPriceId, ProjectId, ReportId, SessionId,
SharedInstanceId, SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId,
ThreadMessageId, UserSubscriptionId, VersionId,
};
use ariadne::ids::base62_impl::to_base62;
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
@@ -164,10 +164,6 @@ db_id_interface!(
ChargeId,
generator: generate_charge_id @ "charges",
);
db_id_interface!(
CampaignDonationId,
generator: generate_campaign_donation_id @ "campaign_donations",
);
db_id_interface!(
CollectionId,
generator: generate_collection_id @ "collections",
@@ -11,7 +11,6 @@ use crate::util::error::Context;
use ariadne::ids::base62_impl::{parse_base62, to_base62};
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::hash::Hash;
@@ -49,21 +48,12 @@ pub struct DBUser {
pub created: DateTime<Utc>,
pub role: String,
pub badges: Badges,
#[serde(default)]
pub campaign_pride_26: Option<Pride26CampaignDonation>,
pub allow_friend_requests: bool,
pub is_subscribed_to_newsletter: bool,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct Pride26CampaignDonation {
pub last_donated_at: DateTime<Utc>,
pub has_badge: bool,
pub has_midas: bool,
}
impl DBUser {
pub async fn insert(
&self,
@@ -190,16 +180,6 @@ impl DBUser {
SELECT id, email,
avatar_url, raw_avatar_url, username, bio,
created, role, badges,
(
SELECT MAX(campaign_donations.donated_at)
FROM campaign_donations
WHERE campaign_donations.user_id = users.id
) AS campaign_pride_26_last_donated_at,
(
SELECT SUM(campaign_donations.amount_usd)
FROM campaign_donations
WHERE campaign_donations.user_id = users.id
) AS campaign_pride_26_total_amount_donated_usd,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
@@ -228,21 +208,6 @@ impl DBUser {
created: u.created,
role: u.role,
badges: Badges::from_bits(u.badges as u64).unwrap_or_default(),
campaign_pride_26: u
.campaign_pride_26_last_donated_at
.zip(u.campaign_pride_26_total_amount_donated_usd)
.map(
|(
last_donated_at,
total_amount_donated_usd,
)| Pride26CampaignDonation {
last_donated_at,
has_badge: total_amount_donated_usd
>= Decimal::ONE,
has_midas: total_amount_donated_usd
>= Decimal::from(5),
},
),
password: u.password,
paypal_id: u.paypal_id,
paypal_country: u.paypal_country,
-3
View File
@@ -295,9 +295,6 @@ vars! {
DELPHI_SLACK_WEBHOOK: String = "";
TREMENDOUS_CAMPAIGN_ID: String = "none";
TILTIFY_CLIENT_ID: String = "";
TILTIFY_CLIENT_SECRET: String = "";
TILTIFY_PRIDE_26_CAMPAIGN_ID: String = "";
// server pinging
SERVER_PING_MAX_CONCURRENT: usize = 16usize;
-6
View File
@@ -26,7 +26,6 @@ use crate::util::anrok;
use crate::util::archon::ArchonClient;
use crate::util::http::HttpClient;
use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters};
use crate::util::tiltify::TiltifyClient;
use sync::friends::handle_pubsub;
pub mod auth;
@@ -74,7 +73,6 @@ pub struct LabrinthConfig {
pub archon_client: web::Data<ArchonClient>,
pub gotenberg_client: GotenbergClient,
pub http_client: web::Data<HttpClient>,
pub tiltify_client: web::Data<TiltifyClient>,
}
#[allow(clippy::too_many_arguments)]
@@ -110,8 +108,6 @@ pub fn app_setup(
let scheduler = scheduler::Scheduler::new();
let http_client = web::Data::new(HttpClient::new());
let tiltify_client =
web::Data::new(TiltifyClient::new(http_client.get_ref().clone()));
{
let pool_ref = pool.clone();
let http_ref = http_client.clone();
@@ -316,7 +312,6 @@ pub fn app_setup(
anrok_client,
gotenberg_client,
http_client,
tiltify_client,
archon_client: web::Data::new(
ArchonClient::from_env()
.expect("ARCHON_URL and PYRO_API_KEY must be set"),
@@ -348,7 +343,6 @@ pub fn app_config(
.app_data(labrinth_config.search_backend.clone())
.app_data(web::Data::new(labrinth_config.gotenberg_client.clone()))
.app_data(labrinth_config.http_client.clone())
.app_data(labrinth_config.tiltify_client.clone())
.app_data(labrinth_config.session_queue.clone())
.app_data(labrinth_config.payouts_queue.clone())
.app_data(labrinth_config.email_queue.clone())
-1
View File
@@ -1,7 +1,6 @@
use ariadne::ids::base62_id;
base62_id!(ChargeId);
base62_id!(CampaignDonationId);
base62_id!(CollectionId);
base62_id!(FileId);
base62_id!(ImageId);
+1 -14
View File
@@ -56,7 +56,6 @@ pub struct User {
pub created: DateTime<Utc>,
pub role: Role,
pub badges: Badges,
pub campaigns: UserCampaigns,
pub auth_providers: Option<Vec<AuthProvider>>,
pub email: Option<String>,
@@ -73,11 +72,6 @@ pub struct User {
pub github_id: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserCampaigns {
pub pride_26: Option<Pride26CampaignDonation>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserPayoutData {
pub paypal_address: Option<String>,
@@ -87,8 +81,7 @@ pub struct UserPayoutData {
pub balance: Decimal,
}
use crate::database::models::user_item::{DBUser, Pride26CampaignDonation};
use crate::database::models::user_item::DBUser;
impl From<DBUser> for User {
fn from(data: DBUser) -> Self {
Self {
@@ -101,9 +94,6 @@ impl From<DBUser> for User {
created: data.created,
role: Role::from_string(&data.role),
badges: data.badges,
campaigns: UserCampaigns {
pride_26: data.campaign_pride_26,
},
payout_data: None,
auth_providers: None,
has_password: None,
@@ -152,9 +142,6 @@ impl User {
created: db_user.created,
role: Role::from_string(&db_user.role),
badges: db_user.badges,
campaigns: UserCampaigns {
pride_26: db_user.campaign_pride_26,
},
auth_providers: Some(auth_providers),
has_password: Some(db_user.password.is_some()),
has_totp: Some(db_user.totp_secret.is_some()),
@@ -1,387 +0,0 @@
use actix_web::{get, post, web};
use chrono::{DateTime, Utc};
use eyre::eyre;
use reqwest::Method;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tracing::{info, warn};
use uuid::Uuid;
use crate::{
database::{
PgPool, PgTransaction,
models::{
DBCampaignDonationId, DBUser, DBUserId,
generate_campaign_donation_id,
},
redis::RedisPool,
},
env::ENV,
models::payouts::TremendousForexResponse,
queue::payouts::PayoutsQueue,
routes::ApiError,
util::{error::Context, http::HttpClient, tiltify::TiltifyClient},
};
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(tiltify_webhook).service(pride_26);
}
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
struct TiltifyWebhook {
data: TiltifyData,
meta: TiltifyMeta,
}
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
struct TiltifyData {
amount: AmountRaised,
donor_name: String,
completed_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
struct AmountRaised {
currency: String,
value: Decimal,
}
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
struct TiltifyMeta {
attempted_at: DateTime<Utc>,
event_type: String,
generated_at: DateTime<Utc>,
id: Uuid,
subscription_source_id: Uuid,
subscription_source_type: String,
}
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CampaignInfo {
total_donations_usd: Decimal,
target_usd: Decimal,
num_donators: usize,
}
const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info";
const CAMPAIGN_INFO_CACHE_TTL_SECONDS: i64 = 15 * 60;
#[derive(Debug, Deserialize)]
struct TiltifyCampaignResponse {
data: TiltifyCampaign,
}
#[derive(Debug, Deserialize)]
struct TiltifyCampaign {
goal: AmountRaised,
total_amount_raised: AmountRaised,
}
#[derive(Debug, Deserialize)]
struct TiltifyDonationResponse {
data: Vec<TiltifyDonation>,
metadata: TiltifyPaginationMetadata,
}
#[derive(Debug, Deserialize)]
struct TiltifyDonation {
donor_name: String,
}
#[derive(Debug, Deserialize)]
struct TiltifyPaginationMetadata {
after: Option<String>,
}
struct CampaignDonation {
id: DBCampaignDonationId,
tiltify_event_id: Uuid,
raw_data: serde_json::Value,
donated_at: DateTime<Utc>,
amount_usd: Option<Decimal>,
user_id: Option<DBUserId>,
}
impl CampaignDonation {
async fn insert(
&self,
transaction: &mut PgTransaction<'_>,
) -> Result<bool, ApiError> {
let user_id = self.user_id.map(|id| id.0);
let inserted = sqlx::query!(
"
insert into campaign_donations (id, tiltify_event_id, raw_data, donated_at, amount_usd, user_id)
values ($1, $2::text::uuid, $3, $4, $5, $6)
on conflict (tiltify_event_id) do nothing
returning id
",
self.id.0,
self.tiltify_event_id.to_string(),
self.raw_data,
self.donated_at,
self.amount_usd,
user_id,
)
.fetch_optional(transaction)
.await
.wrap_internal_err("inserting campaign donation")?;
Ok(inserted.is_some())
}
}
#[utoipa::path]
#[post("/webhook")]
pub async fn tiltify_webhook(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
payouts_queue: web::Data<PayoutsQueue>,
web::Json(raw_payload): web::Json<serde_json::Value>,
) -> Result<(), ApiError> {
// deserialize the JSON in the request handler, not in the params,
// since if the JSON fails to deserialize then it's *our* fault,
// not the caller's.
let payload = TiltifyWebhook::deserialize(&raw_payload)
.wrap_internal_err_with(|| {
eyre!(
"invalid Tiltify webhook payload schema\n{}",
serde_json::to_string_pretty(&raw_payload)
.expect("serializing should not fail")
)
})?;
// no matter what, we need to insert this donation record into the db
// so we'll make one upfront
let mut transaction = pool
.begin()
.await
.wrap_internal_err("beginning transaction")?;
let id = generate_campaign_donation_id(&mut transaction).await?;
let mut donation = CampaignDonation {
id,
tiltify_event_id: payload.meta.id,
raw_data: raw_payload,
donated_at: payload.data.completed_at,
amount_usd: None,
user_id: None,
};
let username = async {
// then we can attempt user lookups
let username = payload.data.donor_name.clone();
let user = DBUser::get(&username, &**pool, &redis)
.await
.wrap_err("fetching user from database")?
.wrap_err_with(|| {
eyre!("got donation for user '{username}' which does not exist")
})?;
donation.user_id = Some(user.id);
eyre::Ok(username)
}
.await
.inspect_err(|err| {
warn!("Failed to resolve donation to Modrinth user: {err:?}")
})
.ok();
let amount_usd = async {
// and insert value amount
let amount_usd =
amount_raised_usd(&payload.data.amount, &payouts_queue)
.await
.wrap_err("failed to get donation amount")?;
donation.amount_usd = Some(amount_usd);
eyre::Ok(amount_usd)
}
.await
.inspect_err(|err| warn!("Failed to resolve donation amount: {err:?}"))
.ok();
info!(
"Resolved donation from {} for US${}",
username.as_deref().unwrap_or("<unknown>"),
amount_usd
.map(|a| a.to_string())
.unwrap_or_else(|| "<unknown>".to_string())
);
let inserted = donation
.insert(&mut transaction)
.await
.wrap_internal_err("inserting donation")?;
if !inserted {
transaction
.commit()
.await
.wrap_internal_err("committing duplicate donation transaction")?;
info!("Ignoring duplicate Tiltify webhook {}", payload.meta.id);
return Ok(());
}
transaction
.commit()
.await
.wrap_internal_err("committing transaction")?;
if let Some(user_id) = donation.user_id {
DBUser::clear_caches(&[(user_id, username)], &redis)
.await
.wrap_internal_err("clearing user caches")?;
}
Ok(())
}
#[utoipa::path]
#[get("/pride-26")]
pub async fn pride_26(
http: web::Data<HttpClient>,
redis: web::Data<RedisPool>,
tiltify: web::Data<TiltifyClient>,
) -> Result<web::Json<CampaignInfo>, ApiError> {
let campaign_id = &ENV.TILTIFY_PRIDE_26_CAMPAIGN_ID;
let mut redis_connection = redis
.connect()
.await
.wrap_internal_err("connecting to redis")?;
if let Some(cached) = redis_connection
.get(CAMPAIGN_INFO_CACHE_NAMESPACE, campaign_id)
.await
.wrap_internal_err("getting cached campaign info")?
{
let campaign_info = serde_json::from_str::<CampaignInfo>(&cached)
.wrap_internal_err("parsing cached campaign info")?;
return Ok(web::Json(campaign_info));
}
let access_token = tiltify
.access_token()
.await
.wrap_internal_err("fetching Tiltify access token")?;
let url = format!(
"https://v5api.tiltify.com/api/public/team_campaigns/{campaign_id}",
);
let response = http
.get(url)
.bearer_auth(&access_token)
.send()
.await
.wrap_internal_err("fetching campaign from Tiltify")?
.error_for_status()
.wrap_internal_err("fetching campaign from Tiltify")?
.json::<TiltifyCampaignResponse>()
.await
.wrap_internal_err("parsing Tiltify response")?;
let raised_currency = &response.data.total_amount_raised.currency;
if raised_currency != "USD" {
return Err(ApiError::Internal(eyre!(
"total amount raised is in {raised_currency}, must be USD"
)));
}
let goal_currency = &response.data.goal.currency;
if goal_currency != "USD" {
return Err(ApiError::Internal(eyre!(
"goal amount is in {goal_currency}, must be USD"
)));
}
let campaign_info = CampaignInfo {
total_donations_usd: response.data.total_amount_raised.value,
target_usd: response.data.goal.value,
num_donators: num_donators(&http, &access_token, campaign_id).await?,
};
redis_connection
.set_serialized_to_json(
CAMPAIGN_INFO_CACHE_NAMESPACE,
campaign_id,
&campaign_info,
Some(CAMPAIGN_INFO_CACHE_TTL_SECONDS),
)
.await
.wrap_internal_err("caching campaign info")?;
Ok(web::Json(campaign_info))
}
async fn num_donators(
http: &HttpClient,
access_token: &str,
campaign_id: &str,
) -> Result<usize, ApiError> {
let mut after = None;
let mut donors = HashSet::new();
loop {
let url = format!(
"https://v5api.tiltify.com/api/public/team_campaigns/{campaign_id}/donations"
);
let mut request = http
.get(url)
.bearer_auth(access_token)
.query(&[("limit", "100")]);
if let Some(after) = &after {
request = request.query(&[("after", after)]);
}
let response = request
.send()
.await
.wrap_internal_err("fetching donations from Tiltify")?
.error_for_status()
.wrap_internal_err("fetching donations from Tiltify")?
.json::<TiltifyDonationResponse>()
.await
.wrap_internal_err("parsing Tiltify donations response")?;
donors.extend(
response
.data
.into_iter()
.map(|donation| donation.donor_name)
.filter(|donor_name| donor_name != "Anonymous"),
);
match response.metadata.after {
Some(next_after) => after = Some(next_after),
None => break,
}
}
Ok(donors.len())
}
async fn amount_raised_usd(
amount: &AmountRaised,
payouts_queue: &PayoutsQueue,
) -> Result<Decimal, ApiError> {
let currency = amount.currency.to_uppercase();
if currency == "USD" {
return Ok(amount.value);
}
let forex: TremendousForexResponse = payouts_queue
.make_tremendous_request(Method::GET, "forex", None::<()>)
.await
.wrap_internal_err("failed to fetch Tremendous forex data")?;
let usd_to_currency = forex
.forex
.get(&currency)
.copied()
.wrap_internal_err_with(|| {
eyre!("no Tremendous forex rate for '{currency}'")
})?;
Ok(amount.value / usd_to_currency)
}
@@ -239,7 +239,6 @@ impl TempUser {
created: Utc::now(),
role: Role::Developer.to_string(),
badges: Badges::default(),
campaign_pride_26: None,
allow_friend_requests: true,
is_subscribed_to_newsletter: false,
}
@@ -1574,7 +1573,6 @@ pub async fn create_account_with_password(
created: Utc::now(),
role: Role::Developer.to_string(),
badges: Badges::default(),
campaign_pride_26: None,
allow_friend_requests: true,
is_subscribed_to_newsletter: new_account
.sign_up_newsletter
-6
View File
@@ -1,7 +1,6 @@
pub mod admin;
pub mod affiliate;
pub mod billing;
pub mod campaign;
pub mod delphi;
pub mod external_notifications;
pub mod flows;
@@ -86,11 +85,6 @@ pub fn utoipa_config(
.wrap(default_cors())
.configure(affiliate::config),
)
.service(
utoipa_actix_web::scope("/_internal/campaign")
.wrap(default_cors())
.configure(campaign::config),
)
.service(
utoipa_actix_web::scope("/_internal/search-management")
.wrap(default_cors())
@@ -0,0 +1,533 @@
use std::collections::HashMap;
use actix_web::{HttpRequest, post, web};
use serde::Serialize;
use super::{DownloadSource, GetRequest, TimeRange, normalize_download_source};
use crate::{
auth::get_user_from_headers,
database::{
PgPool,
models::{DBProjectId, DBUser, DBVersionId},
redis::RedisPool,
},
models::{ids::VersionId, pats::Scopes, v3::analytics::DownloadReason},
queue::session::AuthQueue,
routes::ApiError,
};
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(fetch_facets);
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct FacetsResponse {
pub facets: AnalyticsFacets,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct AnalyticsFacets {
pub project_views: ProjectViewsFacets,
pub project_downloads: ProjectDownloadsFacets,
pub project_playtime: ProjectPlaytimeFacets,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct ProjectViewsFacets {
pub domain: Vec<FacetValue<String>>,
pub site_path: Vec<FacetValue<String>>,
pub monetized: Vec<FacetValue<bool>>,
pub country: Vec<FacetValue<String>>,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct ProjectDownloadsFacets {
pub domain: Vec<FacetValue<String>>,
pub user_agent: Vec<FacetValue<DownloadSource>>,
pub version_id: Vec<FacetValue<VersionId>>,
pub monetized: Vec<FacetValue<bool>>,
pub country: Vec<FacetValue<String>>,
pub reason: Vec<FacetValue<DownloadReason>>,
pub game_version: Vec<FacetValue<String>>,
pub loader: Vec<FacetValue<String>>,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct ProjectPlaytimeFacets {
pub version_id: Vec<FacetValue<VersionId>>,
pub loader: Vec<FacetValue<String>>,
pub game_version: Vec<FacetValue<String>>,
pub country: Vec<FacetValue<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)]
pub struct FacetValue<T> {
pub value: T,
pub count: u64,
}
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
struct StringFacetRow {
value: String,
count: u64,
}
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
struct VersionFacetRow {
value: DBVersionId,
count: u64,
}
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
struct BoolFacetRow {
value: bool,
count: u64,
}
#[utoipa::path(
responses((status = OK, body = inline(FacetsResponse))),
)]
#[post("/facets")]
pub async fn fetch_facets(
http_req: HttpRequest,
req: web::Json<GetRequest>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
clickhouse: web::Data<clickhouse::Client>,
) -> Result<web::Json<FacetsResponse>, ApiError> {
let user = get_user_from_headers(
&http_req,
&**pool,
&redis,
&session_queue,
Scopes::ANALYTICS,
)
.await?
.1;
let project_ids = if req.project_ids.is_empty() {
DBUser::get_projects(user.id.into(), &**pool, &redis).await?
} else {
req.project_ids
.iter()
.map(|id| DBProjectId::from(*id))
.collect::<Vec<_>>()
};
let project_ids =
super::filter_allowed_project_ids(&project_ids, &user, &pool, &redis)
.await?;
let parent_version_ids =
fetch_project_version_ids(&project_ids, &pool).await?;
Ok(web::Json(FacetsResponse {
facets: AnalyticsFacets {
project_views: fetch_project_views_facets(
&clickhouse,
&project_ids,
&req.time_range,
)
.await?,
project_downloads: fetch_project_downloads_facets(
&clickhouse,
&project_ids,
&req.time_range,
)
.await?,
project_playtime: fetch_project_playtime_facets(
&clickhouse,
&project_ids,
&parent_version_ids,
&req.time_range,
)
.await?,
},
}))
}
async fn fetch_project_version_ids(
project_ids: &[DBProjectId],
pool: &PgPool,
) -> Result<Vec<DBVersionId>, ApiError> {
let project_id_values =
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
Ok(sqlx::query!(
"
SELECT id
FROM versions
WHERE mod_id = ANY($1)
",
&project_id_values,
)
.fetch_all(pool)
.await?
.into_iter()
.map(|row| DBVersionId(row.id))
.collect())
}
async fn fetch_project_views_facets(
clickhouse: &clickhouse::Client,
project_ids: &[DBProjectId],
time_range: &TimeRange,
) -> Result<ProjectViewsFacets, ApiError> {
Ok(ProjectViewsFacets {
domain: fetch_string_facet(
clickhouse,
"SELECT domain AS value, COUNT(*) AS count FROM views WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} AND domain != '' GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?,
site_path: fetch_string_facet(
clickhouse,
"SELECT site_path AS value, COUNT(*) AS count FROM views WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} AND site_path != '' GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?,
monetized: fetch_bool_facet(
clickhouse,
"SELECT monetized AS value, COUNT(*) AS count FROM views WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?,
country: fetch_string_facet(
clickhouse,
"SELECT country AS value, COUNT(*) AS count FROM views WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} AND country != '' GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?,
})
}
async fn fetch_project_downloads_facets(
clickhouse: &clickhouse::Client,
project_ids: &[DBProjectId],
time_range: &TimeRange,
) -> Result<ProjectDownloadsFacets, ApiError> {
let user_agents = fetch_string_facet(
clickhouse,
"SELECT user_agent AS value, COUNT(*) AS count FROM downloads WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} AND user_agent != '' GROUP BY value",
project_ids,
time_range,
)
.await?;
let user_agent = normalize_download_source_facets(&user_agents);
Ok(ProjectDownloadsFacets {
domain: fetch_string_facet(
clickhouse,
"SELECT domain AS value, COUNT(*) AS count FROM downloads WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} AND domain != '' GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?,
user_agent,
version_id: fetch_version_facet(
clickhouse,
"SELECT version_id AS value, COUNT(*) AS count FROM downloads WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} AND version_id != 0 GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?,
monetized: fetch_bool_facet(
clickhouse,
"SELECT user_id != 0 AS value, COUNT(*) AS count FROM downloads WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?,
country: fetch_string_facet(
clickhouse,
"SELECT country AS value, COUNT(*) AS count FROM downloads WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} AND country != '' GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?,
reason: fetch_string_facet(
clickhouse,
"SELECT reason AS value, COUNT(*) AS count FROM downloads WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} AND reason != '' GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?
.into_iter()
.filter_map(|reason| {
reason.value.parse().ok().map(|value| FacetValue {
value,
count: reason.count,
})
})
.collect(),
game_version: fetch_string_facet(
clickhouse,
"SELECT game_version AS value, COUNT(*) AS count FROM downloads WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} AND game_version != '' GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?,
loader: fetch_string_facet(
clickhouse,
"SELECT loader AS value, COUNT(*) AS count FROM downloads WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64} AND project_id IN {project_ids: Array(UInt64)} AND loader != '' GROUP BY value ORDER BY value",
project_ids,
time_range,
)
.await?,
})
}
fn normalize_download_source_facets(
user_agents: &[FacetValue<String>],
) -> Vec<FacetValue<DownloadSource>> {
let mut counts = HashMap::<DownloadSource, u64>::new();
for user_agent in user_agents {
if let Some(source) = normalize_download_source(&user_agent.value) {
*counts.entry(source).or_default() += user_agent.count;
}
}
let mut sources = counts
.into_iter()
.map(|(value, count)| FacetValue { value, count })
.collect::<Vec<_>>();
sources.sort_by(|a, b| {
download_source_sort_key(&a.value)
.cmp(download_source_sort_key(&b.value))
});
sources
}
fn download_source_sort_key(source: &DownloadSource) -> &str {
match source {
DownloadSource::Named(name) => name,
DownloadSource::Website => "website",
DownloadSource::ModrinthApp => "modrinth_app",
DownloadSource::ModrinthHosting => "modrinth_hosting",
DownloadSource::ModrinthMaven => "modrinth_maven",
DownloadSource::Other => "other",
}
}
async fn fetch_project_playtime_facets(
clickhouse: &clickhouse::Client,
project_ids: &[DBProjectId],
parent_version_ids: &[DBVersionId],
time_range: &TimeRange,
) -> Result<ProjectPlaytimeFacets, ApiError> {
Ok(ProjectPlaytimeFacets {
version_id: fetch_playtime_version_facet(
clickhouse,
project_ids,
parent_version_ids,
time_range,
)
.await?,
loader: fetch_playtime_string_facet(
clickhouse,
"loader",
project_ids,
parent_version_ids,
time_range,
)
.await?,
game_version: fetch_playtime_string_facet(
clickhouse,
"game_version",
project_ids,
parent_version_ids,
time_range,
)
.await?,
country: fetch_playtime_string_facet(
clickhouse,
"country",
project_ids,
parent_version_ids,
time_range,
)
.await?,
})
}
async fn fetch_string_facet(
clickhouse: &clickhouse::Client,
query: &str,
project_ids: &[DBProjectId],
time_range: &TimeRange,
) -> Result<Vec<FacetValue<String>>, ApiError> {
let mut rows = clickhouse
.query(query)
.param("time_range_start", time_range.start.timestamp())
.param("time_range_end", time_range.end.timestamp())
.param("project_ids", project_ids)
.fetch::<StringFacetRow>()?;
let mut values = Vec::new();
while let Some(row) = rows.next().await? {
values.push(FacetValue {
value: row.value,
count: row.count,
});
}
Ok(values)
}
async fn fetch_version_facet(
clickhouse: &clickhouse::Client,
query: &str,
project_ids: &[DBProjectId],
time_range: &TimeRange,
) -> Result<Vec<FacetValue<VersionId>>, ApiError> {
let mut rows = clickhouse
.query(query)
.param("time_range_start", time_range.start.timestamp())
.param("time_range_end", time_range.end.timestamp())
.param("project_ids", project_ids)
.fetch::<VersionFacetRow>()?;
let mut values = Vec::new();
while let Some(row) = rows.next().await? {
values.push(FacetValue {
value: row.value.into(),
count: row.count,
});
}
Ok(values)
}
async fn fetch_bool_facet(
clickhouse: &clickhouse::Client,
query: &str,
project_ids: &[DBProjectId],
time_range: &TimeRange,
) -> Result<Vec<FacetValue<bool>>, ApiError> {
let mut rows = clickhouse
.query(query)
.param("time_range_start", time_range.start.timestamp())
.param("time_range_end", time_range.end.timestamp())
.param("project_ids", project_ids)
.fetch::<BoolFacetRow>()?;
let mut values = Vec::new();
while let Some(row) = rows.next().await? {
values.push(FacetValue {
value: row.value,
count: row.count,
});
}
Ok(values)
}
async fn fetch_playtime_string_facet(
clickhouse: &clickhouse::Client,
column: &str,
project_ids: &[DBProjectId],
parent_version_ids: &[DBVersionId],
time_range: &TimeRange,
) -> Result<Vec<FacetValue<String>>, ApiError> {
let query = format!(
"SELECT {column} AS value, COUNT(*) AS count
FROM playtime
WHERE recorded BETWEEN {{time_range_start: Int64}} AND {{time_range_end: Int64}}
AND (project_id IN {{project_ids: Array(UInt64)}} OR parent IN {{parent_version_ids: Array(UInt64)}})
AND {column} != ''
GROUP BY value
ORDER BY value"
);
let mut rows = clickhouse
.query(&query)
.param("time_range_start", time_range.start.timestamp())
.param("time_range_end", time_range.end.timestamp())
.param("project_ids", project_ids)
.param("parent_version_ids", parent_version_ids)
.fetch::<StringFacetRow>()?;
let mut values = Vec::new();
while let Some(row) = rows.next().await? {
values.push(FacetValue {
value: row.value,
count: row.count,
});
}
Ok(values)
}
async fn fetch_playtime_version_facet(
clickhouse: &clickhouse::Client,
project_ids: &[DBProjectId],
parent_version_ids: &[DBVersionId],
time_range: &TimeRange,
) -> Result<Vec<FacetValue<VersionId>>, ApiError> {
let mut rows = clickhouse
.query(
"SELECT version_id AS value, COUNT(*) AS count
FROM playtime
WHERE recorded BETWEEN {time_range_start: Int64} AND {time_range_end: Int64}
AND (project_id IN {project_ids: Array(UInt64)} OR parent IN {parent_version_ids: Array(UInt64)})
AND version_id != 0
GROUP BY value
ORDER BY value",
)
.param("time_range_start", time_range.start.timestamp())
.param("time_range_end", time_range.end.timestamp())
.param("project_ids", project_ids)
.param("parent_version_ids", parent_version_ids)
.fetch::<VersionFacetRow>()?;
let mut values = Vec::new();
while let Some(row) = rows.next().await? {
values.push(FacetValue {
value: row.value.into(),
count: row.count,
});
}
Ok(values)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_agent_facets_use_normalized_sources() {
let user_agents = vec![
FacetValue {
value: "MultiMC/5.0".to_string(),
count: 2,
},
FacetValue {
value: "MultiMC/6.0".to_string(),
count: 3,
},
FacetValue {
value: "PrismLauncher/6.1".to_string(),
count: 5,
},
FacetValue {
value: "curl/8.7.1".to_string(),
count: 7,
},
FacetValue {
value: "Mozilla/5.0 AppleWebKit/537.36".to_string(),
count: 11,
},
];
assert_eq!(
normalize_download_source_facets(&user_agents),
vec![
FacetValue {
value: DownloadSource::Named("MultiMC".into()),
count: 5,
},
FacetValue {
value: DownloadSource::Named("Prism Launcher".into()),
count: 5,
},
FacetValue {
value: DownloadSource::Website,
count: 11,
},
],
);
}
}
@@ -1,81 +0,0 @@
use super::super::all_download_sources;
use super::{
AnalyticsFacets, ProjectDownloadsFacets, ProjectPlaytimeFacets,
ProjectViewsFacets,
};
use crate::{
database::{PgPool, redis::RedisPool},
models::v3::analytics::DownloadReason,
routes::ApiError,
util::tags::valid_download_tags,
};
pub async fn fetch(
pool: &PgPool,
redis: &RedisPool,
) -> Result<AnalyticsFacets, ApiError> {
let tags = valid_download_tags(pool, redis).await?;
let mut loaders = tags.loaders.iter().cloned().collect::<Vec<_>>();
loaders.sort();
let mut game_versions =
tags.game_versions.iter().cloned().collect::<Vec<_>>();
game_versions.sort();
let loader_facets = loaders;
let game_version_facets = game_versions;
let country_facets = country_facets();
Ok(AnalyticsFacets {
project_views: ProjectViewsFacets {
domain: Vec::new(),
site_path: Vec::new(),
monetized: bool_facets(),
country: country_facets.clone(),
},
project_downloads: ProjectDownloadsFacets {
domain: Vec::new(),
user_agent: download_source_facets(),
version_id: Vec::new(),
monetized: bool_facets(),
country: country_facets.clone(),
reason: download_reason_facets(),
game_version: game_version_facets.clone(),
loader: loader_facets.clone(),
},
project_playtime: ProjectPlaytimeFacets {
version_id: Vec::new(),
loader: loader_facets,
game_version: game_version_facets,
country: country_facets,
},
})
}
fn bool_facets() -> Vec<bool> {
vec![false, true]
}
fn download_reason_facets() -> Vec<DownloadReason> {
[
DownloadReason::Standalone,
DownloadReason::Dependency,
DownloadReason::Modpack,
DownloadReason::Update,
]
.into_iter()
.collect()
}
fn download_source_facets() -> Vec<super::super::DownloadSource> {
all_download_sources()
}
fn country_facets() -> Vec<String> {
let mut countries = rust_iso3166::ALL_ALPHA2
.iter()
.map(|country| country.to_string())
.collect::<Vec<_>>();
countries.push("XX".to_string());
countries.sort();
countries
}
@@ -1,84 +0,0 @@
mod fixed;
use actix_web::{HttpRequest, post, web};
use serde::Serialize;
use super::DownloadSource;
use crate::models::{
ids::VersionId, pats::Scopes, v3::analytics::DownloadReason,
};
use crate::{
auth::get_user_from_headers,
database::{PgPool, redis::RedisPool},
queue::session::AuthQueue,
routes::ApiError,
};
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(fetch_facets);
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct FacetsResponse {
pub facets: AnalyticsFacets,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct AnalyticsFacets {
pub project_views: ProjectViewsFacets,
pub project_downloads: ProjectDownloadsFacets,
pub project_playtime: ProjectPlaytimeFacets,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct ProjectViewsFacets {
pub domain: Vec<String>,
pub site_path: Vec<String>,
pub monetized: Vec<bool>,
pub country: Vec<String>,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct ProjectDownloadsFacets {
pub domain: Vec<String>,
pub user_agent: Vec<DownloadSource>,
pub version_id: Vec<VersionId>,
pub monetized: Vec<bool>,
pub country: Vec<String>,
pub reason: Vec<DownloadReason>,
pub game_version: Vec<String>,
pub loader: Vec<String>,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct ProjectPlaytimeFacets {
pub version_id: Vec<VersionId>,
pub loader: Vec<String>,
pub game_version: Vec<String>,
pub country: Vec<String>,
}
#[utoipa::path(
responses((status = OK, body = inline(FacetsResponse))),
)]
#[post("/facets")]
pub async fn fetch_facets(
http_req: HttpRequest,
_req: web::Json<super::GetRequest>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<FacetsResponse>, ApiError> {
get_user_from_headers(
&http_req,
&**pool,
&redis,
&session_queue,
Scopes::ANALYTICS,
)
.await?;
let facets = fixed::fetch(&pool, &redis).await?;
Ok(web::Json(FacetsResponse { facets }))
}
@@ -62,8 +62,7 @@ const AFFILIATE_CODE_CLICKS: &str = {
COUNT(*) AS clicks
FROM affiliate_code_clicks
WHERE
recorded >= {TIME_RANGE_START}
AND recorded < {TIME_RANGE_END}
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
-- make sure that the REAL affiliate code id is included,
-- not the possibly-zero one,
-- by using `affiliate_code_clicks.affiliate_code_id` instead of `project_id`
@@ -75,8 +75,7 @@ pub(crate) async fn fetch(
INNER JOIN charges c ON c.subscription_id = us.id
WHERE
ac.affiliate = $4
AND usa.created_at >= $1
AND usa.created_at < $2
AND usa.created_at BETWEEN $1 AND $2
AND c.status = 'succeeded'
AND (cardinality($6::bigint[]) = 0 OR affiliate_code = ANY($6))
GROUP BY bucket, affiliate_code",
@@ -71,8 +71,7 @@ pub(crate) async fn fetch(
WHERE
user_id = $4
AND payouts_values.affiliate_code_source IS NOT NULL
AND created >= $1
AND created < $2
AND created BETWEEN $1 AND $2
AND (cardinality($6::bigint[]) = 0 OR affiliate_code_source = ANY($6))
GROUP BY bucket, affiliate_code_source",
)
@@ -24,14 +24,12 @@ pub use affiliate_code_revenue::{
AffiliateCodeRevenue, AffiliateCodeRevenueField,
AffiliateCodeRevenueFilters,
};
#[cfg(test)]
pub(crate) use project_downloads::normalize_download_source;
pub use project_downloads::{
DownloadSource, ProjectDownloads, ProjectDownloadsField,
ProjectDownloadsFilters,
};
pub(crate) use project_downloads::{
all_download_sources, fetch as fetch_project_downloads,
fetch as fetch_project_downloads, normalize_download_source,
};
pub(crate) use project_playtime::fetch as fetch_project_playtime;
pub use project_playtime::{
@@ -20,7 +20,6 @@ use crate::{
use super::super::{
ClickhouseFilterParam, QueryClickhouseContext, add_to_time_slice,
condense_country, none_if_empty, none_if_zero_version_id,
normalize_loader_for_project,
};
use super::{AnalyticsData, Metrics, ProjectAnalytics, ProjectMetrics};
@@ -170,7 +169,6 @@ impl<'de> Deserialize<'de> for DownloadSource {
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
struct DownloadRow {
bucket: u64,
source_project_id: DBProjectId,
project_id: DBProjectId,
domain: String,
user_agent: String,
@@ -204,8 +202,7 @@ const DOWNLOADS: &str = {
formatcp!(
"SELECT
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
downloads.project_id AS source_project_id,
if({USE_PROJECT_ID}, downloads.project_id, 0) AS project_id,
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
if({USE_DOMAIN}, domain, '') AS domain,
if({USE_USER_AGENT}, user_agent, '') AS user_agent,
if({USE_VERSION_ID}, version_id, 0) AS version_id,
@@ -217,8 +214,7 @@ const DOWNLOADS: &str = {
COUNT(*) AS downloads
FROM downloads
WHERE
recorded >= {TIME_RANGE_START}
AND recorded < {TIME_RANGE_END}
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
-- make sure that the REAL project id is included,
-- not the possibly-zero one,
-- by using `downloads.project_id` instead of `project_id`
@@ -230,7 +226,7 @@ const DOWNLOADS: &str = {
AND (empty({FILTER_REASON}) OR downloads.reason IN {FILTER_REASON})
AND (empty({FILTER_GAME_VERSION}) OR downloads.game_version IN {FILTER_GAME_VERSION})
AND (empty({FILTER_LOADER}) OR downloads.loader IN {FILTER_LOADER})
GROUP BY bucket, source_project_id, project_id, domain, user_agent, version_id, monetized, country, reason, game_version, loader"
GROUP BY bucket, project_id, domain, user_agent, version_id, monetized, country, reason, game_version, loader"
)
};
@@ -355,13 +351,7 @@ pub(crate) async fn fetch(
},
game_version: uses_column("use_game_version")
.then(|| row.game_version.clone()),
loader: uses_column("use_loader").then(|| {
normalize_loader_for_project(
row.loader.clone(),
row.source_project_id,
cx.project_loaders,
)
}),
loader: uses_column("use_loader").then(|| row.loader.clone()),
};
*buckets.entry(key).or_default() += row.downloads;
@@ -416,30 +406,6 @@ impl DownloadSourcePattern {
}
}
pub(crate) fn all_download_sources() -> Vec<DownloadSource> {
let mut sources = DOWNLOAD_SOURCE_PATTERNS
.iter()
.map(|(_, source)| source.into_source())
.collect::<Vec<_>>();
sources.push(DownloadSource::Other);
sources.sort_by(|a, b| {
download_source_sort_key(a).cmp(download_source_sort_key(b))
});
sources.dedup();
sources
}
fn download_source_sort_key(source: &DownloadSource) -> &str {
match source {
DownloadSource::Named(name) => name,
DownloadSource::Website => "website",
DownloadSource::ModrinthApp => "modrinth_app",
DownloadSource::ModrinthHosting => "modrinth_hosting",
DownloadSource::ModrinthMaven => "modrinth_maven",
DownloadSource::Other => "other",
}
}
static DOWNLOAD_SOURCE_PATTERNS: LazyLock<Vec<(Regex, DownloadSourcePattern)>> =
LazyLock::new(|| {
use DownloadSourcePattern as P;
@@ -12,7 +12,6 @@ use crate::{
use super::super::{
ClickhouseFilterParam, QueryClickhouseContext, add_to_time_slice,
condense_country, none_if_empty, none_if_zero_version_id,
normalize_loader_for_project,
};
use super::{AnalyticsData, Metrics, ProjectAnalytics, ProjectMetrics};
@@ -80,7 +79,6 @@ pub struct ProjectPlaytime {
#[derive(Debug, clickhouse::Row, serde::Deserialize)]
struct PlaytimeRow {
bucket: u64,
source_project_id: DBProjectId,
project_id: DBProjectId,
parent_version_id: DBVersionId,
version_id: DBVersionId,
@@ -105,7 +103,6 @@ const PLAYTIME: &str = {
formatcp!(
"SELECT
bucket,
source_project_id,
if({USE_PROJECT_ID}, source_project_id, 0) AS project_id,
parent_version_id,
version_id,
@@ -125,8 +122,7 @@ const PLAYTIME: &str = {
seconds
FROM playtime
WHERE
recorded >= {TIME_RANGE_START}
AND recorded < {TIME_RANGE_END}
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
AND playtime.project_id IN {PROJECT_IDS}
AND (empty({FILTER_VERSION_ID}) OR playtime.version_id IN {FILTER_VERSION_ID})
AND (empty({FILTER_LOADER}) OR playtime.loader IN {FILTER_LOADER})
@@ -146,15 +142,14 @@ const PLAYTIME: &str = {
seconds
FROM playtime
WHERE
recorded >= {TIME_RANGE_START}
AND recorded < {TIME_RANGE_END}
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
AND parent IN {PARENT_VERSION_IDS}
AND (empty({FILTER_VERSION_ID}) OR playtime.version_id IN {FILTER_VERSION_ID})
AND (empty({FILTER_LOADER}) OR playtime.loader IN {FILTER_LOADER})
AND (empty({FILTER_GAME_VERSION}) OR playtime.game_version IN {FILTER_GAME_VERSION})
AND (empty({FILTER_COUNTRY}) OR playtime.country IN {FILTER_COUNTRY})
)
GROUP BY bucket, source_project_id, project_id, parent_version_id, version_id, loader, game_version, country"
GROUP BY bucket, project_id, parent_version_id, version_id, loader, game_version, country"
)
};
@@ -233,25 +228,11 @@ pub(crate) async fn fetch(
} else {
row.project_id
};
let source_project_id = if row.source_project_id.0 == 0 {
parent_version_projects
.get(&row.parent_version_id)
.copied()
.unwrap_or(row.source_project_id)
} else {
row.source_project_id
};
let key = PlaytimeBucket {
bucket: row.bucket,
project_id,
version_id: uses_column("use_version_id").then_some(row.version_id),
loader: uses_column("use_loader").then(|| {
normalize_loader_for_project(
row.loader.clone(),
source_project_id,
cx.project_loaders,
)
}),
loader: uses_column("use_loader").then(|| row.loader.clone()),
game_version: uses_column("use_game_version")
.then(|| row.game_version.clone()),
country: uses_column("use_country").then(|| row.country.clone()),
@@ -57,8 +57,7 @@ pub(crate) async fn fetch(
-- for affiliate code revenue, see `affiliate_code_revenue`
payouts_values.mod_id IS NOT NULL
AND payouts_values.mod_id = ANY($4)
AND created >= $1
AND created < $2
AND created BETWEEN $1 AND $2
GROUP BY bucket, mod_id",
)
.bind(req.time_range.start)
@@ -103,8 +103,7 @@ const VIEWS: &str = {
COUNT(*) AS views
FROM views
WHERE
recorded >= {TIME_RANGE_START}
AND recorded < {TIME_RANGE_END}
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
-- make sure that the REAL project id is included,
-- not the possibly-zero one,
-- by using `views.project_id` instead of `project_id`
@@ -11,10 +11,7 @@ mod facets;
mod metrics;
mod old;
use std::{
collections::{HashMap, HashSet},
num::NonZeroU64,
};
use std::{collections::HashMap, num::NonZeroU64};
use crate::database::PgPool;
use actix_web::{HttpRequest, post, web};
@@ -29,7 +26,6 @@ use crate::{
},
database::{
self, DBProject,
models::version_item::VersionQueryResult,
models::{
DBAffiliateCode, DBAffiliateCodeId, DBProjectId, DBUser, DBVersion,
DBVersionId,
@@ -48,7 +44,6 @@ use crate::{
routes::ApiError,
};
#[cfg(test)]
pub(crate) use metrics::normalize_download_source;
pub use metrics::*;
@@ -113,7 +108,6 @@ pub const MIN_RESOLUTION: TimeDelta = TimeDelta::minutes(60);
/// Maximum number of [`TimeSlice`]s in a [`GetResponse`], controlled by
/// [`TimeRange::resolution`].
pub const MAX_TIME_SLICES: usize = 1024;
pub(crate) const UNKNOWN_LOADER: &str = "unknown";
// response
@@ -283,7 +277,7 @@ pub async fn fetch_analytics(
.filter(|version| {
visible_version_ids.contains(&version.inner.id)
&& version.inner.date_published >= req.time_range.start
&& version.inner.date_published < req.time_range.end
&& version.inner.date_published <= req.time_range.end
})
.map(|version| ProjectAnalyticsEvent {
project_id: version.inner.project_id.into(),
@@ -311,7 +305,6 @@ pub async fn fetch_analytics(
.into_iter()
.map(|code| code.id)
.collect::<Vec<_>>();
let project_loaders = project_loader_map(&parent_version_data);
let mut query_clickhouse_cx = QueryClickhouseContext {
clickhouse: &clickhouse,
@@ -320,7 +313,6 @@ pub async fn fetch_analytics(
project_ids: &project_ids,
parent_version_ids: &parent_version_ids,
affiliate_code_ids: &affiliate_code_ids,
project_loaders: &project_loaders,
};
if let Some(metrics) = &req.return_metrics.project_views {
@@ -412,41 +404,6 @@ pub(crate) fn condense_country(country: String, count: u64) -> String {
}
}
pub(crate) fn project_loader_map(
versions: &[VersionQueryResult],
) -> HashMap<DBProjectId, HashSet<String>> {
let mut loaders = HashMap::<DBProjectId, HashSet<String>>::new();
for version in versions {
loaders
.entry(version.inner.project_id)
.or_default()
.extend(version.loaders.iter().cloned());
}
loaders
}
pub(crate) fn normalize_loader_for_project(
loader: String,
project_id: DBProjectId,
project_loaders: &HashMap<DBProjectId, HashSet<String>>,
) -> String {
if loader.is_empty() {
return loader;
}
let loader_is_valid = project_loaders
.get(&project_id)
.is_some_and(|loaders| loaders.contains(&loader));
if loader_is_valid {
loader
} else {
UNKNOWN_LOADER.to_string()
}
}
async fn fetch_project_status_change_events(
project_ids: &[DBProjectId],
time_range: &TimeRange,
@@ -466,8 +423,7 @@ async fn fetch_project_status_change_events(
WHERE
t.mod_id = ANY($1)
AND tm.body->>'type' = 'status_change'
AND tm.created >= $2
AND tm.created < $3
AND tm.created BETWEEN $2 AND $3
"#,
&project_id_values,
time_range.start,
@@ -506,7 +462,6 @@ pub(crate) struct QueryClickhouseContext<'a> {
pub(crate) project_ids: &'a [DBProjectId],
pub(crate) parent_version_ids: &'a [DBVersionId],
pub(crate) affiliate_code_ids: &'a [DBAffiliateCodeId],
pub(crate) project_loaders: &'a HashMap<DBProjectId, HashSet<String>>,
}
#[derive(Debug, Clone, Copy, Default)]
@@ -645,9 +600,6 @@ pub(crate) fn add_to_time_slice(
bucket: usize,
data: AnalyticsData,
) -> Result<(), ApiError> {
// Bucketed analytics queries must filter time ranges as `[start, end)`.
// `widthBucket` returns `num_time_slices + 1` for values at or after
// `end`, which is outside the response slice array.
// row.recorded < time_range_start => bucket = 0
// row.recorded >= time_range_end => bucket = num_time_slices
// (note: this is out of range of `time_slices`!)
@@ -337,7 +337,7 @@ pub async fn revenue_get(
"
SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start
FROM payouts_values
WHERE user_id = $1 AND created >= $2 AND created < $3
WHERE user_id = $1 AND created BETWEEN $2 AND $3
GROUP by mod_id, interval_start ORDER BY interval_start
",
user.id.0 as i64,
@@ -356,7 +356,7 @@ pub async fn revenue_get(
"
SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start
FROM payouts_values
WHERE mod_id = ANY($1) AND created >= $2 AND created < $3
WHERE mod_id = ANY($1) AND created BETWEEN $2 AND $3
GROUP by mod_id, interval_start ORDER BY interval_start
",
&project_ids.iter().map(|x| x.0 as i64).collect::<Vec<_>>(),
-1
View File
@@ -18,6 +18,5 @@ pub mod redis;
pub mod routes;
pub mod sentry;
pub mod tags;
pub mod tiltify;
pub mod validate;
pub mod webhook;
-91
View File
@@ -1,91 +0,0 @@
use std::time::{Duration, Instant};
use eyre::eyre;
use serde::Deserialize;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
env::ENV,
util::{error::Context, http::HttpClient},
};
#[derive(Debug)]
pub struct TiltifyClient {
http: HttpClient,
token: Mutex<Option<TiltifyAccessToken>>,
}
#[derive(Debug)]
struct TiltifyAccessToken {
access_token: String,
expires_at: Instant,
}
#[derive(Debug, Deserialize)]
struct TiltifyTokenResponse {
access_token: String,
expires_in: u64,
}
impl TiltifyClient {
pub fn new(http: HttpClient) -> Self {
Self {
http,
token: Mutex::new(None),
}
}
pub async fn access_token(&self) -> eyre::Result<String> {
let mut token = self.token.lock().await;
if let Some(token) = token.as_ref()
&& token.expires_at > Instant::now()
{
return Ok(token.access_token.clone());
}
let response = self.fetch_access_token().await?;
let expires_at = Instant::now()
+ Duration::from_secs(response.expires_in)
.saturating_sub(Duration::from_secs(60));
let access_token = response.access_token;
*token = Some(TiltifyAccessToken {
access_token: access_token.clone(),
expires_at,
});
Ok(access_token)
}
async fn fetch_access_token(&self) -> eyre::Result<TiltifyTokenResponse> {
if ENV.TILTIFY_CLIENT_ID.is_empty()
|| ENV.TILTIFY_CLIENT_SECRET.is_empty()
{
return Err(eyre!(
"TILTIFY_CLIENT_ID and TILTIFY_CLIENT_SECRET must be set"
));
}
let response = self
.http
.post("https://v5api.tiltify.com/oauth/token")
.json(&json!({
"grant_type": "client_credentials",
"client_id": &ENV.TILTIFY_CLIENT_ID,
"client_secret": &ENV.TILTIFY_CLIENT_SECRET,
"scope": "public",
}))
.send()
.await
.wrap_err("fetching OAuth token")?
.error_for_status()
.wrap_err("fetching OAuth token")?
.json::<TiltifyTokenResponse>()
.await
.wrap_err("parsing OAuth token response")?;
Ok(response)
}
}
-2
View File
@@ -18,7 +18,6 @@ import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3'
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCampaignInternalModule } from './labrinth/campaign/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal'
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
@@ -81,7 +80,6 @@ export const MODULE_REGISTRY = {
labrinth_auth_internal: LabrinthAuthInternalModule,
labrinth_auth_v2: LabrinthAuthV2Module,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_campaign_internal: LabrinthCampaignInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
labrinth_globals_internal: LabrinthGlobalsInternalModule,
@@ -1,21 +0,0 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthCampaignInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_campaign_internal'
}
/**
* Get Pride 2026 campaign fundraising progress.
* GET /_internal/campaign/pride-26
*/
public async getPride26(): Promise<Labrinth.Campaign.Internal.CampaignInfo> {
return this.client.request<Labrinth.Campaign.Internal.CampaignInfo>('/campaign/pride-26', {
api: 'labrinth',
version: 'internal',
method: 'GET',
skipAuth: true,
})
}
}
@@ -1,17 +1,6 @@
import type { RawDecimal } from '../../utils/types'
import type { ISO3166 } from '../iso3166/types'
export namespace Labrinth {
export namespace Campaign {
export namespace Internal {
export type CampaignInfo = {
total_donations_usd: RawDecimal
target_usd: RawDecimal
num_donators: number
}
}
}
export namespace Billing {
export namespace Internal {
export type PriceDuration = 'five-days' | 'monthly' | 'quarterly' | 'yearly'
@@ -386,36 +375,41 @@ export namespace Labrinth {
facets: AnalyticsFacets
}
export type AnalyticsFacet<T> = {
value: T
downloads: number
}
export type AnalyticsFacets = {
project_views?: Partial<ProjectViewsFacets>
project_downloads?: Partial<ProjectDownloadsFacets>
project_playtime?: Partial<ProjectPlaytimeFacets>
project_views: ProjectViewsFacets
project_downloads: ProjectDownloadsFacets
project_playtime: ProjectPlaytimeFacets
}
export type ProjectViewsFacets = {
domain: string[]
site_path: string[]
monetized: boolean[]
country: string[]
domain: AnalyticsFacet<string>[]
site_path: AnalyticsFacet<string>[]
monetized: AnalyticsFacet<boolean>[]
country: AnalyticsFacet<string>[]
}
export type ProjectDownloadsFacets = {
project_id: string[]
domain: string[]
user_agent: string[]
version_id: string[]
monetized: boolean[]
country: string[]
reason: DownloadReason[]
game_version: string[]
loader: string[]
project_id: AnalyticsFacet<string>[]
domain: AnalyticsFacet<string>[]
user_agent: AnalyticsFacet<string>[]
version_id: AnalyticsFacet<string>[]
monetized: AnalyticsFacet<boolean>[]
country: AnalyticsFacet<string>[]
reason: AnalyticsFacet<DownloadReason>[]
game_version: AnalyticsFacet<string>[]
loader: AnalyticsFacet<string>[]
}
export type ProjectPlaytimeFacets = {
version_id: string[]
loader: string[]
game_version: string[]
country: string[]
version_id: AnalyticsFacet<string>[]
loader: AnalyticsFacet<string>[]
game_version: AnalyticsFacet<string>[]
country: AnalyticsFacet<string>[]
}
export type TimeSlice = AnalyticsData[]
@@ -1036,7 +1030,6 @@ export namespace Labrinth {
icon_url: string | null
color: number | null
members: Projects.v3.TeamMember[]
moderation_notes?: Users.Common.ModerationNote | null
}
export type CreateOrganizationRequest = {
@@ -1261,7 +1254,7 @@ export namespace Labrinth {
}
export namespace Users {
export namespace Common {
namespace Common {
export type Role = 'developer' | 'moderator' | 'admin'
export type AuthProvider =
@@ -1279,15 +1272,6 @@ export namespace Labrinth {
venmo_handle?: string
balance: number
}
export type ModerationNote = {
notes: string
last_modified: string
created_at: string
last_author: string
user_rating: number
version: number
}
}
export namespace v2 {
@@ -1319,16 +1303,6 @@ export namespace Labrinth {
export type AuthProvider = Common.AuthProvider
export type UserPayoutData = Common.UserPayoutData
export type Pride26CampaignDonation = {
last_donated_at: string
has_badge: boolean
has_midas: boolean
}
export type UserCampaigns = {
pride_26: Pride26CampaignDonation | null
}
export type User = {
id: string
username: string
@@ -1337,7 +1311,6 @@ export namespace Labrinth {
created: string
role: Role
badges: number
campaigns: UserCampaigns
auth_providers?: AuthProvider[]
email?: string
email_verified?: boolean
@@ -1346,7 +1319,6 @@ export namespace Labrinth {
payout_data?: UserPayoutData
stripe_customer_id?: string
allow_friend_requests?: boolean
moderation_notes?: Common.ModerationNote | null
github_id?: number
}
@@ -6,37 +6,6 @@ export class LabrinthUsersV3Module extends AbstractModule {
return 'labrinth_users_v3'
}
/**
* Get the authenticated user.
* GET /v3/user
*/
public async getAuthenticated(): Promise<Labrinth.Users.v3.User> {
return this.client.request<Labrinth.Users.v3.User>('/user', {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get a user by ID or username
*
* @param idOrUsername - The user's ID or username
* @returns Promise resolving to the user data
*
* GET /v3/user/{id}
*/
public async get(idOrUsername: string): Promise<Labrinth.Users.v3.User> {
return this.client.request<Labrinth.Users.v3.User>(
`/user/${encodeURIComponent(idOrUsername)}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Get all projects the authenticated user can access directly or through
* their organizations.
@@ -6,7 +6,6 @@ use crate::{minecraft_skins::SkinSource, state::MinecraftSkinVariant};
use super::super::super::Skin;
const MODRINTH_PRIDE_SKIN_PACK_SECTION: &str = "Modrinth Pride";
const DEFAULT_SKINS_SECTION: &str = "Default skins";
const MINECON_EARTH_2017_SKIN_PACK_SECTION: &str = "MINECON Earth 2017";
const BUILDERS_AND_BIOMES_SKIN_PACK_SECTION: &str = "Builders & Biomes";
@@ -17,122 +16,14 @@ const THE_COPPER_AGE_SKIN_PACK_SECTION: &str = "The Copper Age";
const MOUNTS_OF_MAYHEM_SKIN_PACK_SECTION: &str = "Mounts of Mayhem";
const TINY_TAKEOVER_SKIN_PACK_SECTION: &str = "Tiny Takeover";
/// A list of bundled Minecraft skins to make available to the user.
/// A list of default Minecraft skins to make available to the user, created by Mojang.
pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
//
// The Mojang skins below are available in the vanilla Minecraft launcher, and were found
// The skins below are available in the vanilla Minecraft launcher, and were found
// by reverse engineering the behavior of the Minecraft launcher. The textures are
// publicly available at `https://textures.minecraft.net/texture/<texture_key>`.
//
vec![Skin {
texture_key: Arc::from("local-cb3974d16e88a6bd1fe91b0bd58aa5a89abc01f66aed0efc732b84091a523fc9"),
name: Some(Arc::from("Mr. Pack - Asexual")),
section: Some(Arc::from(MODRINTH_PRIDE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,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"
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("local-017ef388aa15a32c47af5b6230c0969085c30e2a884e8853fb449bcdcb60d6f4"),
name: Some(Arc::from("Mr. Pack - Bisexual")),
section: Some(Arc::from(MODRINTH_PRIDE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAANNklEQVR4AczbD5BdZXnH8edegSXgJpEkxCawSZCMJGQGakGMgILOQFKHVlpgaqd/1GArbcc6UEqFBoqMU1qwxLEUaAGZ6kwdTacz0ErWDhW0NEAdDbYNiY4EQkyLNDSQGkMcvJ7P6z6Xs5d79+7uJZDMfnl/7+953ve85z1/7zmHZvT5d9HfndM65eMru5Kx5RefXuJKyM+yT/evebjvBJx43Ow479xj47ilw+3B0jyxvdt/2kWWklJnyUs+/JbTW7e9+8zW/b9+bmv72tWtZ26/sJWx16L86egnWPLbfmY4sOb0Y9tZNA9HjPw4Vrz7yFBmAl33li+6ofX2petb7122qXXrNx5s/PZ9X2uc9dnRxvIb/jUWXvofcdz8t7X8+/beF1/1yeg7AblSSlsedJL1LPmpszzpyNfHyhlz4uy5hwkXHCInXLki3nvbSPzm3SPx5acujvmHXlZir+Z/+k7AQ/+9J+548KnC49/ZE8i6WA6W303z3tN8NM7f90ics2ubamHz7Q82/nb28fHns382Pjjr7Dhl5oUx85Cb4tX+13cCPvqWBfE3FywPu30OjuaJWfFbVl8aox+5Jr5249oCzRPT5tf+89bGGd++orFs83sa6hWt1x8+3Dpz7Wi8/f2fjzPP/VScvnJtnHzaWVVo/N9zP/poC+PdCB46/anW+05Aduh4t0uDTn/9qe9IGXv/Z1shjXosvbGy8f/79jR2Pff9xs6nn2w88eSWxpbH/r3x6CMP5ASNpb1U1Fe2rl/KmJ7qOwF2y4XDd8S8ORvaS6B5YkwrrjzijUsCdHr0IDz9o0+GZVlm9kPzxNKbbtm8qLrOX/vwRa06ZhjOzP06XrTq+pj5pq1xyJEb4i+33tnmzue+En+4+5uhf/cF3RDr1r9lw1Vh6Yze20hMjlx066ufV3r/r8d3t/PoPLkNzVoWwzNmlthh+79XToCOa5opJofWRlsaqU/scx8htxN98Va88cTI/nOZ/NRicnjZhp4KZQKc1PK4toJX3bU51n1jZ+ln/2EL4+hZJ8blD6yOT7x/eYHmiZWk6j9XVW20reS4+wYToW/LEAPNE1Pv5NyRO8adCyzLOWN0+5oWaF69XWebemwi3bSFMsGA3OGBdo3GKetmlpXKPIPnieFD6zeHNtAu8/SNrHeWE8VmHbqusf/5LYXHn36oYcVPqm7K9EHzMi6XPx3KHpC7jy2Tndy4+XWBm38wK5B+ljzIQfrZR/bJp+HWGDTEpsKj1T3JVPInk9u0NSUaEGjeYx9aEL906O5YvXNHNC9YE+cce/s4eGJy5GqjrT5A8xJ1t8ig6756P+zidfrlTzZe9oB7Rp+K+q6bjd3QYM/Gr8STG/4odmy9tUDzxJD5WepLn1l356hu64PmZXwy5RP3XtF6asstBXoybSaT08yB5AlMIx6sJF63ZHEMrzy7XONd52meGORCW2RfvPrJUSyRI5b1fuXi1X/WOPaESwp0v/xe8U6/+YX3fbnx9as3Fq457QuNG955b3zpAxuj8qNa0Lj8/930cMyZvaoBg6niDVS5DWh/2y88HDtv3hnbPvnduO/Dm0pJ83bfsyO2f+5bUS2vTbUAvwALbo+r+oR/Jyw7tYXFi05owd5gz9i1e0OLnrBxl2A5BLr4bWv0xn8rW54x9+TTFH35yFXr4vLrbhsHT8PqEqboykSxeoN9e/fEnt3PFurjs2fW8yaj+07AN//vx+1+pnJ7e8xfj8Yzt8+Ouz/9YtDtTmqi2uKBmjVlOd3x5YL6ToDEq951Xaz/5U/Ei9ueUO3LSacujcX7l7TzaB4jVzjLXh6/F4cfMRzDs48qyJnq+LRJmtVAyk/TLDOQZfUEJz63+Vtx2YOb8pzQPl61mTPr6FadbKccmTU/jj7keLLN0NCMqPJjqCoxc9ZRgfSqsvS3YP6iFhzvdaLj393f2xFf/O534sqvbw0/rZ0XkG060l9WbdYHQWPu/IVRdVKSqwHF0NhgaaaYnOIfPoNVGKr0zKOOLnrTlcvjoV0PxIzGxqCZYnJo0IeNtZm3YFHUkSsmD0PDRyja2AtUXtj3Q0UhdcaK2ec/TYPIHNqupb6/6rjawvHCCy8tgG8SxGi5BqoEzb/kVy6I835+ZVwdIwGaJyZHLuj6YHutpHYv7NmrGIcT4TijqnTzKrvn38vOAblyZr9s4WrrZ2uTATGeXGdkGqlzpT7WWhgQSy9zeHSiXl/J9JVioPH8s9+PHdu2sArGBBXlMzufDDlyeRPxsgmQbMVQPbEJ8OAyBTHwQCcWzLvnSxvLQ1APQmmeWOYpecjJoZO6Z0WQMRvAxsk6DfUs5aj3o/n8c89GHjuZrDGyblbRrleHhzba8uSChsHeeM3lceYz95UHoTRPDHJBQ6wbYhPhkEXm0Mj6ZMqmM3A2slJwHNlCzgGO+ZxVdciHtvKgjbagLfwDM9fH+a0/IctNixj2VDcx2iQSaD7o9FJ31i2fh9T6Vnd+UdbbqnejmY0Es6MsebnlcxJ4Sba1J8jTDuIWnsc0zRODXG14sNLZlzrNo+t7Co1OXz5P38okc7PerSznAB0kkmgDdLzDeQA0xOTIhT3BBPFgC1i4ExVonhjkalPaVpdNlz+DlwOa5yph8joxOdAe8kFDDNqpT0TTAhOdQN113u6P6oYkQMPg5cAKWYB24KnDioKGmByoa+vESFtZJVKL0Zi3aHEkx7x5Wbln4CcmObGcnED9TUSzPkvZgQbp52B5qeWpy0lPvU79LF7X9Rxt9ZUnwIxlXWz0gXsDd//j30dy/adujpvu/Ez83hVXF+hbPr8+/uDaGwo0T7vss1fZPGPdm+Njx88rrJozJ6B+zYqRWHLZm2LB7y6I2ecd02b+hUtj7po3xFuvH4kzqrYGGWP/aCsLu/6YXa7ZPMhJn+Zl3YQi62J3bfud2PC+8wt/uuoXA9v/+JLAV4c+HfjME5cGaNCPND8e2mZfvcryPMDbWlT3/Q3QqH63l+cE3uPV4fv9D291vN0B7Q0PqnNFefNTnTvKWyAe5MgFnR7NAw0xzxiMBcYGGpbfC+3Qa8XTLyfBrHQrX+v3993GVPcGHV/fCZh38RfHPaOvL/xg0IOOr+8EbF+7umVFB51pfRwIBh1f3wkYue7esgcMOtMHYuX1Oej4+k6AhUwFDyJ65U8U69XmQPuv+AQYcPXApBw2jy3/pxbqHn0wcUAmwPXbSnoWADo9+mDiFZ8A1+5cQc8CkPV6LL3XunzFJ8AKuZV1GPzc0/eU5wE0T+xg44BMgLs4K1r/MCo9/sHEAZkAK1i/p69rsYOJAzYB7ulzRes6vYOlbDo+k+o3f3kZkfVeg5TXL0c829d1et1KeajH3DvgpLe+s1WHV8+ra32Io+53032fB3Rr5Gcsv9eubQD1yx7N06YbYsgYDXVXDviGsA5PvBvON+LoFq97TbvnV39jSWh01zsWBeiHfmt5PW+cFoe24wJVxaxb4frCaZ5YlTLur+7pE5mQsbzf94U5xNOj60xVl3NA3k+f9dnR8hW3TtKjp0K1soHq6XF557j1/mtbu6p39//8Dx+MDX/xrpd1VeU2rDQEc8ubMHXv/I+87PfDNwCLf/XkMEb+dMenbZ0yAXVjIj2dX4Rzx74p8GWJd/kT9S/mYSbyviHf+U+2vT560W38U5qA6fwirH9TUH+X32uQnf6g7ev9dRv/pCYgZ246x51vCnxb4B1+fTC9tCe6yENgqu279TvR+Cc1ATlzPY67cqw75quFl1+BVdn+W3H+X5VvC3xjAHmoEuSWy271qL39TUDlj/tbdem/xE0798X9P9xfcGKEcwXGJfeoTDT+SU1Aj36LXQ0+hqo3yKCZ1cDCewUevEcADTE5cr0bUCJ1bn0eOl+b8zpzeNNh4AmoL9Qrr2rrRt4f2JU9+88cmqcuJydMPXECTJ1lvmLLepbdcjM22XLgCbDSuTBbFxPdKFlx+XKyrRJ8eKfgrZArAXigIeYbgHob8ekw8ARUz//by63eAZTvCawk2oEOIYZsa9KQabQJyrqVRtYzJi+96ZYDT4AF2xKgYYCgE8e3l6qgIeawUIIGPRmmkturv4EnwDGvc1vDMa1u6zo+rSRK3MdQY1+EGTjk1uPyIKbUD2jQoPNcQg/CwBNg4VZeWd8LciWyLPGxL0voJOM5Efzce5TggQb9SjHwBDiO89inkbu5QVqxRB3qcuSqmwTQsPfA1u6GGOQOStcJqK7R5UNkv/uR9XrJt3C7faXHfT/gOm8XtVKgO3FfoK0tinzPr/Ruv3wDsGhx+5sAfiKuP+2MoRM3SvVnBzSvjnXRrusEmHVBCwANPuj0raA6UtdzMk+cDzp97/TzfX79fb9vAPJ7AKV3/Yl3/9poq69Otoz9P4idzw/4Sf767DoBngVI8GwAtGcGfv9DHRZc/3bAtwS+KfBtgW8MfGsAGr4ngG8L5q55Q/jWIN/ne6dPw7t/+BYA+X2A9/3w7j/zjaGT/M3i2QHE06Pr/AQAAP//g9BdGgAAAAZJREFUAwCU7JmMYDXKAQAAAABJRU5ErkJggg=="
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("local-6721474dc24f7ebfac096c3df0bbc0ff7bd3d7d63e8bce13754b0db9acab3805"),
name: Some(Arc::from("Mr. Pack - Genderfluid")),
section: Some(Arc::from(MODRINTH_PRIDE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,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"
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("local-7f01d162a7dedfd0e1bf1a03533ca9eecd8105dd1008e3297ff9d9d8b1582b8c"),
name: Some(Arc::from("Mr. Pack - Intersex")),
section: Some(Arc::from(MODRINTH_PRIDE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAANZklEQVR4AcybDZBWVRnHn/sqrGC7i8ICLrALIiOs2/gxoBIqqI1AkxGmNjRTmqhpNeSApEnICGNaoOKUKZOipVOmNDXaAGtj4gcpZuJHIejwteAmErSwhYCjt/M77P/t7Mt93/vuviwssz/P//yf55x77rmf773XjKX8u/w3F8Uj545ORLG6q8f4OCWQrzKl+yMeTp2AU07sZRePH2QnDivPDhaNR2xP44EuVJIkrRIPYvfvujPGxIsuPDde8fXxcePsifH2By+LiR0pDoy+wNLPPqHcYOqYQdksNB70rPnU6i881iiVgA69JWOuiFdPviNunrHMHnh9ZfStZ1+Mxj3aENXNf8kGTH/bTux3tpuaOH53zyeHfTJSJ0ArRcmWB7RQXSW+tMpLV/4ymjtzis27YTRhD4fI8Fvq7cuLauyKp2rsmS1XW79uM3zscP4ndQJe+WeLPbRyi2fDey0GqhPTYPGTtLzz5l5io2d/XlVb8+DK6Fe9TrKf9Drdrqo830ZWXGYVR99jh/tf6gTccEa1/eLSOmO31+DQeMRY8fsnTreGaXPsxQWzPWg8Yq1t4h/9bYvd/FJza9XizxxTHp87u8E+d+Xjdu74e23M6Nl22lnjFM+Wuz6+IYas0SrwoLXa4SJ1AtTz2e5cwC4NaPlLRp0naXs+2OiREcSiFxfttXdW7FIo+s/elmjHrg+jpm2bo02b10Zr3/lr9Oarz0dKyC3DlQ11bl5766kTwG45oPwhq+q9PNs3Go8Y5h634pQ9+w8xQMtDw9Ir37YVpzch28W2j+8ylsUy1RCNR0xeR8vM5e46f9uqy+MQZhg4Nad1XDvhTqsYus6OPna5/Wzd4iyLdz1n329ebfTPfcGvb5xit0wba2hBLKl/lg1cFYb1yL+NiJFDLiT1leb53v+xIXtsGlont7LKEVbeo8L30X3/+/4EyHGNxiRGDpo2tEWDNPcK3DNw6OADGo8Y9VzoC6++/ymm/rVMfGli5OCpDbo9+AngpKbjmhWc9cgaW/j6gd11f/cB1rfyFJv5/ES7/co6DxqPmBY2y7WhLXX6ok80ExHW8YjhEaOey/iah9qcC1gW54yGxqkxoPHCdrltwlghnQm3AgNiywCaazSMXFjhb4bUEYPHIwbXLFljtAHaKY++QfXcslCsstvCaP/utZ4N216JWPFT3YmYPtB4ipOL3xH8HqDdhy2jThasOcrgvv9WGshXiQfkgHz1oT7x0cCtMaCBWHt4092TtCe/mNwMW5NEBgRovHeuqbZLujXbxKatlrl0ql006ME24BEjh1za0JY+AI0nqHOLDOjQp54Gu3hIWn6xcb8HPN2wxZ/8chtxQwMtLz9nm5ffbFvXPeBB4xGD3HYcBvQpnztH6mx9QOMpXky5adlN8Za193vQxbQpJiejgegERiM8YCXhqCGDrXz0+f4az3UejUcMyAXagvrCC0+OxAQ5xFRPKwdP/HE0aPj1HnRafr54rp95Ysoz0Wu3vuyZc9YT0fyxy2zpN18255tbUJv8f72xynr3mhABg3HxCFxuBLRf9KVV1nRfk228a709e90bvkTjNT+91Rofe8vc8rK4BfAL0MPtsasX/Bs+YlQMg2uHx8DewJ6xo3l5jC7YOCHoD4EEP2s1LPiL3/IYfU47iyKVabMW2sx5i9qAR0N3CaNIpFAsbLB3T4u1NO/0hONjzwzzitGpE7D6359m+8m9vc0GEsRJv/2DrRp5upUtnWc3T5uckGHmtrgnMVik2dHxqfvUCSBx1gXzbMlXbrdPNm6imsqpo4bZ0L7Ds3lVVVWGh8FKh2WoFcMrxDE9y6281/Ee8to7PtqIjFuo/2mqUgGV7gmOPbbmLZux8g2dE7LHK216V/aNQ9SOcsjAShvYL0JmKSvrYS7fylwJFZXHG8hzpe+vul9tDBzvIZbz76n3t9qT69+zW15b539ac14AtclJP6iaCQeBhj79BpjrxCe7AVlZ62DRmMTI8f4xPbA8ZU5XHN/X6ob2t5WT6u3a12rss5PetTnzF3uPGDk+2f0H3b21TVV1rYWQS8yl+b+y8p6+1H/YC9D79n5E4ZFWzJsp/8kwCOWg2bWo73cduy1s+/b9fwH4TAIxNLkMlBLQ+GNHnWMXf2G0LXU/gRuv3eE1HjFyyAV0ONh8K0m7fS17KNrAibCN4SpJnrPz/mVyI1o5Zt9vYbf1lcNkADE8cjkjo0GalVqz/gOr716NbWg8KsqRpg7Uw5XEE8RA9d07P7StG9dieRgTUKHc3rTZyCEfrxAHTQDJrBi4JzYGeMBlCogBHqAFC8Zbv26jjVtd7UHjEVMeJR5octAi9FgRUIwNwMZRHQ3UVZJDPY3M7l07TceOkmkMqjOrkK27w4M2tMUjF9DAYBfMmWnbjv67dRu43dB4xIBcQAOxJIgVgkMWlIMG1YspM5yB1YiVAo4jthDnAI55zSp1IB9oSx7QhraAZuFfnPRVG1F3JtLftBAD4rQRJKDxAS1POrfO8vFAmr6pc36hDNtSTyKjRgTVkUo8bXlNAp5QW/YE8mgHxFm4jmk0HjEglzZ4wEqrL+poPHS4p6Ah1ycfj74phXJVTyr9OYAOBEloBsjxDpwHAA3EyCEX2BOYIDxgC7BwTlSAxiMG5NLGt3WXTS5/DJ4cQONxlWDycmFygPZAPqCBGNCOeiEyLFDQCVDnOs/uD+6GxAANDJ4cYIVYAO0AjzqwooAGYuQAddpyYkSzspQgTQwNVbWDTQw8eYS/Z8AXTLJgOZpA+itEJpwldUAD+RosnjR51MmRRz0kPIuHOsyhLX3pBKiY6sQanl9m8NQff2fiznvvs3sWP2zfvelWD/r+x5fYjbfN96DxaKc+85WZcxaebD84qcozoXdvA+pz6mtsyIyhVv2daut18cAs/S4bZn2mHmdn3llj57i2DNJa/6FZWWDXb7X9NRsPyJGPxlOdCQXViT2y8du2fMpkzx0TJhk0/vB6gxfKfmrw8KbpBmhAv5qZa7RVX/lK/zyAt7Xg7vsjQIP73e6fE/AeLwSf3//AWx3e7gCaNzzgzhX+zY87d/i3QHhADrmAlofGAzQQ4xkDYwHGBmhg+fmgHeRbcfn+JKhKUnmk398njSn0Sh1f6gRUXf1kFC6wq+lSx5c6AY2zJ8asdKkzTR+dQanjS52AmnnL/B5Q6kx3xsrTZ6njS50AFtIeeBCRL79QLF+bzvYP+QQwYPfAxB82d1dsip/ps99recS7Ep0yAVy/WUk9D0DLQ3clDvkEcO3WCup5gOphTN6RLg/5BLBC3Mqyy+t5ABqPWFejUyaAuzhWdNAJ/SP+oeWhuxKdMgGsYHhPH2piXYlOmwDu6bWioZbXVcoMx6dwv/n9ywjV8w2SvLQc4mofanlJJXkQxrh3gFPPHBuH4IV5oaYP4hD6STr1eUBSI37G4ufbtRlAeNlD49EmCWKgGBqoc+UAviEMwSOeBOcb4pAUD70Mu+cL3xhiNHrkvFoD9CvX1oV5bTRxoG2bgKsw66xwuHA0HjGX0uYv9OgTlKCY7vf5whyIy0OHtFf7c4Dup8c92uC/4qYTeej24FbWwD099u8c1624Ld7h3t3/6fdX2fK7LzioK5cbsdJAUFueCaPOO/9jZ3zP+AZg8NdOM8aI39Hx0TbET0BoFNId+UXYp/WbAr4s4V1+of6J8TATdN+gd/7FtqePfCSNv10T0JFfhOE3BeG7/HyDzPVLbR/2lzT+oiZAM9eR445vCvi2gHf44WDyaZ7ogg6B9rZP6rfQ+IuaAM1cnuPOH+sc827h/pefK7N/9ZN/7r8t4BsDIA9cArn+susetWe/CXB+m78J0/9s9zTttRUf7fdwYgTOFdAmOU+l0PiLmoA8/XrbDd7K3BtkQGO6gRnvFfCA9wiABmLkkMu7AUqQ1tbHg9zX5ni5OXgdoeQJCBfKKy+3dU33B+zKPPtXDhqPOjmaMOqCE6C0Sr1iU11lUq5ixZYlTwArrYWxdaHQjRIrTj45aksJ+MA7Bd4KcSUAPEADMb4BCNsQ7wglT4B7/p9drnsH4L8nYCUhG8gRxEBtmTRQGpoJUp2VBtUVI09eR8uSJ4AFsyUADQwQ0ILjm5eqgAZiHBaUgAZ0MbQnN19/JU8AxzydszU4pqmzdTk+WUnwcT6Gav0ijIEDuWGcPCBGST+ABjSgdS5Bl0LJE8DCWXnKcC/QSqj08dYvS9BCcU0EvvYeSsADNKAPFSVPAMexjn00aDdnkKyYoA7UySGXOpMAaGDvAbZ2EsSA3FJJnAB3jfYfIvO7H1QPS3wWzm7vdJvvB7jOs4uyUoDOhfsC2rJFQe/5KXm3778BqB2c/SYAXxCnP9oxhly4UQqfHaDxQlgX2iVOALNOkAUAGvABLZ8VpA7SYY7yiOMDWj7v9PU+P3zfzzcA+h6Aknf9gnf/tKEtfeWytvX/Qcx9foAv9OszcQJ4FkACzwYAzTMDfv8DdWDB4bcDfEvANwV8W8A3BnxrAGjgewLg24I+U48zvjXQ+3ze6aOBd//AtwCg7wN43w+8+1c+Y8hFv1l4dgDE5aFD/gcAAP//8wl/twAAAAZJREFUAwAZRKKMlN9HBwAAAABJRU5ErkJggg=="
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("local-9417023c7f4674feff1592df3cab3bea791a2dd2bc453eb03033859fbf9483f9"),
name: Some(Arc::from("Mr. Pack - Lesbian")),
section: Some(Arc::from(MODRINTH_PRIDE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,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"
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("local-88b4e9d37f25f947c12dfc5c354af30601016cf852e90681633c92b750e43761"),
name: Some(Arc::from("Mr. Pack - MLM")),
section: Some(Arc::from(MODRINTH_PRIDE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,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"
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("local-8fcc36625f7b7ce18362ab5eaec276653f26af331f4ec38d00a610c93da69640"),
name: Some(Arc::from("Mr. Pack - Nonbinary")),
section: Some(Arc::from(MODRINTH_PRIDE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,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"
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("local-7cc8f7620b66002be6e531ac0f90f050eca831d7827a66c01cf6256b0a4f936e"),
name: Some(Arc::from("Mr. Pack - Pride")),
section: Some(Arc::from(MODRINTH_PRIDE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,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"
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("local-2674d92834203a9fc2b3c435fc8c89fb68de8c62eac674b885c1fe7868ab04b6"),
name: Some(Arc::from("Mr. Pack - Transgender")),
section: Some(Arc::from(MODRINTH_PRIDE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,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"
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("46acd06e8483b176e8ea39fc12fe105eb3a2a4970f5100057e9d84d4b60bdfa7"),
name: Some(Arc::from("Alex")),
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
@@ -348,18 +239,6 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("local-8776f8b6615e2051b3da182b8208ae8700254d3cc49eac36aef85f7577c85e4a"),
name: Some(Arc::from("Mr. Pack")),
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,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"
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
//
// The skins below come from free skin packs released by Mojang for Java Edition players.
//
-1
View File
@@ -58,7 +58,6 @@ pub enum FeatureFlag {
ServerRamAsBytesAlwaysOn,
AlwaysShowAppControls,
SkipUnknownPackWarning,
PrideFundraiser,
ServersInApp,
ServerProjectQa,
I18nDebug,
+6 -55
View File
@@ -121,30 +121,6 @@ function generateIconExports(): {
})
}
// Process badge icons from icons/badges/
const badgesDir = path.join(iconsDir, 'badges')
if (fs.existsSync(badgesDir)) {
const badgeFiles = fs.readdirSync(badgesDir).filter((file) => file.endsWith('.svg'))
badgeFiles.forEach((file) => {
const baseName = path.basename(file, '.svg')
let pascalName = toPascalCase(baseName)
if (pascalName === '') {
pascalName = 'Unknown'
}
if (!pascalName.endsWith('Badge')) {
pascalName += 'Badge'
}
icons.push({
importPath: `./icons/badges/${file}?component`,
pascalName,
privateName: `_${pascalName}`,
})
})
}
// Sort by import path using simple-import-sort's algorithm
icons.sort((a, b) => compareImportSources(a.importPath, b.importPath))
@@ -180,7 +156,7 @@ function generateIconExports(): {
function runTests(): void {
console.log('🧪 Running conversion tests...\n')
const testCases: Array<{ input: string; expected: string; suffix?: string }> = [
const testCases: Array<{ input: string; expected: string }> = [
{ input: 'align-left', expected: 'AlignLeftIcon' },
{ input: 'arrow-big-up-dash', expected: 'ArrowBigUpDashIcon' },
{ input: 'check-check', expected: 'CheckCheckIcon' },
@@ -195,17 +171,13 @@ function runTests(): void {
{ input: 'list_bulleted', expected: 'ListBulletedIcon' },
{ input: 'test.name', expected: 'TestNameIcon' },
{ input: 'test-name_final.icon', expected: 'TestNameFinalIcon' },
{ input: 'downloads-500m', expected: 'Downloads500mBadge', suffix: 'Badge' },
{ input: 'early-modpack', expected: 'EarlyModpackBadge', suffix: 'Badge' },
{ input: 'plus', expected: 'PlusBadge', suffix: 'Badge' },
]
let passed = 0
let failed = 0
testCases.forEach(({ input, expected, suffix = 'Icon' }) => {
const base = toPascalCase(input)
const result = base.endsWith(suffix) ? base : base + suffix
testCases.forEach(({ input, expected }) => {
const result = toPascalCase(input) + (toPascalCase(input).endsWith('Icon') ? '' : 'Icon')
const success = result === expected
if (success) {
@@ -340,26 +312,6 @@ function getExpectedIconExports(iconsDir: string): string[] {
})
}
// Process badge icons from icons/badges/
const badgesDir = path.join(iconsDir, 'badges')
if (fs.existsSync(badgesDir)) {
const badgeFiles = fs.readdirSync(badgesDir).filter((file) => file.endsWith('.svg'))
badgeFiles.forEach((file) => {
const baseName = path.basename(file, '.svg')
let pascalName = toPascalCase(baseName)
if (pascalName === '') {
pascalName = 'Unknown'
}
if (!pascalName.endsWith('Badge')) {
pascalName += 'Badge'
}
exports.push(pascalName)
})
}
return exports.sort()
}
@@ -369,15 +321,14 @@ function getActualIconExports(indexFile: string): string[] {
}
const content = fs.readFileSync(indexFile, 'utf8')
const exportMatches =
content.match(/export const (\w+(?:Icon|Badge)) = _\w+(?:Icon|Badge)/g) || []
const exportMatches = content.match(/export const (\w+Icon) = _\w+Icon/g) || []
return exportMatches
.map((match) => {
const result = match.match(/export const (\w+(?:Icon|Badge))/)
const result = match.match(/export const (\w+Icon)/)
return result ? result[1] : ''
})
.filter((name) => name.endsWith('Icon') || name.endsWith('Badge'))
.filter((name) => name.endsWith('Icon'))
.sort()
}
-40
View File
@@ -23,26 +23,6 @@ import _ArrowUpZAIcon from './icons/arrow-up-z-a.svg?component'
import _AsteriskIcon from './icons/asterisk.svg?component'
import _BadgeCheckIcon from './icons/badge-check.svg?component'
import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
import _AlphaBadge from './icons/badges/alpha.svg?component'
import _BetaBadge from './icons/badges/beta.svg?component'
import _Downloads1mBadge from './icons/badges/downloads-1m.svg?component'
import _Downloads10mBadge from './icons/badges/downloads-10m.svg?component'
import _Downloads25mBadge from './icons/badges/downloads-25m.svg?component'
import _Downloads50mBadge from './icons/badges/downloads-50m.svg?component'
import _Downloads100mBadge from './icons/badges/downloads-100m.svg?component'
import _Downloads250mBadge from './icons/badges/downloads-250m.svg?component'
import _Downloads500mBadge from './icons/badges/downloads-500m.svg?component'
import _EarlyDatapackBadge from './icons/badges/early-datapack.svg?component'
import _EarlyHostingBadge from './icons/badges/early-hosting.svg?component'
import _EarlyModpackBadge from './icons/badges/early-modpack.svg?component'
import _EarlyPluginBadge from './icons/badges/early-plugin.svg?component'
import _EarlyResourcepackBadge from './icons/badges/early-resourcepack.svg?component'
import _EarlyServersBadge from './icons/badges/early-servers.svg?component'
import _EarlyShadersBadge from './icons/badges/early-shaders.svg?component'
import _ModeratorBadge from './icons/badges/moderator.svg?component'
import _PlusBadge from './icons/badges/plus.svg?component'
import _PrideBadge from './icons/badges/pride.svg?component'
import _StaffBadge from './icons/badges/staff.svg?component'
import _BanIcon from './icons/ban.svg?component'
import _BellIcon from './icons/bell.svg?component'
import _BellRingIcon from './icons/bell-ring.svg?component'
@@ -443,26 +423,6 @@ export const ArrowUpZAIcon = _ArrowUpZAIcon
export const AsteriskIcon = _AsteriskIcon
export const BadgeCheckIcon = _BadgeCheckIcon
export const BadgeDollarSignIcon = _BadgeDollarSignIcon
export const AlphaBadge = _AlphaBadge
export const BetaBadge = _BetaBadge
export const Downloads1mBadge = _Downloads1mBadge
export const Downloads10mBadge = _Downloads10mBadge
export const Downloads25mBadge = _Downloads25mBadge
export const Downloads50mBadge = _Downloads50mBadge
export const Downloads100mBadge = _Downloads100mBadge
export const Downloads250mBadge = _Downloads250mBadge
export const Downloads500mBadge = _Downloads500mBadge
export const EarlyDatapackBadge = _EarlyDatapackBadge
export const EarlyHostingBadge = _EarlyHostingBadge
export const EarlyModpackBadge = _EarlyModpackBadge
export const EarlyPluginBadge = _EarlyPluginBadge
export const EarlyResourcepackBadge = _EarlyResourcepackBadge
export const EarlyServersBadge = _EarlyServersBadge
export const EarlyShadersBadge = _EarlyShadersBadge
export const ModeratorBadge = _ModeratorBadge
export const PlusBadge = _PlusBadge
export const PrideBadge = _PrideBadge
export const StaffBadge = _StaffBadge
export const BanIcon = _BanIcon
export const BellIcon = _BellIcon
export const BellRingIcon = _BellRingIcon
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.3 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="90" fill="none" viewBox="0 0 90 90"><path fill="#33363d" d="M11.041 30.917a16.666 16.666 0 0 1 19.917-19.875 16.666 16.666 0 0 1 28.083 0 16.666 16.666 0 0 1 19.917 19.917 16.666 16.666 0 0 1 0 28.083 16.666 16.666 0 0 1-19.875 19.917 16.666 16.666 0 0 1-28.125 0A16.666 16.666 0 0 1 11.04 59.084a16.667 16.667 0 0 1 0-28.167"/><path fill="url(#downloads-10m-a)" fill-opacity=".7" d="M11.041 30.917a16.666 16.666 0 0 1 19.917-19.875 16.666 16.666 0 0 1 28.083 0 16.666 16.666 0 0 1 19.917 19.917 16.666 16.666 0 0 1 0 28.083 16.666 16.666 0 0 1-19.875 19.917 16.666 16.666 0 0 1-28.125 0A16.666 16.666 0 0 1 11.04 59.084a16.667 16.667 0 0 1 0-28.167"/><path fill="url(#downloads-10m-b)" fill-opacity=".7" d="M83.647 45a13.67 13.67 0 0 0-6.305-11.514 3 3 0 0 1-1.312-3.185A13.667 13.667 0 0 0 59.7 13.969a3 3 0 0 1-3.186-1.31 13.67 13.67 0 0 0-23.029 0 3 3 0 0 1-3.183 1.31A13.668 13.668 0 0 0 13.97 30.268a3 3 0 0 1-1.325 3.185 13.67 13.67 0 0 0-4.664 18.14 13.67 13.67 0 0 0 4.665 4.957 3 3 0 0 1 1.324 3.184 13.67 13.67 0 0 0 9.54 16.09c2.21.64 4.546.712 6.792.208l.231-.043c1.16-.166 2.32.36 2.956 1.36a13.67 13.67 0 0 0 11.532 6.332v3l-.526-.01a16.7 16.7 0 0 1-7.032-1.802l-.464-.246a16.67 16.67 0 0 1-6.041-5.663 16.67 16.67 0 0 1-15.041-4.1l-.378-.366a16.7 16.7 0 0 1-4.071-6.625l-.155-.503a16.67 16.67 0 0 1-.272-8.281 16.7 16.7 0 0 1-5.426-5.588l-.261-.457a16.67 16.67 0 0 1-.247-15.612l.247-.465a16.67 16.67 0 0 1 5.687-6.045 16.7 16.7 0 0 1 .132-7.775l.14-.507a16.7 16.7 0 0 1 3.86-6.75l.366-.377a16.67 16.67 0 0 1 14.904-4.573l.515.107a16.7 16.7 0 0 1 5.581-5.382l.456-.259a16.667 16.667 0 0 1 21.757 5.204l.289.437a16.667 16.667 0 0 1 19.917 19.917A16.67 16.67 0 0 1 86.647 45l-.01.524a16.7 16.7 0 0 1-2.04 7.48l-.258.456a16.7 16.7 0 0 1-5.381 5.582 16.67 16.67 0 0 1-4.466 15.419l-.377.366a16.7 16.7 0 0 1-6.75 3.86l-.508.14a16.7 16.7 0 0 1-7.774.132 16.67 16.67 0 0 1-6.041 5.663l-.465.246a16.7 16.7 0 0 1-7.556 1.812v-3a13.67 13.67 0 0 0 11.53-6.331l.134-.194a3 3 0 0 1 3.047-1.125 13.67 13.67 0 0 0 16.09-9.54c.64-2.21.712-4.546.208-6.792a3 3 0 0 1 1.312-3.183A13.67 13.67 0 0 0 83.647 45"/><path fill="#000" fill-opacity=".5" fill-rule="evenodd" d="M46.709 6.46a13.66 13.66 0 0 0-8.274 1.573 13.67 13.67 0 0 0-4.95 4.625 3 3 0 0 1-3.183 1.312A13.668 13.668 0 0 0 13.97 30.267a3 3 0 0 1-.489 2.395c-.22.309-.502.579-.835.79a13.68 13.68 0 0 0-6.25 13.267 13.67 13.67 0 0 0 6.25 9.83 3 3 0 0 1 1.307 1.812c.11.44.12.91.017 1.372a13.6 13.6 0 0 0-.155 5.116 13.66 13.66 0 0 0 3.843 7.52 13.67 13.67 0 0 0 12.644 3.662l.231-.043c1.16-.166 2.32.36 2.956 1.36q.116.183.238.36a13.64 13.64 0 0 0 4.715 4.285 13.67 13.67 0 0 0 18.11-4.644q.064-.1.133-.194a3 3 0 0 1 3.047-1.125 13.67 13.67 0 0 0 16.63-13.35c-.001-1-.111-2-.332-2.982a3.01 3.01 0 0 1 1.312-3.183 13.668 13.668 0 0 0 0-23.029 3 3 0 0 1-.54-.441 2.998 2.998 0 0 1-.772-2.744 13.674 13.674 0 0 0-10.785-16.425 13.7 13.7 0 0 0-5.546.093 3 3 0 0 1-3.185-1.31 13.67 13.67 0 0 0-9.805-6.198m-36.1 43.685a10.667 10.667 0 0 1 3.641-14.158 6 6 0 0 0 2.648-6.37 10.666 10.666 0 0 1 12.747-12.72l.232.047a6 6 0 0 0 6.136-2.67 10.668 10.668 0 0 1 17.973 0l.131.197a6 6 0 0 0 6.24 2.424 10.67 10.67 0 0 1 12.746 12.748 6 6 0 0 0 2.623 6.37 10.667 10.667 0 0 1 3.766 13.813l-.157.297a10.7 10.7 0 0 1-3.61 3.864 6 6 0 0 0-2.622 6.368 10.667 10.667 0 0 1-12.721 12.747 6 6 0 0 0-6.095 2.25l-.068.096-.134.194-.065.096a10.67 10.67 0 0 1-8.664 4.937l-.336.005a10.67 10.67 0 0 1-8.815-4.66l-.184-.282a6 6 0 0 0-5.914-2.72l-.122.021-.231.043-.109.022a10.668 10.668 0 0 1-12.747-12.721 6 6 0 0 0-2.45-6.239l-.198-.13a10.7 10.7 0 0 1-3.64-3.87" clip-rule="evenodd"/><path fill="#fff" d="M30.826 51.364V63h-2.46v-9.301h-.068l-2.665 1.67v-2.181l2.88-1.824zm7.328 11.892q-1.465-.006-2.523-.722-1.05-.716-1.619-2.074-.562-1.358-.557-3.267 0-1.903.563-3.244.568-1.341 1.62-2.04 1.056-.705 2.516-.704 1.46 0 2.511.704 1.057.705 1.626 2.046.568 1.336.562 3.238 0 1.916-.568 3.273-.562 1.357-1.614 2.074-1.05.716-2.517.716m0-2.04q1 0 1.597-1.006.596-1.005.59-3.017 0-1.323-.272-2.204-.267-.881-.762-1.324a1.66 1.66 0 0 0-1.153-.443q-.994 0-1.59.994-.598.994-.603 2.977 0 1.34.267 2.239.273.891.767 1.34.495.444 1.16.444m6.521-9.852h3.035l3.204 7.818h.136l3.205-7.818h3.034V63h-2.386v-7.574h-.097l-3.011 7.517H50.17l-3.012-7.545h-.096V63h-2.387zm18.05 9.863v-8.045h2.034v8.045zm-3.006-3.005v-2.035h8.046v2.035z"/><path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M45 35V23m5 7-5 5-5-5m14 5v4a2 2 0 0 1-2 2H38a2 2 0 0 1-2-2v-4"/><defs><linearGradient id="downloads-10m-a" x1="7.5" x2="80" y1="-3" y2="91" gradientUnits="userSpaceOnUse"><stop stop-color="#42ffdf"/><stop offset="1" stop-color="#170fff"/></linearGradient><linearGradient id="downloads-10m-b" x1="7.5" x2="80" y1="-3" y2="91" gradientUnits="userSpaceOnUse"><stop stop-color="#5d46f2"/><stop offset="1" stop-color="#b2ebf8"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="90" fill="none" viewBox="0 0 90 90"><path fill="#33363d" d="M11.041 30.917a16.666 16.666 0 0 1 19.917-19.875 16.666 16.666 0 0 1 28.083 0 16.666 16.666 0 0 1 19.917 19.917 16.666 16.666 0 0 1 0 28.083 16.666 16.666 0 0 1-19.875 19.917 16.666 16.666 0 0 1-28.125 0A16.666 16.666 0 0 1 11.04 59.084a16.667 16.667 0 0 1 0-28.167"/><path fill="url(#downloads-1m-a)" fill-opacity=".7" d="M11.041 30.917a16.666 16.666 0 0 1 19.917-19.875 16.666 16.666 0 0 1 28.083 0 16.666 16.666 0 0 1 19.917 19.917 16.666 16.666 0 0 1 0 28.083 16.666 16.666 0 0 1-19.875 19.917 16.666 16.666 0 0 1-28.125 0A16.666 16.666 0 0 1 11.04 59.084a16.667 16.667 0 0 1 0-28.167"/><path fill="url(#downloads-1m-b)" fill-opacity=".7" d="M83.647 45a13.67 13.67 0 0 0-6.305-11.514 3 3 0 0 1-1.312-3.185A13.667 13.667 0 0 0 59.7 13.969a3 3 0 0 1-3.186-1.31 13.67 13.67 0 0 0-23.029 0 3 3 0 0 1-3.183 1.31A13.668 13.668 0 0 0 13.97 30.268a3 3 0 0 1-1.325 3.185 13.67 13.67 0 0 0-4.664 18.14 13.67 13.67 0 0 0 4.665 4.957 3 3 0 0 1 1.324 3.184 13.67 13.67 0 0 0 9.54 16.09c2.21.64 4.546.712 6.792.208l.231-.043c1.16-.166 2.32.36 2.956 1.36a13.67 13.67 0 0 0 11.532 6.332v3l-.526-.01a16.7 16.7 0 0 1-7.032-1.802l-.464-.246a16.67 16.67 0 0 1-6.041-5.663 16.67 16.67 0 0 1-15.041-4.1l-.378-.366a16.7 16.7 0 0 1-4.071-6.625l-.155-.503a16.67 16.67 0 0 1-.272-8.281 16.7 16.7 0 0 1-5.426-5.588l-.261-.457a16.67 16.67 0 0 1-.247-15.612l.247-.465a16.67 16.67 0 0 1 5.687-6.045 16.7 16.7 0 0 1 .132-7.775l.14-.507a16.7 16.7 0 0 1 3.86-6.75l.366-.377a16.67 16.67 0 0 1 14.904-4.573l.515.107a16.7 16.7 0 0 1 5.581-5.382l.456-.259a16.667 16.667 0 0 1 21.757 5.204l.289.437a16.667 16.667 0 0 1 19.917 19.917A16.67 16.67 0 0 1 86.647 45l-.01.524a16.7 16.7 0 0 1-2.04 7.48l-.258.456a16.7 16.7 0 0 1-5.381 5.582 16.67 16.67 0 0 1-4.466 15.419l-.377.366a16.7 16.7 0 0 1-6.75 3.86l-.508.14a16.7 16.7 0 0 1-7.774.132 16.67 16.67 0 0 1-6.041 5.663l-.465.246a16.7 16.7 0 0 1-7.556 1.812v-3a13.67 13.67 0 0 0 11.53-6.331l.134-.194a3 3 0 0 1 3.047-1.125 13.67 13.67 0 0 0 16.09-9.54c.64-2.21.712-4.546.208-6.792a3 3 0 0 1 1.312-3.183A13.67 13.67 0 0 0 83.647 45"/><path fill="#000" fill-opacity=".5" fill-rule="evenodd" d="M46.709 6.46a13.66 13.66 0 0 0-8.274 1.573 13.67 13.67 0 0 0-4.95 4.625 3 3 0 0 1-3.183 1.312A13.668 13.668 0 0 0 13.97 30.267a3 3 0 0 1-.489 2.395c-.22.309-.502.579-.835.79a13.68 13.68 0 0 0-6.25 13.267 13.67 13.67 0 0 0 6.25 9.83 3 3 0 0 1 1.307 1.812c.11.44.12.91.017 1.372a13.6 13.6 0 0 0-.155 5.116 13.66 13.66 0 0 0 3.843 7.52 13.67 13.67 0 0 0 12.644 3.662l.231-.043c1.16-.166 2.32.36 2.956 1.36q.116.183.238.36a13.64 13.64 0 0 0 4.715 4.285 13.67 13.67 0 0 0 18.11-4.644q.064-.1.133-.194a3 3 0 0 1 3.047-1.125 13.67 13.67 0 0 0 16.63-13.35c-.001-1-.111-2-.332-2.982a3.01 3.01 0 0 1 1.312-3.183 13.668 13.668 0 0 0 0-23.029 3 3 0 0 1-.54-.441 2.998 2.998 0 0 1-.772-2.744 13.674 13.674 0 0 0-10.785-16.425 13.7 13.7 0 0 0-5.546.093 3 3 0 0 1-3.185-1.31 13.67 13.67 0 0 0-9.805-6.198m-36.1 43.685a10.667 10.667 0 0 1 3.641-14.158 6 6 0 0 0 2.648-6.37 10.666 10.666 0 0 1 12.747-12.72l.232.047a6 6 0 0 0 6.136-2.67 10.668 10.668 0 0 1 17.973 0l.131.197a6 6 0 0 0 6.24 2.424 10.67 10.67 0 0 1 12.746 12.748 6 6 0 0 0 2.623 6.37 10.667 10.667 0 0 1 3.766 13.813l-.157.297a10.7 10.7 0 0 1-3.61 3.864 6 6 0 0 0-2.622 6.368 10.667 10.667 0 0 1-12.721 12.747 6 6 0 0 0-6.095 2.25l-.068.096-.134.194-.065.096a10.67 10.67 0 0 1-8.664 4.937l-.336.005a10.67 10.67 0 0 1-8.815-4.66l-.184-.282a6 6 0 0 0-5.914-2.72l-.122.021-.231.043-.109.022a10.668 10.668 0 0 1-12.747-12.721 6 6 0 0 0-2.45-6.239l-.198-.13a10.7 10.7 0 0 1-3.64-3.87" clip-rule="evenodd"/><path fill="#fff" d="M36.334 51.364V63h-2.46v-9.301h-.069l-2.664 1.67v-2.181l2.88-1.824zm2.834 0h3.034l3.204 7.818h.137l3.204-7.818h3.034V63h-2.386v-7.574h-.097l-3.011 7.517h-1.625l-3.011-7.545h-.097V63h-2.386zm18.05 9.863v-8.045h2.033v8.045zm-3.006-3.005v-2.035h8.045v2.035z"/><path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M45 35V23m5 7-5 5-5-5m14 5v4a2 2 0 0 1-2 2H38a2 2 0 0 1-2-2v-4"/><defs><linearGradient id="downloads-1m-a" x1="7.5" x2="80" y1="-3" y2="91" gradientUnits="userSpaceOnUse"><stop stop-color="#adff42"/><stop offset="1" stop-color="#0fe7ff"/></linearGradient><linearGradient id="downloads-1m-b" x1="7.5" x2="80" y1="-3" y2="91" gradientUnits="userSpaceOnUse"><stop stop-color="#46f291"/><stop offset="1" stop-color="#b2f8c4"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.4 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="90" fill="none" viewBox="0 0 90 90"><path fill="#71b1f5" fill-rule="evenodd" d="M65 10c7.49 0 13.607 5.881 13.982 13.281l.003.075.015.567.001.077v10c0 3.918-1.61 7.459-4.204 10a13.96 13.96 0 0 1 4.188 9.356l.015.567.001.077v10c0 7.732-6.269 14.001-14.001 14.001H24.999c-7.733 0-14-6.27-14-14.001V54c0-3.918 1.61-7.459 4.203-10A13.96 13.96 0 0 1 11 34V24c0-7.732 6.268-14 14-14z" clip-rule="evenodd"/><path fill="url(#early-hosting-a)" fill-rule="evenodd" d="M65 10c7.49 0 13.607 5.881 13.982 13.281l.003.075.015.567.001.077v10c0 3.918-1.61 7.459-4.204 10a13.96 13.96 0 0 1 4.188 9.356l.015.567.001.077v10c0 7.732-6.269 14.001-14.001 14.001H24.999c-7.733 0-14-6.27-14-14.001V54c0-3.918 1.61-7.459 4.203-10A13.96 13.96 0 0 1 11 34V24c0-7.732 6.268-14 14-14z" clip-rule="evenodd"/><path fill="#23258c" fill-rule="evenodd" d="M65 13c5.885 0 10.691 4.621 10.986 10.434l.015.566v10c0 4.438-2.63 8.26-6.416 9.999 3.624 1.664 6.188 5.239 6.401 9.435l.015.566v10c0 6.075-4.926 11.001-11.001 11.001H24.999c-6.075 0-11-4.926-11-11.001V54c0-4.44 2.63-8.262 6.417-10-3.786-1.739-6.417-5.561-6.417-10V24c0-6.075 4.925-11 11-11zM34.496 55a5.98 5.98 0 0 1 1.53 4c0 1.538-.58 2.94-1.53 4.001H64V55zM30 56a3 3 0 1 0 0 6h.026a3 3 0 0 0 0-6zm4.496-31a5.98 5.98 0 0 1 1.53 4c0 1.538-.58 2.94-1.53 4.001H64V25zM30 26a3 3 0 1 0 0 6h.026a3 3 0 0 0 0-6z" clip-rule="evenodd"/><path fill="url(#early-hosting-b)" fill-rule="evenodd" d="M65 13c5.885 0 10.691 4.621 10.986 10.434l.015.566v10c0 4.438-2.63 8.26-6.416 9.999 3.624 1.664 6.188 5.239 6.401 9.435l.015.566v10c0 6.075-4.926 11.001-11.001 11.001H24.999c-6.075 0-11-4.926-11-11.001V54c0-4.44 2.63-8.262 6.417-10-3.786-1.739-6.417-5.561-6.417-10V24c0-6.075 4.925-11 11-11zM34.496 55a5.98 5.98 0 0 1 1.53 4c0 1.538-.58 2.94-1.53 4.001H64V55zM30 56a3 3 0 1 0 0 6h.026a3 3 0 0 0 0-6zm4.496-31a5.98 5.98 0 0 1 1.53 4c0 1.538-.58 2.94-1.53 4.001H64V25zM30 26a3 3 0 1 0 0 6h.026a3 3 0 0 0 0-6z" clip-rule="evenodd"/><path fill="#3b89f4" d="M67.001 24A2 2 0 0 0 65 22H24.999a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2.001H65A2 2 0 0 0 67.001 34zm6 10A8 8 0 0 1 65 42.001H24.999a8 8 0 0 1-8-8.001V24a8 8 0 0 1 8-8H65a8 8 0 0 1 8.001 8z"/><path fill="url(#early-hosting-c)" d="M67.001 24A2 2 0 0 0 65 22H24.999a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2.001H65A2 2 0 0 0 67.001 34zm6 10A8 8 0 0 1 65 42.001H24.999a8 8 0 0 1-8-8.001V24a8 8 0 0 1 8-8H65a8 8 0 0 1 8.001 8z"/><path fill="#3b89f4" d="M67.001 54A2 2 0 0 0 65 52H24.999a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2.001H65A2 2 0 0 0 67.001 64zm6 10A8 8 0 0 1 65 72.001H24.999a8 8 0 0 1-8-8.001V54a8 8 0 0 1 8-8H65a8 8 0 0 1 8.001 8z"/><path fill="url(#early-hosting-d)" d="M67.001 54A2 2 0 0 0 65 52H24.999a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2.001H65A2 2 0 0 0 67.001 64zm6 10A8 8 0 0 1 65 72.001H24.999a8 8 0 0 1-8-8.001V54a8 8 0 0 1 8-8H65a8 8 0 0 1 8.001 8z"/><path fill="#3b89f4" d="M30.026 26a3 3 0 0 1 0 6H30a3 3 0 0 1 0-6z"/><path fill="url(#early-hosting-e)" d="M30.026 26a3 3 0 0 1 0 6H30a3 3 0 0 1 0-6z"/><path fill="#3b89f4" d="M30.026 56a3 3 0 0 1 0 6H30a3 3 0 0 1 0-6z"/><path fill="url(#early-hosting-f)" d="M30.026 56a3 3 0 0 1 0 6H30a3 3 0 0 1 0-6z"/><defs><linearGradient id="early-hosting-a" x1="24.943" x2="79.403" y1="-.014" y2="82.407" gradientUnits="userSpaceOnUse"><stop stop-color="#bddcf3"/><stop offset="1" stop-color="#64aaf4"/></linearGradient><linearGradient id="early-hosting-b" x1="26.712" x2="76.367" y1="3.87" y2="79.018" gradientUnits="userSpaceOnUse"><stop stop-color="#2f137c"/><stop offset="1" stop-color="#19359a"/></linearGradient><linearGradient id="early-hosting-c" x1="28.482" x2="73.332" y1="7.754" y2="75.629" gradientUnits="userSpaceOnUse"><stop stop-color="#2aa3f3"/><stop offset="1" stop-color="#6d3bf7"/></linearGradient><linearGradient id="early-hosting-d" x1="28.482" x2="73.332" y1="7.754" y2="75.629" gradientUnits="userSpaceOnUse"><stop stop-color="#2aa3f3"/><stop offset="1" stop-color="#6d3bf7"/></linearGradient><linearGradient id="early-hosting-e" x1="28.482" x2="73.332" y1="7.754" y2="75.629" gradientUnits="userSpaceOnUse"><stop stop-color="#2aa3f3"/><stop offset="1" stop-color="#6d3bf7"/></linearGradient><linearGradient id="early-hosting-f" x1="28.482" x2="73.332" y1="7.754" y2="75.629" gradientUnits="userSpaceOnUse"><stop stop-color="#2aa3f3"/><stop offset="1" stop-color="#6d3bf7"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="90" fill="none" viewBox="0 0 90 90"><path fill="url(#early-servers-a)" d="M77.157 44.15a6 6 0 0 0-.625-2.337l-9.195-18.365a11.34 11.34 0 0 0-9.744-6.275l-.056-.002-.29-.005H31.803a11.34 11.34 0 0 0-10.135 6.274l-9.2 18.373a6 6 0 0 0-.635 2.686v16a11.33 11.33 0 0 0 5.514 9.726 11.34 11.34 0 0 0 5.819 1.608h42.667l.563-.014a11.334 11.334 0 0 0 10.77-11.32v-16q0-.175-.01-.349M20.834 47.5v13a2.334 2.334 0 0 0 2.333 2.334h42.667a2.335 2.335 0 0 0 2.333-2.334v-13zM19.395 64.27a5 5 0 0 1-.181-.19zM65.167 50.5v9.334H42.962a5.99 5.99 0 0 0 2.23-4.667c0-1.885-.87-3.567-2.23-4.667zM28.5 58.166a3 3 0 1 1 0-6h.026a3 3 0 0 1 0 6zm10.666 0a3 3 0 0 1 0-6h.026a3 3 0 0 1 0 6zm-7.357-32-.163.006a2.33 2.33 0 0 0-1.925 1.289l-7.03 14.038H66.31l-7.025-14.03-.005-.008a2.33 2.33 0 0 0-1.925-1.29l-.163-.005zm24.971 3 4.673 9.333H27.55l4.673-9.333zm3.53-4.992a5.3 5.3 0 0 1 1.628 1.901l.03.051a5.33 5.33 0 0 0-1.966-2.16zm-31.619-.001a5 5 0 0 0 .308-.205zm2.787-.996q-.102.008-.202.018.08-.009.16-.015zm25.962-.004q-.02-.002-.04-.003l-.065-.001zM22.69 44.499a3 3 0 0 1-2.682-4.343l7.025-14.03-9.2 18.373v3a3 3 0 0 1 3-3zm45.476 0a3 3 0 0 1 3 3v-3l-9.195-18.365 7.02 14.022A3 3 0 0 1 66.31 44.5zm6 16a8.35 8.35 0 0 1-2.44 5.893 8.33 8.33 0 0 1-5.893 2.441H23.167a8.32 8.32 0 0 1-4.958-1.636 8.34 8.34 0 0 1-3.214-5.065 8.3 8.3 0 0 1-.161-1.633v-16a3 3 0 0 1 .317-1.343l9.2-18.373a8.33 8.33 0 0 1 7.455-4.617h25.39l.29.005a8.3 8.3 0 0 1 3.625.974q.24.128.472.272a8.33 8.33 0 0 1 3.071 3.374l9.196 18.365a3 3 0 0 1 .317 1.343zm6 0a14.333 14.333 0 0 1-13.697 14.32l-.563.013-.073.001H23.167A14.335 14.335 0 0 1 8.834 60.499v-16c0-1.398.326-2.778.952-4.028l9.2-18.373a14.33 14.33 0 0 1 5.27-5.78l.044-.027.339-.202q.022-.015.046-.027a14.34 14.34 0 0 1 7.117-1.896h25.495l.29.005.111.004a14.34 14.34 0 0 1 12.321 7.93l9.196 18.366a9 9 0 0 1 .952 4.028z"/><path fill="url(#early-servers-b)" d="M74.167 44.499a3 3 0 0 0-.317-1.343l-9.196-18.365a8.33 8.33 0 0 0-7.168-4.62l-.29-.005h-25.39a8.33 8.33 0 0 0-7.454 4.617l-9.2 18.373a3 3 0 0 0-.318 1.343v16a8.334 8.334 0 0 0 8.333 8.334h42.667a8.334 8.334 0 0 0 8.333-8.334zm-51 18.334a2.334 2.334 0 0 1-2.333-2.334v-13h47.333v13a2.334 2.334 0 0 1-2.333 2.334zM28.5 52.166a3 3 0 0 0 0 6h.026a3 3 0 0 0 0-6zm28.692-26 .163.006a2.33 2.33 0 0 1 1.925 1.289l.005.008 7.025 14.03H22.69l7.03-14.038a2.33 2.33 0 0 1 1.925-1.29l.163-.005zM27.55 38.499h33.904l-4.673-9.333H32.222zm8.617 16.667a3 3 0 0 0 3 3h.026a3 3 0 0 0 0-6h-.026a3 3 0 0 0-3 3m9.026 0c0 1.886-.87 3.567-2.23 4.667h22.205v-9.334H42.962a5.99 5.99 0 0 1 2.23 4.667m31.975 5.333a11.334 11.334 0 0 1-10.77 11.32l-.563.014H23.167a11.335 11.335 0 0 1-11.333-11.334v-16c0-.932.217-1.852.635-2.686l9.2-18.373a11.34 11.34 0 0 1 4.168-4.573l.339-.202a11.34 11.34 0 0 1 5.628-1.499h25.442l.291.005.056.002a11.33 11.33 0 0 1 9.744 6.275l9.195 18.365a6 6 0 0 1 .635 2.686z"/><path fill="url(#early-servers-c)" d="M68.167 47.499H20.834v13a2.334 2.334 0 0 0 2.333 2.334h42.667a2.335 2.335 0 0 0 2.333-2.334zm-39.64 4.667a3 3 0 0 1 0 6H28.5a3 3 0 1 1 0-6zm10.665 0a3 3 0 0 1 0 6h-.026a3 3 0 0 1 0-6zm-7.546-25.994a2.33 2.33 0 0 0-1.925 1.289l-7.03 14.038H66.31l-7.025-14.03-.005-.008a2.33 2.33 0 0 0-1.925-1.29l-.163-.005H31.81zm42.521 34.327a8.334 8.334 0 0 1-8.333 8.334H23.167a8.335 8.335 0 0 1-8.333-8.334v-16a3 3 0 0 1 .317-1.343l9.2-18.373a8.33 8.33 0 0 1 7.455-4.617h25.39l.29.005a8.33 8.33 0 0 1 7.168 4.62l9.196 18.365a3 3 0 0 1 .317 1.343z"/><defs><linearGradient id="early-servers-a" x1="23.461" x2="68.576" y1="5.232" y2="85.514" gradientUnits="userSpaceOnUse"><stop stop-color="#bddcf3"/><stop offset="1" stop-color="#64aaf4"/></linearGradient><linearGradient id="early-servers-b" x1="25.23" x2="65.537" y1="9.116" y2="82.018" gradientUnits="userSpaceOnUse"><stop stop-color="#2f137c"/><stop offset="1" stop-color="#19359a"/></linearGradient><linearGradient id="early-servers-c" x1="27" x2="62.5" y1="12.999" y2="78.499" gradientUnits="userSpaceOnUse"><stop stop-color="#2aa3f3"/><stop offset="1" stop-color="#6d3bf7"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="90" fill="none" viewBox="0 0 90 90"><path fill="#d9d9d9" d="M78.5 49.252c0 21.25-14.875 31.875-32.555 38.037a4.25 4.25 0 0 1-2.848-.042C25.375 81.127 10.5 70.502 10.5 49.252v-29.75a4.25 4.25 0 0 1 4.25-4.25c8.5 0 19.125-5.1 26.52-11.56a4.97 4.97 0 0 1 6.46 0c7.438 6.502 18.02 11.56 26.52 11.56a4.25 4.25 0 0 1 4.25 4.25z"/><path fill="url(#moderator-a)" d="M78.5 49.252c0 21.25-14.875 31.875-32.555 38.037a4.25 4.25 0 0 1-2.848-.042C25.375 81.127 10.5 70.502 10.5 49.252v-29.75a4.25 4.25 0 0 1 4.25-4.25c8.5 0 19.125-5.1 26.52-11.56a4.97 4.97 0 0 1 6.46 0c7.438 6.502 18.02 11.56 26.52 11.56a4.25 4.25 0 0 1 4.25 4.25z"/><path fill="#000" fill-opacity=".6" d="M78.5 49.252c0 21.25-14.875 31.875-32.555 38.037a4.25 4.25 0 0 1-2.848-.042C25.375 81.127 10.5 70.502 10.5 49.252v-29.75a4.25 4.25 0 0 1 4.25-4.25c8.5 0 19.125-5.1 26.52-11.56a4.97 4.97 0 0 1 6.46 0c7.438 6.502 18.02 11.56 26.52 11.56a4.25 4.25 0 0 1 4.25 4.25z"/><path fill="#000" d="M77 19.502a2.75 2.75 0 0 0-2.75-2.75c-8.98 0-19.891-5.278-27.494-11.92l-.244-.19a3.47 3.47 0 0 0-4.268.19c-7.44 6.495-18.172 11.753-27.071 11.916l-.423.004a2.75 2.75 0 0 0-2.75 2.75v29.75c0 10.229 3.562 17.822 9.226 23.623 5.526 5.66 13.117 9.675 21.544 12.668l.817.286.034.012c.59.22 1.237.23 1.834.03 8.731-3.044 16.624-7.142 22.323-12.99C73.438 67.077 77 59.482 77 49.253zm3 29.75c0 11.021-3.875 19.364-10.073 25.724-6.158 6.317-14.541 10.611-23.489 13.73l-.011.004a5.75 5.75 0 0 1-3.82-.046c-8.97-3.097-17.364-7.38-23.528-13.693C12.875 68.616 9 60.273 9 49.252v-29.75a5.75 5.75 0 0 1 5.75-5.75l.758-.015c7.897-.292 17.77-5.055 24.775-11.174l.013-.011a6.47 6.47 0 0 1 8.185-.183l.223.183.014.01c7.271 6.358 17.516 11.19 25.532 11.19a5.75 5.75 0 0 1 5.75 5.75z"/><path fill="url(#moderator-b)" d="M77 19.502a2.75 2.75 0 0 0-2.75-2.75c-8.98 0-19.891-5.278-27.494-11.92l-.244-.19a3.47 3.47 0 0 0-4.268.19c-7.44 6.495-18.172 11.753-27.071 11.916l-.423.004a2.75 2.75 0 0 0-2.75 2.75v29.75c0 10.229 3.562 17.822 9.226 23.623 5.526 5.66 13.117 9.675 21.544 12.668l.817.286.034.012c.59.22 1.237.23 1.834.03 8.731-3.044 16.624-7.142 22.323-12.99C73.438 67.077 77 59.482 77 49.253zm3 29.75c0 11.021-3.875 19.364-10.073 25.724-6.158 6.317-14.541 10.611-23.489 13.73l-.011.004a5.75 5.75 0 0 1-3.82-.046c-8.97-3.097-17.364-7.38-23.528-13.693C12.875 68.616 9 60.273 9 49.252v-29.75a5.75 5.75 0 0 1 5.75-5.75l.758-.015c7.897-.292 17.77-5.055 24.775-11.174l.013-.011a6.47 6.47 0 0 1 8.185-.183l.223.183.014.01c7.271 6.358 17.516 11.19 25.532 11.19a5.75 5.75 0 0 1 5.75 5.75z"/><path fill="url(#moderator-c)" d="M41.75 19.002a2.75 2.75 0 0 1 5.5 0v3.44a39 39 0 0 0 17.25 4.033H67a2.75 2.75 0 1 1 0 5.5h-1.999l7.081 19.302a2.75 2.75 0 0 1-.909 3.13A15.07 15.07 0 0 1 62 57.528c-3.315 0-6.533-1.1-9.173-3.123a2.75 2.75 0 0 1-.909-3.129l7.201-19.632a44.5 44.5 0 0 1-11.869-3.162v33.769H57a2.75 2.75 0 1 1 0 5.5H32a2.75 2.75 0 0 1 0-5.5h9.75V28.483a44.5 44.5 0 0 1-11.87 3.162l7.202 19.632a2.75 2.75 0 0 1-.909 3.13A15.07 15.07 0 0 1 27 57.528c-3.315 0-6.533-1.1-9.173-3.123a2.75 2.75 0 0 1-.909-3.129L24 31.975H22a2.75 2.75 0 0 1 0-5.5h2.5a39 39 0 0 0 17.25-4.034zm-18.9 32.073a9.5 9.5 0 0 0 4.15.954c1.44 0 2.857-.328 4.15-.954L27 39.765zm35 0a9.5 9.5 0 0 0 4.15.954c1.44 0 2.856-.328 4.15-.954L62 39.765z"/><defs><linearGradient id="moderator-a" x1="32.26" x2="84.247" y1="20.057" y2="133.125" gradientUnits="userSpaceOnUse"><stop stop-color="#ffb66c"/><stop offset=".298" stop-color="#ffa347"/><stop offset="1" stop-color="#ff5100"/></linearGradient><linearGradient id="moderator-b" x1="32.26" x2="84.247" y1="20.057" y2="133.125" gradientUnits="userSpaceOnUse"><stop stop-color="#ffb66c"/><stop offset=".298" stop-color="#ffa347"/><stop offset="1" stop-color="#ff5100"/></linearGradient><linearGradient id="moderator-c" x1="35.5" x2="58" y1="28.502" y2="95.002" gradientUnits="userSpaceOnUse"><stop stop-color="#ffb66c"/><stop offset=".298" stop-color="#ffa347"/><stop offset="1" stop-color="#ff5100"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="90" fill="none" viewBox="0 0 90 90"><path fill="url(#plus-a)" d="M72 57H58a1 1 0 0 0-1 1v14a7 7 0 0 1-7 7H40a7 7 0 0 1-7-7h3l.005.206A4 4 0 0 0 40 76h10l.206-.005A4 4 0 0 0 54 72V58a4 4 0 0 1 4-4h14l.206-.005A4 4 0 0 0 76 50V40a4 4 0 0 0-3.794-3.995L72 36H58a4 4 0 0 1-4-4V18a4 4 0 0 0-3.794-3.995L50 14H40a4 4 0 0 0-3.995 3.794L36 18h-3a7 7 0 0 1 7-7h10a7 7 0 0 1 7 7v14a1 1 0 0 0 1 1h14a7 7 0 0 1 7 7v10a7 7 0 0 1-7 7M33 18h3v14h-3zm-1 18H18a4 4 0 0 0-3.995 3.794L14 40h-3a7 7 0 0 1 7-7h14a1 1 0 0 0 1-1h3l-.005.206A4 4 0 0 1 32 36m1 22h3v14h-3zm-1-1H18a7 7 0 0 1-7-7h3a4 4 0 0 0 4 4h14l.206.005a4 4 0 0 1 3.79 3.789L36 58h-3a1 1 0 0 0-1-1M11 40h3v10h-3z"/><path fill="#000" fill-opacity=".4" d="M72 57H58a1 1 0 0 0-1 1v14a7 7 0 0 1-7 7H40a7 7 0 0 1-7-7h3l.005.206A4 4 0 0 0 40 76h10l.206-.005A4 4 0 0 0 54 72V58a4 4 0 0 1 4-4h14l.206-.005A4 4 0 0 0 76 50V40a4 4 0 0 0-3.794-3.995L72 36H58a4 4 0 0 1-4-4V18a4 4 0 0 0-3.794-3.995L50 14H40a4 4 0 0 0-3.995 3.794L36 18h-3a7 7 0 0 1 7-7h10a7 7 0 0 1 7 7v14a1 1 0 0 0 1 1h14a7 7 0 0 1 7 7v10a7 7 0 0 1-7 7M33 18h3v14h-3zm-1 18H18a4 4 0 0 0-3.995 3.794L14 40h-3a7 7 0 0 1 7-7h14a1 1 0 0 0 1-1h3l-.005.206A4 4 0 0 1 32 36m1 22h3v14h-3zm-1-1H18a7 7 0 0 1-7-7h3a4 4 0 0 0 4 4h14l.206.005a4 4 0 0 1 3.79 3.789L36 58h-3a1 1 0 0 0-1-1M11 40h3v10h-3z"/><path fill="url(#plus-b)" d="M58 54q-.105 0-.206.005A4 4 0 0 0 54 58h-3a7 7 0 0 1 6.64-6.991L58 51h13.97l.096-.002A1 1 0 0 0 73 50V40a1 1 0 0 0-.934-.997L71.97 39H58a7 7 0 0 1-7-7h3q0 .105.005.206A4 4 0 0 0 58 36h14l.206.005A4 4 0 0 1 76 40v10a4 4 0 0 1-3.794 3.995L72 54zM40 76a4 4 0 0 1-3.995-3.794L36 72h-3a7 7 0 0 0 7 7h10a7 7 0 0 0 7-7V58a1 1 0 0 1 1-1h14a7 7 0 0 0 7-7V40a7 7 0 0 0-7-7H58a1 1 0 0 1-1-1V18a7 7 0 0 0-7-7H40a7 7 0 0 0-7 7h3l.005-.206A4 4 0 0 1 40 14h10l.206.005A4 4 0 0 1 54 18v14h-3V18a1 1 0 0 0-.934-.997L49.97 17H40a1 1 0 0 0-.998.934L39 18.03v14.005l-.001.036-.006.246-.002.041A7 7 0 0 1 32 39H18a1 1 0 0 0-.998.934L17 40.03V50a1 1 0 0 0 .898.995L18 51h14.035l.036.001.246.006.041.002a7 7 0 0 1 6.633 6.633l.002.04.006.247.001.036V71.97l.002.097a1 1 0 0 0 .9.928L40 73h9.97l.096-.002A1 1 0 0 0 51 72V58h3v14a4 4 0 0 1-3.794 3.995L50 76zm-8-40a4 4 0 0 0 3.995-3.794L36 32V18h-3v14a1 1 0 0 1-1 1H18a7 7 0 0 0-7 7h3l.005-.206A4 4 0 0 1 18 36zm-2-6V18c0-5.35 4.202-9.72 9.485-9.987L40 8h10c5.523 0 10 4.477 10 10v12h12c5.523 0 10 4.477 10 10v10c0 5.523-4.477 10-10 10H60v12c0 5.523-4.477 10-10 10H40c-5.523 0-10-4.477-10-10h6V58h-3v14h-3V60H18c-5.523 0-10-4.477-10-10h3q0 .18.009.36A7 7 0 0 0 18 57h14a1 1 0 0 1 1 1h3l-.005-.206a4 4 0 0 0-3.789-3.79L32 54H18q-.105 0-.206-.005A4 4 0 0 1 14 50V40h-3v10H8V40l.013-.515C8.28 34.202 12.65 30 18 30z"/><path fill="#fff" fill-opacity=".4" d="M58 54q-.105 0-.206.005A4 4 0 0 0 54 58h-3a7 7 0 0 1 6.64-6.991L58 51h13.97l.096-.002A1 1 0 0 0 73 50V40a1 1 0 0 0-.934-.997L71.97 39H58a7 7 0 0 1-7-7h3q0 .105.005.206A4 4 0 0 0 58 36h14l.206.005A4 4 0 0 1 76 40v10a4 4 0 0 1-3.794 3.995L72 54zM40 76a4 4 0 0 1-3.995-3.794L36 72h-3a7 7 0 0 0 7 7h10a7 7 0 0 0 7-7V58a1 1 0 0 1 1-1h14a7 7 0 0 0 7-7V40a7 7 0 0 0-7-7H58a1 1 0 0 1-1-1V18a7 7 0 0 0-7-7H40a7 7 0 0 0-7 7h3l.005-.206A4 4 0 0 1 40 14h10l.206.005A4 4 0 0 1 54 18v14h-3V18a1 1 0 0 0-.934-.997L49.97 17H40a1 1 0 0 0-.998.934L39 18.03v14.005l-.001.036-.006.246-.002.041A7 7 0 0 1 32 39H18a1 1 0 0 0-.998.934L17 40.03V50a1 1 0 0 0 .898.995L18 51h14.035l.036.001.246.006.041.002a7 7 0 0 1 6.633 6.633l.002.04.006.247.001.036V71.97l.002.097a1 1 0 0 0 .9.928L40 73h9.97l.096-.002A1 1 0 0 0 51 72V58h3v14a4 4 0 0 1-3.794 3.995L50 76zm-8-40a4 4 0 0 0 3.995-3.794L36 32V18h-3v14a1 1 0 0 1-1 1H18a7 7 0 0 0-7 7h3l.005-.206A4 4 0 0 1 18 36zm-2-6V18c0-5.35 4.202-9.72 9.485-9.987L40 8h10c5.523 0 10 4.477 10 10v12h12c5.523 0 10 4.477 10 10v10c0 5.523-4.477 10-10 10H60v12c0 5.523-4.477 10-10 10H40c-5.523 0-10-4.477-10-10h6V58h-3v14h-3V60H18c-5.523 0-10-4.477-10-10h3q0 .18.009.36A7 7 0 0 0 18 57h14a1 1 0 0 1 1 1h3l-.005-.206a4 4 0 0 0-3.789-3.79L32 54H18q-.105 0-.206-.005A4 4 0 0 1 14 50V40h-3v10H8V40l.013-.515C8.28 34.202 12.65 30 18 30z"/><path fill="url(#plus-c)" d="M32 36a4 4 0 0 0 4-4V18a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4H58a4 4 0 0 0-4 4v14a4 4 0 0 1-4 4H40a4 4 0 0 1-4-4V58a4 4 0 0 0-4-4H18a4 4 0 0 1-4-4V40a4 4 0 0 1 4-4z"/><defs><linearGradient id="plus-a" x1="29" x2="55.5" y1="4.5" y2="85" gradientUnits="userSpaceOnUse"><stop stop-color="#d000ff"/><stop offset="1" stop-color="#7b00ff"/></linearGradient><linearGradient id="plus-b" x1="29" x2="55.5" y1="4.5" y2="85" gradientUnits="userSpaceOnUse"><stop stop-color="#d000ff"/><stop offset="1" stop-color="#2f00ff"/></linearGradient><linearGradient id="plus-c" x1="29" x2="55.5" y1="4.5" y2="85" gradientUnits="userSpaceOnUse"><stop stop-color="#da5fff"/><stop offset="1" stop-color="#7b00ff"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

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