Compare commits

...

37 Commits

Author SHA1 Message Date
Calum H.
42a2451990 fix: border around server icon 2025-08-05 17:38:33 +01:00
Calum H.
54958491b3 fix: scale for medal bg 2025-08-05 16:57:23 +01:00
Calum H.
b497c944a0 feat: use same gradient as promo 2025-08-05 16:55:30 +01:00
Calum H.
e89ebf6915 fix: lint 2025-08-05 13:12:11 +01:00
Calum H.
5b46f521b7 feat: countdown on server panel 2025-08-05 13:08:18 +01:00
Calum H.
e010e617c1 feat: finish server card layout 2025-08-05 12:42:53 +01:00
Calum H.
0b3cbbd37a fix: light mode medal promotion 2025-08-05 12:20:47 +01:00
IMB11
ea1789ebf6 fix: colors for dark mode only 2025-08-04 18:33:38 +01:00
IMB11
6401a8937b fix: styling changes 2025-08-04 18:08:02 +01:00
Calum H.
2da2b4aec7 feat: medal server card 2025-08-04 14:15:37 +01:00
Calum H.
d1a478dbf2 feat: medal promotion on servers page 2025-08-04 13:19:34 +01:00
Prospector
99493b9917 Updated changelog 2025-08-01 21:31:22 -04:00
IMB11
72a52eb7b1 fix: improve error message for rate limiting (#4101)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-08-01 21:27:25 +00:00
IMB11
b33e12c71d fix: startup settings not visible on hard page refresh/direct load (#4100)
* fix: startup settings not visible on hard page refresh/direct load

* refactor: const func => named
2025-08-01 21:22:22 +00:00
IMB11
82d86839c7 fix: approve status incorrect (#4104) 2025-08-01 20:24:40 +00:00
coolbot
3a20e15340 Coolbot/moderation updates aug1 (#4103)
* oop, all commas!

* Only show slug stuff when needed.

* Move status alerts to top of message, getting rid of separators.

* redist libs message altered, and now shows on plugins too

* Update versions.ts

remove unnecessary import

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>

* Tweak summary formatting msg

* Update license messages to use flink

* reorder link text to match the settings page

* add Description clarity button

---------

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
2025-08-01 20:21:28 +00:00
jade
1c89b84314 fix(moderation): Replace dead modpack link with a valid one in side-types message (#4095) 2025-07-31 17:50:33 +00:00
IMB11
6387fb21c6 feat: Moderation Dashboard Overhaul (#4059)
* feat: Moderation Dashboard Overhaul

* fix: lint issues

* fix: issues

* fix: report layout

* fix: lint

* fix: impl quick replies

* fix: remove test qr

* feat: individual report page + use new backend

* feat: memoize filtering

* feat: apply optimizations to moderation queue

* fix: lint issues

* feat: impl quick reply functionality

* fix: top level await

* fix: dep issue

* fix: dep issue x2

* fix: dep issue

* feat: intl extract

* fix: dev-187

* fix: dev-186 & review project btn

* fix: dev-176

* remove redundant moderation button from user dropdown

* correct a msg and add admin to read filter

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
2025-07-29 21:19:25 +00:00
Alejandro González
c7d0839bfb fix(labrinth): retire Sendy for new email newsletter subscriptions (#4073)
* tweak(frontend): do not sign up for the newsletter by default

* fix(labrinth): retire Sendy for new email newsletter subscriptions
2025-07-29 09:51:50 +00:00
Josiah Glosson
175b90be5a Legacy ping support (#4062)
* Detection of protocol versions before 18w47b

* Refactor old_protocol_versions into protocol_version

* Ping servers closer to how a client of an instance's version would ping a server

* Allow pinging legacy servers from a modern profile in the same way a modern client would

* Ping 1.4.2 through 1.5.2 like a Vanilla client in those versions would when in such an instance
2025-07-28 14:44:34 +00:00
coolbot
13103b4950 various moderation fixes and improvements (#4061)
* Typo correction

* show optimization button when present in additional categories

* add more formatted link shortcuts

* Add info text to env info stage

* Only show gallery relevancy button when relevant.

* add unsupported project type message to versions stage

* Fix misuse of slug message.

* Update unsupported_project.md

* lint fix
2025-07-28 12:56:47 +00:00
Alejandro González
8804478221 fix(frontend): hide subscription button in blog before sub status is determined (#4072) 2025-07-27 20:29:21 +00:00
Emma Alexia
b8982a6d17 Hopefully fix collection visibility once and for all (#4070)
* Hopefully fix collection visibility once and for all

Follow up to #3408 and #3864

* Use same unlisted approach for collections as is used for projects
2025-07-27 18:23:49 +00:00
Emma Alexia
ff88724d01 Allow modification of failed charges on admin billing page (#4045)
* Allow modification of failed charges on admin billing page

Allows cancelling a failed subscription and forcing another charge attempt

* use addNotification
2025-07-27 17:30:16 +00:00
Emma Alexia
7dffb352d5 Fix duplicate "Upload icon Select file" on collections (#4069)
* Fix duplicate "Upload icon Select file" on collections

![lol](https://i.imgur.com/NKfvfQD.png)

* fix lint
2025-07-27 17:27:02 +00:00
Emma Alexia
1df6e29aa1 Ensure server status info is always passed to "My servers" page (#4071)
This took an insanely long time to debug and figure out you would not believe
2025-07-27 17:10:52 +00:00
Emma Alexia
5deb4179ad Re-enable the Moderation tab for projects that are approved (#4067)
By request of the moderation team. This would allow easier access
if, e.g., the moderators tell the author of a metadata problem they
need to correct.
2025-07-27 17:07:39 +00:00
Alejandro González
358cf31c87 feat(labrinth): basic offset pagination for moderation reports and projects (#4063) 2025-07-26 12:32:35 +00:00
Prospector
6db1d66591 else if 2025-07-24 10:38:23 -07:00
Prospector
8052fda840 Bump report limit to 1500 2025-07-24 10:37:01 -07:00
IMB11
15892a88d3 fix: handle identified files properly in the checklist (#4004)
* fix: handle identified files from the backend

* fix: allFiles not being emitted after permissions flow completed

* fix: properly handle identified projects

* fix: jade issues

* fix: import

* fix: issue with perm gen msgs

* fix: incomplete error
2025-07-23 08:34:55 +00:00
Alejandro González
32793c50e1 feat(app): better external browser Modrinth login flow (#4033)
* fix(app-frontend): do not emit exceptions when no loaders are available

* refactor(app): simplify Microsoft login code without functional changes

* feat(app): external browser auth flow for Modrinth account login

* chore: address Clippy lint

* chore(app/oauth_utils): simplify `handle_reply` error handling according to review

* chore(app-lib): simplify `Url` usage out of MC auth module
2025-07-22 22:55:18 +00:00
Alejandro González
0e0ca1971a chore(ci): switch back to upstream cache-cargo-install-action (#4047) 2025-07-22 22:43:04 +00:00
Alejandro González
bb9af18eed perf(docker): cache image builds through cache mounts and GHA cache (#4020)
* perf(docker): cache image builds through cache mounts and GHA cache

* tweak(ci/docker): switch to inline registry cache
2025-07-22 22:31:56 +00:00
Alejandro González
d4516d3527 feat(app): configurable Modrinth endpoints through .env files (#4015) 2025-07-21 22:55:57 +00:00
Josiah Glosson
87de47fe5e Use rust-lld linker on MSVC Windows (#4042)
The latest version of MSVC fails when linking labrinth, making now a perfect opportunity to switch over to the rust-lld linker instead.
2025-07-21 22:35:05 +00:00
Emma Alexia
7d76fe1b6a Add more info about last attempts to admin billing dashboard (#4029) 2025-07-21 08:35:36 +00:00
178 changed files with 6248 additions and 2614 deletions

View File

@@ -2,5 +2,8 @@
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
[build]
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -22,23 +22,26 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Fetch docker metadata
id: docker_meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: ghcr.io/modrinth/daedalus
- name: Login to GitHub Images
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with:
file: ./apps/daedalus_client/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/modrinth/daedalus:main
cache-to: type=inline

View File

@@ -20,23 +20,26 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Fetch docker metadata
id: docker_meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: ghcr.io/modrinth/labrinth
- name: Login to GitHub Images
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with:
file: ./apps/labrinth/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/modrinth/labrinth:main
cache-to: type=inline

View File

@@ -75,7 +75,7 @@ jobs:
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: ⚙️ Set application version
- name: ⚙️ Set application version and environment
shell: bash
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
@@ -84,6 +84,8 @@ jobs:
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8

View File

@@ -52,7 +52,7 @@ jobs:
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install
- name: 🧰 Setup cargo-sqlx
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
uses: taiki-e/cache-cargo-install-action@v2
with:
tool: sqlx-cli
locked: false
@@ -74,6 +74,10 @@ jobs:
cp .env.local .env
sqlx database setup
- name: ⚙️ Set app environment
working-directory: packages/app-lib
run: cp .env.staging .env
- name: 🔍 Lint and test
run: pnpm run ci

47
Cargo.lock generated
View File

@@ -5731,6 +5731,17 @@ dependencies = [
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_macros 0.12.1",
"phf_shared 0.12.1",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.8.0"
@@ -5781,6 +5792,16 @@ dependencies = [
"rand 0.8.5",
]
[[package]]
name = "phf_generator"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b"
dependencies = [
"fastrand 2.3.0",
"phf_shared 0.12.1",
]
[[package]]
name = "phf_macros"
version = "0.10.0"
@@ -5808,6 +5829,19 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "phf_macros"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368"
dependencies = [
"phf_generator 0.12.1",
"phf_shared 0.12.1",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "phf_shared"
version = "0.8.0"
@@ -5835,6 +5869,15 @@ dependencies = [
"siphasher 1.0.1",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher 1.0.1",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -8930,6 +8973,7 @@ dependencies = [
"data-url",
"dirs",
"discord-rich-presence",
"dotenvy",
"dunce",
"either",
"encoding_rs",
@@ -8945,6 +8989,7 @@ dependencies = [
"notify-debouncer-mini",
"p256",
"paste",
"phf 0.12.1",
"png",
"quartz_nbt",
"quick-xml 0.37.5",
@@ -8984,6 +9029,8 @@ dependencies = [
"dashmap",
"either",
"enumset",
"hyper 1.6.0",
"hyper-util",
"native-dialog",
"paste",
"serde",

View File

@@ -67,6 +67,7 @@ heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper = "1.6.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"http1",
"native-tokio",
@@ -98,6 +99,7 @@ notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2"
paste = "1.0.15"
phf = { version = "0.12.1", features = ["macros"] }
png = "0.17.16"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"

View File

@@ -61,9 +61,10 @@ import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
@@ -263,6 +264,8 @@ const incompatibilityWarningModal = ref()
const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
@@ -272,8 +275,24 @@ async function fetchCredentials() {
}
async function signIn() {
await login().catch(handleError)
await fetchCredentials()
modrinthLoginFlowWaitModal.value.show()
try {
await login()
await fetchCredentials()
} catch (error) {
if (
typeof error === 'object' &&
typeof error['message'] === 'string' &&
error.message.includes('Login canceled')
) {
// Not really an error due to being a result of user interaction, show nothing
} else {
handleError(error)
}
} finally {
modrinthLoginFlowWaitModal.value.hide()
}
}
async function logOut() {
@@ -402,6 +421,9 @@ function handleAuxClick(e) {
<Suspense>
<AppSettingsModal ref="settingsModal" />
</Suspense>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
</Suspense>
<Suspense>
<InstanceCreationModal ref="installationModal" />
</Suspense>

View File

@@ -305,12 +305,16 @@ const [
get_game_versions().then(shallowRef).catch(handleError),
get_loaders()
.then((value) =>
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
ref(
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
),
)
.then(ref)
.catch(handleError),
.catch((err) => {
handleError(err)
return ref([])
}),
])
loaders.value.unshift('vanilla')

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> Sign in
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
Please sign in at the browser window that just opened to continue.
</p>
</ModalWrapper>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import {
type ProtocolVersion,
type ServerWorld,
type ServerData,
type WorldWithProfile,
@@ -33,7 +34,7 @@ const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, number | null>>({})
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6
@@ -121,11 +122,8 @@ async function populateJumpBackIn() {
}
})
// fetch each server's data
Promise.all(
servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
),
servers.forEach(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
)
}
@@ -150,8 +148,8 @@ async function populateJumpBackIn() {
.slice(0, MAX_JUMP_BACK_IN)
}
async function refreshServer(address: string, instancePath: string) {
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
function refreshServer(address: string, instancePath: string) {
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
}
async function joinWorld(world: WorldWithProfile) {

View File

@@ -1,7 +1,14 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import type {
ProtocolVersion,
ServerStatus,
ServerWorld,
SingleplayerWorld,
World,
set_world_display_status,
getWorldIdentifier,
} from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import {
useRelativeTime,
@@ -55,7 +62,7 @@ const props = withDefaults(
playingWorld?: boolean
startingInstance?: boolean
supportsQuickPlay?: boolean
currentProtocol?: number | null
currentProtocol?: ProtocolVersion | null
highlighted?: boolean
// Server only
@@ -102,7 +109,8 @@ const serverIncompatible = computed(
!!props.serverStatus &&
!!props.serverStatus.version?.protocol &&
!!props.currentProtocol &&
props.serverStatus.version.protocol !== props.currentProtocol,
(props.serverStatus.version.protocol !== props.currentProtocol.version ||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
)
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)

View File

@@ -16,3 +16,7 @@ export async function logout() {
export async function get() {
return await invoke('plugin:mr-auth|get')
}
export async function cancelLogin() {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}

View File

@@ -51,6 +51,7 @@ export type ServerStatus = {
version?: {
name: string
protocol: number
legacy: boolean
}
favicon?: string
enforces_secure_chat: boolean
@@ -70,11 +71,17 @@ export interface Chat {
export type ServerData = {
refreshing: boolean
lastSuccessfulRefresh?: number
status?: ServerStatus
rawMotd?: string | Chat
renderedMotd?: string
}
export type ProtocolVersion = {
version: number
legacy: boolean
}
export async function get_recent_worlds(
limit: number,
displayStatuses?: DisplayStatus[],
@@ -156,13 +163,13 @@ export async function remove_server_from_profile(path: string, index: number): P
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
}
export async function get_profile_protocol_version(path: string): Promise<number | null> {
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
}
export async function get_server_status(
address: string,
protocolVersion: number | null = null,
protocolVersion: ProtocolVersion | null = null,
): Promise<ServerStatus> {
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
}
@@ -206,30 +213,39 @@ export function isServerWorld(world: World): world is ServerWorld {
export async function refreshServerData(
serverData: ServerData,
protocolVersion: number | null,
protocolVersion: ProtocolVersion | null,
address: string,
): Promise<void> {
const refreshTime = Date.now()
serverData.refreshing = true
await get_server_status(address, protocolVersion)
.then((status) => {
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
// Don't update if there was a more recent successful refresh
return
}
serverData.lastSuccessfulRefresh = Date.now()
serverData.status = status
if (status.description) {
serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description)
}
})
.catch((err) => {
console.error(`Refreshing addr: ${address}`, err)
})
.finally(() => {
serverData.refreshing = false
})
.catch((err) => {
console.error(`Refreshing addr ${address}`, protocolVersion, err)
if (!protocolVersion?.legacy) {
refreshServerData(serverData, { version: 74, legacy: true }, address)
}
})
}
export async function refreshServers(
export function refreshServers(
worlds: World[],
serverData: Record<string, ServerData>,
protocolVersion: number | null,
protocolVersion: ProtocolVersion | null,
) {
const servers = worlds.filter(isServerWorld)
servers.forEach((server) => {
@@ -243,10 +259,8 @@ export async function refreshServers(
})
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
Promise.all(
Object.keys(serverData).map((address) =>
refreshServerData(serverData[address], protocolVersion, address),
),
Object.keys(serverData).forEach((address) =>
refreshServerData(serverData[address], protocolVersion, address),
)
}

View File

@@ -134,6 +134,7 @@ import {
} from '@modrinth/ui'
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
import {
type ProtocolVersion,
type SingleplayerWorld,
type World,
type ServerWorld,
@@ -210,7 +211,9 @@ const worldPlaying = ref<World>()
const worlds = ref<World[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
const protocolVersion = ref<ProtocolVersion | null>(
await get_profile_protocol_version(instance.value.path),
)
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
@@ -246,7 +249,7 @@ async function refreshAllWorlds() {
worlds.value = await refreshWorlds(instance.value.path).finally(
() => (refreshingAll.value = false),
)
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
refreshServers(worlds.value, serverData.value, protocolVersion.value)
const hasNoWorlds = worlds.value.length === 0

View File

@@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?;
println!("Open URL {} in a browser", login.redirect_uri.as_str());
println!("Open URL {} in a browser", login.auth_request_uri.as_str());
println!("Please enter URL code: ");
let mut input = String::new();

View File

@@ -31,6 +31,8 @@ thiserror.workspace = true
daedalus.workspace = true
chrono.workspace = true
either.workspace = true
hyper = { workspace = true, features = ["server"] }
hyper-util.workspace = true
url.workspace = true
urlencoding.workspace = true

View File

@@ -120,7 +120,12 @@ fn main() {
.plugin(
"mr-auth",
InlinedPlugin::new()
.commands(&["modrinth_login", "logout", "get"])
.commands(&[
"modrinth_login",
"logout",
"get",
"cancel_modrinth_login",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),

View File

@@ -33,7 +33,7 @@ pub async fn login<R: Runtime>(
let window = tauri::WebviewWindowBuilder::new(
&app,
"signin",
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err(
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|_| {
theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(),
@@ -77,6 +77,7 @@ pub async fn login<R: Runtime>(
window.close()?;
Ok(None)
}
#[tauri::command]
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?)

View File

@@ -22,6 +22,8 @@ pub mod cache;
pub mod friends;
pub mod worlds;
mod oauth_utils;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
// // Main returnable Theseus GUI error

View File

@@ -1,79 +1,70 @@
use crate::api::Result;
use chrono::{Duration, Utc};
use crate::api::TheseusSerializableError;
use crate::api::oauth_utils;
use tauri::Manager;
use tauri::Runtime;
use tauri::plugin::TauriPlugin;
use tauri::{Manager, Runtime, UserAttentionType};
use tauri_plugin_opener::OpenerExt;
use theseus::prelude::*;
use tokio::sync::oneshot;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("mr-auth")
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,])
.invoke_handler(tauri::generate_handler![
modrinth_login,
logout,
get,
cancel_modrinth_login,
])
.build()
}
#[tauri::command]
pub async fn modrinth_login<R: Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<ModrinthCredentials>> {
let redirect_uri = mr_auth::authenticate_begin_flow();
) -> Result<ModrinthCredentials> {
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
auth_code_recv_socket_tx,
));
let start = Utc::now();
let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
if let Some(window) = app.get_webview_window("modrinth-signin") {
window.close()?;
}
let auth_request_uri = format!(
"{}?launcher=true&ipver={}&port={}",
mr_auth::authenticate_begin_flow(),
if auth_code_recv_socket.is_ipv4() {
"4"
} else {
"6"
},
auth_code_recv_socket.port()
);
let window = tauri::WebviewWindowBuilder::new(
&app,
"modrinth-signin",
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| {
theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(),
app.opener()
.open_url(auth_request_uri, None::<&str>)
.map_err(|e| {
TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError(format!(
"Failed to open auth request URI: {e}"
))
.into(),
)
.as_error()
})?),
)
.min_inner_size(420.0, 632.0)
.inner_size(420.0, 632.0)
.max_inner_size(420.0, 632.0)
.zoom_hotkeys_enabled(false)
.title("Sign into Modrinth")
.always_on_top(true)
.center()
.build()?;
})?;
window.request_user_attention(Some(UserAttentionType::Critical))?;
let Some(auth_code) = auth_code.await.unwrap()? else {
return Err(TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
));
};
while (Utc::now() - start) < Duration::minutes(10) {
if window.title().is_err() {
// user closed window, cancelling flow
return Ok(None);
}
let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
if window
.url()?
.as_str()
.starts_with("https://launcher-files.modrinth.com")
{
let url = window.url()?;
let code = url.query_pairs().find(|(key, _)| key == "code");
window.close()?;
return if let Some((_, code)) = code {
let val = mr_auth::authenticate_finish_flow(&code).await?;
Ok(Some(val))
} else {
Ok(None)
};
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
if let Some(main_window) = app.get_window("main") {
main_window.set_focus().ok();
}
window.close()?;
Ok(None)
Ok(credentials)
}
#[tauri::command]
@@ -85,3 +76,8 @@ pub async fn logout() -> Result<()> {
pub async fn get() -> Result<Option<ModrinthCredentials>> {
Ok(theseus::mr_auth::get_credentials().await?)
}
#[tauri::command]
pub fn cancel_modrinth_login() {
oauth_utils::auth_code_reply::stop_listeners();
}

View File

@@ -0,0 +1,159 @@
//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP
//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and
//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps.
//!
//! This server is needed for the step 4 of the OAuth authentication dance represented in
//! figure 1 of [RFC 8252].
//!
//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/
//!
//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::{LazyLock, Mutex},
time::Duration,
};
use hyper::body::Incoming;
use hyper_util::rt::{TokioIo, TokioTimer};
use theseus::ErrorKind;
use tokio::{
net::TcpListener,
sync::{broadcast, oneshot},
};
static SERVER_SHUTDOWN: LazyLock<broadcast::Sender<()>> =
LazyLock::new(|| broadcast::channel(1024).0);
/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects
/// on a loopback interface with an ephemeral port. The caller can know the bound socket address
/// by listening on the counterpart channel for `listen_socket_tx`.
///
/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned.
pub async fn listen(
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
) -> Result<Option<String>, theseus::Error> {
// IPv4 is tried first for the best compatibility and performance with most systems.
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
// to prevent failures deriving from improper name resolution setup. Any available
// ephemeral port is used to prevent conflicts with other services. This is all as per
// RFC 8252's recommendations
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
];
let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await {
Ok(listener) => {
listen_socket_tx
.send(listener.local_addr().map_err(|e| {
ErrorKind::OtherError(format!(
"Failed to get auth code reply socket address: {e}"
))
.into()
}))
.ok();
listener
}
Err(e) => {
let error_msg =
format!("Failed to bind auth code reply socket: {e}");
listen_socket_tx
.send(Err(ErrorKind::OtherError(error_msg.clone()).into()))
.ok();
return Err(ErrorKind::OtherError(error_msg).into());
}
};
let mut auth_code = Mutex::new(None);
let mut shutdown_notification = SERVER_SHUTDOWN.subscribe();
while auth_code.get_mut().unwrap().is_none() {
let client_socket = tokio::select! {
biased;
_ = shutdown_notification.recv() => {
break;
}
conn_accept_result = listener.accept() => {
match conn_accept_result {
Ok((socket, _)) => socket,
Err(e) => {
tracing::warn!("Failed to accept auth code reply: {e}");
continue;
}
}
}
};
if let Err(e) = hyper::server::conn::http1::Builder::new()
.keep_alive(false)
.header_read_timeout(Duration::from_secs(5))
.timer(TokioTimer::new())
.auto_date_header(false)
.serve_connection(
TokioIo::new(client_socket),
hyper::service::service_fn(|req| handle_reply(req, &auth_code)),
)
.await
{
tracing::warn!("Failed to handle auth code reply: {e}");
}
}
Ok(auth_code.into_inner().unwrap())
}
/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers.
pub fn stop_listeners() {
SERVER_SHUTDOWN.send(()).ok();
}
async fn handle_reply(
req: hyper::Request<Incoming>,
auth_code_out: &Mutex<Option<String>>,
) -> Result<hyper::Response<String>, hyper::http::Error> {
if req.method() != hyper::Method::GET {
return hyper::Response::builder()
.status(hyper::StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET")
.body("".into());
}
// The authorization code is guaranteed to be sent as a "code" query parameter
// in the request URI query string as per RFC 6749 § 4.1.2
let auth_code = req.uri().query().and_then(|query_string| {
query_string
.split('&')
.filter_map(|query_pair| query_pair.split_once('='))
.find_map(|(key, value)| (key == "code").then_some(value))
});
let response = if let Some(auth_code) = auth_code {
*auth_code_out.lock().unwrap() = Some(auth_code.to_string());
hyper::Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "text/html;charset=utf-8")
.body(
include_str!("auth_code_reply/page.html")
.replace("{{title}}", "Success")
.replace("{{message}}", "You have successfully signed in! You can close this page now."),
)
} else {
hyper::Response::builder()
.status(hyper::StatusCode::BAD_REQUEST)
.header("Content-Type", "text/html;charset=utf-8")
.body(
include_str!("auth_code_reply/page.html")
.replace("{{title}}", "Error")
.replace("{{message}}", "Authorization code not found. Please try signing in again."),
)
}?;
Ok(response)
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
//! Assorted utilities for OAuth 2.0 authorization flows.
pub mod auth_code_reply;

View File

@@ -5,8 +5,8 @@ use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata;
use theseus::profile::{QuickPlayType, get_full_path};
use theseus::worlds::{
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
WorldWithProfile,
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
WorldType, WorldWithProfile,
};
use theseus::{profile, worlds};
@@ -183,14 +183,16 @@ pub async fn remove_server_from_profile(
}
#[tauri::command]
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> {
pub async fn get_profile_protocol_version(
path: &str,
) -> Result<Option<ProtocolVersion>> {
Ok(worlds::get_profile_protocol_version(path).await?)
}
#[tauri::command]
pub async fn get_server_status(
address: &str,
protocol_version: Option<i32>,
protocol_version: Option<ProtocolVersion>,
) -> Result<ServerStatus> {
Ok(worlds::get_server_status(address, protocol_version).await?)
}

View File

@@ -63,6 +63,7 @@
"height": 800,
"resizable": true,
"title": "Modrinth App",
"label": "main",
"width": 1280,
"minHeight": 700,
"minWidth": 1100,

View File

@@ -1,9 +1,19 @@
# syntax=docker/dockerfile:1
FROM rust:1.88.0 AS build
WORKDIR /usr/src/daedalus
COPY . .
RUN cargo build --release --package daedalus_client
RUN --mount=type=cache,target=/usr/src/daedalus/target \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry \
cargo build --release --package daedalus_client
FROM build AS artifacts
RUN --mount=type=cache,target=/usr/src/daedalus/target \
mkdir /daedalus \
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
FROM debian:bookworm-slim
@@ -11,7 +21,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
WORKDIR /daedalus_client
COPY --from=artifacts /daedalus /daedalus
CMD /daedalus/daedalus_client
WORKDIR /daedalus_client
CMD ["/daedalus/daedalus_client"]

View File

@@ -59,10 +59,12 @@
"markdown-it": "14.1.0",
"pathe": "^1.1.2",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^4.4.1",
"prettier": "^3.6.2",
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"three": "^0.172.0",
"vue-confetti-explosion": "^1.0.2",
"vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",

View File

@@ -0,0 +1,490 @@
<svg width="1120" height="116" viewBox="0 0 1120 116" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_9092_23263" fill="white">
<path
d="M460.704 -89.7906L418.995 -98.1936C415.591 -98.8731 412.786 -95.5784 414.023 -92.3281L429.187 -52.2419C430.194 -49.5896 433.662 -48.9476 435.498 -51.081L463.062 -83.0993C465.103 -85.4707 463.796 -89.1809 460.711 -89.7994L460.704 -89.7906Z" />
<path
d="M375.343 -163.469L389.792 -123.4C390.965 -120.129 388.124 -116.865 384.731 -117.612L342.9 -126.719C340.131 -127.327 338.986 -130.666 340.823 -132.799L368.386 -164.818C370.428 -167.189 374.284 -166.442 375.351 -163.478L375.343 -163.469Z" />
<path
d="M263.038 -121.213L274.559 -86.7584C275.398 -84.2678 277.512 -82.397 280.082 -81.8845L360.91 -65.7792C363.48 -65.2665 365.6 -63.437 366.485 -60.9671L394.325 16.7081C395.21 19.1779 397.363 21.0052 399.953 21.4584L435.687 27.8069C438.261 28.2613 440.866 27.3445 442.558 25.3785L498.485 -39.587C500.094 -41.456 500.626 -44.0395 499.897 -46.4205L490.583 -76.5881C490.392 -77.2139 489.995 -78.4479 489.551 -79.861C488.599 -82.849 484.745 -83.6556 482.737 -81.2863C481.871 -80.2812 481.082 -79.3643 480.596 -78.8001L430.703 -21.059C428.062 -17.991 423.057 -18.9759 421.68 -22.8226L395.993 -94.9369C395.064 -97.5367 392.837 -99.4584 390.141 -99.9873L315.122 -114.797C311.119 -115.594 309.418 -120.397 312.05 -123.473L361.73 -181.398C362.171 -181.909 362.966 -182.852 363.868 -183.917C365.861 -186.269 364.49 -189.942 361.417 -190.428C360.039 -190.649 358.786 -190.854 358.073 -190.963L326.901 -195.74C324.442 -196.111 321.976 -195.212 320.359 -193.335L264.433 -128.369C262.74 -126.403 262.211 -123.678 263.047 -121.205L263.038 -121.213Z" />
<path
d="M268.307 204.958L310.016 213.361C313.42 214.041 316.226 210.746 314.988 207.496L299.825 167.41C298.817 164.757 295.349 164.115 293.513 166.249L265.949 198.267C263.908 200.639 265.215 204.349 268.3 204.967L268.307 204.958Z" />
<path
d="M353.668 278.637L339.219 238.568C338.046 235.297 340.887 232.033 344.28 232.78L386.111 241.887C388.88 242.495 390.025 245.834 388.189 247.967L360.625 279.986C358.584 282.357 354.727 281.61 353.66 278.646L353.668 278.637Z" />
<path
d="M465.973 236.381L454.452 201.926C453.613 199.436 451.499 197.565 448.93 197.052L368.101 180.947C365.531 180.434 363.411 178.605 362.526 176.135L334.686 98.4597C333.801 95.9899 331.648 94.1626 329.058 93.7094L293.324 87.3609C290.75 86.9065 288.145 87.8232 286.453 89.7892L230.526 154.755C228.917 156.624 228.385 159.207 229.114 161.588L238.428 191.756C238.619 192.382 239.016 193.616 239.46 195.029C240.412 198.017 244.266 198.823 246.274 196.454C247.14 195.449 247.929 194.532 248.415 193.968L298.308 136.227C300.95 133.159 305.954 134.144 307.331 137.99L333.018 210.105C333.947 212.704 336.174 214.626 338.87 215.155L413.889 229.965C417.892 230.762 419.593 235.565 416.961 238.64L367.281 296.566C366.84 297.077 366.045 298.019 365.143 299.085C363.15 301.436 364.521 305.11 367.594 305.596C368.972 305.817 370.225 306.022 370.938 306.131L402.11 310.908C404.569 311.279 407.035 310.38 408.652 308.502L464.579 243.537C466.271 241.571 466.8 238.846 465.964 236.373L465.973 236.381Z" />
<path
d="M440.999 55.1022L399.29 46.6992C395.886 46.0197 393.08 49.3145 394.318 52.5647L409.482 92.6509C410.489 95.3032 413.957 95.9452 415.793 93.8118L443.357 61.7935C445.398 59.4221 444.091 55.7119 441.006 55.0934L440.999 55.1022Z" />
<path
d="M355.638 -18.5761L370.087 21.4924C371.26 24.7638 368.419 28.0279 365.026 27.2811L323.195 18.1735C320.426 17.5658 319.281 14.2267 321.117 12.0934L348.681 -19.925C350.723 -22.2964 354.579 -21.5491 355.646 -18.5849L355.638 -18.5761Z" />
<path
d="M243.333 23.6798L254.854 58.1344C255.693 60.6251 257.807 62.4958 260.376 63.0083L341.205 79.1136C343.775 79.6263 345.895 81.4558 346.78 83.9257L374.62 161.601C375.505 164.071 377.658 165.898 380.248 166.351L415.982 172.7C418.555 173.154 421.161 172.237 422.853 170.271L478.78 105.306C480.389 103.437 480.921 100.853 480.192 98.4723L470.878 68.3047C470.687 67.6789 470.29 66.4449 469.846 65.0319C468.894 62.0438 465.04 61.2373 463.032 63.6066C462.166 64.6116 461.377 65.5285 460.891 66.0927L410.998 123.834C408.357 126.902 403.352 125.917 401.975 122.07L376.288 49.9559C375.359 47.3562 373.132 45.4345 370.436 44.9055L295.417 30.0955C291.414 29.2991 289.713 24.496 292.345 21.4203L342.025 -36.5052C342.466 -37.0165 343.261 -37.9588 344.163 -39.0243C346.156 -41.3759 344.785 -45.049 341.712 -45.535C340.334 -45.7564 339.081 -45.9615 338.368 -46.07L307.196 -50.8473C304.737 -51.2181 302.271 -50.3195 300.654 -48.4417L244.727 16.5238C243.035 18.4898 242.506 21.2145 243.342 23.6875L243.333 23.6798Z" />
<path
d="M693.703 -89.7894L651.995 -98.1924C648.59 -98.8719 645.785 -95.5771 647.023 -92.3269L662.186 -52.2407C663.194 -49.5884 666.661 -48.9464 668.498 -51.0798L696.061 -83.0981C698.103 -85.4695 696.795 -89.1797 693.711 -89.7982L693.703 -89.7894Z" />
<path
d="M608.342 -163.468L622.791 -123.399C623.964 -120.128 621.123 -116.864 617.73 -117.611L575.899 -126.718C573.13 -127.326 571.985 -130.665 573.822 -132.798L601.385 -164.817C603.427 -167.188 607.283 -166.441 608.35 -163.477L608.342 -163.468Z" />
<path
d="M496.037 -121.212L507.559 -86.7572C508.397 -84.2665 510.511 -82.3958 513.081 -81.8833L593.909 -65.778C596.48 -65.2653 598.599 -63.4358 599.484 -60.9659L627.324 16.7093C628.209 19.1791 630.362 21.0064 632.952 21.4596L668.686 27.8081C671.26 28.2625 673.865 27.3458 675.558 25.3798L731.485 -39.5858C733.094 -41.4548 733.626 -44.0383 732.897 -46.4193L723.582 -76.5869C723.392 -77.2127 722.995 -78.4467 722.55 -79.8598C721.599 -82.8478 717.745 -83.6543 715.736 -81.285C714.871 -80.28 714.081 -79.3631 713.596 -78.7989L663.702 -21.0578C661.061 -17.9897 656.056 -18.9746 654.68 -22.8213L628.992 -94.9357C628.063 -97.5354 625.837 -99.4571 623.141 -99.9861L548.121 -114.796C544.118 -115.593 542.417 -120.396 545.05 -123.471L594.73 -181.397C595.17 -181.908 595.966 -182.85 596.867 -183.916C598.861 -186.267 597.49 -189.941 594.416 -190.427C593.038 -190.648 591.785 -190.853 591.072 -190.962L559.901 -195.739C557.441 -196.11 554.975 -195.211 553.359 -193.333L497.432 -128.368C495.739 -126.402 495.211 -123.677 496.046 -121.204L496.037 -121.212Z" />
<path
d="M501.308 204.961L543.017 213.364C546.421 214.043 549.227 210.748 547.989 207.498L532.826 167.412C531.818 164.76 528.35 164.118 526.514 166.251L498.95 198.269C496.909 200.641 498.216 204.351 501.301 204.969L501.308 204.961Z" />
<path
d="M586.669 278.639L572.221 238.57C571.047 235.299 573.888 232.035 577.282 232.782L619.112 241.889C621.882 242.497 623.026 245.836 621.19 247.969L593.626 279.988C591.585 282.359 587.728 281.612 586.662 278.648L586.669 278.639Z" />
<path
d="M698.974 236.383L687.453 201.928C686.615 199.438 684.501 197.567 681.931 197.054L601.102 180.949C598.532 180.436 596.412 178.607 595.527 176.137L567.687 98.4619C566.802 95.9921 564.649 94.1648 562.059 93.7116L526.325 87.3631C523.752 86.9087 521.146 87.8254 519.454 89.7914L463.527 154.757C461.918 156.626 461.386 159.209 462.115 161.59L471.429 191.758C471.62 192.384 472.017 193.618 472.461 195.031C473.413 198.019 477.267 198.826 479.276 196.456C480.141 195.451 480.93 194.534 481.416 193.97L531.309 136.229C533.951 133.161 538.955 134.146 540.332 137.993L566.02 210.107C566.948 212.707 569.175 214.628 571.871 215.157L646.89 229.967C650.893 230.764 652.594 235.567 649.962 238.643L600.282 296.568C599.842 297.079 599.046 298.022 598.144 299.087C596.151 301.439 597.522 305.112 600.595 305.598C601.973 305.819 603.226 306.024 603.939 306.133L635.111 310.91C637.57 311.281 640.036 310.382 641.653 308.504L697.58 243.539C699.272 241.573 699.801 238.848 698.965 236.375L698.974 236.383Z" />
<path
d="M674 55.1032L632.291 46.7001C628.887 46.0206 626.082 49.3155 627.319 52.5657L642.483 92.6519C643.49 95.3041 646.958 95.9462 648.795 93.8128L676.358 61.7945C678.4 59.4231 677.092 55.7129 674.008 55.0944L674 55.1032Z" />
<path
d="M588.639 -18.5751L603.088 21.4934C604.261 24.7648 601.42 28.0289 598.027 27.2821L556.196 18.1745C553.427 17.5667 552.282 14.2277 554.119 12.0943L581.682 -19.924C583.724 -22.2954 587.58 -21.5481 588.647 -18.584L588.639 -18.5751Z" />
<path
d="M476.334 23.6808L487.856 58.1354C488.694 60.626 490.808 62.4968 493.378 63.0093L574.206 79.1146C576.776 79.6273 578.896 81.4568 579.781 83.9267L607.621 161.602C608.506 164.072 610.659 165.899 613.249 166.352L648.983 172.701C651.557 173.155 654.162 172.238 655.855 170.272L711.781 105.307C713.39 103.438 713.922 100.854 713.193 98.4733L703.879 68.3057C703.688 67.6799 703.291 66.4459 702.847 65.0328C701.896 62.0448 698.041 61.2382 696.033 63.6075C695.168 64.6126 694.378 65.5295 693.893 66.0937L643.999 123.835C641.358 126.903 636.353 125.918 634.977 122.071L609.289 49.9569C608.36 47.3571 606.134 45.4354 603.438 44.9065L528.418 30.0964C524.415 29.3 522.714 24.4969 525.346 21.4212L575.027 -36.5042C575.467 -37.0156 576.262 -37.9578 577.164 -39.0234C579.158 -41.3749 577.786 -45.048 574.713 -45.5341C573.335 -45.7554 572.082 -45.9606 571.369 -46.069L540.198 -50.8463C537.738 -51.2171 535.272 -50.3185 533.656 -48.4407L477.729 16.5248C476.036 18.4908 475.508 21.2154 476.343 23.6885L476.334 23.6808Z" />
<path
d="M931.704 -89.7906L889.995 -98.1936C886.591 -98.8731 883.785 -95.5784 885.023 -92.3281L900.186 -52.2419C901.194 -49.5896 904.662 -48.9476 906.498 -51.081L934.062 -83.0993C936.103 -85.4707 934.796 -89.1809 931.711 -89.7994L931.704 -89.7906Z" />
<path
d="M846.343 -163.469L860.791 -123.4C861.964 -120.129 859.124 -116.865 855.73 -117.612L813.9 -126.719C811.13 -127.327 809.986 -130.666 811.822 -132.799L839.386 -164.818C841.427 -167.189 845.284 -166.442 846.35 -163.478L846.343 -163.469Z" />
<path
d="M734.038 -121.213L745.559 -86.7584C746.397 -84.2678 748.511 -82.397 751.081 -81.8845L831.91 -65.7792C834.48 -65.2665 836.6 -63.437 837.485 -60.9671L865.325 16.7081C866.21 19.1779 868.362 21.0052 870.953 21.4584L906.687 27.8069C909.26 28.2613 911.866 27.3445 913.558 25.3785L969.485 -39.587C971.094 -41.456 971.626 -44.0395 970.897 -46.4205L961.583 -76.5881C961.392 -77.2139 960.995 -78.4479 960.551 -79.861C959.599 -82.849 955.745 -83.6556 953.736 -81.2863C952.871 -80.2812 952.082 -79.3643 951.596 -78.8001L901.703 -21.059C899.061 -17.991 894.057 -18.9759 892.68 -22.8226L866.992 -94.9369C866.064 -97.5367 863.837 -99.4584 861.141 -99.9873L786.122 -114.797C782.119 -115.594 780.418 -120.397 783.05 -123.473L832.73 -181.398C833.17 -181.909 833.966 -182.852 834.868 -183.917C836.861 -186.269 835.49 -189.942 832.417 -190.428C831.039 -190.649 829.786 -190.854 829.073 -190.963L797.901 -195.74C795.442 -196.111 792.976 -195.212 791.359 -193.335L735.432 -128.369C733.74 -126.403 733.211 -123.678 734.047 -121.205L734.038 -121.213Z" />
<path
d="M739.307 204.957L781.016 213.36C784.42 214.04 787.225 210.745 785.988 207.495L770.824 167.409C769.817 164.756 766.349 164.114 764.513 166.248L736.949 198.266C734.907 200.638 736.215 204.348 739.299 204.966L739.307 204.957Z" />
<path
d="M824.668 278.636L810.219 238.567C809.046 235.296 811.887 232.032 815.28 232.779L857.111 241.886C859.88 242.494 861.025 245.833 859.188 247.966L831.625 279.985C829.583 282.356 825.727 281.609 824.66 278.645L824.668 278.636Z" />
<path
d="M936.973 236.38L925.452 201.925C924.613 199.435 922.499 197.564 919.929 197.051L839.101 180.946C836.531 180.433 834.411 178.604 833.526 176.134L805.686 98.4587C804.801 95.9889 802.648 94.1616 800.058 93.7084L764.324 87.3599C761.75 86.9055 759.145 87.8223 757.452 89.7883L701.526 154.754C699.917 156.623 699.385 159.206 700.114 161.587L709.428 191.755C709.619 192.381 710.016 193.615 710.46 195.028C711.411 198.016 715.266 198.822 717.274 196.453C718.139 195.448 718.929 194.531 719.415 193.967L769.308 136.226C771.949 133.158 776.954 134.143 778.331 137.989L804.018 210.104C804.947 212.703 807.174 214.625 809.87 215.154L884.889 229.964C888.892 230.761 890.593 235.564 887.961 238.639L838.281 296.565C837.84 297.076 837.045 298.018 836.143 299.084C834.15 301.436 835.521 305.109 838.594 305.595C839.972 305.816 841.225 306.021 841.938 306.13L873.109 310.907C875.569 311.278 878.035 310.379 879.652 308.501L935.578 243.536C937.271 241.57 937.8 238.845 936.964 236.372L936.973 236.38Z" />
<path
d="M911.999 55.102L870.29 46.6989C866.886 46.0194 864.08 49.3142 865.318 52.5645L880.481 92.6507C881.489 95.3029 884.957 95.9449 886.793 93.8116L914.357 61.7933C916.398 59.4218 915.091 55.7117 912.006 55.0932L911.999 55.102Z" />
<path
d="M826.638 -18.5764L841.086 21.4922C842.259 24.7636 839.418 28.0277 836.025 27.2808L794.195 18.1733C791.425 17.5655 790.281 14.2265 792.117 12.0931L819.681 -19.9252C821.722 -22.2966 825.579 -21.5494 826.645 -18.5852L826.638 -18.5764Z" />
<path
d="M714.333 23.6796L725.854 58.1342C726.692 60.6248 728.806 62.4956 731.376 63.0081L812.205 79.1134C814.775 79.6261 816.895 81.4556 817.78 83.9254L845.62 161.601C846.505 164.07 848.657 165.898 851.248 166.351L886.981 172.699C889.555 173.154 892.161 172.237 893.853 170.271L949.78 105.306C951.389 103.437 951.921 100.853 951.192 98.4721L941.878 68.3045C941.687 67.6787 941.29 66.4447 940.846 65.0316C939.894 62.0436 936.04 61.237 934.031 63.6063C933.166 64.6113 932.377 65.5283 931.891 66.0925L881.997 123.834C879.356 126.902 874.352 125.917 872.975 122.07L847.287 49.9557C846.359 47.3559 844.132 45.4342 841.436 44.9053L766.417 30.0952C762.414 29.2988 760.713 24.4957 763.345 21.42L813.025 -36.5054C813.465 -37.0168 814.261 -37.959 815.163 -39.0246C817.156 -41.3761 815.785 -45.0493 812.712 -45.5353C811.333 -45.7567 810.081 -45.9618 809.368 -46.0702L778.196 -50.8475C775.737 -51.2183 773.271 -50.3197 771.654 -48.4419L715.727 16.5236C714.035 18.4896 713.506 21.2142 714.342 23.6873L714.333 23.6796Z" />
<path
d="M1164.7 -89.7906L1122.99 -98.1936C1119.59 -98.8731 1116.78 -95.5784 1118.02 -92.3281L1133.19 -52.2419C1134.19 -49.5896 1137.66 -48.9476 1139.5 -51.081L1167.06 -83.0993C1169.1 -85.4707 1167.79 -89.1809 1164.71 -89.7994L1164.7 -89.7906Z" />
<path
d="M1079.34 -163.469L1093.79 -123.4C1094.96 -120.129 1092.12 -116.865 1088.73 -117.612L1046.9 -126.719C1044.13 -127.327 1042.98 -130.666 1044.82 -132.799L1072.39 -164.818C1074.43 -167.189 1078.28 -166.442 1079.35 -163.478L1079.34 -163.469Z" />
<path
d="M967.037 -121.213L978.558 -86.7584C979.397 -84.2678 981.511 -82.397 984.081 -81.8845L1064.91 -65.7792C1067.48 -65.2665 1069.6 -63.437 1070.48 -60.9671L1098.32 16.7081C1099.21 19.1779 1101.36 21.0052 1103.95 21.4584L1139.69 27.8069C1142.26 28.2613 1144.87 27.3445 1146.56 25.3785L1202.48 -39.587C1204.09 -41.456 1204.63 -44.0395 1203.9 -46.4205L1194.58 -76.5881C1194.39 -77.2139 1193.99 -78.4479 1193.55 -79.861C1192.6 -82.849 1188.74 -83.6556 1186.74 -81.2863C1185.87 -80.2812 1185.08 -79.3643 1184.6 -78.8001L1134.7 -21.059C1132.06 -17.991 1127.06 -18.9759 1125.68 -22.8226L1099.99 -94.9369C1099.06 -97.5367 1096.84 -99.4584 1094.14 -99.9873L1019.12 -114.797C1015.12 -115.594 1013.42 -120.397 1016.05 -123.473L1065.73 -181.398C1066.17 -181.909 1066.97 -182.852 1067.87 -183.917C1069.86 -186.269 1068.49 -189.942 1065.42 -190.428C1064.04 -190.649 1062.79 -190.854 1062.07 -190.963L1030.9 -195.74C1028.44 -196.111 1025.97 -195.212 1024.36 -193.335L968.432 -128.369C966.739 -126.403 966.21 -123.678 967.046 -121.205L967.037 -121.213Z" />
<path
d="M972.308 204.959L1014.02 213.362C1017.42 214.042 1020.23 210.747 1018.99 207.497L1003.83 167.411C1002.82 164.758 999.35 164.116 997.514 166.25L969.95 198.268C967.908 200.639 969.216 204.35 972.3 204.968L972.308 204.959Z" />
<path
d="M1057.67 278.638L1043.22 238.569C1042.05 235.298 1044.89 232.034 1048.28 232.78L1090.11 241.888C1092.88 242.496 1094.03 245.835 1092.19 247.968L1064.63 279.987C1062.58 282.358 1058.73 281.611 1057.66 278.647L1057.67 278.638Z" />
<path
d="M1169.97 236.382L1158.45 201.927C1157.61 199.437 1155.5 197.566 1152.93 197.053L1072.1 180.948C1069.53 180.435 1067.41 178.606 1066.53 176.136L1038.69 98.4607C1037.8 95.9908 1035.65 94.1636 1033.06 93.7104L997.325 87.3618C994.751 86.9075 992.146 87.8242 990.453 89.7902L934.527 154.756C932.918 156.625 932.386 159.208 933.115 161.589L942.429 191.757C942.62 192.383 943.017 193.617 943.461 195.03C944.413 198.018 948.267 198.824 950.275 196.455C951.14 195.45 951.93 194.533 952.416 193.969L1002.31 136.228C1004.95 133.16 1009.95 134.145 1011.33 137.991L1037.02 210.106C1037.95 212.705 1040.17 214.627 1042.87 215.156L1117.89 229.966C1121.89 230.763 1123.59 235.566 1120.96 238.641L1071.28 296.567C1070.84 297.078 1070.05 298.02 1069.14 299.086C1067.15 301.437 1068.52 305.111 1071.6 305.597C1072.97 305.818 1074.23 306.023 1074.94 306.132L1106.11 310.909C1108.57 311.28 1111.04 310.381 1112.65 308.503L1168.58 243.538C1170.27 241.572 1170.8 238.847 1169.96 236.374L1169.97 236.382Z" />
<path
d="M1145 55.102L1103.29 46.6989C1099.89 46.0194 1097.08 49.3142 1098.32 52.5645L1113.48 92.6507C1114.49 95.3029 1117.96 95.9449 1119.79 93.8116L1147.36 61.7933C1149.4 59.4218 1148.09 55.7117 1145.01 55.0932L1145 55.102Z" />
<path
d="M1059.64 -18.5764L1074.09 21.4922C1075.26 24.7636 1072.42 28.0277 1069.03 27.2808L1027.2 18.1733C1024.43 17.5655 1023.28 14.2265 1025.12 12.0931L1052.68 -19.9252C1054.72 -22.2966 1058.58 -21.5494 1059.65 -18.5852L1059.64 -18.5764Z" />
<path
d="M947.334 23.6796L958.855 58.1342C959.694 60.6248 961.808 62.4956 964.377 63.0081L1045.21 79.1134C1047.78 79.6261 1049.9 81.4556 1050.78 83.9254L1078.62 161.601C1079.51 164.07 1081.66 165.898 1084.25 166.351L1119.98 172.699C1122.56 173.154 1125.16 172.237 1126.85 170.271L1182.78 105.306C1184.39 103.437 1184.92 100.853 1184.19 98.4721L1174.88 68.3045C1174.69 67.6787 1174.29 66.4447 1173.85 65.0316C1172.9 62.0436 1169.04 61.237 1167.03 63.6063C1166.17 64.6113 1165.38 65.5283 1164.89 66.0925L1115 123.834C1112.36 126.902 1107.35 125.917 1105.98 122.07L1080.29 49.9557C1079.36 47.3559 1077.13 45.4342 1074.44 44.9053L999.418 30.0952C995.415 29.2988 993.714 24.4957 996.346 21.42L1046.03 -36.5054C1046.47 -37.0168 1047.26 -37.959 1048.16 -39.0246C1050.16 -41.3761 1048.79 -45.0493 1045.71 -45.5353C1044.33 -45.7567 1043.08 -45.9618 1042.37 -46.0702L1011.2 -50.8475C1008.74 -51.2183 1006.27 -50.3197 1004.66 -48.4419L948.728 16.5236C947.036 18.4896 946.507 21.2142 947.343 23.6873L947.334 23.6796Z" />
<path
d="M-10.2954 -89.7842L-52.0041 -98.1873C-55.4084 -98.8668 -58.2138 -95.572 -56.976 -92.3217L-41.8127 -52.2355C-40.805 -49.5833 -37.3374 -48.9413 -35.5009 -51.0746L-7.93733 -83.093C-5.89584 -85.4644 -7.20342 -89.1745 -10.2878 -89.7931L-10.2954 -89.7842Z" />
<path
d="M-95.6563 -163.463L-81.2076 -123.394C-80.0346 -120.123 -82.8755 -116.859 -86.2686 -117.605L-128.099 -126.713C-130.869 -127.321 -132.013 -130.66 -130.177 -132.793L-102.613 -164.811C-100.572 -167.183 -96.7151 -166.436 -95.6487 -163.471L-95.6563 -163.463Z" />
<path
d="M-207.961 -121.207L-196.44 -86.7521C-195.602 -84.2614 -193.488 -82.3906 -190.918 -81.8782L-110.089 -65.7728C-107.519 -65.2602 -105.399 -63.4306 -104.514 -60.9608L-76.6744 16.7144C-75.7891 19.1843 -73.6365 21.0115 -71.0462 21.4648L-35.3125 27.8132C-32.7387 28.2676 -30.1333 27.3509 -28.4408 25.3849L27.486 -39.5807C29.0949 -41.4496 29.627 -44.0332 28.898 -46.4142L19.5838 -76.5818C19.3929 -77.2076 18.996 -78.4415 18.5516 -79.8546C17.6002 -82.8427 13.746 -83.6492 11.7374 -81.2799C10.8722 -80.2749 10.0828 -79.358 9.59708 -78.7937L-40.2965 -21.0526C-42.9377 -17.9846 -47.9423 -18.9695 -49.3189 -22.8162L-75.0067 -94.9306C-75.9355 -97.5303 -78.1619 -99.452 -80.8578 -99.9809L-155.877 -114.791C-159.88 -115.587 -161.581 -120.391 -158.949 -123.466L-109.269 -181.392C-108.829 -181.903 -108.033 -182.845 -107.131 -183.911C-105.138 -186.262 -106.509 -189.935 -109.582 -190.422C-110.961 -190.643 -112.213 -190.848 -112.926 -190.956L-144.098 -195.734C-146.557 -196.105 -149.023 -195.206 -150.64 -193.328L-206.567 -128.363C-208.259 -126.397 -208.788 -123.672 -207.952 -121.199L-207.961 -121.207Z" />
<path
d="M-202.692 204.964L-160.984 213.367C-157.579 214.046 -154.774 210.752 -156.012 207.501L-171.175 167.415C-172.183 164.763 -175.65 164.121 -177.487 166.254L-205.05 198.272C-207.092 200.644 -205.784 204.354 -202.7 204.973L-202.692 204.964Z" />
<path
d="M-117.331 278.642L-131.78 238.574C-132.953 235.302 -130.112 232.038 -126.719 232.785L-84.8883 241.892C-82.119 242.5 -80.9743 245.839 -82.8109 247.973L-110.374 279.991C-112.416 282.362 -116.273 281.615 -117.339 278.651L-117.331 278.642Z" />
<path
d="M-5.0264 236.386L-16.5478 201.932C-17.386 199.441 -19.5 197.57 -22.0699 197.058L-102.898 180.952C-105.469 180.44 -107.588 178.61 -108.474 176.14L-136.313 98.4651C-137.198 95.9952 -139.351 94.168 -141.941 93.7148L-177.675 87.3662C-180.249 86.9119 -182.854 87.8286 -184.547 89.7946L-240.474 154.76C-242.083 156.629 -242.615 159.213 -241.886 161.594L-232.571 191.761C-232.381 192.387 -231.984 193.621 -231.539 195.034C-230.588 198.022 -226.734 198.829 -224.725 196.459C-223.86 195.454 -223.07 194.537 -222.585 193.973L-172.691 136.232C-170.05 133.164 -165.045 134.149 -163.669 137.996L-137.981 210.11C-137.052 212.71 -134.826 214.631 -132.13 215.16L-57.1103 229.971C-53.1076 230.767 -51.4064 235.57 -54.0387 238.646L-103.719 296.571C-104.159 297.083 -104.955 298.025 -105.856 299.09C-107.85 301.442 -106.479 305.115 -103.405 305.601C-102.027 305.822 -100.775 306.028 -100.061 306.136L-68.8898 310.913C-66.4306 311.284 -63.9642 310.385 -62.3477 308.508L-6.42088 243.542C-4.72842 241.576 -4.19972 238.852 -5.03531 236.378L-5.0264 236.386Z" />
<path
d="M-30.0004 55.1083L-71.7092 46.7053C-75.1135 46.0258 -77.9188 49.3206 -76.6811 52.5708L-61.5178 92.657C-60.5101 95.3093 -57.0425 95.9513 -55.206 93.8179L-27.6424 61.7996C-25.6009 59.4282 -26.9085 55.7181 -29.9929 55.0995L-30.0004 55.1083Z" />
<path
d="M-115.361 -18.57L-100.913 21.4985C-99.7397 24.7699 -102.581 28.034 -105.974 27.2872L-147.804 18.1796C-150.574 17.5719 -151.718 14.2328 -149.882 12.0995L-122.318 -19.9189C-120.277 -22.2903 -116.42 -21.543 -115.354 -18.5788L-115.361 -18.57Z" />
<path
d="M-227.666 23.6859L-216.145 58.1405C-215.307 60.6312 -213.193 62.5019 -210.623 63.0144L-129.794 79.1198C-127.224 79.6324 -125.104 81.4619 -124.219 83.9318L-96.3795 161.607C-95.4942 164.077 -93.3416 165.904 -90.7513 166.357L-55.0176 172.706C-52.4438 173.16 -49.8384 172.243 -48.1459 170.277L7.78091 105.312C9.38986 103.443 9.9219 100.859 9.19295 98.4784L-0.121262 68.3108C-0.312151 67.685 -0.709083 66.451 -1.15346 65.038C-2.10493 62.0499 -5.95909 61.2434 -7.96773 63.6127C-8.83292 64.6177 -9.62228 65.5346 -10.108 66.0988L-60.0016 123.84C-62.6428 126.908 -67.6474 125.923 -69.0239 122.076L-94.7117 49.962C-95.6405 47.3623 -97.8669 45.4406 -100.563 44.9116L-175.582 30.1016C-179.585 29.3052 -181.286 24.5021 -178.654 21.4264L-128.974 -36.4991C-128.534 -37.0104 -127.738 -37.9527 -126.836 -39.0182C-124.843 -41.3698 -126.214 -45.0429 -129.288 -45.5289C-130.666 -45.7503 -131.918 -45.9554 -132.631 -46.0639L-163.803 -50.8412C-166.262 -51.212 -168.728 -50.3134 -170.345 -48.4356L-226.272 16.5299C-227.964 18.4959 -228.493 21.2206 -227.657 23.6936L-227.666 23.6859Z" />
<path
d="M222.706 -89.7842L180.997 -98.1873C177.593 -98.8668 174.787 -95.572 176.025 -92.3217L191.188 -52.2355C192.196 -49.5833 195.663 -48.9413 197.5 -51.0746L225.064 -83.093C227.105 -85.4644 225.798 -89.1745 222.713 -89.7931L222.706 -89.7842Z" />
<path
d="M137.345 -163.463L151.793 -123.394C152.966 -120.123 150.125 -116.859 146.732 -117.605L104.902 -126.713C102.132 -127.321 100.988 -130.66 102.824 -132.793L130.388 -164.811C132.429 -167.183 136.286 -166.436 137.352 -163.471L137.345 -163.463Z" />
<path
d="M25.0397 -121.207L36.5611 -86.7521C37.3993 -84.2614 39.5133 -82.3906 42.0832 -81.8782L122.912 -65.7728C125.482 -65.2602 127.602 -63.4306 128.487 -60.9608L156.327 16.7144C157.212 19.1843 159.364 21.0115 161.955 21.4648L197.688 27.8132C200.262 28.2676 202.868 27.3509 204.56 25.3849L260.487 -39.5807C262.096 -41.4496 262.628 -44.0332 261.899 -46.4142L252.585 -76.5818C252.394 -77.2076 251.997 -78.4415 251.553 -79.8546C250.601 -82.8427 246.747 -83.6492 244.738 -81.2799C243.873 -80.2749 243.084 -79.358 242.598 -78.7937L192.704 -21.0526C190.063 -17.9846 185.059 -18.9695 183.682 -22.8162L157.994 -94.9306C157.066 -97.5303 154.839 -99.452 152.143 -99.9809L77.1236 -114.791C73.1209 -115.587 71.4197 -120.391 74.052 -123.466L123.732 -181.392C124.172 -181.903 124.968 -182.845 125.87 -183.911C127.863 -186.262 126.492 -189.935 123.418 -190.422C122.04 -190.643 120.788 -190.848 120.075 -190.956L88.9031 -195.734C86.4439 -196.105 83.9776 -195.206 82.361 -193.328L26.4342 -128.363C24.7417 -126.397 24.213 -123.672 25.0486 -121.199L25.0397 -121.207Z" />
<path
d="M30.3106 204.966L72.0193 213.369C75.4237 214.048 78.229 210.753 76.9913 207.503L61.828 167.417C60.8203 164.765 57.3527 164.123 55.5162 166.256L27.9526 198.274C25.9111 200.646 27.2187 204.356 30.303 204.975L30.3106 204.966Z" />
<path
d="M115.672 278.644L101.223 238.576C100.05 235.304 102.891 232.04 106.284 232.787L148.115 241.894C150.884 242.502 152.029 245.841 150.192 247.975L122.628 279.993C120.587 282.364 116.73 281.617 115.664 278.653L115.672 278.644Z" />
<path
d="M227.977 236.388L216.455 201.934C215.617 199.443 213.503 197.572 210.933 197.06L130.104 180.954C127.534 180.442 125.415 178.612 124.529 176.142L96.6897 98.467C95.8044 95.9972 93.6518 94.17 91.0615 93.7167L55.3278 87.3682C52.754 86.9138 50.1486 87.8306 48.4561 89.7966L-7.47073 154.762C-9.07968 156.631 -9.61172 159.215 -8.88277 161.596L0.431442 191.763C0.622332 192.389 1.01926 193.623 1.46364 195.036C2.41511 198.024 6.26927 198.831 8.27791 196.461C9.1431 195.456 9.93247 194.539 10.4182 193.975L60.3118 136.234C62.953 133.166 67.9575 134.151 69.3341 137.998L95.0219 210.112C95.9507 212.712 98.1771 214.633 100.873 215.162L175.893 229.972C179.895 230.769 181.597 235.572 178.964 238.648L129.284 296.573C128.844 297.084 128.048 298.027 127.147 299.092C125.153 301.444 126.524 305.117 129.598 305.603C130.976 305.824 132.228 306.029 132.942 306.138L164.113 310.915C166.572 311.286 169.039 310.387 170.655 308.51L226.582 243.544C228.274 241.578 228.803 238.853 227.968 236.38L227.977 236.388Z" />
<path
d="M203.002 55.1083L161.294 46.7053C157.889 46.0258 155.084 49.3206 156.322 52.5708L171.485 92.657C172.493 95.3093 175.96 95.9513 177.797 93.8179L205.361 61.7996C207.402 59.4282 206.094 55.7181 203.01 55.0995L203.002 55.1083Z" />
<path
d="M117.642 -18.57L132.09 21.4985C133.263 24.7699 130.422 28.034 127.029 27.2872L85.1985 18.1796C82.4292 17.5719 81.2845 14.2328 83.121 12.0995L110.685 -19.9189C112.726 -22.2903 116.583 -21.543 117.649 -18.5788L117.642 -18.57Z" />
<path
d="M5.33659 23.6859L16.858 58.1405C17.6961 60.6312 19.8102 62.5019 22.3801 63.0144L103.209 79.1198C105.779 79.6324 107.898 81.4619 108.784 83.9318L136.623 161.607C137.509 164.077 139.661 165.904 142.252 166.357L177.985 172.706C180.559 173.16 183.165 172.243 184.857 170.277L240.784 105.312C242.393 103.443 242.925 100.859 242.196 98.4784L232.882 68.3108C232.691 67.685 232.294 66.451 231.849 65.038C230.898 62.0499 227.044 61.2434 225.035 63.6127C224.17 64.6177 223.381 65.5346 222.895 66.0988L173.001 123.84C170.36 126.908 165.356 125.923 163.979 122.076L138.291 49.962C137.362 47.3623 135.136 45.4406 132.44 44.9116L57.4205 30.1016C53.4178 29.3052 51.7166 24.5021 54.3488 21.4264L104.029 -36.4991C104.469 -37.0104 105.265 -37.9527 106.167 -39.0182C108.16 -41.3698 106.789 -45.0429 103.715 -45.5289C102.337 -45.7503 101.085 -45.9554 100.372 -46.0639L69.2 -50.8412C66.7408 -51.212 64.2744 -50.3134 62.6579 -48.4356L6.73106 16.5299C5.0386 18.4959 4.5099 21.2206 5.34549 23.6936L5.33659 23.6859Z" />
</mask>
<path
d="M460.704 -89.7906L418.995 -98.1936C415.591 -98.8731 412.786 -95.5784 414.023 -92.3281L429.187 -52.2419C430.194 -49.5896 433.662 -48.9476 435.498 -51.081L463.062 -83.0993C465.103 -85.4707 463.796 -89.1809 460.711 -89.7994L460.704 -89.7906Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M375.343 -163.469L389.792 -123.4C390.965 -120.129 388.124 -116.865 384.731 -117.612L342.9 -126.719C340.131 -127.327 338.986 -130.666 340.823 -132.799L368.386 -164.818C370.428 -167.189 374.284 -166.442 375.351 -163.478L375.343 -163.469Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M263.038 -121.213L274.559 -86.7584C275.398 -84.2678 277.512 -82.397 280.082 -81.8845L360.91 -65.7792C363.48 -65.2665 365.6 -63.437 366.485 -60.9671L394.325 16.7081C395.21 19.1779 397.363 21.0052 399.953 21.4584L435.687 27.8069C438.261 28.2613 440.866 27.3445 442.558 25.3785L498.485 -39.587C500.094 -41.456 500.626 -44.0395 499.897 -46.4205L490.583 -76.5881C490.392 -77.2139 489.995 -78.4479 489.551 -79.861C488.599 -82.849 484.745 -83.6556 482.737 -81.2863C481.871 -80.2812 481.082 -79.3643 480.596 -78.8001L430.703 -21.059C428.062 -17.991 423.057 -18.9759 421.68 -22.8226L395.993 -94.9369C395.064 -97.5367 392.837 -99.4584 390.141 -99.9873L315.122 -114.797C311.119 -115.594 309.418 -120.397 312.05 -123.473L361.73 -181.398C362.171 -181.909 362.966 -182.852 363.868 -183.917C365.861 -186.269 364.49 -189.942 361.417 -190.428C360.039 -190.649 358.786 -190.854 358.073 -190.963L326.901 -195.74C324.442 -196.111 321.976 -195.212 320.359 -193.335L264.433 -128.369C262.74 -126.403 262.211 -123.678 263.047 -121.205L263.038 -121.213Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M268.307 204.958L310.016 213.361C313.42 214.041 316.226 210.746 314.988 207.496L299.825 167.41C298.817 164.757 295.349 164.115 293.513 166.249L265.949 198.267C263.908 200.639 265.215 204.349 268.3 204.967L268.307 204.958Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M353.668 278.637L339.219 238.568C338.046 235.297 340.887 232.033 344.28 232.78L386.111 241.887C388.88 242.495 390.025 245.834 388.189 247.967L360.625 279.986C358.584 282.357 354.727 281.61 353.66 278.646L353.668 278.637Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M465.973 236.381L454.452 201.926C453.613 199.436 451.499 197.565 448.93 197.052L368.101 180.947C365.531 180.434 363.411 178.605 362.526 176.135L334.686 98.4597C333.801 95.9899 331.648 94.1626 329.058 93.7094L293.324 87.3609C290.75 86.9065 288.145 87.8232 286.453 89.7892L230.526 154.755C228.917 156.624 228.385 159.207 229.114 161.588L238.428 191.756C238.619 192.382 239.016 193.616 239.46 195.029C240.412 198.017 244.266 198.823 246.274 196.454C247.14 195.449 247.929 194.532 248.415 193.968L298.308 136.227C300.95 133.159 305.954 134.144 307.331 137.99L333.018 210.105C333.947 212.704 336.174 214.626 338.87 215.155L413.889 229.965C417.892 230.762 419.593 235.565 416.961 238.64L367.281 296.566C366.84 297.077 366.045 298.019 365.143 299.085C363.15 301.436 364.521 305.11 367.594 305.596C368.972 305.817 370.225 306.022 370.938 306.131L402.11 310.908C404.569 311.279 407.035 310.38 408.652 308.502L464.579 243.537C466.271 241.571 466.8 238.846 465.964 236.373L465.973 236.381Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M440.999 55.1022L399.29 46.6992C395.886 46.0197 393.08 49.3145 394.318 52.5647L409.482 92.6509C410.489 95.3032 413.957 95.9452 415.793 93.8118L443.357 61.7935C445.398 59.4221 444.091 55.7119 441.006 55.0934L440.999 55.1022Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M355.638 -18.5761L370.087 21.4924C371.26 24.7638 368.419 28.0279 365.026 27.2811L323.195 18.1735C320.426 17.5658 319.281 14.2267 321.117 12.0934L348.681 -19.925C350.723 -22.2964 354.579 -21.5491 355.646 -18.5849L355.638 -18.5761Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M243.333 23.6798L254.854 58.1344C255.693 60.6251 257.807 62.4958 260.376 63.0083L341.205 79.1136C343.775 79.6263 345.895 81.4558 346.78 83.9257L374.62 161.601C375.505 164.071 377.658 165.898 380.248 166.351L415.982 172.7C418.555 173.154 421.161 172.237 422.853 170.271L478.78 105.306C480.389 103.437 480.921 100.853 480.192 98.4723L470.878 68.3047C470.687 67.6789 470.29 66.4449 469.846 65.0319C468.894 62.0438 465.04 61.2373 463.032 63.6066C462.166 64.6116 461.377 65.5285 460.891 66.0927L410.998 123.834C408.357 126.902 403.352 125.917 401.975 122.07L376.288 49.9559C375.359 47.3562 373.132 45.4345 370.436 44.9055L295.417 30.0955C291.414 29.2991 289.713 24.496 292.345 21.4203L342.025 -36.5052C342.466 -37.0165 343.261 -37.9588 344.163 -39.0243C346.156 -41.3759 344.785 -45.049 341.712 -45.535C340.334 -45.7564 339.081 -45.9615 338.368 -46.07L307.196 -50.8473C304.737 -51.2181 302.271 -50.3195 300.654 -48.4417L244.727 16.5238C243.035 18.4898 242.506 21.2145 243.342 23.6875L243.333 23.6798Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M693.703 -89.7894L651.995 -98.1924C648.59 -98.8719 645.785 -95.5771 647.023 -92.3269L662.186 -52.2407C663.194 -49.5884 666.661 -48.9464 668.498 -51.0798L696.061 -83.0981C698.103 -85.4695 696.795 -89.1797 693.711 -89.7982L693.703 -89.7894Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M608.342 -163.468L622.791 -123.399C623.964 -120.128 621.123 -116.864 617.73 -117.611L575.899 -126.718C573.13 -127.326 571.985 -130.665 573.822 -132.798L601.385 -164.817C603.427 -167.188 607.283 -166.441 608.35 -163.477L608.342 -163.468Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M496.037 -121.212L507.559 -86.7572C508.397 -84.2665 510.511 -82.3958 513.081 -81.8833L593.909 -65.778C596.48 -65.2653 598.599 -63.4358 599.484 -60.9659L627.324 16.7093C628.209 19.1791 630.362 21.0064 632.952 21.4596L668.686 27.8081C671.26 28.2625 673.865 27.3458 675.558 25.3798L731.485 -39.5858C733.094 -41.4548 733.626 -44.0383 732.897 -46.4193L723.582 -76.5869C723.392 -77.2127 722.995 -78.4467 722.55 -79.8598C721.599 -82.8478 717.745 -83.6543 715.736 -81.285C714.871 -80.28 714.081 -79.3631 713.596 -78.7989L663.702 -21.0578C661.061 -17.9897 656.056 -18.9746 654.68 -22.8213L628.992 -94.9357C628.063 -97.5354 625.837 -99.4571 623.141 -99.9861L548.121 -114.796C544.118 -115.593 542.417 -120.396 545.05 -123.471L594.73 -181.397C595.17 -181.908 595.966 -182.85 596.867 -183.916C598.861 -186.267 597.49 -189.941 594.416 -190.427C593.038 -190.648 591.785 -190.853 591.072 -190.962L559.901 -195.739C557.441 -196.11 554.975 -195.211 553.359 -193.333L497.432 -128.368C495.739 -126.402 495.211 -123.677 496.046 -121.204L496.037 -121.212Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M501.308 204.961L543.017 213.364C546.421 214.043 549.227 210.748 547.989 207.498L532.826 167.412C531.818 164.76 528.35 164.118 526.514 166.251L498.95 198.269C496.909 200.641 498.216 204.351 501.301 204.969L501.308 204.961Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M586.669 278.639L572.221 238.57C571.047 235.299 573.888 232.035 577.282 232.782L619.112 241.889C621.882 242.497 623.026 245.836 621.19 247.969L593.626 279.988C591.585 282.359 587.728 281.612 586.662 278.648L586.669 278.639Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M698.974 236.383L687.453 201.928C686.615 199.438 684.501 197.567 681.931 197.054L601.102 180.949C598.532 180.436 596.412 178.607 595.527 176.137L567.687 98.4619C566.802 95.9921 564.649 94.1648 562.059 93.7116L526.325 87.3631C523.752 86.9087 521.146 87.8254 519.454 89.7914L463.527 154.757C461.918 156.626 461.386 159.209 462.115 161.59L471.429 191.758C471.62 192.384 472.017 193.618 472.461 195.031C473.413 198.019 477.267 198.826 479.276 196.456C480.141 195.451 480.93 194.534 481.416 193.97L531.309 136.229C533.951 133.161 538.955 134.146 540.332 137.993L566.02 210.107C566.948 212.707 569.175 214.628 571.871 215.157L646.89 229.967C650.893 230.764 652.594 235.567 649.962 238.643L600.282 296.568C599.842 297.079 599.046 298.022 598.144 299.087C596.151 301.439 597.522 305.112 600.595 305.598C601.973 305.819 603.226 306.024 603.939 306.133L635.111 310.91C637.57 311.281 640.036 310.382 641.653 308.504L697.58 243.539C699.272 241.573 699.801 238.848 698.965 236.375L698.974 236.383Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M674 55.1032L632.291 46.7001C628.887 46.0206 626.082 49.3155 627.319 52.5657L642.483 92.6519C643.49 95.3041 646.958 95.9462 648.795 93.8128L676.358 61.7945C678.4 59.4231 677.092 55.7129 674.008 55.0944L674 55.1032Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M588.639 -18.5751L603.088 21.4934C604.261 24.7648 601.42 28.0289 598.027 27.2821L556.196 18.1745C553.427 17.5667 552.282 14.2277 554.119 12.0943L581.682 -19.924C583.724 -22.2954 587.58 -21.5481 588.647 -18.584L588.639 -18.5751Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M476.334 23.6808L487.856 58.1354C488.694 60.626 490.808 62.4968 493.378 63.0093L574.206 79.1146C576.776 79.6273 578.896 81.4568 579.781 83.9267L607.621 161.602C608.506 164.072 610.659 165.899 613.249 166.352L648.983 172.701C651.557 173.155 654.162 172.238 655.855 170.272L711.781 105.307C713.39 103.438 713.922 100.854 713.193 98.4733L703.879 68.3057C703.688 67.6799 703.291 66.4459 702.847 65.0328C701.896 62.0448 698.041 61.2382 696.033 63.6075C695.168 64.6126 694.378 65.5295 693.893 66.0937L643.999 123.835C641.358 126.903 636.353 125.918 634.977 122.071L609.289 49.9569C608.36 47.3571 606.134 45.4354 603.438 44.9065L528.418 30.0964C524.415 29.3 522.714 24.4969 525.346 21.4212L575.027 -36.5042C575.467 -37.0156 576.262 -37.9578 577.164 -39.0234C579.158 -41.3749 577.786 -45.048 574.713 -45.5341C573.335 -45.7554 572.082 -45.9606 571.369 -46.069L540.198 -50.8463C537.738 -51.2171 535.272 -50.3185 533.656 -48.4407L477.729 16.5248C476.036 18.4908 475.508 21.2154 476.343 23.6885L476.334 23.6808Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M931.704 -89.7906L889.995 -98.1936C886.591 -98.8731 883.785 -95.5784 885.023 -92.3281L900.186 -52.2419C901.194 -49.5896 904.662 -48.9476 906.498 -51.081L934.062 -83.0993C936.103 -85.4707 934.796 -89.1809 931.711 -89.7994L931.704 -89.7906Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M846.343 -163.469L860.791 -123.4C861.964 -120.129 859.124 -116.865 855.73 -117.612L813.9 -126.719C811.13 -127.327 809.986 -130.666 811.822 -132.799L839.386 -164.818C841.427 -167.189 845.284 -166.442 846.35 -163.478L846.343 -163.469Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M734.038 -121.213L745.559 -86.7584C746.397 -84.2678 748.511 -82.397 751.081 -81.8845L831.91 -65.7792C834.48 -65.2665 836.6 -63.437 837.485 -60.9671L865.325 16.7081C866.21 19.1779 868.362 21.0052 870.953 21.4584L906.687 27.8069C909.26 28.2613 911.866 27.3445 913.558 25.3785L969.485 -39.587C971.094 -41.456 971.626 -44.0395 970.897 -46.4205L961.583 -76.5881C961.392 -77.2139 960.995 -78.4479 960.551 -79.861C959.599 -82.849 955.745 -83.6556 953.736 -81.2863C952.871 -80.2812 952.082 -79.3643 951.596 -78.8001L901.703 -21.059C899.061 -17.991 894.057 -18.9759 892.68 -22.8226L866.992 -94.9369C866.064 -97.5367 863.837 -99.4584 861.141 -99.9873L786.122 -114.797C782.119 -115.594 780.418 -120.397 783.05 -123.473L832.73 -181.398C833.17 -181.909 833.966 -182.852 834.868 -183.917C836.861 -186.269 835.49 -189.942 832.417 -190.428C831.039 -190.649 829.786 -190.854 829.073 -190.963L797.901 -195.74C795.442 -196.111 792.976 -195.212 791.359 -193.335L735.432 -128.369C733.74 -126.403 733.211 -123.678 734.047 -121.205L734.038 -121.213Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M739.307 204.957L781.016 213.36C784.42 214.04 787.225 210.745 785.988 207.495L770.824 167.409C769.817 164.756 766.349 164.114 764.513 166.248L736.949 198.266C734.907 200.638 736.215 204.348 739.299 204.966L739.307 204.957Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M824.668 278.636L810.219 238.567C809.046 235.296 811.887 232.032 815.28 232.779L857.111 241.886C859.88 242.494 861.025 245.833 859.188 247.966L831.625 279.985C829.583 282.356 825.727 281.609 824.66 278.645L824.668 278.636Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M936.973 236.38L925.452 201.925C924.613 199.435 922.499 197.564 919.929 197.051L839.101 180.946C836.531 180.433 834.411 178.604 833.526 176.134L805.686 98.4587C804.801 95.9889 802.648 94.1616 800.058 93.7084L764.324 87.3599C761.75 86.9055 759.145 87.8223 757.452 89.7883L701.526 154.754C699.917 156.623 699.385 159.206 700.114 161.587L709.428 191.755C709.619 192.381 710.016 193.615 710.46 195.028C711.411 198.016 715.266 198.822 717.274 196.453C718.139 195.448 718.929 194.531 719.415 193.967L769.308 136.226C771.949 133.158 776.954 134.143 778.331 137.989L804.018 210.104C804.947 212.703 807.174 214.625 809.87 215.154L884.889 229.964C888.892 230.761 890.593 235.564 887.961 238.639L838.281 296.565C837.84 297.076 837.045 298.018 836.143 299.084C834.15 301.436 835.521 305.109 838.594 305.595C839.972 305.816 841.225 306.021 841.938 306.13L873.109 310.907C875.569 311.278 878.035 310.379 879.652 308.501L935.578 243.536C937.271 241.57 937.8 238.845 936.964 236.372L936.973 236.38Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M911.999 55.102L870.29 46.6989C866.886 46.0194 864.08 49.3142 865.318 52.5645L880.481 92.6507C881.489 95.3029 884.957 95.9449 886.793 93.8116L914.357 61.7933C916.398 59.4218 915.091 55.7117 912.006 55.0932L911.999 55.102Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M826.638 -18.5764L841.086 21.4922C842.259 24.7636 839.418 28.0277 836.025 27.2808L794.195 18.1733C791.425 17.5655 790.281 14.2265 792.117 12.0931L819.681 -19.9252C821.722 -22.2966 825.579 -21.5494 826.645 -18.5852L826.638 -18.5764Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M714.333 23.6796L725.854 58.1342C726.692 60.6248 728.806 62.4956 731.376 63.0081L812.205 79.1134C814.775 79.6261 816.895 81.4556 817.78 83.9254L845.62 161.601C846.505 164.07 848.657 165.898 851.248 166.351L886.981 172.699C889.555 173.154 892.161 172.237 893.853 170.271L949.78 105.306C951.389 103.437 951.921 100.853 951.192 98.4721L941.878 68.3045C941.687 67.6787 941.29 66.4447 940.846 65.0316C939.894 62.0436 936.04 61.237 934.031 63.6063C933.166 64.6113 932.377 65.5283 931.891 66.0925L881.997 123.834C879.356 126.902 874.352 125.917 872.975 122.07L847.287 49.9557C846.359 47.3559 844.132 45.4342 841.436 44.9053L766.417 30.0952C762.414 29.2988 760.713 24.4957 763.345 21.42L813.025 -36.5054C813.465 -37.0168 814.261 -37.959 815.163 -39.0246C817.156 -41.3761 815.785 -45.0493 812.712 -45.5353C811.333 -45.7567 810.081 -45.9618 809.368 -46.0702L778.196 -50.8475C775.737 -51.2183 773.271 -50.3197 771.654 -48.4419L715.727 16.5236C714.035 18.4896 713.506 21.2142 714.342 23.6873L714.333 23.6796Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1164.7 -89.7906L1122.99 -98.1936C1119.59 -98.8731 1116.78 -95.5784 1118.02 -92.3281L1133.19 -52.2419C1134.19 -49.5896 1137.66 -48.9476 1139.5 -51.081L1167.06 -83.0993C1169.1 -85.4707 1167.79 -89.1809 1164.71 -89.7994L1164.7 -89.7906Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1079.34 -163.469L1093.79 -123.4C1094.96 -120.129 1092.12 -116.865 1088.73 -117.612L1046.9 -126.719C1044.13 -127.327 1042.98 -130.666 1044.82 -132.799L1072.39 -164.818C1074.43 -167.189 1078.28 -166.442 1079.35 -163.478L1079.34 -163.469Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M967.037 -121.213L978.558 -86.7584C979.397 -84.2678 981.511 -82.397 984.081 -81.8845L1064.91 -65.7792C1067.48 -65.2665 1069.6 -63.437 1070.48 -60.9671L1098.32 16.7081C1099.21 19.1779 1101.36 21.0052 1103.95 21.4584L1139.69 27.8069C1142.26 28.2613 1144.87 27.3445 1146.56 25.3785L1202.48 -39.587C1204.09 -41.456 1204.63 -44.0395 1203.9 -46.4205L1194.58 -76.5881C1194.39 -77.2139 1193.99 -78.4479 1193.55 -79.861C1192.6 -82.849 1188.74 -83.6556 1186.74 -81.2863C1185.87 -80.2812 1185.08 -79.3643 1184.6 -78.8001L1134.7 -21.059C1132.06 -17.991 1127.06 -18.9759 1125.68 -22.8226L1099.99 -94.9369C1099.06 -97.5367 1096.84 -99.4584 1094.14 -99.9873L1019.12 -114.797C1015.12 -115.594 1013.42 -120.397 1016.05 -123.473L1065.73 -181.398C1066.17 -181.909 1066.97 -182.852 1067.87 -183.917C1069.86 -186.269 1068.49 -189.942 1065.42 -190.428C1064.04 -190.649 1062.79 -190.854 1062.07 -190.963L1030.9 -195.74C1028.44 -196.111 1025.97 -195.212 1024.36 -193.335L968.432 -128.369C966.739 -126.403 966.21 -123.678 967.046 -121.205L967.037 -121.213Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M972.308 204.959L1014.02 213.362C1017.42 214.042 1020.23 210.747 1018.99 207.497L1003.83 167.411C1002.82 164.758 999.35 164.116 997.514 166.25L969.95 198.268C967.908 200.639 969.216 204.35 972.3 204.968L972.308 204.959Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1057.67 278.638L1043.22 238.569C1042.05 235.298 1044.89 232.034 1048.28 232.78L1090.11 241.888C1092.88 242.496 1094.03 245.835 1092.19 247.968L1064.63 279.987C1062.58 282.358 1058.73 281.611 1057.66 278.647L1057.67 278.638Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1169.97 236.382L1158.45 201.927C1157.61 199.437 1155.5 197.566 1152.93 197.053L1072.1 180.948C1069.53 180.435 1067.41 178.606 1066.53 176.136L1038.69 98.4607C1037.8 95.9908 1035.65 94.1636 1033.06 93.7104L997.325 87.3618C994.751 86.9075 992.146 87.8242 990.453 89.7902L934.527 154.756C932.918 156.625 932.386 159.208 933.115 161.589L942.429 191.757C942.62 192.383 943.017 193.617 943.461 195.03C944.413 198.018 948.267 198.824 950.275 196.455C951.14 195.45 951.93 194.533 952.416 193.969L1002.31 136.228C1004.95 133.16 1009.95 134.145 1011.33 137.991L1037.02 210.106C1037.95 212.705 1040.17 214.627 1042.87 215.156L1117.89 229.966C1121.89 230.763 1123.59 235.566 1120.96 238.641L1071.28 296.567C1070.84 297.078 1070.05 298.02 1069.14 299.086C1067.15 301.437 1068.52 305.111 1071.6 305.597C1072.97 305.818 1074.23 306.023 1074.94 306.132L1106.11 310.909C1108.57 311.28 1111.04 310.381 1112.65 308.503L1168.58 243.538C1170.27 241.572 1170.8 238.847 1169.96 236.374L1169.97 236.382Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1145 55.102L1103.29 46.6989C1099.89 46.0194 1097.08 49.3142 1098.32 52.5645L1113.48 92.6507C1114.49 95.3029 1117.96 95.9449 1119.79 93.8116L1147.36 61.7933C1149.4 59.4218 1148.09 55.7117 1145.01 55.0932L1145 55.102Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1059.64 -18.5764L1074.09 21.4922C1075.26 24.7636 1072.42 28.0277 1069.03 27.2808L1027.2 18.1733C1024.43 17.5655 1023.28 14.2265 1025.12 12.0931L1052.68 -19.9252C1054.72 -22.2966 1058.58 -21.5494 1059.65 -18.5852L1059.64 -18.5764Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M947.334 23.6796L958.855 58.1342C959.694 60.6248 961.808 62.4956 964.377 63.0081L1045.21 79.1134C1047.78 79.6261 1049.9 81.4556 1050.78 83.9254L1078.62 161.601C1079.51 164.07 1081.66 165.898 1084.25 166.351L1119.98 172.699C1122.56 173.154 1125.16 172.237 1126.85 170.271L1182.78 105.306C1184.39 103.437 1184.92 100.853 1184.19 98.4721L1174.88 68.3045C1174.69 67.6787 1174.29 66.4447 1173.85 65.0316C1172.9 62.0436 1169.04 61.237 1167.03 63.6063C1166.17 64.6113 1165.38 65.5283 1164.89 66.0925L1115 123.834C1112.36 126.902 1107.35 125.917 1105.98 122.07L1080.29 49.9557C1079.36 47.3559 1077.13 45.4342 1074.44 44.9053L999.418 30.0952C995.415 29.2988 993.714 24.4957 996.346 21.42L1046.03 -36.5054C1046.47 -37.0168 1047.26 -37.959 1048.16 -39.0246C1050.16 -41.3761 1048.79 -45.0493 1045.71 -45.5353C1044.33 -45.7567 1043.08 -45.9618 1042.37 -46.0702L1011.2 -50.8475C1008.74 -51.2183 1006.27 -50.3197 1004.66 -48.4419L948.728 16.5236C947.036 18.4896 946.507 21.2142 947.343 23.6873L947.334 23.6796Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-10.2954 -89.7842L-52.0041 -98.1873C-55.4084 -98.8668 -58.2138 -95.572 -56.976 -92.3217L-41.8127 -52.2355C-40.805 -49.5833 -37.3374 -48.9413 -35.5009 -51.0746L-7.93733 -83.093C-5.89584 -85.4644 -7.20342 -89.1745 -10.2878 -89.7931L-10.2954 -89.7842Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-95.6563 -163.463L-81.2076 -123.394C-80.0346 -120.123 -82.8755 -116.859 -86.2686 -117.605L-128.099 -126.713C-130.869 -127.321 -132.013 -130.66 -130.177 -132.793L-102.613 -164.811C-100.572 -167.183 -96.7151 -166.436 -95.6487 -163.471L-95.6563 -163.463Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-207.961 -121.207L-196.44 -86.7521C-195.602 -84.2614 -193.488 -82.3906 -190.918 -81.8782L-110.089 -65.7728C-107.519 -65.2602 -105.399 -63.4306 -104.514 -60.9608L-76.6744 16.7144C-75.7891 19.1843 -73.6365 21.0115 -71.0462 21.4648L-35.3125 27.8132C-32.7387 28.2676 -30.1333 27.3509 -28.4408 25.3849L27.486 -39.5807C29.0949 -41.4496 29.627 -44.0332 28.898 -46.4142L19.5838 -76.5818C19.3929 -77.2076 18.996 -78.4415 18.5516 -79.8546C17.6002 -82.8427 13.746 -83.6492 11.7374 -81.2799C10.8722 -80.2749 10.0828 -79.358 9.59708 -78.7937L-40.2965 -21.0526C-42.9377 -17.9846 -47.9423 -18.9695 -49.3189 -22.8162L-75.0067 -94.9306C-75.9355 -97.5303 -78.1619 -99.452 -80.8578 -99.9809L-155.877 -114.791C-159.88 -115.587 -161.581 -120.391 -158.949 -123.466L-109.269 -181.392C-108.829 -181.903 -108.033 -182.845 -107.131 -183.911C-105.138 -186.262 -106.509 -189.935 -109.582 -190.422C-110.961 -190.643 -112.213 -190.848 -112.926 -190.956L-144.098 -195.734C-146.557 -196.105 -149.023 -195.206 -150.64 -193.328L-206.567 -128.363C-208.259 -126.397 -208.788 -123.672 -207.952 -121.199L-207.961 -121.207Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-202.692 204.964L-160.984 213.367C-157.579 214.046 -154.774 210.752 -156.012 207.501L-171.175 167.415C-172.183 164.763 -175.65 164.121 -177.487 166.254L-205.05 198.272C-207.092 200.644 -205.784 204.354 -202.7 204.973L-202.692 204.964Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-117.331 278.642L-131.78 238.574C-132.953 235.302 -130.112 232.038 -126.719 232.785L-84.8883 241.892C-82.119 242.5 -80.9743 245.839 -82.8109 247.973L-110.374 279.991C-112.416 282.362 -116.273 281.615 -117.339 278.651L-117.331 278.642Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-5.0264 236.386L-16.5478 201.932C-17.386 199.441 -19.5 197.57 -22.0699 197.058L-102.898 180.952C-105.469 180.44 -107.588 178.61 -108.474 176.14L-136.313 98.4651C-137.198 95.9952 -139.351 94.168 -141.941 93.7148L-177.675 87.3662C-180.249 86.9119 -182.854 87.8286 -184.547 89.7946L-240.474 154.76C-242.083 156.629 -242.615 159.213 -241.886 161.594L-232.571 191.761C-232.381 192.387 -231.984 193.621 -231.539 195.034C-230.588 198.022 -226.734 198.829 -224.725 196.459C-223.86 195.454 -223.07 194.537 -222.585 193.973L-172.691 136.232C-170.05 133.164 -165.045 134.149 -163.669 137.996L-137.981 210.11C-137.052 212.71 -134.826 214.631 -132.13 215.16L-57.1103 229.971C-53.1076 230.767 -51.4064 235.57 -54.0387 238.646L-103.719 296.571C-104.159 297.083 -104.955 298.025 -105.856 299.09C-107.85 301.442 -106.479 305.115 -103.405 305.601C-102.027 305.822 -100.775 306.028 -100.061 306.136L-68.8898 310.913C-66.4306 311.284 -63.9642 310.385 -62.3477 308.508L-6.42088 243.542C-4.72842 241.576 -4.19972 238.852 -5.03531 236.378L-5.0264 236.386Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-30.0004 55.1083L-71.7092 46.7053C-75.1135 46.0258 -77.9188 49.3206 -76.6811 52.5708L-61.5178 92.657C-60.5101 95.3093 -57.0425 95.9513 -55.206 93.8179L-27.6424 61.7996C-25.6009 59.4282 -26.9085 55.7181 -29.9929 55.0995L-30.0004 55.1083Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-115.361 -18.57L-100.913 21.4985C-99.7397 24.7699 -102.581 28.034 -105.974 27.2872L-147.804 18.1796C-150.574 17.5719 -151.718 14.2328 -149.882 12.0995L-122.318 -19.9189C-120.277 -22.2903 -116.42 -21.543 -115.354 -18.5788L-115.361 -18.57Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-227.666 23.6859L-216.145 58.1405C-215.307 60.6312 -213.193 62.5019 -210.623 63.0144L-129.794 79.1198C-127.224 79.6324 -125.104 81.4619 -124.219 83.9318L-96.3795 161.607C-95.4942 164.077 -93.3416 165.904 -90.7513 166.357L-55.0176 172.706C-52.4438 173.16 -49.8384 172.243 -48.1459 170.277L7.78091 105.312C9.38986 103.443 9.9219 100.859 9.19295 98.4784L-0.121262 68.3108C-0.312151 67.685 -0.709083 66.451 -1.15346 65.038C-2.10493 62.0499 -5.95909 61.2434 -7.96773 63.6127C-8.83292 64.6177 -9.62228 65.5346 -10.108 66.0988L-60.0016 123.84C-62.6428 126.908 -67.6474 125.923 -69.0239 122.076L-94.7117 49.962C-95.6405 47.3623 -97.8669 45.4406 -100.563 44.9116L-175.582 30.1016C-179.585 29.3052 -181.286 24.5021 -178.654 21.4264L-128.974 -36.4991C-128.534 -37.0104 -127.738 -37.9527 -126.836 -39.0182C-124.843 -41.3698 -126.214 -45.0429 -129.288 -45.5289C-130.666 -45.7503 -131.918 -45.9554 -132.631 -46.0639L-163.803 -50.8412C-166.262 -51.212 -168.728 -50.3134 -170.345 -48.4356L-226.272 16.5299C-227.964 18.4959 -228.493 21.2206 -227.657 23.6936L-227.666 23.6859Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M222.706 -89.7842L180.997 -98.1873C177.593 -98.8668 174.787 -95.572 176.025 -92.3217L191.188 -52.2355C192.196 -49.5833 195.663 -48.9413 197.5 -51.0746L225.064 -83.093C227.105 -85.4644 225.798 -89.1745 222.713 -89.7931L222.706 -89.7842Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M137.345 -163.463L151.793 -123.394C152.966 -120.123 150.125 -116.859 146.732 -117.605L104.902 -126.713C102.132 -127.321 100.988 -130.66 102.824 -132.793L130.388 -164.811C132.429 -167.183 136.286 -166.436 137.352 -163.471L137.345 -163.463Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M25.0397 -121.207L36.5611 -86.7521C37.3993 -84.2614 39.5133 -82.3906 42.0832 -81.8782L122.912 -65.7728C125.482 -65.2602 127.602 -63.4306 128.487 -60.9608L156.327 16.7144C157.212 19.1843 159.364 21.0115 161.955 21.4648L197.688 27.8132C200.262 28.2676 202.868 27.3509 204.56 25.3849L260.487 -39.5807C262.096 -41.4496 262.628 -44.0332 261.899 -46.4142L252.585 -76.5818C252.394 -77.2076 251.997 -78.4415 251.553 -79.8546C250.601 -82.8427 246.747 -83.6492 244.738 -81.2799C243.873 -80.2749 243.084 -79.358 242.598 -78.7937L192.704 -21.0526C190.063 -17.9846 185.059 -18.9695 183.682 -22.8162L157.994 -94.9306C157.066 -97.5303 154.839 -99.452 152.143 -99.9809L77.1236 -114.791C73.1209 -115.587 71.4197 -120.391 74.052 -123.466L123.732 -181.392C124.172 -181.903 124.968 -182.845 125.87 -183.911C127.863 -186.262 126.492 -189.935 123.418 -190.422C122.04 -190.643 120.788 -190.848 120.075 -190.956L88.9031 -195.734C86.4439 -196.105 83.9776 -195.206 82.361 -193.328L26.4342 -128.363C24.7417 -126.397 24.213 -123.672 25.0486 -121.199L25.0397 -121.207Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M30.3106 204.966L72.0193 213.369C75.4237 214.048 78.229 210.753 76.9913 207.503L61.828 167.417C60.8203 164.765 57.3527 164.123 55.5162 166.256L27.9526 198.274C25.9111 200.646 27.2187 204.356 30.303 204.975L30.3106 204.966Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M115.672 278.644L101.223 238.576C100.05 235.304 102.891 232.04 106.284 232.787L148.115 241.894C150.884 242.502 152.029 245.841 150.192 247.975L122.628 279.993C120.587 282.364 116.73 281.617 115.664 278.653L115.672 278.644Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M227.977 236.388L216.455 201.934C215.617 199.443 213.503 197.572 210.933 197.06L130.104 180.954C127.534 180.442 125.415 178.612 124.529 176.142L96.6897 98.467C95.8044 95.9972 93.6518 94.17 91.0615 93.7167L55.3278 87.3682C52.754 86.9138 50.1486 87.8306 48.4561 89.7966L-7.47073 154.762C-9.07968 156.631 -9.61172 159.215 -8.88277 161.596L0.431442 191.763C0.622332 192.389 1.01926 193.623 1.46364 195.036C2.41511 198.024 6.26927 198.831 8.27791 196.461C9.1431 195.456 9.93247 194.539 10.4182 193.975L60.3118 136.234C62.953 133.166 67.9575 134.151 69.3341 137.998L95.0219 210.112C95.9507 212.712 98.1771 214.633 100.873 215.162L175.893 229.972C179.895 230.769 181.597 235.572 178.964 238.648L129.284 296.573C128.844 297.084 128.048 298.027 127.147 299.092C125.153 301.444 126.524 305.117 129.598 305.603C130.976 305.824 132.228 306.029 132.942 306.138L164.113 310.915C166.572 311.286 169.039 310.387 170.655 308.51L226.582 243.544C228.274 241.578 228.803 238.853 227.968 236.38L227.977 236.388Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M203.002 55.1083L161.294 46.7053C157.889 46.0258 155.084 49.3206 156.322 52.5708L171.485 92.657C172.493 95.3093 175.96 95.9513 177.797 93.8179L205.361 61.7996C207.402 59.4282 206.094 55.7181 203.01 55.0995L203.002 55.1083Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M117.642 -18.57L132.09 21.4985C133.263 24.7699 130.422 28.034 127.029 27.2872L85.1985 18.1796C82.4292 17.5719 81.2845 14.2328 83.121 12.0995L110.685 -19.9189C112.726 -22.2903 116.583 -21.543 117.649 -18.5788L117.642 -18.57Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M5.33659 23.6859L16.858 58.1405C17.6961 60.6312 19.8102 62.5019 22.3801 63.0144L103.209 79.1198C105.779 79.6324 107.898 81.4619 108.784 83.9318L136.623 161.607C137.509 164.077 139.661 165.904 142.252 166.357L177.985 172.706C180.559 173.16 183.165 172.243 184.857 170.277L240.784 105.312C242.393 103.443 242.925 100.859 242.196 98.4784L232.882 68.3108C232.691 67.685 232.294 66.451 231.849 65.038C230.898 62.0499 227.044 61.2434 225.035 63.6127C224.17 64.6177 223.381 65.5346 222.895 66.0988L173.001 123.84C170.36 126.908 165.356 125.923 163.979 122.076L138.291 49.962C137.362 47.3623 135.136 45.4406 132.44 44.9116L57.4205 30.1016C53.4178 29.3052 51.7166 24.5021 54.3488 21.4264L104.029 -36.4991C104.469 -37.0104 105.265 -37.9527 106.167 -39.0182C108.16 -41.3698 106.789 -45.0429 103.715 -45.5289C102.337 -45.7503 101.085 -45.9554 100.372 -46.0639L69.2 -50.8412C66.7408 -51.212 64.2744 -50.3134 62.6579 -48.4356L6.73106 16.5299C5.0386 18.4959 4.5099 21.2206 5.34549 23.6936L5.33659 23.6859Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M460.704 -89.7906L418.995 -98.1936C415.591 -98.8731 412.786 -95.5784 414.023 -92.3281L429.187 -52.2419C430.194 -49.5896 433.662 -48.9476 435.498 -51.081L463.062 -83.0993C465.103 -85.4707 463.796 -89.1809 460.711 -89.7994L460.704 -89.7906Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M375.343 -163.469L389.792 -123.4C390.965 -120.129 388.124 -116.865 384.731 -117.612L342.9 -126.719C340.131 -127.327 338.986 -130.666 340.823 -132.799L368.386 -164.818C370.428 -167.189 374.284 -166.442 375.351 -163.478L375.343 -163.469Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M263.038 -121.213L274.559 -86.7584C275.398 -84.2678 277.512 -82.397 280.082 -81.8845L360.91 -65.7792C363.48 -65.2665 365.6 -63.437 366.485 -60.9671L394.325 16.7081C395.21 19.1779 397.363 21.0052 399.953 21.4584L435.687 27.8069C438.261 28.2613 440.866 27.3445 442.558 25.3785L498.485 -39.587C500.094 -41.456 500.626 -44.0395 499.897 -46.4205L490.583 -76.5881C490.392 -77.2139 489.995 -78.4479 489.551 -79.861C488.599 -82.849 484.745 -83.6556 482.737 -81.2863C481.871 -80.2812 481.082 -79.3643 480.596 -78.8001L430.703 -21.059C428.062 -17.991 423.057 -18.9759 421.68 -22.8226L395.993 -94.9369C395.064 -97.5367 392.837 -99.4584 390.141 -99.9873L315.122 -114.797C311.119 -115.594 309.418 -120.397 312.05 -123.473L361.73 -181.398C362.171 -181.909 362.966 -182.852 363.868 -183.917C365.861 -186.269 364.49 -189.942 361.417 -190.428C360.039 -190.649 358.786 -190.854 358.073 -190.963L326.901 -195.74C324.442 -196.111 321.976 -195.212 320.359 -193.335L264.433 -128.369C262.74 -126.403 262.211 -123.678 263.047 -121.205L263.038 -121.213Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M268.307 204.958L310.016 213.361C313.42 214.041 316.226 210.746 314.988 207.496L299.825 167.41C298.817 164.757 295.349 164.115 293.513 166.249L265.949 198.267C263.908 200.639 265.215 204.349 268.3 204.967L268.307 204.958Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M353.668 278.637L339.219 238.568C338.046 235.297 340.887 232.033 344.28 232.78L386.111 241.887C388.88 242.495 390.025 245.834 388.189 247.967L360.625 279.986C358.584 282.357 354.727 281.61 353.66 278.646L353.668 278.637Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M465.973 236.381L454.452 201.926C453.613 199.436 451.499 197.565 448.93 197.052L368.101 180.947C365.531 180.434 363.411 178.605 362.526 176.135L334.686 98.4597C333.801 95.9899 331.648 94.1626 329.058 93.7094L293.324 87.3609C290.75 86.9065 288.145 87.8232 286.453 89.7892L230.526 154.755C228.917 156.624 228.385 159.207 229.114 161.588L238.428 191.756C238.619 192.382 239.016 193.616 239.46 195.029C240.412 198.017 244.266 198.823 246.274 196.454C247.14 195.449 247.929 194.532 248.415 193.968L298.308 136.227C300.95 133.159 305.954 134.144 307.331 137.99L333.018 210.105C333.947 212.704 336.174 214.626 338.87 215.155L413.889 229.965C417.892 230.762 419.593 235.565 416.961 238.64L367.281 296.566C366.84 297.077 366.045 298.019 365.143 299.085C363.15 301.436 364.521 305.11 367.594 305.596C368.972 305.817 370.225 306.022 370.938 306.131L402.11 310.908C404.569 311.279 407.035 310.38 408.652 308.502L464.579 243.537C466.271 241.571 466.8 238.846 465.964 236.373L465.973 236.381Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M440.999 55.1022L399.29 46.6992C395.886 46.0197 393.08 49.3145 394.318 52.5647L409.482 92.6509C410.489 95.3032 413.957 95.9452 415.793 93.8118L443.357 61.7935C445.398 59.4221 444.091 55.7119 441.006 55.0934L440.999 55.1022Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M355.638 -18.5761L370.087 21.4924C371.26 24.7638 368.419 28.0279 365.026 27.2811L323.195 18.1735C320.426 17.5658 319.281 14.2267 321.117 12.0934L348.681 -19.925C350.723 -22.2964 354.579 -21.5491 355.646 -18.5849L355.638 -18.5761Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M243.333 23.6798L254.854 58.1344C255.693 60.6251 257.807 62.4958 260.376 63.0083L341.205 79.1136C343.775 79.6263 345.895 81.4558 346.78 83.9257L374.62 161.601C375.505 164.071 377.658 165.898 380.248 166.351L415.982 172.7C418.555 173.154 421.161 172.237 422.853 170.271L478.78 105.306C480.389 103.437 480.921 100.853 480.192 98.4723L470.878 68.3047C470.687 67.6789 470.29 66.4449 469.846 65.0319C468.894 62.0438 465.04 61.2373 463.032 63.6066C462.166 64.6116 461.377 65.5285 460.891 66.0927L410.998 123.834C408.357 126.902 403.352 125.917 401.975 122.07L376.288 49.9559C375.359 47.3562 373.132 45.4345 370.436 44.9055L295.417 30.0955C291.414 29.2991 289.713 24.496 292.345 21.4203L342.025 -36.5052C342.466 -37.0165 343.261 -37.9588 344.163 -39.0243C346.156 -41.3759 344.785 -45.049 341.712 -45.535C340.334 -45.7564 339.081 -45.9615 338.368 -46.07L307.196 -50.8473C304.737 -51.2181 302.271 -50.3195 300.654 -48.4417L244.727 16.5238C243.035 18.4898 242.506 21.2145 243.342 23.6875L243.333 23.6798Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M693.703 -89.7894L651.995 -98.1924C648.59 -98.8719 645.785 -95.5771 647.023 -92.3269L662.186 -52.2407C663.194 -49.5884 666.661 -48.9464 668.498 -51.0798L696.061 -83.0981C698.103 -85.4695 696.795 -89.1797 693.711 -89.7982L693.703 -89.7894Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M608.342 -163.468L622.791 -123.399C623.964 -120.128 621.123 -116.864 617.73 -117.611L575.899 -126.718C573.13 -127.326 571.985 -130.665 573.822 -132.798L601.385 -164.817C603.427 -167.188 607.283 -166.441 608.35 -163.477L608.342 -163.468Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M496.037 -121.212L507.559 -86.7572C508.397 -84.2665 510.511 -82.3958 513.081 -81.8833L593.909 -65.778C596.48 -65.2653 598.599 -63.4358 599.484 -60.9659L627.324 16.7093C628.209 19.1791 630.362 21.0064 632.952 21.4596L668.686 27.8081C671.26 28.2625 673.865 27.3458 675.558 25.3798L731.485 -39.5858C733.094 -41.4548 733.626 -44.0383 732.897 -46.4193L723.582 -76.5869C723.392 -77.2127 722.995 -78.4467 722.55 -79.8598C721.599 -82.8478 717.745 -83.6543 715.736 -81.285C714.871 -80.28 714.081 -79.3631 713.596 -78.7989L663.702 -21.0578C661.061 -17.9897 656.056 -18.9746 654.68 -22.8213L628.992 -94.9357C628.063 -97.5354 625.837 -99.4571 623.141 -99.9861L548.121 -114.796C544.118 -115.593 542.417 -120.396 545.05 -123.471L594.73 -181.397C595.17 -181.908 595.966 -182.85 596.867 -183.916C598.861 -186.267 597.49 -189.941 594.416 -190.427C593.038 -190.648 591.785 -190.853 591.072 -190.962L559.901 -195.739C557.441 -196.11 554.975 -195.211 553.359 -193.333L497.432 -128.368C495.739 -126.402 495.211 -123.677 496.046 -121.204L496.037 -121.212Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M501.308 204.961L543.017 213.364C546.421 214.043 549.227 210.748 547.989 207.498L532.826 167.412C531.818 164.76 528.35 164.118 526.514 166.251L498.95 198.269C496.909 200.641 498.216 204.351 501.301 204.969L501.308 204.961Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M586.669 278.639L572.221 238.57C571.047 235.299 573.888 232.035 577.282 232.782L619.112 241.889C621.882 242.497 623.026 245.836 621.19 247.969L593.626 279.988C591.585 282.359 587.728 281.612 586.662 278.648L586.669 278.639Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M698.974 236.383L687.453 201.928C686.615 199.438 684.501 197.567 681.931 197.054L601.102 180.949C598.532 180.436 596.412 178.607 595.527 176.137L567.687 98.4619C566.802 95.9921 564.649 94.1648 562.059 93.7116L526.325 87.3631C523.752 86.9087 521.146 87.8254 519.454 89.7914L463.527 154.757C461.918 156.626 461.386 159.209 462.115 161.59L471.429 191.758C471.62 192.384 472.017 193.618 472.461 195.031C473.413 198.019 477.267 198.826 479.276 196.456C480.141 195.451 480.93 194.534 481.416 193.97L531.309 136.229C533.951 133.161 538.955 134.146 540.332 137.993L566.02 210.107C566.948 212.707 569.175 214.628 571.871 215.157L646.89 229.967C650.893 230.764 652.594 235.567 649.962 238.643L600.282 296.568C599.842 297.079 599.046 298.022 598.144 299.087C596.151 301.439 597.522 305.112 600.595 305.598C601.973 305.819 603.226 306.024 603.939 306.133L635.111 310.91C637.57 311.281 640.036 310.382 641.653 308.504L697.58 243.539C699.272 241.573 699.801 238.848 698.965 236.375L698.974 236.383Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M674 55.1032L632.291 46.7001C628.887 46.0206 626.082 49.3155 627.319 52.5657L642.483 92.6519C643.49 95.3041 646.958 95.9462 648.795 93.8128L676.358 61.7945C678.4 59.4231 677.092 55.7129 674.008 55.0944L674 55.1032Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M588.639 -18.5751L603.088 21.4934C604.261 24.7648 601.42 28.0289 598.027 27.2821L556.196 18.1745C553.427 17.5667 552.282 14.2277 554.119 12.0943L581.682 -19.924C583.724 -22.2954 587.58 -21.5481 588.647 -18.584L588.639 -18.5751Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M476.334 23.6808L487.856 58.1354C488.694 60.626 490.808 62.4968 493.378 63.0093L574.206 79.1146C576.776 79.6273 578.896 81.4568 579.781 83.9267L607.621 161.602C608.506 164.072 610.659 165.899 613.249 166.352L648.983 172.701C651.557 173.155 654.162 172.238 655.855 170.272L711.781 105.307C713.39 103.438 713.922 100.854 713.193 98.4733L703.879 68.3057C703.688 67.6799 703.291 66.4459 702.847 65.0328C701.896 62.0448 698.041 61.2382 696.033 63.6075C695.168 64.6126 694.378 65.5295 693.893 66.0937L643.999 123.835C641.358 126.903 636.353 125.918 634.977 122.071L609.289 49.9569C608.36 47.3571 606.134 45.4354 603.438 44.9065L528.418 30.0964C524.415 29.3 522.714 24.4969 525.346 21.4212L575.027 -36.5042C575.467 -37.0156 576.262 -37.9578 577.164 -39.0234C579.158 -41.3749 577.786 -45.048 574.713 -45.5341C573.335 -45.7554 572.082 -45.9606 571.369 -46.069L540.198 -50.8463C537.738 -51.2171 535.272 -50.3185 533.656 -48.4407L477.729 16.5248C476.036 18.4908 475.508 21.2154 476.343 23.6885L476.334 23.6808Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M931.704 -89.7906L889.995 -98.1936C886.591 -98.8731 883.785 -95.5784 885.023 -92.3281L900.186 -52.2419C901.194 -49.5896 904.662 -48.9476 906.498 -51.081L934.062 -83.0993C936.103 -85.4707 934.796 -89.1809 931.711 -89.7994L931.704 -89.7906Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M846.343 -163.469L860.791 -123.4C861.964 -120.129 859.124 -116.865 855.73 -117.612L813.9 -126.719C811.13 -127.327 809.986 -130.666 811.822 -132.799L839.386 -164.818C841.427 -167.189 845.284 -166.442 846.35 -163.478L846.343 -163.469Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M734.038 -121.213L745.559 -86.7584C746.397 -84.2678 748.511 -82.397 751.081 -81.8845L831.91 -65.7792C834.48 -65.2665 836.6 -63.437 837.485 -60.9671L865.325 16.7081C866.21 19.1779 868.362 21.0052 870.953 21.4584L906.687 27.8069C909.26 28.2613 911.866 27.3445 913.558 25.3785L969.485 -39.587C971.094 -41.456 971.626 -44.0395 970.897 -46.4205L961.583 -76.5881C961.392 -77.2139 960.995 -78.4479 960.551 -79.861C959.599 -82.849 955.745 -83.6556 953.736 -81.2863C952.871 -80.2812 952.082 -79.3643 951.596 -78.8001L901.703 -21.059C899.061 -17.991 894.057 -18.9759 892.68 -22.8226L866.992 -94.9369C866.064 -97.5367 863.837 -99.4584 861.141 -99.9873L786.122 -114.797C782.119 -115.594 780.418 -120.397 783.05 -123.473L832.73 -181.398C833.17 -181.909 833.966 -182.852 834.868 -183.917C836.861 -186.269 835.49 -189.942 832.417 -190.428C831.039 -190.649 829.786 -190.854 829.073 -190.963L797.901 -195.74C795.442 -196.111 792.976 -195.212 791.359 -193.335L735.432 -128.369C733.74 -126.403 733.211 -123.678 734.047 -121.205L734.038 -121.213Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M739.307 204.957L781.016 213.36C784.42 214.04 787.225 210.745 785.988 207.495L770.824 167.409C769.817 164.756 766.349 164.114 764.513 166.248L736.949 198.266C734.907 200.638 736.215 204.348 739.299 204.966L739.307 204.957Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M824.668 278.636L810.219 238.567C809.046 235.296 811.887 232.032 815.28 232.779L857.111 241.886C859.88 242.494 861.025 245.833 859.188 247.966L831.625 279.985C829.583 282.356 825.727 281.609 824.66 278.645L824.668 278.636Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M936.973 236.38L925.452 201.925C924.613 199.435 922.499 197.564 919.929 197.051L839.101 180.946C836.531 180.433 834.411 178.604 833.526 176.134L805.686 98.4587C804.801 95.9889 802.648 94.1616 800.058 93.7084L764.324 87.3599C761.75 86.9055 759.145 87.8223 757.452 89.7883L701.526 154.754C699.917 156.623 699.385 159.206 700.114 161.587L709.428 191.755C709.619 192.381 710.016 193.615 710.46 195.028C711.411 198.016 715.266 198.822 717.274 196.453C718.139 195.448 718.929 194.531 719.415 193.967L769.308 136.226C771.949 133.158 776.954 134.143 778.331 137.989L804.018 210.104C804.947 212.703 807.174 214.625 809.87 215.154L884.889 229.964C888.892 230.761 890.593 235.564 887.961 238.639L838.281 296.565C837.84 297.076 837.045 298.018 836.143 299.084C834.15 301.436 835.521 305.109 838.594 305.595C839.972 305.816 841.225 306.021 841.938 306.13L873.109 310.907C875.569 311.278 878.035 310.379 879.652 308.501L935.578 243.536C937.271 241.57 937.8 238.845 936.964 236.372L936.973 236.38Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M911.999 55.102L870.29 46.6989C866.886 46.0194 864.08 49.3142 865.318 52.5645L880.481 92.6507C881.489 95.3029 884.957 95.9449 886.793 93.8116L914.357 61.7933C916.398 59.4218 915.091 55.7117 912.006 55.0932L911.999 55.102Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M826.638 -18.5764L841.086 21.4922C842.259 24.7636 839.418 28.0277 836.025 27.2808L794.195 18.1733C791.425 17.5655 790.281 14.2265 792.117 12.0931L819.681 -19.9252C821.722 -22.2966 825.579 -21.5494 826.645 -18.5852L826.638 -18.5764Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M714.333 23.6796L725.854 58.1342C726.692 60.6248 728.806 62.4956 731.376 63.0081L812.205 79.1134C814.775 79.6261 816.895 81.4556 817.78 83.9254L845.62 161.601C846.505 164.07 848.657 165.898 851.248 166.351L886.981 172.699C889.555 173.154 892.161 172.237 893.853 170.271L949.78 105.306C951.389 103.437 951.921 100.853 951.192 98.4721L941.878 68.3045C941.687 67.6787 941.29 66.4447 940.846 65.0316C939.894 62.0436 936.04 61.237 934.031 63.6063C933.166 64.6113 932.377 65.5283 931.891 66.0925L881.997 123.834C879.356 126.902 874.352 125.917 872.975 122.07L847.287 49.9557C846.359 47.3559 844.132 45.4342 841.436 44.9053L766.417 30.0952C762.414 29.2988 760.713 24.4957 763.345 21.42L813.025 -36.5054C813.465 -37.0168 814.261 -37.959 815.163 -39.0246C817.156 -41.3761 815.785 -45.0493 812.712 -45.5353C811.333 -45.7567 810.081 -45.9618 809.368 -46.0702L778.196 -50.8475C775.737 -51.2183 773.271 -50.3197 771.654 -48.4419L715.727 16.5236C714.035 18.4896 713.506 21.2142 714.342 23.6873L714.333 23.6796Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1164.7 -89.7906L1122.99 -98.1936C1119.59 -98.8731 1116.78 -95.5784 1118.02 -92.3281L1133.19 -52.2419C1134.19 -49.5896 1137.66 -48.9476 1139.5 -51.081L1167.06 -83.0993C1169.1 -85.4707 1167.79 -89.1809 1164.71 -89.7994L1164.7 -89.7906Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1079.34 -163.469L1093.79 -123.4C1094.96 -120.129 1092.12 -116.865 1088.73 -117.612L1046.9 -126.719C1044.13 -127.327 1042.98 -130.666 1044.82 -132.799L1072.39 -164.818C1074.43 -167.189 1078.28 -166.442 1079.35 -163.478L1079.34 -163.469Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M967.037 -121.213L978.558 -86.7584C979.397 -84.2678 981.511 -82.397 984.081 -81.8845L1064.91 -65.7792C1067.48 -65.2665 1069.6 -63.437 1070.48 -60.9671L1098.32 16.7081C1099.21 19.1779 1101.36 21.0052 1103.95 21.4584L1139.69 27.8069C1142.26 28.2613 1144.87 27.3445 1146.56 25.3785L1202.48 -39.587C1204.09 -41.456 1204.63 -44.0395 1203.9 -46.4205L1194.58 -76.5881C1194.39 -77.2139 1193.99 -78.4479 1193.55 -79.861C1192.6 -82.849 1188.74 -83.6556 1186.74 -81.2863C1185.87 -80.2812 1185.08 -79.3643 1184.6 -78.8001L1134.7 -21.059C1132.06 -17.991 1127.06 -18.9759 1125.68 -22.8226L1099.99 -94.9369C1099.06 -97.5367 1096.84 -99.4584 1094.14 -99.9873L1019.12 -114.797C1015.12 -115.594 1013.42 -120.397 1016.05 -123.473L1065.73 -181.398C1066.17 -181.909 1066.97 -182.852 1067.87 -183.917C1069.86 -186.269 1068.49 -189.942 1065.42 -190.428C1064.04 -190.649 1062.79 -190.854 1062.07 -190.963L1030.9 -195.74C1028.44 -196.111 1025.97 -195.212 1024.36 -193.335L968.432 -128.369C966.739 -126.403 966.21 -123.678 967.046 -121.205L967.037 -121.213Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M972.308 204.959L1014.02 213.362C1017.42 214.042 1020.23 210.747 1018.99 207.497L1003.83 167.411C1002.82 164.758 999.35 164.116 997.514 166.25L969.95 198.268C967.908 200.639 969.216 204.35 972.3 204.968L972.308 204.959Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1057.67 278.638L1043.22 238.569C1042.05 235.298 1044.89 232.034 1048.28 232.78L1090.11 241.888C1092.88 242.496 1094.03 245.835 1092.19 247.968L1064.63 279.987C1062.58 282.358 1058.73 281.611 1057.66 278.647L1057.67 278.638Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1169.97 236.382L1158.45 201.927C1157.61 199.437 1155.5 197.566 1152.93 197.053L1072.1 180.948C1069.53 180.435 1067.41 178.606 1066.53 176.136L1038.69 98.4607C1037.8 95.9908 1035.65 94.1636 1033.06 93.7104L997.325 87.3618C994.751 86.9075 992.146 87.8242 990.453 89.7902L934.527 154.756C932.918 156.625 932.386 159.208 933.115 161.589L942.429 191.757C942.62 192.383 943.017 193.617 943.461 195.03C944.413 198.018 948.267 198.824 950.275 196.455C951.14 195.45 951.93 194.533 952.416 193.969L1002.31 136.228C1004.95 133.16 1009.95 134.145 1011.33 137.991L1037.02 210.106C1037.95 212.705 1040.17 214.627 1042.87 215.156L1117.89 229.966C1121.89 230.763 1123.59 235.566 1120.96 238.641L1071.28 296.567C1070.84 297.078 1070.05 298.02 1069.14 299.086C1067.15 301.437 1068.52 305.111 1071.6 305.597C1072.97 305.818 1074.23 306.023 1074.94 306.132L1106.11 310.909C1108.57 311.28 1111.04 310.381 1112.65 308.503L1168.58 243.538C1170.27 241.572 1170.8 238.847 1169.96 236.374L1169.97 236.382Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1145 55.102L1103.29 46.6989C1099.89 46.0194 1097.08 49.3142 1098.32 52.5645L1113.48 92.6507C1114.49 95.3029 1117.96 95.9449 1119.79 93.8116L1147.36 61.7933C1149.4 59.4218 1148.09 55.7117 1145.01 55.0932L1145 55.102Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1059.64 -18.5764L1074.09 21.4922C1075.26 24.7636 1072.42 28.0277 1069.03 27.2808L1027.2 18.1733C1024.43 17.5655 1023.28 14.2265 1025.12 12.0931L1052.68 -19.9252C1054.72 -22.2966 1058.58 -21.5494 1059.65 -18.5852L1059.64 -18.5764Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M947.334 23.6796L958.855 58.1342C959.694 60.6248 961.808 62.4956 964.377 63.0081L1045.21 79.1134C1047.78 79.6261 1049.9 81.4556 1050.78 83.9254L1078.62 161.601C1079.51 164.07 1081.66 165.898 1084.25 166.351L1119.98 172.699C1122.56 173.154 1125.16 172.237 1126.85 170.271L1182.78 105.306C1184.39 103.437 1184.92 100.853 1184.19 98.4721L1174.88 68.3045C1174.69 67.6787 1174.29 66.4447 1173.85 65.0316C1172.9 62.0436 1169.04 61.237 1167.03 63.6063C1166.17 64.6113 1165.38 65.5283 1164.89 66.0925L1115 123.834C1112.36 126.902 1107.35 125.917 1105.98 122.07L1080.29 49.9557C1079.36 47.3559 1077.13 45.4342 1074.44 44.9053L999.418 30.0952C995.415 29.2988 993.714 24.4957 996.346 21.42L1046.03 -36.5054C1046.47 -37.0168 1047.26 -37.959 1048.16 -39.0246C1050.16 -41.3761 1048.79 -45.0493 1045.71 -45.5353C1044.33 -45.7567 1043.08 -45.9618 1042.37 -46.0702L1011.2 -50.8475C1008.74 -51.2183 1006.27 -50.3197 1004.66 -48.4419L948.728 16.5236C947.036 18.4896 946.507 21.2142 947.343 23.6873L947.334 23.6796Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-10.2954 -89.7842L-52.0041 -98.1873C-55.4084 -98.8668 -58.2138 -95.572 -56.976 -92.3217L-41.8127 -52.2355C-40.805 -49.5833 -37.3374 -48.9413 -35.5009 -51.0746L-7.93733 -83.093C-5.89584 -85.4644 -7.20342 -89.1745 -10.2878 -89.7931L-10.2954 -89.7842Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-95.6563 -163.463L-81.2076 -123.394C-80.0346 -120.123 -82.8755 -116.859 -86.2686 -117.605L-128.099 -126.713C-130.869 -127.321 -132.013 -130.66 -130.177 -132.793L-102.613 -164.811C-100.572 -167.183 -96.7151 -166.436 -95.6487 -163.471L-95.6563 -163.463Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-207.961 -121.207L-196.44 -86.7521C-195.602 -84.2614 -193.488 -82.3906 -190.918 -81.8782L-110.089 -65.7728C-107.519 -65.2602 -105.399 -63.4306 -104.514 -60.9608L-76.6744 16.7144C-75.7891 19.1843 -73.6365 21.0115 -71.0462 21.4648L-35.3125 27.8132C-32.7387 28.2676 -30.1333 27.3509 -28.4408 25.3849L27.486 -39.5807C29.0949 -41.4496 29.627 -44.0332 28.898 -46.4142L19.5838 -76.5818C19.3929 -77.2076 18.996 -78.4415 18.5516 -79.8546C17.6002 -82.8427 13.746 -83.6492 11.7374 -81.2799C10.8722 -80.2749 10.0828 -79.358 9.59708 -78.7937L-40.2965 -21.0526C-42.9377 -17.9846 -47.9423 -18.9695 -49.3189 -22.8162L-75.0067 -94.9306C-75.9355 -97.5303 -78.1619 -99.452 -80.8578 -99.9809L-155.877 -114.791C-159.88 -115.587 -161.581 -120.391 -158.949 -123.466L-109.269 -181.392C-108.829 -181.903 -108.033 -182.845 -107.131 -183.911C-105.138 -186.262 -106.509 -189.935 -109.582 -190.422C-110.961 -190.643 -112.213 -190.848 -112.926 -190.956L-144.098 -195.734C-146.557 -196.105 -149.023 -195.206 -150.64 -193.328L-206.567 -128.363C-208.259 -126.397 -208.788 -123.672 -207.952 -121.199L-207.961 -121.207Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-202.692 204.964L-160.984 213.367C-157.579 214.046 -154.774 210.752 -156.012 207.501L-171.175 167.415C-172.183 164.763 -175.65 164.121 -177.487 166.254L-205.05 198.272C-207.092 200.644 -205.784 204.354 -202.7 204.973L-202.692 204.964Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-117.331 278.642L-131.78 238.574C-132.953 235.302 -130.112 232.038 -126.719 232.785L-84.8883 241.892C-82.119 242.5 -80.9743 245.839 -82.8109 247.973L-110.374 279.991C-112.416 282.362 -116.273 281.615 -117.339 278.651L-117.331 278.642Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-5.0264 236.386L-16.5478 201.932C-17.386 199.441 -19.5 197.57 -22.0699 197.058L-102.898 180.952C-105.469 180.44 -107.588 178.61 -108.474 176.14L-136.313 98.4651C-137.198 95.9952 -139.351 94.168 -141.941 93.7148L-177.675 87.3662C-180.249 86.9119 -182.854 87.8286 -184.547 89.7946L-240.474 154.76C-242.083 156.629 -242.615 159.213 -241.886 161.594L-232.571 191.761C-232.381 192.387 -231.984 193.621 -231.539 195.034C-230.588 198.022 -226.734 198.829 -224.725 196.459C-223.86 195.454 -223.07 194.537 -222.585 193.973L-172.691 136.232C-170.05 133.164 -165.045 134.149 -163.669 137.996L-137.981 210.11C-137.052 212.71 -134.826 214.631 -132.13 215.16L-57.1103 229.971C-53.1076 230.767 -51.4064 235.57 -54.0387 238.646L-103.719 296.571C-104.159 297.083 -104.955 298.025 -105.856 299.09C-107.85 301.442 -106.479 305.115 -103.405 305.601C-102.027 305.822 -100.775 306.028 -100.061 306.136L-68.8898 310.913C-66.4306 311.284 -63.9642 310.385 -62.3477 308.508L-6.42088 243.542C-4.72842 241.576 -4.19972 238.852 -5.03531 236.378L-5.0264 236.386Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-30.0004 55.1083L-71.7092 46.7053C-75.1135 46.0258 -77.9188 49.3206 -76.6811 52.5708L-61.5178 92.657C-60.5101 95.3093 -57.0425 95.9513 -55.206 93.8179L-27.6424 61.7996C-25.6009 59.4282 -26.9085 55.7181 -29.9929 55.0995L-30.0004 55.1083Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-115.361 -18.57L-100.913 21.4985C-99.7397 24.7699 -102.581 28.034 -105.974 27.2872L-147.804 18.1796C-150.574 17.5719 -151.718 14.2328 -149.882 12.0995L-122.318 -19.9189C-120.277 -22.2903 -116.42 -21.543 -115.354 -18.5788L-115.361 -18.57Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-227.666 23.6859L-216.145 58.1405C-215.307 60.6312 -213.193 62.5019 -210.623 63.0144L-129.794 79.1198C-127.224 79.6324 -125.104 81.4619 -124.219 83.9318L-96.3795 161.607C-95.4942 164.077 -93.3416 165.904 -90.7513 166.357L-55.0176 172.706C-52.4438 173.16 -49.8384 172.243 -48.1459 170.277L7.78091 105.312C9.38986 103.443 9.9219 100.859 9.19295 98.4784L-0.121262 68.3108C-0.312151 67.685 -0.709083 66.451 -1.15346 65.038C-2.10493 62.0499 -5.95909 61.2434 -7.96773 63.6127C-8.83292 64.6177 -9.62228 65.5346 -10.108 66.0988L-60.0016 123.84C-62.6428 126.908 -67.6474 125.923 -69.0239 122.076L-94.7117 49.962C-95.6405 47.3623 -97.8669 45.4406 -100.563 44.9116L-175.582 30.1016C-179.585 29.3052 -181.286 24.5021 -178.654 21.4264L-128.974 -36.4991C-128.534 -37.0104 -127.738 -37.9527 -126.836 -39.0182C-124.843 -41.3698 -126.214 -45.0429 -129.288 -45.5289C-130.666 -45.7503 -131.918 -45.9554 -132.631 -46.0639L-163.803 -50.8412C-166.262 -51.212 -168.728 -50.3134 -170.345 -48.4356L-226.272 16.5299C-227.964 18.4959 -228.493 21.2206 -227.657 23.6936L-227.666 23.6859Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M222.706 -89.7842L180.997 -98.1873C177.593 -98.8668 174.787 -95.572 176.025 -92.3217L191.188 -52.2355C192.196 -49.5833 195.663 -48.9413 197.5 -51.0746L225.064 -83.093C227.105 -85.4644 225.798 -89.1745 222.713 -89.7931L222.706 -89.7842Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M137.345 -163.463L151.793 -123.394C152.966 -120.123 150.125 -116.859 146.732 -117.605L104.902 -126.713C102.132 -127.321 100.988 -130.66 102.824 -132.793L130.388 -164.811C132.429 -167.183 136.286 -166.436 137.352 -163.471L137.345 -163.463Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M25.0397 -121.207L36.5611 -86.7521C37.3993 -84.2614 39.5133 -82.3906 42.0832 -81.8782L122.912 -65.7728C125.482 -65.2602 127.602 -63.4306 128.487 -60.9608L156.327 16.7144C157.212 19.1843 159.364 21.0115 161.955 21.4648L197.688 27.8132C200.262 28.2676 202.868 27.3509 204.56 25.3849L260.487 -39.5807C262.096 -41.4496 262.628 -44.0332 261.899 -46.4142L252.585 -76.5818C252.394 -77.2076 251.997 -78.4415 251.553 -79.8546C250.601 -82.8427 246.747 -83.6492 244.738 -81.2799C243.873 -80.2749 243.084 -79.358 242.598 -78.7937L192.704 -21.0526C190.063 -17.9846 185.059 -18.9695 183.682 -22.8162L157.994 -94.9306C157.066 -97.5303 154.839 -99.452 152.143 -99.9809L77.1236 -114.791C73.1209 -115.587 71.4197 -120.391 74.052 -123.466L123.732 -181.392C124.172 -181.903 124.968 -182.845 125.87 -183.911C127.863 -186.262 126.492 -189.935 123.418 -190.422C122.04 -190.643 120.788 -190.848 120.075 -190.956L88.9031 -195.734C86.4439 -196.105 83.9776 -195.206 82.361 -193.328L26.4342 -128.363C24.7417 -126.397 24.213 -123.672 25.0486 -121.199L25.0397 -121.207Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M30.3106 204.966L72.0193 213.369C75.4237 214.048 78.229 210.753 76.9913 207.503L61.828 167.417C60.8203 164.765 57.3527 164.123 55.5162 166.256L27.9526 198.274C25.9111 200.646 27.2187 204.356 30.303 204.975L30.3106 204.966Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M115.672 278.644L101.223 238.576C100.05 235.304 102.891 232.04 106.284 232.787L148.115 241.894C150.884 242.502 152.029 245.841 150.192 247.975L122.628 279.993C120.587 282.364 116.73 281.617 115.664 278.653L115.672 278.644Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M227.977 236.388L216.455 201.934C215.617 199.443 213.503 197.572 210.933 197.06L130.104 180.954C127.534 180.442 125.415 178.612 124.529 176.142L96.6897 98.467C95.8044 95.9972 93.6518 94.17 91.0615 93.7167L55.3278 87.3682C52.754 86.9138 50.1486 87.8306 48.4561 89.7966L-7.47073 154.762C-9.07968 156.631 -9.61172 159.215 -8.88277 161.596L0.431442 191.763C0.622332 192.389 1.01926 193.623 1.46364 195.036C2.41511 198.024 6.26927 198.831 8.27791 196.461C9.1431 195.456 9.93247 194.539 10.4182 193.975L60.3118 136.234C62.953 133.166 67.9575 134.151 69.3341 137.998L95.0219 210.112C95.9507 212.712 98.1771 214.633 100.873 215.162L175.893 229.972C179.895 230.769 181.597 235.572 178.964 238.648L129.284 296.573C128.844 297.084 128.048 298.027 127.147 299.092C125.153 301.444 126.524 305.117 129.598 305.603C130.976 305.824 132.228 306.029 132.942 306.138L164.113 310.915C166.572 311.286 169.039 310.387 170.655 308.51L226.582 243.544C228.274 241.578 228.803 238.853 227.968 236.38L227.977 236.388Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M203.002 55.1083L161.294 46.7053C157.889 46.0258 155.084 49.3206 156.322 52.5708L171.485 92.657C172.493 95.3093 175.96 95.9513 177.797 93.8179L205.361 61.7996C207.402 59.4282 206.094 55.7181 203.01 55.0995L203.002 55.1083Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M117.642 -18.57L132.09 21.4985C133.263 24.7699 130.422 28.034 127.029 27.2872L85.1985 18.1796C82.4292 17.5719 81.2845 14.2328 83.121 12.0995L110.685 -19.9189C112.726 -22.2903 116.583 -21.543 117.649 -18.5788L117.642 -18.57Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M5.33659 23.6859L16.858 58.1405C17.6961 60.6312 19.8102 62.5019 22.3801 63.0144L103.209 79.1198C105.779 79.6324 107.898 81.4619 108.784 83.9318L136.623 161.607C137.509 164.077 139.661 165.904 142.252 166.357L177.985 172.706C180.559 173.16 183.165 172.243 184.857 170.277L240.784 105.312C242.393 103.443 242.925 100.859 242.196 98.4784L232.882 68.3108C232.691 67.685 232.294 66.451 231.849 65.038C230.898 62.0499 227.044 61.2434 225.035 63.6127C224.17 64.6177 223.381 65.5346 222.895 66.0988L173.001 123.84C170.36 126.908 165.356 125.923 163.979 122.076L138.291 49.962C137.362 47.3623 135.136 45.4406 132.44 44.9116L57.4205 30.1016C53.4178 29.3052 51.7166 24.5021 54.3488 21.4264L104.029 -36.4991C104.469 -37.0104 105.265 -37.9527 106.167 -39.0182C108.16 -41.3698 106.789 -45.0429 103.715 -45.5289C102.337 -45.7503 101.085 -45.9554 100.372 -46.0639L69.2 -50.8412C66.7408 -51.212 64.2744 -50.3134 62.6579 -48.4356L6.73106 16.5299C5.0386 18.4959 4.5099 21.2206 5.34549 23.6936L5.33659 23.6859Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
</svg>

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,12 @@
<svg viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="72" height="72" rx="12" fill="currentColor" fill-opacity="0.12" />
<path
d="M22.562 46.8959L31.189 42.2804C31.8922 41.9026 31.8873 40.9333 31.1763 40.558L22.426 35.9147C21.8463 35.6082 21.1377 36.0016 21.1373 36.6321L21.1319 46.0958C21.1315 46.7967 21.9244 47.2404 22.562 46.8985L22.562 46.8959Z"
fill="currentColor" />
<path
d="M48.7804 47.0911L40.1588 42.3472C39.4561 41.9589 39.4621 40.9896 40.1735 40.625L48.9288 36.112C49.5092 35.8141 50.2172 36.218 50.2168 36.8485L50.2114 46.3122C50.211 47.0131 49.4178 47.445 48.7804 47.0937L48.7804 47.0911Z"
fill="currentColor" />
<path
d="M62.1735 23.588L54.919 19.3778C54.3937 19.0742 53.7374 19.0615 53.2066 19.3468L36.5152 28.3219C35.9844 28.6072 35.3333 28.6024 34.8028 28.3092L18.1193 19.0882C17.5888 18.7951 16.9323 18.7954 16.4069 19.0937L9.14477 23.1933C8.62214 23.4891 8.30179 24.0235 8.30145 24.6046L8.29042 43.8064C8.2901 44.3589 8.58797 44.877 9.07744 45.1777L15.2929 48.9763C15.4214 49.0554 15.6784 49.2085 15.971 49.3853C16.5917 49.7573 17.3935 49.3359 17.3884 48.6401C17.3886 48.3431 17.3887 48.0721 17.3888 47.9053L17.4315 30.8117C17.432 29.9049 18.4636 29.3471 19.2894 29.8041L34.7506 38.3879C35.3084 38.697 35.9922 38.7021 36.5477 38.4013L52.0186 30.0477C52.845 29.603 53.8731 30.1762 53.8753 31.083L53.8983 48.177C53.8982 48.3282 53.9008 48.6044 53.9033 48.9171C53.9084 49.6077 54.707 50.0358 55.3225 49.6729C55.599 49.5108 55.8509 49.3642 55.9931 49.2792L62.2128 45.5732C62.7025 45.2798 63.0011 44.7687 63.0014 44.2137L63.0125 25.0118C63.0128 24.4307 62.693 23.8889 62.1707 23.588L62.1735 23.588Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -165,6 +165,11 @@ html {
--landing-raw-bg: #fff;
--medal-promotion-bg: #fff;
--medal-promotion-bg-orange: #c08a3a;
--medal-promotion-text-orange: #a86200;
--medal-promotion-bg-gradient: linear-gradient(90deg, rgba(255, 184, 75, 0.15) 0%, #fff 100%);
--banner-error-bg: #fee2e2;
--banner-error-text: #991b1b;
--banner-error-border: #ef4444;
@@ -301,6 +306,11 @@ html {
--landing-raw-bg: #000;
--medal-promotion-bg: #000;
--medal-promotion-bg-orange: #ffb84b54;
--medal-promotion-text-orange: #ffb84b;
--medal-promotion-bg-gradient: linear-gradient(90deg, #ffb74b21, transparent 50%, #000 100%);
--hover-filter: brightness(120%);
--active-filter: brightness(140%);

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,28 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { MailIcon, CheckIcon } from "@modrinth/assets";
import { ref, watchEffect } from "vue";
import { ref } from "vue";
import { useBaseFetch } from "~/composables/fetch.js";
const auth = await useAuth();
const showSubscriptionConfirmation = ref(false);
const subscribed = ref(false);
async function checkSubscribed() {
if (auth.value?.user) {
try {
const { data } = await useBaseFetch("auth/email/subscribe", {
method: "GET",
});
subscribed.value = data?.subscribed || false;
} catch {
subscribed.value = false;
const showSubscribeButton = useAsyncData(
async () => {
if (auth.value?.user) {
try {
const { subscribed } = await useBaseFetch("auth/email/subscribe", {
method: "GET",
});
return !subscribed;
} catch {
return true;
}
} else {
return false;
}
}
}
watchEffect(() => {
checkSubscribed();
});
},
{ watch: [auth], server: false },
);
async function subscribe() {
try {
@@ -35,14 +34,19 @@ async function subscribe() {
} finally {
setTimeout(() => {
showSubscriptionConfirmation.value = false;
subscribed.value = true;
showSubscribeButton.status.value = "success";
showSubscribeButton.data.value = false;
}, 2500);
}
}
</script>
<template>
<ButtonStyled v-if="auth?.user && !subscribed" color="brand" type="outlined">
<ButtonStyled
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
color="brand"
type="outlined"
>
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
<template v-else> <CheckIcon /> Subscribed! </template>

View File

@@ -0,0 +1,179 @@
<template>
<div class="universal-card">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
<div class="min-w-0 flex-1">
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
Score: {{ report.priority_score }}
</span>
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
:class="{
'text-brand': report.status === 'approved',
'text-red': report.status === 'rejected',
'text-secondary': report.status === 'pending',
}"
>
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
</span>
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
{{
report.version.files.find((file) => file.primary)?.filename ||
"Unknown primary file"
}}
</span>
</div>
</div>
</div>
</div>
<div
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
>
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</span>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex gap-2">
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Accept
</button>
</ButtonStyled>
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Reject
</button>
</ButtonStyled>
</div>
<div class="flex justify-center gap-2 sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="versionUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div class="text-sm text-secondary sm:hidden">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import {
Avatar,
useRelativeTime,
OverflowMenu,
type OverflowMenuOption,
ButtonStyled,
} from "@modrinth/ui";
import {
EllipsisVerticalIcon,
OrganizationIcon,
EyeIcon,
ClipboardCopyIcon,
LinkIcon,
} from "@modrinth/assets";
import type { ExtendedDelphiReport } from "@modrinth/moderation";
const props = defineProps<{
report: ExtendedDelphiReport;
}>();
const formatRelativeTime = useRelativeTime();
const isPending = computed(() => props.report.status === "pending");
const quickActions: OverflowMenuOption[] = [
{
id: "copy-link",
action: () => {
const base = window.location.origin;
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`;
navigator.clipboard.writeText(reviewUrl).then(() => {
addNotification({
type: "success",
title: "Tech review link copied",
text: "The link to this tech review has been copied to your clipboard.",
});
});
},
},
{
id: "copy-id",
action: () => {
navigator.clipboard.writeText(props.report.version.id).then(() => {
addNotification({
type: "success",
title: "Version ID copied",
text: "The ID of this version has been copied to your clipboard.",
});
});
},
},
];
const versionUrl = computed(() => {
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`;
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,204 @@
<template>
<div
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex-shrink-0 rounded-lg">
<Avatar size="48px" :src="queueEntry.project.icon_url" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<h3 class="truncate text-lg font-semibold">
{{ queueEntry.project.name }}
</h3>
<nuxt-link
v-if="queueEntry.owner"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/user/${queueEntry.owner.user.username}`"
>
<Avatar
:src="queueEntry.owner.user.avatar_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="queueEntry.org"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/organization/${queueEntry.org.slug}`"
>
<Avatar
:src="queueEntry.org.icon_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.org.name }}</span>
</nuxt-link>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
<BoxIcon
v-if="queueEntry.project.project_type === 'mod'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PaintbrushIcon
v-else-if="queueEntry.project.project_type === 'resourcepack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<BracesIcon
v-else-if="queueEntry.project.project_type === 'datapack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="queueEntry.project.project_type === 'modpack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="queueEntry.project.project_type === 'shader'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PlugIcon
v-else-if="queueEntry.project.project_type === 'plugin'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<span class="hidden sm:inline">{{
props.queueEntry.project.project_types.map(formatProjectType).join(", ")
}}</span>
<span class="sm:hidden">{{
formatProjectType(props.queueEntry.project.project_type ?? "project").substring(0, 3)
}}</span>
</span>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<div class="flex flex-row gap-2 text-sm">
Requesting
<Badge
v-if="props.queueEntry.project.requested_status"
:type="props.queueEntry.project.requested_status"
class="status"
/>
</div>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<span
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
class="truncate text-sm"
:class="{
'text-red': daysInQueue > 4,
'text-orange': daysInQueue > 2,
}"
>
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
<span class="sm:hidden">{{
getSubmittedTime(queueEntry).replace("Submitted ", "")
}}</span>
</span>
</div>
<div class="flex items-center justify-end gap-2 sm:justify-start">
<ButtonStyled circular>
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
<EyeIcon class="size-4" />
</NuxtLink>
</ButtonStyled>
<ButtonStyled circular color="orange" @click="openProjectForReview">
<button>
<ScaleIcon class="size-4" />
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import {
EyeIcon,
PaintbrushIcon,
ScaleIcon,
BoxIcon,
GlassesIcon,
PlugIcon,
PackageOpenIcon,
BracesIcon,
} from "@modrinth/assets";
import { useRelativeTime, Avatar, ButtonStyled, Badge } from "@modrinth/ui";
import {
formatProjectType,
type Organization,
type Project,
type TeamMember,
} from "@modrinth/utils";
import { computed } from "vue";
import { useModerationStore } from "~/store/moderation.ts";
import type { ModerationProject } from "~/helpers/moderation";
const formatRelativeTime = useRelativeTime();
const moderationStore = useModerationStore();
const props = defineProps<{
queueEntry: ModerationProject;
}>();
function getDaysQueued(date: Date): number {
const now = new Date();
const diff = now.getTime() - date.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
const queuedDate = computed(() => {
return dayjs(
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated,
);
});
const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate());
});
function openProjectForReview() {
moderationStore.setSingleProject(props.queueEntry.project.id);
navigateTo({
name: "type-id",
params: {
type: "project",
id: props.queueEntry.project.id,
},
state: {
showChecklist: true,
},
});
}
function getSubmittedTime(project: any): string {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated;
if (!date) return "Unknown";
try {
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`;
} catch {
return "Unknown";
}
}
</script>

View File

@@ -0,0 +1,275 @@
<template>
<div class="universal-card">
<div
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
>
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="flex items-center gap-2">
Reported for
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
{{ formattedReportType }}
</span>
</span>
<span class="flex items-center gap-2">
<span class="hidden sm:inline">By</span>
<span class="sm:hidden">Reporter:</span>
<nuxt-link
:to="`/user/${report.reporter_user.username}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.reporter_user.avatar_url"
circle
size="1.75rem"
class="flex-shrink-0"
/>
<span class="truncate">{{ report.reporter_user.username }}</span>
</nuxt-link>
</span>
</span>
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
<span class="text-md whitespace-nowrap text-secondary">{{
formatRelativeTime(report.created)
}}</span>
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
<OverflowMenu :options="visibleQuickReplies">
<span class="hidden sm:inline">Quick Reply</span>
<span class="sr-only sm:hidden">Quick Reply</span>
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
<hr class="my-4 rounded-xl border-solid text-divider" />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar
:src="reportItemAvatarUrl"
:circle="report.item_type === 'user'"
size="3rem"
class="flex-shrink-0"
/>
<div class="min-w-0 flex-1">
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target && report.item_type != 'user'"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target?.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name || "Unknown User" }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
{{ formattedItemType }}
</span>
<span
v-if="report.item_type === 'version' && report.version"
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
>
{{
report.version.files.find((file) => file.primary)?.filename || "Unknown Version"
}}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="reportItemUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<CollapsibleRegion class="my-4" ref="collapsibleRegion">
<ReportThread
v-if="report.thread"
ref="reportThread"
class="mb-16 sm:mb-0"
:thread="report.thread"
:report="report"
:reporter="report.reporter_user"
@update-thread="updateThread"
/>
</CollapsibleRegion>
</div>
</template>
<script setup lang="ts">
import {
Avatar,
useRelativeTime,
OverflowMenu,
type OverflowMenuOption,
CollapsibleRegion,
ButtonStyled,
} from "@modrinth/ui";
import {
EllipsisVerticalIcon,
OrganizationIcon,
EyeIcon,
ClipboardCopyIcon,
LinkIcon,
} from "@modrinth/assets";
import {
type ExtendedReport,
reportQuickReplies,
type ReportQuickReply,
} from "@modrinth/moderation";
import ChevronDownIcon from "../servers/icons/ChevronDownIcon.vue";
import ReportThread from "../thread/ReportThread.vue";
const props = defineProps<{
report: ExtendedReport;
}>();
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null);
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null);
const formatRelativeTime = useRelativeTime();
function updateThread(newThread: any) {
if (props.report.thread) {
Object.assign(props.report.thread, newThread);
}
}
const quickActions: OverflowMenuOption[] = [
{
id: "copy-link",
action: () => {
const base = window.location.origin;
const reportUrl = `${base}/moderation/reports/${props.report.id}`;
navigator.clipboard.writeText(reportUrl).then(() => {
addNotification({
type: "success",
title: "Report link copied",
text: "The link to this report has been copied to your clipboard.",
});
});
},
},
{
id: "copy-id",
action: () => {
navigator.clipboard.writeText(props.report.id).then(() => {
addNotification({
type: "success",
title: "Report ID copied",
text: "The ID of this report has been copied to your clipboard.",
});
});
},
},
];
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
return reportQuickReplies
.filter((reply) => {
if (reply.shouldShow === undefined) return true;
if (typeof reply.shouldShow === "function") {
return reply.shouldShow(props.report);
}
return reply.shouldShow;
})
.map(
(reply) =>
({
id: reply.label,
action: () => handleQuickReply(reply),
}) as OverflowMenuOption,
);
});
async function handleQuickReply(reply: ReportQuickReply) {
const message =
typeof reply.message === "function" ? await reply.message(props.report) : reply.message;
collapsibleRegion.value?.setCollapsed(false);
await nextTick();
reportThread.value?.setReplyContent(message);
}
const reportItemAvatarUrl = computed(() => {
switch (props.report.item_type) {
case "project":
case "version":
return props.report.project?.icon_url || "";
case "user":
return props.report.user?.avatar_url || "";
default:
return undefined;
}
});
const reportItemTitle = computed(() => {
if (props.report.item_type === "user") return props.report.user?.username || "Unknown User";
return props.report.project?.title || "Unknown Project";
});
const reportItemUrl = computed(() => {
switch (props.report.item_type) {
case "user":
return `/user/${props.report.user?.username}`;
case "project":
return `/${props.report.project?.project_type}/${props.report.project?.slug}`;
case "version":
return `/${props.report.project?.project_type}/${props.report.project?.slug}/versions/${props.report.version?.id}`;
}
});
const formattedItemType = computed(() => {
const itemType = props.report.item_type;
return itemType.charAt(0).toUpperCase() + itemType.slice(1);
});
const formattedReportType = computed(() => {
const reportType = props.report.report_type;
// some are split by -, some are split by " "
const words = reportType.includes("-") ? reportType.split("-") : reportType.split(" ");
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
});
</script>
<style lang="scss" scoped></style>

View File

@@ -29,7 +29,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref } from "vue";
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
@@ -64,7 +64,7 @@ function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
}
function isMac() {
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
return navigator.platform.toUpperCase().includes("MAC");
}
function show(event?: MouseEvent) {

View File

@@ -42,9 +42,9 @@
<div v-if="done">
<p>
You are done moderating this project!
<template v-if="futureProjectCount > 0">
<template v-if="moderationStore.hasItems">
There are
{{ futureProjectCount }} left.
{{ moderationStore.queueLength }} left.
</template>
</p>
</div>
@@ -98,7 +98,7 @@
<div v-if="toggleActions.length > 0" class="toggle-actions-group space-y-3">
<template v-for="action in toggleActions" :key="getActionKey(action)">
<Checkbox
:model-value="actionStates[getActionId(action)]?.selected ?? false"
:model-value="isActionSelected(action)"
:label="action.label"
:description="action.description"
:disabled="false"
@@ -215,49 +215,31 @@
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled v-if="!done && !generatedMessage && futureProjectCount > 0">
<button @click="goToNextProject">
<ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
<button @click="skipCurrentProject">
<XIcon aria-hidden="true" />
Skip
Skip ({{ moderationStore.queueLength }} left)
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<div v-if="done">
<ButtonStyled v-if="futureProjectCount > 0" color="brand">
<button @click="goToNextProject">
<RightArrowIcon aria-hidden="true" />
Next Project
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand">
<button @click="exitModeration">
<CheckIcon aria-hidden="true" />
Done
<ButtonStyled color="brand">
<button @click="endChecklist(undefined)">
<template v-if="hasNextProject">
<RightArrowIcon aria-hidden="true" />
Next Project ({{ moderationStore.queueLength }} left)
</template>
<template v-else>
<CheckIcon aria-hidden="true" />
All Done!
</template>
</button>
</ButtonStyled>
</div>
<div v-else-if="generatedMessage" class="flex items-center gap-2">
<OverflowMenu :options="stageOptions" class="bg-transparent p-0">
<ButtonStyled circular>
<button v-tooltip="`Stages`">
<ListBulletedIcon />
</button>
</ButtonStyled>
<template
v-for="opt in stageOptions.filter(
(opt) => 'id' in opt && 'text' in opt && 'icon' in opt,
)"
#[opt.id]
:key="opt.id"
>
<component :is="opt.icon" v-if="opt.icon" class="mr-2" />
{{ opt.text }}
</template>
</OverflowMenu>
<ButtonStyled>
<button @click="goBackToStages">
<LeftArrowIcon aria-hidden="true" />
@@ -277,7 +259,7 @@
</button>
</ButtonStyled>
<ButtonStyled color="green">
<button @click="sendMessage('approved')">
<button @click="sendMessage(project.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" />
Approve
</button>
@@ -368,44 +350,42 @@ import {
DropdownSelect,
MarkdownEditor,
} from "@modrinth/ui";
import { type Project, renderHighlightedString, type ModerationJudgements } from "@modrinth/utils";
import {
type Project,
renderHighlightedString,
type ModerationJudgements,
type ModerationModpackItem,
type ProjectStatus,
} from "@modrinth/utils";
import { computedAsync, useLocalStorage } from "@vueuse/core";
import type {
Action,
MultiSelectChipsAction,
DropdownAction,
ButtonAction,
ToggleAction,
ConditionalButtonAction,
Stage,
import {
type Action,
type MultiSelectChipsAction,
type DropdownAction,
type ButtonAction,
type ToggleAction,
type ConditionalButtonAction,
type Stage,
finalPermissionMessages,
} from "@modrinth/moderation";
import * as prettier from "prettier";
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
import KeybindsModal from "./ChecklistKeybindsModal.vue";
import { finalPermissionMessages } from "@modrinth/moderation/data/modpack-permissions-stage";
import prettier from "prettier";
import { useModerationStore } from "~/store/moderation.ts";
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
const props = withDefaults(
defineProps<{
project: Project;
futureProjectIds?: string[];
collapsed: boolean;
}>(),
{
futureProjectIds: () => [] as string[],
},
);
const props = defineProps<{
project: Project;
collapsed: boolean;
}>();
const moderationStore = useModerationStore();
const variables = computed(() => {
return flattenProjectVariables(props.project);
});
const futureProjectCount = computed(() => {
const ids = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
return ids.length;
});
const modpackPermissionsComplete = ref(false);
const modpackJudgements = ref<ModerationJudgements>({});
const isModpackPermissionsStage = computed(() => {
@@ -419,7 +399,6 @@ const done = ref(false);
function handleModpackPermissionsComplete() {
modpackPermissionsComplete.value = true;
nextStage();
}
const emit = defineEmits<{
@@ -530,7 +509,7 @@ function handleKeybinds(event: KeyboardEvent) {
isLoadingMessage: loadingMessage.value,
isModpackPermissionsStage: isModpackPermissionsStage.value,
futureProjectCount: futureProjectCount.value,
futureProjectCount: moderationStore.queueLength,
visibleActionsCount: visibleActions.value.length,
focusedActionIndex: focusedActionIndex.value,
@@ -543,13 +522,13 @@ function handleKeybinds(event: KeyboardEvent) {
tryGoNext: nextStage,
tryGoBack: previousStage,
tryGenerateMessage: generateMessage,
trySkipProject: goToNextProject,
trySkipProject: skipCurrentProject,
tryToggleCollapse: () => emit("toggleCollapsed"),
tryResetProgress: resetProgress,
tryExitModeration: () => emit("exit"),
tryApprove: () => sendMessage("approved"),
tryApprove: () => sendMessage(props.project.requested_status),
tryReject: () => sendMessage("rejected"),
tryWithhold: () => sendMessage("withheld"),
tryEditMessage: goBackToStages,
@@ -666,12 +645,17 @@ function initializeStageActions(stage: Stage, stageIndex: number) {
}
function getActionId(action: Action, index?: number): string {
// If index is not provided, find it in the current stage's actions
if (index === undefined) {
index = currentStageObj.value.actions.indexOf(action);
}
return getActionIdForStage(action, currentStage.value, index);
}
function getActionKey(action: Action): string {
const index = visibleActions.value.indexOf(action);
return `${currentStage.value}-${index}-${getActionId(action)}`;
// Find the actual index of this action in the current stage's actions array
const index = currentStageObj.value.actions.indexOf(action);
return `${currentStage.value}-${index}-${getActionId(action, index)}`;
}
const visibleActions = computed(() => {
@@ -741,7 +725,8 @@ const multiSelectActions = computed(() =>
);
function getDropdownValue(action: DropdownAction) {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const visibleOptions = getVisibleDropdownOptions(action);
const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0;
@@ -756,12 +741,14 @@ function getDropdownValue(action: DropdownAction) {
}
function isActionSelected(action: Action): boolean {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
return actionStates.value[actionId]?.selected || false;
}
function toggleAction(action: Action) {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const state = actionStates.value[actionId];
if (state) {
state.selected = !state.selected;
@@ -770,7 +757,8 @@ function toggleAction(action: Action) {
}
function selectDropdownOption(action: DropdownAction, selected: any) {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const state = actionStates.value[actionId];
if (state && selected !== undefined && selected !== null) {
const optionIndex = action.options.findIndex(
@@ -786,7 +774,8 @@ function selectDropdownOption(action: DropdownAction, selected: any) {
}
function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined;
const visibleOptions = getVisibleMultiSelectOptions(action);
@@ -797,7 +786,8 @@ function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): bo
}
function toggleChip(action: MultiSelectChipsAction, optionIndex: number) {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const state = actionStates.value[actionId];
if (state && state.value instanceof Set) {
const visibleOptions = getVisibleMultiSelectOptions(action);
@@ -823,6 +813,31 @@ const isAnyVisibleInputs = computed(() => {
});
});
function getModpackFilesFromStorage(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
} {
try {
const sessionData = sessionStorage.getItem(`modpack-permissions-data-${props.project.id}`);
const interactive = sessionData ? (JSON.parse(sessionData) as ModerationModpackItem[]) : [];
const permanentNoData = sessionStorage.getItem(
`modpack-permissions-permanent-no-${props.project.id}`,
);
const permanentNo = permanentNoData
? (JSON.parse(permanentNoData) as ModerationModpackItem[])
: [];
return {
interactive: interactive || [],
permanentNo: permanentNo || [],
};
} catch (error) {
console.warn("Failed to parse session storage modpack data:", error);
return { interactive: [], permanentNo: [] };
}
}
async function assembleFullMessage() {
const messageParts: MessagePart[] = [];
@@ -1045,7 +1060,7 @@ function nextStage() {
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
addNotification({
title: "Modpack permissions stage unfinished",
message: "Please complete the modpack permissions stage before proceeding.",
text: "Please complete the modpack permissions stage before proceeding.",
type: "error",
});
@@ -1092,13 +1107,14 @@ async function generateMessage() {
const baseMessage = await assembleFullMessage();
let fullMessage = baseMessage;
if (
props.project.project_type === "modpack" &&
Object.keys(modpackJudgements.value).length > 0
) {
const modpackMessage = generateModpackMessage(modpackJudgements.value);
if (modpackMessage) {
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
if (props.project.project_type === "modpack") {
const modpackFilesData = getModpackFilesFromStorage();
if (modpackFilesData.interactive.length > 0 || modpackFilesData.permanentNo.length > 0) {
const modpackMessage = generateModpackMessage(modpackFilesData);
if (modpackMessage) {
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
}
}
}
@@ -1121,7 +1137,7 @@ async function generateMessage() {
console.error("Error generating message:", error);
addNotification({
title: "Error generating message",
message: "Failed to generate moderation message. Please try again.",
text: "Failed to generate moderation message. Please try again.",
type: "error",
});
} finally {
@@ -1129,25 +1145,34 @@ async function generateMessage() {
}
}
function generateModpackMessage(judgements: ModerationJudgements) {
function generateModpackMessage(allFiles: {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
}) {
const issues = [];
const attributeMods = [];
const noMods = [];
const permanentNoMods = [];
const unidentifiedMods = [];
const attributeMods: string[] = [];
const noMods: string[] = [];
const permanentNoMods: string[] = [];
const unidentifiedMods: string[] = [];
for (const [, judgement] of Object.entries(judgements)) {
if (judgement.status === "with-attribution") {
attributeMods.push(judgement.file_name);
} else if (judgement.status === "no") {
noMods.push(judgement.file_name);
} else if (judgement.status === "permanent-no") {
permanentNoMods.push(judgement.file_name);
} else if (judgement.status === "unidentified") {
unidentifiedMods.push(judgement.file_name);
allFiles.interactive.forEach((file) => {
if (file.status === "unidentified") {
if (file.approved === "no") {
unidentifiedMods.push(file.file_name);
}
} else if (file.status === "with-attribution" && file.approved === "no") {
attributeMods.push(file.file_name);
} else if (file.status === "no" && file.approved === "no") {
noMods.push(file.file_name);
} else if (file.status === "permanent-no") {
permanentNoMods.push(file.file_name);
}
}
});
allFiles.permanentNo.forEach((file) => {
permanentNoMods.push(file.file_name);
});
if (
attributeMods.length > 0 ||
@@ -1157,6 +1182,12 @@ function generateModpackMessage(judgements: ModerationJudgements) {
) {
issues.push("## Copyrighted content");
if (unidentifiedMods.length > 0) {
issues.push(
`${finalPermissionMessages.unidentified}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
if (attributeMods.length > 0) {
issues.push(
`${finalPermissionMessages["with-attribution"]}\n${attributeMods.map((mod) => `- ${mod}`).join("\n")}`,
@@ -1172,18 +1203,13 @@ function generateModpackMessage(judgements: ModerationJudgements) {
`${finalPermissionMessages["permanent-no"]}\n${permanentNoMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
if (unidentifiedMods.length > 0) {
issues.push(
`${finalPermissionMessages["unidentified"]}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
);
}
}
return issues.join("\n\n");
}
async function sendMessage(status: "approved" | "rejected" | "withheld") {
const hasNextProject = ref(false);
async function sendMessage(status: ProjectStatus) {
try {
await useBaseFetch(`project/${props.project.id}`, {
method: "PATCH",
@@ -1217,55 +1243,73 @@ async function sendMessage(status: "approved" | "rejected" | "withheld") {
done.value = true;
// Clear local storage for future reviews
localStorage.removeItem(`modpack-permissions-${props.project.id}`);
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
localStorage.removeItem(`moderation-actions-${props.project.slug}`);
localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
actionStates.value = {};
addNotification({
title: "Moderation submitted",
message: `Project ${status} successfully.`,
type: "success",
});
hasNextProject.value = await moderationStore.completeCurrentProject(
props.project.id,
"completed",
);
} catch (error) {
console.error("Error submitting moderation:", error);
addNotification({
title: "Error submitting moderation",
message: "Failed to submit moderation decision. Please try again.",
text: "Failed to submit moderation decision. Please try again.",
type: "error",
});
}
}
async function goToNextProject() {
const currentIds = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
async function endChecklist(status?: string) {
clearProjectLocalStorage();
if (currentIds.length === 0) {
await navigateTo("/moderation/review");
return;
if (!hasNextProject.value) {
await navigateTo({
name: "moderation",
state: {
confetti: true,
},
});
await nextTick();
if (moderationStore.currentQueue.total > 1) {
addNotification({
title: "Moderation completed",
text: `You have completed the moderation queue.`,
type: "success",
});
} else {
addNotification({
title: "Moderation submitted",
text: `Project ${status ?? "completed successfully"}.`,
type: "success",
});
}
} else {
navigateTo({
name: "type-id",
params: {
type: "project",
id: moderationStore.getCurrentProjectId(),
},
state: {
showChecklist: true,
},
});
}
const nextProjectId = currentIds[0];
const remainingIds = currentIds.slice(1);
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingIds));
await router.push({
name: "type-id",
params: {
type: "project",
id: nextProjectId,
},
state: {
showChecklist: true,
},
});
}
async function exitModeration() {
await navigateTo("/moderation/review");
async function skipCurrentProject() {
hasNextProject.value = await moderationStore.completeCurrentProject(props.project.id, "skipped");
await endChecklist("skipped");
}
function clearProjectLocalStorage() {
localStorage.removeItem(`modpack-permissions-${props.project.id}`);
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
localStorage.removeItem(`moderation-actions-${props.project.slug}`);
localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
localStorage.removeItem(`moderation-stage-${props.project.slug}`);
actionStates.value = {};
}
const isLastVisibleStage = computed(() => {

View File

@@ -8,7 +8,7 @@
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions obtained. You may skip this step!</p>
<p>All permissions already obtained.</p>
</div>
<div v-else-if="!modPackData[currentIndex]">
@@ -157,7 +157,7 @@ import type {
} from "@modrinth/utils";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted } from "vue";
import { useLocalStorage } from "@vueuse/core";
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
const props = defineProps<{
projectId: string;
@@ -182,7 +182,26 @@ const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
const modPackData = ref<ModerationModpackItem[] | null>(null);
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
`modpack-permissions-data-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
`modpack-permissions-permanent-no-${props.projectId}`,
[],
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : []),
write: (v: any) => JSON.stringify(v),
},
},
);
const currentIndex = ref(0);
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
@@ -251,7 +270,45 @@ async function fetchModPackData(): Promise<void> {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse;
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
.filter(([_, file]) => file.status === "permanent-no")
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name));
permanentNoFiles.value = permanentNoItems;
const sortedData: ModerationModpackItem[] = [
...Object.entries(data.identified || {})
.filter(
([_, file]) =>
file.status !== "yes" &&
file.status !== "with-attribution-and-source" &&
file.status !== "permanent-no",
)
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
...(file.status === "unidentified" && {
proof: "",
url: "",
title: "",
}),
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.unknown_files || {})
.map(
([sha1, fileName]): ModerationUnknownModpackItem => ({
@@ -310,6 +367,7 @@ async function fetchModPackData(): Promise<void> {
} catch (error) {
console.error("Failed to fetch modpack data:", error);
modPackData.value = [];
permanentNoFiles.value = [];
persistAll();
}
}
@@ -321,6 +379,14 @@ function goToPrevious(): void {
}
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++;
@@ -396,6 +462,17 @@ onMounted(() => {
}
});
watch(
modPackData,
(newValue) => {
if (newValue && newValue.length === 0) {
emit("complete");
clearPersistedData();
}
},
{ immediate: true },
);
watch(
() => props.projectId,
() => {
@@ -406,6 +483,20 @@ watch(
}
},
);
function getModpackFiles(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
} {
return {
interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value,
};
}
defineExpose({
getModpackFiles,
});
</script>
<style scoped>

View File

@@ -1,13 +1,21 @@
<template>
<template v-if="moderation">
<Chips v-model="reasonFilter" :items="reasons" />
<p v-if="reports.length === MAX_REPORTS" class="text-red">
There are at least {{ MAX_REPORTS }} open reports. This page is at its max reports and will
not show any more recent ones.
</p>
<p v-else-if="reasonFilter === 'All'">There are {{ filteredReports.length }} open reports.</p>
<p v-else>
There are {{ filteredReports.length }}/{{ reports.length }} open '{{ reasonFilter }}' reports.
</p>
</template>
<ReportInfo
v-for="report in reports.filter(
(x) =>
(moderation || x.reporterUser.id === auth.user.id) &&
(viewMode === 'open' ? x.open : !x.open),
)"
v-for="report in filteredReports"
:key="report.id"
:report="report"
:thread="report.thread"
:show-message="false"
:moderation="moderation"
raised
:auth="auth"
@@ -16,11 +24,12 @@
<p v-if="reports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import { Chips } from "@modrinth/ui";
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
defineProps({
const props = defineProps({
moderation: {
type: Boolean,
default: false,
@@ -32,9 +41,14 @@ defineProps({
});
const viewMode = ref("open");
const reasonFilter = ref("All");
const reports = ref([]);
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
const MAX_REPORTS = 1500;
let { data: rawReports } = await useAsyncData("report", () =>
useBaseFetch(`report?count=${MAX_REPORTS}`),
);
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, "");
@@ -51,6 +65,7 @@ const userIds = [...new Set(reporterUsers.concat(reportedUsers))];
const threadIds = [
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
];
const reasons = ["All", ...new Set(rawReports.map((report) => report.report_type))];
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
@@ -93,4 +108,13 @@ reports.value = rawReports.map((report) => {
report.open = true;
return report;
});
const filteredReports = computed(() =>
reports.value?.filter(
(x) =>
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
(viewMode.value === "open" ? x.open : !x.open) &&
(reasonFilter.value === "All" || reasonFilter.value === x.report_type),
),
);
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="experimental-styles-within flex size-24 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
class="experimental-styles-within flex size-16 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<client-only>
<img

View File

@@ -4,26 +4,19 @@
:to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`"
>
<div
v-tooltip="
status === 'suspended'
? suspension_reason === 'upgrading'
? 'This server is being transferred to a new node. It will be unavailable until this process finishes.'
: 'This server has been suspended. Please visit your billing settings or contact Modrinth Support for more information.'
: ''
"
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100"
:class="status === 'suspended' ? '!rounded-b-none opacity-75' : 'active:scale-95'"
class="flex flex-row items-center overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4 transition-transform duration-100"
:class="status === 'suspended' ? '!rounded-b-none border-b-0 opacity-75' : 'active:scale-95'"
data-pyro-server-listing
:data-pyro-server-listing-id="server_id"
>
<UiServersServerIcon v-if="status !== 'suspended'" :image="image" />
<div
v-else
class="bg-bg-secondary flex size-24 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
class="bg-bg-secondary flex size-16 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<LockIcon class="size-20 text-secondary" />
<LockIcon class="size-12 text-secondary" />
</div>
<div class="ml-8 flex flex-col gap-2.5">
<div class="ml-4 flex flex-col gap-2.5">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
<ChevronRightIcon />
@@ -41,7 +34,6 @@
/>
Using {{ projectData?.title || "Unknown" }}
</div>
<div v-else class="min-h-[20px]"></div>
<div
v-if="isConfiguring"
@@ -61,14 +53,35 @@
</div>
<div
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-blue p-4 text-sm font-bold text-contrast"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-blue bg-bg-blue p-4 text-sm font-bold text-contrast"
>
<UiServersPanelSpinner />
Your server's hardware is currently being upgraded and will be back online shortly.
</div>
<div
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been cancelled. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended' && suspension_reason"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended:
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
@@ -82,12 +95,13 @@
<script setup lang="ts">
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
import type { Project, Server } from "@modrinth/utils";
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import { Avatar, CopyCode } from "@modrinth/ui";
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<Partial<Server>>();
if (props.server_id) {
if (props.server_id && props.status === "available") {
// Necessary only to get server icon
await useModrinthServers(props.server_id, ["general"]);
}
@@ -109,11 +123,6 @@ if (props.upstream) {
}
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
if (import.meta.server && projectData.value?.icon_url) {
await useModrinthServers(props.server_id!, ["general"]);
}
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
const isConfiguring = computed(() => props.flows?.intro);
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div
id="medal"
class="medal-promotion flex w-full flex-row justify-between rounded-xl p-6 shadow-xl"
>
<div class="overlay"></div>
<MedalPromoBackground class="background-pattern shadow-xl" />
<div class="z-10 flex flex-col gap-2">
<div class="flex items-center gap-2 text-2xl font-semibold text-contrast">
<ClockIcon class="clock-glow text-medal-orange size-6" /><span>
Try a free
<span class="text-medal-orange">3GB server</span> for 5 days powered by
<span class="text-medal-orange">Medal</span>
</span>
</div>
<div class="flex items-center">
<span class="text-sm text-secondary"
>Limited-time offer. No credit card required. Available for US servers.</span
>
</div>
</div>
<ButtonStyled color="orange" type="outlined" size="large">
<nuxt-link to="https://medal.tv/" class="z-10 my-auto">Learn more <ExternalIcon /></nuxt-link>
</ButtonStyled>
</div>
</template>
<script lang="ts" setup>
import { ClockIcon, ExternalIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import MedalPromoBackground from "~/assets/images/illustrations/medal_promo_background.svg?component";
</script>
<style scoped lang="scss">
.medal-promotion {
position: relative;
border: 1px solid var(--medal-promotion-bg-orange);
overflow: hidden;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--medal-promotion-bg-gradient);
z-index: 1;
border-radius: inherit;
}
.background-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
background-color: var(--medal-promotion-bg);
border-radius: inherit;
color: var(--color-orange);
}
.clock-glow {
filter: drop-shadow(0 0 72px var(--color-orange)) drop-shadow(0 0 36px var(--color-orange))
drop-shadow(0 0 18px var(--color-orange));
}
.text-medal-orange {
color: var(--medal-promotion-text-orange);
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div
class="medal-promotion flex w-full flex-row items-center justify-between rounded-2xl p-4 shadow-xl"
>
<div class="overlay"></div>
<MedalPromoBackground class="background-pattern scale-[125%]" />
<div class="z-10 flex flex-col gap-1">
<div class="flex items-center gap-2 text-lg font-semibold text-contrast">
<ClockIcon class="clock-glow text-medal-orange size-5" />
<span>
Your <span class="text-medal-orange">Medal</span> powered Modrinth Server will expire in
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.days }}</span> days
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.hours }}</span> hours
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.minutes }}</span> minutes
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.seconds }}</span> seconds.
</span>
</div>
</div>
<ButtonStyled color="orange" type="outlined" size="large">
<button class="z-10 my-auto" @click="handleUpgrade"><RocketIcon /> Upgrade</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ClockIcon, RocketIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import dayjs from "dayjs";
import dayjsDuration from "dayjs/plugin/duration";
import MedalPromoBackground from "~/assets/images/illustrations/medal_promo_background.svg?component";
// eslint-disable-next-line import/no-named-as-default-member
dayjs.extend(dayjsDuration);
const props = defineProps<{
expiryDate?: string | Date;
}>();
const expiryDate = computed(() => {
if (props.expiryDate) {
return dayjs(props.expiryDate);
}
return dayjs().add(5, "day");
});
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 });
function handleUpgrade(event: Event) {
event.stopPropagation();
// TODO: Upgrade logic
}
function updateCountdown() {
const now = dayjs();
const diff = expiryDate.value.diff(now);
if (diff <= 0) {
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 };
return;
}
const duration = dayjs.duration(diff);
timeLeftCountdown.value = {
days: duration.days(),
hours: duration.hours(),
minutes: duration.minutes(),
seconds: duration.seconds(),
};
}
updateCountdown();
const intervalId = ref<NodeJS.Timeout | null>(null);
onMounted(() => {
intervalId.value = setInterval(updateCountdown, 1000);
});
onUnmounted(() => {
if (intervalId.value) clearInterval(intervalId.value);
});
</script>
<style scoped lang="scss">
.medal-promotion {
position: relative;
border: 1px solid var(--medal-promotion-bg-orange);
background: inherit; // allows overlay + pattern to take over
overflow: hidden;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--medal-promotion-bg-gradient);
z-index: 1;
border-radius: inherit;
}
.background-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
background-color: var(--medal-promotion-bg);
border-radius: inherit;
color: var(--medal-promotion-text-orange);
}
.clock-glow {
filter: drop-shadow(0 0 72px var(--color-orange)) drop-shadow(0 0 36px var(--color-orange))
drop-shadow(0 0 18px var(--color-orange));
}
.text-medal-orange {
color: var(--medal-promotion-text-orange);
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,243 @@
<template>
<div>
<div
class="medal-promotion flex flex-row items-center overflow-x-hidden rounded-2xl p-4 shadow-xl transition-transform duration-100"
:class="status === 'suspended' ? '!rounded-b-none border-b-0 opacity-75' : ''"
data-pyro-server-listing
:data-pyro-server-listing-id="server_id"
>
<div class="overlay"></div>
<MedalPromoBackground class="background-pattern scale-[125%]" />
<NuxtLink
:to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`"
class="z-10 flex flex-grow flex-row items-center overflow-x-hidden"
:class="status !== 'suspended' && 'active:scale-95'"
>
<MedalServerIcon
v-if="status !== 'suspended'"
class="border-medal-orange z-10 size-16 shrink-0 rounded-xl border-[1px] border-solid bg-bg text-orange"
/>
<div
v-else
class="bg-bg-secondary z-10 flex size-16 shrink-0 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<LockIcon class="size-12 text-secondary" />
</div>
<div class="z-10 ml-4 flex min-w-0 flex-col gap-2.5">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 truncate text-xl font-bold text-contrast">{{ name }}</h2>
<ChevronRightIcon />
<span class="truncate">
<span class="text-medal-orange">
{{ timeLeftCountdown.days }}
</span>
days
<span class="text-medal-orange">
{{ timeLeftCountdown.hours }}
</span>
hours
<span class="text-medal-orange">
{{ timeLeftCountdown.minutes }}
</span>
minutes
<span class="text-medal-orange">
{{ timeLeftCountdown.seconds }}
</span>
seconds remaining...
</span>
</div>
<div
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
>
<Avatar
:src="iconUrl"
no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
alt="Server Icon"
/>
Using {{ projectData?.title || "Unknown" }}
</div>
<div
v-if="isConfiguring"
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
>
<SparklesIcon class="size-5 shrink-0" /> New server
</div>
<UiServersServerInfoLabels
v-else
:server-data="{ game, mc_version, loader, loader_version, net }"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</NuxtLink>
<div class="z-10 ml-auto mr-6">
<ButtonStyled color="orange" type="outlined" size="large">
<button class="my-auto" @click="handleUpgrade"><RocketIcon /> Upgrade</button>
</ButtonStyled>
</div>
</div>
<div
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-blue bg-bg-blue p-4 text-sm font-bold text-contrast"
>
<UiServersPanelSpinner />
Your server's hardware is currently being upgraded and will be back online shortly.
</div>
<div
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been cancelled. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended' && suspension_reason"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended:
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon, LockIcon, SparklesIcon, RocketIcon } from "@modrinth/assets";
import type { Project, Server } from "@modrinth/utils";
import { Avatar, CopyCode, ButtonStyled } from "@modrinth/ui";
import dayjs from "dayjs";
import dayjsDuration from "dayjs/plugin/duration";
import MedalServerIcon from "~/assets/images/servers/medal_server_icon.svg?component";
import MedalPromoBackground from "~/assets/images/illustrations/medal_promo_background.svg?component";
// eslint-disable-next-line import/no-named-as-default-member
dayjs.extend(dayjsDuration);
const props = defineProps<Partial<Server>>();
const showGameLabel = computed(() => !!props.game);
const showLoaderLabel = computed(() => !!props.loader);
let projectData: Ref<Project | null>;
if (props.upstream) {
const { data } = await useAsyncData<Project>(
`server-project-${props.server_id}`,
async (): Promise<Project> => {
const result = await useBaseFetch(`project/${props.upstream?.project_id}`);
return result as Project;
},
);
projectData = data;
} else {
projectData = ref(null);
}
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
const isConfiguring = computed(() => props.flows?.intro);
const expiryDate = dayjs().add(5, "day");
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 });
function handleUpgrade(event: Event) {
event.stopPropagation();
// TODO: Upgrade logic.
}
function updateCountdown() {
const now = dayjs();
const diff = expiryDate.diff(now);
if (diff <= 0) {
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 };
return;
}
const duration = dayjs.duration(diff);
timeLeftCountdown.value = {
days: duration.days(),
hours: duration.hours(),
minutes: duration.minutes(),
seconds: duration.seconds(),
};
}
updateCountdown();
const intervalId = ref<NodeJS.Timeout | null>(null);
onMounted(() => {
intervalId.value = setInterval(updateCountdown, 1000);
});
onUnmounted(() => {
if (intervalId.value) clearInterval(intervalId.value);
});
</script>
<style scoped lang="scss">
.medal-promotion {
position: relative;
border: 1px solid var(--medal-promotion-bg-orange);
background: inherit; // allows overlay + pattern to take over
overflow: hidden;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--medal-promotion-bg-gradient);
z-index: 1;
border-radius: inherit;
}
.background-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
background-color: var(--medal-promotion-bg);
border-radius: inherit;
color: var(--medal-promotion-text-orange);
}
.text-medal-orange {
color: var(--medal-promotion-text-orange);
font-weight: bold;
}
.border-medal-orange {
border-color: var(--medal-promotion-bg-orange);
}
</style>

View File

@@ -34,6 +34,38 @@
</div>
</div>
</Modal>
<Modal ref="modalReply" header="Reply to thread">
<div class="modal-submit universal-body">
<span>
Your project is already approved. As such, the moderation team does not actively monitor
this thread. However, they may still see your message if there is a problem with your
project.
</span>
<span>
If you need to get in contact with the moderation team, please use the
<a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</span>
<Checkbox
v-model="replyConfirmation"
description="Confirm moderators do not actively monitor this"
>
I acknowledge that the moderators do not actively monitor the thread.
</Checkbox>
<div class="input-group push-right">
<button
class="btn btn-primary"
:disabled="!replyConfirmation"
@click="sendReplyFromModal()"
>
<ReplyIcon aria-hidden="true" />
Reply to thread
</button>
</div>
</div>
</Modal>
<div v-if="flags.developerMode" class="thread-id">
Thread ID:
<CopyCode :text="thread.id" />
@@ -71,12 +103,17 @@
v-if="sortedMessages.length > 0"
class="btn btn-primary"
:disabled="!replyBody"
@click="sendReply()"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
>
<ReplyIcon aria-hidden="true" />
Reply
</button>
<button v-else class="btn btn-primary" :disabled="!replyBody" @click="sendReply()">
<button
v-else
class="btn btn-primary"
:disabled="!replyBody"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
>
<SendIcon aria-hidden="true" />
Send
</button>
@@ -289,6 +326,7 @@ const sortedMessages = computed(() => {
});
const modalSubmit = ref(null);
const modalReply = ref(null);
async function updateThreadLocal() {
let threadId = null;
@@ -316,6 +354,11 @@ async function onUploadImage(file) {
return response.url;
}
async function sendReplyFromModal(status = null, privateMessage = false) {
modalReply.value.hide();
await sendReply(status, privateMessage);
}
async function sendReply(status = null, privateMessage = false) {
try {
const body = {
@@ -398,6 +441,7 @@ async function reopenReport() {
const replyWithSubmission = ref(false);
const submissionConfirmation = ref(false);
const replyConfirmation = ref(false);
function openResubmitModal(reply) {
submissionConfirmation.value = false;
@@ -405,6 +449,11 @@ function openResubmitModal(reply) {
modalSubmit.value.show();
}
function openReplyModal(reply) {
replyConfirmation.value = false;
modalReply.value.show();
}
async function resubmit() {
if (replyWithSubmission.value) {
await sendReply("processing");

View File

@@ -0,0 +1,282 @@
<template>
<div>
<div v-if="flags.developerMode" class="mb-4 font-bold text-heading">
Thread ID:
<CopyCode :text="thread.id" />
</div>
<div
v-if="sortedMessages.length > 0"
class="bg-raised flex flex-col space-y-4 rounded-xl p-3 sm:p-4"
>
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:report="report"
:auth="auth"
raised
@update-thread="() => updateThreadLocal()"
/>
</div>
<template v-if="reportClosed">
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2 w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="reopenReport()"
>
<CheckCircleIcon class="size-4" />
Reopen Thread
</button>
</ButtonStyled>
</template>
<template v-else>
<div class="mt-4">
<MarkdownEditor
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
:on-image-upload="onUploadImage"
/>
</div>
<div
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<ButtonStyled v-if="sortedMessages.length > 0" color="brand" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply()"
>
<ReplyIcon class="size-4" />
Reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply()"
>
<SendIcon class="size-4" />
Send
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply(true)"
>
<ScaleIcon class="size-4" />
<span class="hidden sm:inline">Add private note</span>
<span class="sm:hidden">Private note</span>
</button>
</ButtonStyled>
</div>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="replyBody" color="red" class="w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="closeReport(true)"
>
<CheckCircleIcon class="size-4" />
<span class="hidden sm:inline">Close with reply</span>
<span class="sm:hidden">Close & reply</span>
</button>
</ButtonStyled>
<ButtonStyled v-else color="red" class="w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="closeReport()"
>
<CheckCircleIcon class="size-4" />
Close report
</button>
</ButtonStyled>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { CopyCode, MarkdownEditor, ButtonStyled } from "@modrinth/ui";
import { ReplyIcon, SendIcon, CheckCircleIcon, ScaleIcon } from "@modrinth/assets";
import type { Thread, Report, User, ThreadMessage as TypeThreadMessage } from "@modrinth/utils";
import dayjs from "dayjs";
import ThreadMessage from "./ThreadMessage.vue";
import { useImageUpload } from "~/composables/image-upload.ts";
import { isStaff } from "~/helpers/users.js";
const props = defineProps<{
thread: Thread;
reporter: User;
report: Report;
}>();
const auth = await useAuth();
const emit = defineEmits<{
updateThread: [thread: Thread];
}>();
const flags = useFeatureFlags();
const members = computed(() => {
const membersMap: Record<string, User> = {
[props.reporter.id]: props.reporter,
};
for (const member of props.thread.members) {
membersMap[member.id] = member;
}
return membersMap;
});
const replyBody = ref("");
function setReplyContent(content: string) {
replyBody.value = content;
}
defineExpose({
setReplyContent,
});
const sortedMessages = computed(() => {
const messages: TypeThreadMessage[] = [
{
id: null,
author_id: props.reporter.id,
body: {
type: "text",
body: props.report.body || "Report opened.",
private: false,
replying_to: null,
associated_images: [],
},
created: props.report.created,
hide_identity: false,
},
];
if (props.thread) {
messages.push(
...[...props.thread.messages].sort(
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
),
);
}
return messages;
});
async function updateThreadLocal() {
const threadId = props.report.thread_id;
if (threadId) {
try {
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread;
emit("updateThread", thread);
} catch (error) {
console.error("Failed to update thread:", error);
}
}
}
const imageIDs = ref<string[]>([]);
async function onUploadImage(file: File) {
const response = await useImageUpload(file, { context: "thread_message" });
imageIDs.value.push(response.id);
imageIDs.value = imageIDs.value.slice(-10);
return response.url;
}
async function sendReply(privateMessage = false) {
try {
const body: any = {
body: {
type: "text",
body: replyBody.value,
private: privateMessage,
},
};
if (imageIDs.value.length > 0) {
body.body = {
...body.body,
uploaded_images: imageIDs.value,
};
}
await useBaseFetch(`thread/${props.thread.id}`, {
method: "POST",
body,
});
replyBody.value = "";
await updateThreadLocal();
} catch (err: any) {
addNotification({
title: "Error sending message",
text: err.data ? err.data.description : err,
type: "error",
});
}
}
const didCloseReport = ref(false);
const reportClosed = computed(() => {
return didCloseReport.value || (props.report && props.report.closed);
});
async function closeReport(reply = false) {
if (reply) {
await sendReply();
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: "PATCH",
body: {
closed: true,
},
});
await updateThreadLocal();
didCloseReport.value = true;
} catch (err: any) {
addNotification({
title: "Error closing report",
text: err.data ? err.data.description : err,
type: "error",
});
}
}
async function reopenReport() {
try {
await useBaseFetch(`report/${props.report.id}`, {
method: "PATCH",
body: {
closed: false,
},
});
await updateThreadLocal();
} catch (err: any) {
addNotification({
title: "Error reopening report",
text: err.data ? err.data.description : err,
type: "error",
});
}
}
</script>

View File

@@ -36,7 +36,7 @@
v-tooltip="'Modrinth Team'"
/>
<MicrophoneIcon
v-if="report && message.author_id === report.reporterUser.id"
v-if="report && message.author_id === report.reporter_user?.id"
v-tooltip="'Reporter'"
class="reporter-icon"
/>

View File

@@ -25,6 +25,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
// Feature toggles
projectTypesPrimaryNav: false,
enableMedalPromotion: true,
hidePlusPromoInUserMenu: false,
oldProjectCards: true,
newProjectCards: false,

View File

@@ -147,7 +147,7 @@ export async function useServersFetch<T>(
404: "Not Found",
405: "Method Not Allowed",
408: "Request Timeout",
429: "Too Many Requests",
429: "You're making requests too quickly. Please wait a moment and try again.",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
@@ -167,11 +167,17 @@ export async function useServersFetch<T>(
console.error("Fetch error:", error);
const fetchError = new ModrinthServersFetchError(
`[Modrinth Servers] ${message}`,
`[Modrinth Servers] ${error.message}`,
statusCode,
error,
);
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
throw new ModrinthServerError(
`[Modrinth Servers] ${message}`,
statusCode,
fetchError,
module,
v1Error,
);
}
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;

View File

@@ -0,0 +1,236 @@
import type { ExtendedReport, OwnershipTarget } from "@modrinth/moderation";
import type {
Thread,
Version,
User,
Project,
TeamMember,
Organization,
Report,
} from "@modrinth/utils";
export const useModerationCache = () => ({
threads: useState<Map<string, Thread>>("moderation-report-cache-threads", () => new Map()),
users: useState<Map<string, User>>("moderation-report-cache-users", () => new Map()),
projects: useState<Map<string, Project>>("moderation-report-cache-projects", () => new Map()),
versions: useState<Map<string, Version>>("moderation-report-cache-versions", () => new Map()),
teams: useState<Map<string, TeamMember[]>>("moderation-report-cache-teams", () => new Map()),
orgs: useState<Map<string, Organization>>("moderation-report-cache-orgs", () => new Map()),
});
// TODO: @AlexTMjugador - backend should do all of these functions.
export async function enrichReportBatch(reports: Report[]): Promise<ExtendedReport[]> {
if (reports.length === 0) return [];
const cache = useModerationCache();
const threadIDs = reports
.map((r) => r.thread_id)
.filter(Boolean)
.filter((id) => !cache.threads.value.has(id));
const userIDs = [
...reports.filter((r) => r.item_type === "user").map((r) => r.item_id),
...reports.map((r) => r.reporter),
].filter((id) => !cache.users.value.has(id));
const versionIDs = reports
.filter((r) => r.item_type === "version")
.map((r) => r.item_id)
.filter((id) => !cache.versions.value.has(id));
const projectIDs = reports
.filter((r) => r.item_type === "project")
.map((r) => r.item_id)
.filter((id) => !cache.projects.value.has(id));
const [newThreads, newVersions, newUsers] = await Promise.all([
threadIDs.length > 0
? (fetchSegmented(threadIDs, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`) as Promise<
Thread[]
>)
: Promise.resolve([]),
versionIDs.length > 0
? (fetchSegmented(versionIDs, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`) as Promise<
Version[]
>)
: Promise.resolve([]),
[...new Set(userIDs)].length > 0
? (fetchSegmented(
[...new Set(userIDs)],
(ids) => `users?ids=${asEncodedJsonArray(ids)}`,
) as Promise<User[]>)
: Promise.resolve([]),
]);
newThreads.forEach((t) => cache.threads.value.set(t.id, t));
newVersions.forEach((v) => cache.versions.value.set(v.id, v));
newUsers.forEach((u) => cache.users.value.set(u.id, u));
const allVersions = [...newVersions, ...Array.from(cache.versions.value.values())];
const fullProjectIds = new Set([
...projectIDs,
...allVersions
.filter((v) => versionIDs.includes(v.id))
.map((v) => v.project_id)
.filter(Boolean),
]);
const uncachedProjectIds = Array.from(fullProjectIds).filter(
(id) => !cache.projects.value.has(id),
);
const newProjects =
uncachedProjectIds.length > 0
? ((await fetchSegmented(
uncachedProjectIds,
(ids) => `projects?ids=${asEncodedJsonArray(ids)}`,
)) as Project[])
: [];
newProjects.forEach((p) => cache.projects.value.set(p.id, p));
const allProjects = [...newProjects, ...Array.from(cache.projects.value.values())];
const teamIds = [...new Set(allProjects.map((p) => p.team).filter(Boolean))].filter(
(id) => !cache.teams.value.has(id || "invalid team id"),
);
const orgIds = [...new Set(allProjects.map((p) => p.organization).filter(Boolean))].filter(
(id) => !cache.orgs.value.has(id),
);
const [newTeams, newOrgs] = await Promise.all([
teamIds.length > 0
? (fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`) as Promise<
TeamMember[][]
>)
: Promise.resolve([]),
orgIds.length > 0
? (fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
}) as Promise<Organization[]>)
: Promise.resolve([]),
]);
newTeams.forEach((team) => {
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
});
newOrgs.forEach((org) => cache.orgs.value.set(org.id, org));
return reports.map((report) => {
const thread = cache.threads.value.get(report.thread_id) || ({} as Thread);
const version =
report.item_type === "version" ? cache.versions.value.get(report.item_id) : undefined;
const project =
report.item_type === "project"
? cache.projects.value.get(report.item_id)
: report.item_type === "version" && version
? cache.projects.value.get(version.project_id)
: undefined;
let target: OwnershipTarget | undefined;
if (report.item_type === "user") {
const targetUser = cache.users.value.get(report.item_id);
if (targetUser) {
target = {
name: targetUser.username,
slug: targetUser.username,
avatar_url: targetUser.avatar_url,
type: "user",
};
}
} else if (project) {
let owner: TeamMember | null = null;
let org: Organization | null = null;
if (project.team) {
const teamMembers = cache.teams.value.get(project.team);
if (teamMembers) {
owner = teamMembers.find((member) => member.role === "Owner") || null;
}
}
if (project.organization) {
org = cache.orgs.value.get(project.organization) || null;
}
if (org) {
target = {
name: org.name,
avatar_url: org.icon_url,
type: "organization",
slug: org.slug,
};
} else if (owner) {
target = {
name: owner.user.username,
avatar_url: owner.user.avatar_url,
type: "user",
slug: owner.user.username,
};
}
}
return {
...report,
thread,
reporter_user: cache.users.value.get(report.reporter) || ({} as User),
project,
user: report.item_type === "user" ? cache.users.value.get(report.item_id) : undefined,
version,
target,
};
});
}
// Doesn't need to be in @modrinth/moderation because it is specific to the frontend.
export interface ModerationProject {
project: any;
owner: TeamMember | null;
org: Organization | null;
}
export async function enrichProjectBatch(projects: any[]): Promise<ModerationProject[]> {
const teamIds = [...new Set(projects.map((p) => p.team_id).filter(Boolean))];
const orgIds = [...new Set(projects.map((p) => p.organization).filter(Boolean))];
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
]);
const cache = useModerationCache();
teamsData.forEach((team) => {
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
});
orgsData.forEach((org: Organization) => {
cache.orgs.value.set(org.id, org);
});
return projects.map((project) => {
let owner: TeamMember | null = null;
let org: Organization | null = null;
if (project.team_id) {
const teamMembers = cache.teams.value.get(project.team_id);
if (teamMembers) {
owner = teamMembers.find((member) => member.role === "Owner") || null;
}
}
if (project.organization) {
org = cache.orgs.value.get(project.organization) || null;
}
return {
project,
owner,
org,
} as ModerationProject;
});
}

View File

@@ -295,7 +295,7 @@
{
id: 'review-projects',
color: 'orange',
link: '/moderation/review',
link: '/moderation/',
},
{
id: 'review-reports',
@@ -981,23 +981,6 @@ const userMenuOptions = computed(() => {
},
];
if (
(auth.value && auth.value.user && auth.value.user.role === "moderator") ||
auth.value.user.role === "admin"
) {
options = [
...options,
{
divider: true,
},
{
id: "moderation",
color: "orange",
link: "/moderation/review",
},
];
}
options = [
...options,
{

View File

@@ -182,9 +182,6 @@
"collection.button.unfollow-project": {
"message": "Unfollow project"
},
"collection.button.upload-icon": {
"message": "Upload icon"
},
"collection.delete-modal.description": {
"message": "This will remove this collection forever. This action cannot be undone."
},
@@ -479,6 +476,30 @@
"layout.nav.search": {
"message": "Search"
},
"moderation.filter.by": {
"message": "Filter by"
},
"moderation.moderate": {
"message": "Moderate"
},
"moderation.page.projects": {
"message": "Projects"
},
"moderation.page.reports": {
"message": "Reports"
},
"moderation.page.technicalReview": {
"message": "Technical Review"
},
"moderation.search.placeholder": {
"message": "Search..."
},
"moderation.sort.by": {
"message": "Sort by"
},
"moderation.technical.search.placeholder": {
"message": "Search tech reviews..."
},
"profile.button.billing": {
"message": "Manage user billing"
},

View File

@@ -689,7 +689,10 @@
},
{
id: 'moderation-checklist',
action: () => (showModerationChecklist = true),
action: () => {
moderationStore.setSingleProject(project.id);
showModerationChecklist = true;
},
color: 'orange',
hoverOnly: true,
shown:
@@ -870,19 +873,6 @@
@delete-version="deleteVersion"
/>
</div>
<div class="normal-page__ultimate-sidebar">
<!-- Uncomment this to enable the old moderation checklist. -->
<!-- <ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
:project="project"
:future-projects="futureProjects"
:reset-project="resetProject"
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/> -->
</div>
</div>
</div>
@@ -890,9 +880,8 @@
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
class="moderation-checklist"
>
<NewModerationChecklist
<ModerationChecklist
:project="project"
:future-project-ids="futureProjectIds"
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
@@ -951,14 +940,7 @@ import {
useRelativeTime,
} from "@modrinth/ui";
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
import {
formatCategory,
formatProjectType,
isRejected,
isStaff,
isUnderReview,
renderString,
} from "@modrinth/utils";
import { formatCategory, formatProjectType, renderString } from "@modrinth/utils";
import dayjs from "dayjs";
import { Tooltip } from "floating-vue";
import { useLocalStorage } from "@vueuse/core";
@@ -976,11 +958,13 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
import { userCollectProject } from "~/composables/user.js";
import { reportProject } from "~/utils/report-helpers.ts";
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
import ModerationChecklist from "~/components/ui/moderation/checklist/ModerationChecklist.vue";
import { useModerationStore } from "~/store/moderation.ts";
const data = useNuxtApp();
const route = useNativeRoute();
const config = useRuntimeConfig();
const moderationStore = useModerationStore();
const auth = await useAuth();
const user = await useUser();
@@ -1568,12 +1552,6 @@ const showModerationChecklist = useLocalStorage(
);
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
watch(futureProjectIds, (newValue) => {
console.log("Future project IDs updated:", newValue);
});
watch(
showModerationChecklist,
(newValue) => {
@@ -1646,9 +1624,7 @@ const navLinks = computed(() => {
{
label: formatMessage(messages.moderationTab),
href: `${projectUrl}/moderation`,
shown:
!!currentMember.value &&
(isRejected(project.value) || isUnderReview(project.value) || isStaff(auth.value.user)),
shown: !!currentMember.value,
},
];
});

View File

@@ -365,8 +365,10 @@ export default defineNuxtComponent({
if (e.key === "Escape") {
this.expandedGalleryItem = null;
} else if (e.key === "ArrowLeft") {
e.stopPropagation();
this.previousImage();
} else if (e.key === "ArrowRight") {
e.stopPropagation();
this.nextImage();
}
}

View File

@@ -76,8 +76,15 @@
<p>
This is a private conversation thread with the Modrinth moderators. They may message you
with issues concerning this project. This thread is only checked when you submit your
project for review. For additional inquiries, contact
<a href="https://support.modrinth.com">Modrinth Support</a>.
project for review. For additional inquiries, please go to the
<a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</p>
<p v-if="isApproved(project)" class="warning">
<IssuesIcon /> The moderators do not actively monitor this chat. However, they may still see
messages here if there is a problem with your project.
</p>
<ConversationThread
v-if="thread"

View File

@@ -58,6 +58,41 @@
</div>
</div>
</NewModal>
<NewModal ref="modifyModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Modify charge</span>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="cancel" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Cancel server
<span class="text-brand-red">*</span>
</span>
<span>
Whether or not the subscription should be cancelled. Submitting this as "true" will
cancel the subscription, while submitting it as "false" will force another charge
attempt to be made.
</span>
</label>
<Toggle id="cancel" v-model="cancel" />
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="modifying" @click="modifyCharge">
<CheckIcon aria-hidden="true" />
Modify charge
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modifyModal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="page experimental-styles-within">
<div
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
@@ -150,9 +185,26 @@
</template>
</span>
<span class="text-sm text-secondary">
<span
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
class="font-bold"
>
Ended:
</span>
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
<span v-else class="font-bold">Due:</span>
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
<span v-else class="font-bold">Charged:</span>
{{ dayjs(charge.last_attempt).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary"
>({{ formatRelativeTime(charge.last_attempt) }})
</span>
</span>
<div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }}
@@ -184,6 +236,12 @@
Refund options
</button>
</ButtonStyled>
<ButtonStyled v-else-if="charge.status === 'failed'" color="red" color-fill="text">
<button @click="showModifyModal(subscription)">
<CurrencyIcon />
Modify charge
</button>
</ButtonStyled>
</div>
</div>
</div>
@@ -217,7 +275,6 @@ import { products } from "~/generated/state.json";
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
const route = useRoute();
const data = useNuxtApp();
const vintl = useVIntl();
const { formatMessage } = vintl;
@@ -287,6 +344,10 @@ const refundTypes = ref(["full", "partial", "none"]);
const refundAmount = ref(0);
const unprovision = ref(true);
const modifying = ref(false);
const modifyModal = ref();
const cancel = ref(false);
function showRefundModal(charge) {
selectedCharge.value = charge;
refundType.value = "full";
@@ -295,6 +356,12 @@ function showRefundModal(charge) {
refundModal.value.show();
}
function showModifyModal(charge) {
selectedCharge.value = charge;
cancel.value = false;
modifyModal.value.show();
}
async function refundCharge() {
refunding.value = true;
try {
@@ -310,8 +377,7 @@ async function refundCharge() {
await refreshCharges();
refundModal.value.hide();
} catch (err) {
data.$notify({
group: "main",
addNotification({
title: "Error refunding",
text: err.data?.description ?? err,
type: "error",
@@ -320,6 +386,32 @@ async function refundCharge() {
refunding.value = false;
}
async function modifyCharge() {
modifying.value = true;
try {
await useBaseFetch(`billing/subscription/${selectedCharge.value.id}`, {
method: "PATCH",
body: JSON.stringify({
cancelled: cancel.value,
}),
internal: true,
});
addNotification({
title: "Resubscription request submitted",
text: "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.",
type: "success",
});
await refreshCharges();
} catch (err) {
addNotification({
title: "Error reattempting charge",
text: err.data?.description ?? err,
type: "error",
});
}
modifying.value = false;
}
const chargeStatuses = {
open: {
color: "bg-blue",

View File

@@ -1,6 +1,12 @@
<template>
<div>
<template v-if="flow">
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
@@ -189,6 +195,7 @@ const auth = await useAuth();
const route = useNativeRoute();
const redirectTarget = route.query.redirect || "";
const subtleLauncherRedirectUri = ref();
if (route.query.code && !route.fullPath.includes("new_account=true")) {
await finishSignIn();
@@ -262,7 +269,32 @@ async function begin2FASignIn() {
async function finishSignIn(token) {
if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true });
if (!token) {
token = auth.value.token;
}
const usesLocalhostRedirectionScheme =
["4", "6"].includes(route.query.ipver) && Number(route.query.port) < 65536;
const redirectUrl = usesLocalhostRedirectionScheme
? `http://${route.query.ipver === "4" ? "127.0.0.1" : "[::1]"}:${route.query.port}/?code=${token}`
: `https://launcher-files.modrinth.com/?code=${token}`;
if (usesLocalhostRedirectionScheme) {
// When using this redirection scheme, the auth token is very visible in the URL to the user.
// While we could make it harder to find with a POST request, such is security by obscurity:
// the user and other applications would still be able to sniff the token in the request body.
// So, to make the UX a little better by not changing the displayed URL, while keeping the
// token hidden from very casual observation and keeping the protocol as close to OAuth's
// standard flows as possible, let's execute the redirect within an iframe that visually
// covers the entire page.
subtleLauncherRedirectUri.value = redirectUrl;
} else {
await navigateTo(redirectUrl, {
external: true,
});
}
return;
}

View File

@@ -218,7 +218,7 @@ const username = ref("");
const password = ref("");
const confirmPassword = ref("");
const token = ref("");
const subscribe = ref(true);
const subscribe = ref(false);
async function createAccount() {
startLoading();
@@ -247,16 +247,14 @@ async function createAccount() {
},
});
if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${res.session}`, {
external: true,
});
return;
}
await useAuth(res.session);
await useUser();
if (route.query.launcher) {
await navigateTo({ path: "/auth/sign-in", query: route.query });
return;
}
if (route.query.redirect) {
await navigateTo(route.query.redirect);
} else {

View File

@@ -40,7 +40,6 @@
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
{{ formatMessage(messages.uploadIconButton) }}
</FileInput>
<Button
v-if="!deletedIcon && (previewImage || collection.icon_url)"
@@ -479,10 +478,6 @@ const messages = defineMessages({
id: "collection.label.updated-at",
defaultMessage: "Updated {ago}",
},
uploadIconButton: {
id: "collection.button.upload-icon",
defaultMessage: "Upload icon",
},
});
const data = useNuxtApp();

View File

@@ -1,33 +1,84 @@
<template>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Moderation</h1>
<NavStack>
<NavStackItem link="/moderation" label="Overview">
<ModrinthIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/moderation/review" label="Review projects">
<ScaleIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/moderation/reports" label="Reports">
<ReportIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtPage />
<div
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<h1>Moderation</h1>
<NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
<div class="mb-4 sm:hidden">
<Chips
v-model="selectedChip"
:items="mobileNavOptions"
:never-empty="true"
@change="navigateToPage"
/>
</div>
<NuxtPage />
</div>
</template>
<script setup>
import { ModrinthIcon, ScaleIcon, ReportIcon } from "@modrinth/assets";
import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue";
<script setup lang="ts">
import { defineMessages, useVIntl } from "@vintl/vintl";
import { Chips } from "@modrinth/ui";
import NavTabs from "@/components/ui/NavTabs.vue";
definePageMeta({
middleware: "auth",
});
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
const messages = defineMessages({
projectsTitle: {
id: "moderation.page.projects",
defaultMessage: "Projects",
},
technicalReviewTitle: {
id: "moderation.page.technicalReview",
defaultMessage: "Technical Review",
},
reportsTitle: {
id: "moderation.page.reports",
defaultMessage: "Reports",
},
});
const moderationLinks = [
{ label: formatMessage(messages.projectsTitle), href: "/moderation" },
{ label: formatMessage(messages.technicalReviewTitle), href: "/moderation/technical-review" },
{ label: formatMessage(messages.reportsTitle), href: "/moderation/reports" },
];
const mobileNavOptions = [
formatMessage(messages.projectsTitle),
formatMessage(messages.technicalReviewTitle),
formatMessage(messages.reportsTitle),
];
const selectedChip = computed({
get() {
const path = route.path;
if (path === "/moderation/technical-review") {
return formatMessage(messages.technicalReviewTitle);
} else if (path.startsWith("/moderation/reports/")) {
return formatMessage(messages.reportsTitle);
} else {
return formatMessage(messages.projectsTitle);
}
},
set(value: string) {
navigateToPage(value);
},
});
function navigateToPage(selectedOption: string) {
if (selectedOption === formatMessage(messages.technicalReviewTitle)) {
router.push("/moderation/technical-review");
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
router.push("/moderation/reports");
} else {
router.push("/moderation");
}
}
</script>

View File

@@ -1,42 +1,339 @@
<template>
<div>
<section class="universal-card">
<h2>Statistics</h2>
<div class="grid-display">
<div class="grid-display__item">
<div class="label">Projects</div>
<div class="value">
{{ formatNumber(stats.projects, false) }}
</div>
</div>
<div class="grid-display__item">
<div class="label">Versions</div>
<div class="value">
{{ formatNumber(stats.versions, false) }}
</div>
</div>
<div class="grid-display__item">
<div class="label">Files</div>
<div class="value">
{{ formatNumber(stats.files, false) }}
</div>
</div>
<div class="grid-display__item">
<div class="label">Authors</div>
<div class="value">
{{ formatNumber(stats.authors, false) }}
</div>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
</section>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
<ButtonStyled color="orange" class="w-full sm:w-auto">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
@click="moderateAllInFilter()"
>
<ScaleIcon class="size-4 flex-shrink-0" />
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
<span class="sm:hidden">Moderate</span>
</button>
</ButtonStyled>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="mt-4 flex flex-col gap-2">
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
<ModerationQueueCard
v-for="item in paginatedProjects"
v-else
:key="item.project.id"
:queue-entry="item"
:owner="item.owner"
:org="item.org"
/>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup>
import { formatNumber } from "@modrinth/utils";
<script setup lang="ts">
import { DropdownSelect, Button, ButtonStyled, Pagination } from "@modrinth/ui";
import {
XIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
FilterIcon,
ScaleIcon,
} from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { useLocalStorage } from "@vueuse/core";
import ConfettiExplosion from "vue-confetti-explosion";
import Fuse from "fuse.js";
import ModerationQueueCard from "~/components/ui/moderation/ModerationQueueCard.vue";
import { useModerationStore } from "~/store/moderation.ts";
import { enrichProjectBatch, type ModerationProject } from "~/helpers/moderation.ts";
useHead({
title: "Staff overview - Modrinth",
const { formatMessage } = useVIntl();
const moderationStore = useModerationStore();
const route = useRoute();
const router = useRouter();
const visible = ref(false);
if (import.meta.client && history && history.state && history.state.confetti) {
setTimeout(async () => {
history.state.confetti = false;
visible.value = true;
await nextTick();
setTimeout(() => {
visible.value = false;
}, 5000);
}, 1000);
}
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.search.placeholder",
defaultMessage: "Search...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
moderate: {
id: "moderation.moderate",
defaultMessage: "Moderate",
},
});
const { data: stats } = await useAsyncData("statistics", () => useBaseFetch("statistics"));
const { data: allProjects } = await useLazyAsyncData("moderation-projects", async () => {
const startTime = performance.now();
let currentOffset = 0;
const PROJECT_ENDPOINT_COUNT = 350;
const allProjects: ModerationProject[] = [];
const enrichmentPromises: Promise<ModerationProject[]>[] = [];
while (true) {
const projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as any[];
if (projects.length === 0) break;
const enrichmentPromise = enrichProjectBatch(projects);
enrichmentPromises.push(enrichmentPromise);
currentOffset += projects.length;
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
allProjects.push(...completed.flat());
}
if (projects.length < PROJECT_ENDPOINT_COUNT) break;
}
const remainingBatches = await Promise.all(enrichmentPromises);
allProjects.push(...remainingBatches.flat());
const endTime = performance.now();
const duration = endTime - startTime;
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
);
return allProjects;
});
const query = ref(route.query.q?.toString() || "");
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
const currentFilterType = useLocalStorage("moderation-current-filter-type", () => "All projects");
const filterTypes: readonly string[] = readonly([
"All projects",
"Modpacks",
"Mods",
"Resource Packs",
"Data Packs",
"Plugins",
"Shaders",
]);
const currentSortType = useLocalStorage("moderation-current-sort-type", () => "Oldest");
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage));
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null;
return new Fuse(allProjects.value, {
keys: [
{
name: "project.title",
weight: 3,
},
{
name: "project.slug",
weight: 2,
},
{
name: "project.description",
weight: 2,
},
{
name: "project.project_type",
weight: 1,
},
"owner.user.username",
"org.name",
"org.slug",
],
includeScore: true,
threshold: 0.4,
});
});
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null;
return fuse.value.search(query.value).map((result) => result.item);
});
const baseFiltered = computed(() => {
if (!allProjects.value) return [];
return query.value && searchResults.value ? searchResults.value : [...allProjects.value];
});
const typeFiltered = computed(() => {
if (currentFilterType.value === "All projects") return baseFiltered.value;
const filterMap: Record<string, string> = {
Modpacks: "modpack",
Mods: "mod",
"Resource Packs": "resourcepack",
"Data Packs": "datapack",
Plugins: "plugin",
Shaders: "shader",
};
const projectType = filterMap[currentFilterType.value];
if (!projectType) return baseFiltered.value;
return baseFiltered.value.filter((queueItem) =>
queueItem.project.project_types.includes(projectType),
);
});
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value];
if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
return dateA - dateB;
});
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
return dateB - dateA;
});
}
return filtered;
});
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredProjects.value.slice(start, end);
});
function goToPage(page: number) {
currentPage.value = page;
}
function moderateAllInFilter() {
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id));
navigateTo({
name: "type-id",
params: {
type: "project",
id: moderationStore.getCurrentProjectId(),
},
state: {
showChecklist: true,
},
});
}
</script>

View File

@@ -1,17 +0,0 @@
<template>
<ReportView
:auth="auth"
:report-id="route.params.id"
:breadcrumbs-stack="[{ href: '/moderation/reports', label: 'Reports' }]"
/>
</template>
<script setup>
import ReportView from "~/components/ui/report/ReportView.vue";
const auth = await useAuth();
const route = useNativeRoute();
useHead({
title: `Report ${route.params.id} - Modrinth`,
});
</script>

View File

@@ -1,16 +0,0 @@
<template>
<div>
<section class="universal-card">
<h2>Reports</h2>
<ReportsList :auth="auth" moderation />
</section>
</div>
</template>
<script setup>
import ReportsList from "~/components/ui/report/ReportsList.vue";
const auth = await useAuth();
useHead({
title: "Reports - Modrinth",
});
</script>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { Report } from "@modrinth/utils";
import { enrichReportBatch } from "~/helpers/moderation.ts";
import ModerationReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
const { params } = useRoute();
const reportId = params.id as string;
const { data: report } = await useAsyncData(`moderation-report-${reportId}`, async () => {
try {
const report = (await useBaseFetch(`report/${reportId}`, { apiVersion: 3 })) as Report;
const enrichedReport = (await enrichReportBatch([report]))[0];
return enrichedReport;
} catch (error) {
console.error("Error fetching report:", error);
throw createError({
statusCode: 404,
statusMessage: "Report not found",
});
}
});
</script>
<template>
<div class="flex flex-col gap-3">
<ModerationReportCard v-if="report" :report="report" />
</div>
</template>

View File

@@ -0,0 +1,290 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="mt-4 flex flex-col gap-2">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { useLocalStorage } from "@vueuse/core";
import type { Report } from "@modrinth/utils";
import Fuse from "fuse.js";
import type { ExtendedReport } from "@modrinth/moderation";
import ReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
import { enrichReportBatch } from "~/helpers/moderation.ts";
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.search.placeholder",
defaultMessage: "Search...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
});
const { data: allReports } = await useLazyAsyncData("new-moderation-reports", async () => {
const startTime = performance.now();
let currentOffset = 0;
const REPORT_ENDPOINT_COUNT = 350;
const allReports: ExtendedReport[] = [];
const enrichmentPromises: Promise<ExtendedReport[]>[] = [];
while (true) {
const reports = (await useBaseFetch(
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ apiVersion: 3 },
)) as Report[];
if (reports.length === 0) break;
const enrichmentPromise = enrichReportBatch(reports);
enrichmentPromises.push(enrichmentPromise);
currentOffset += reports.length;
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
allReports.push(...completed.flat());
}
if (reports.length < REPORT_ENDPOINT_COUNT) break;
}
const remainingBatches = await Promise.all(enrichmentPromises);
allReports.push(...remainingBatches.flat());
const endTime = performance.now();
const duration = endTime - startTime;
console.debug(
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
);
return allReports;
});
const query = ref(route.query.q?.toString() || "");
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
const currentFilterType = useLocalStorage("moderation-reports-filter-type", () => "All");
const filterTypes: readonly string[] = readonly(["All", "Unread", "Read"]);
const currentSortType = useLocalStorage("moderation-reports-sort-type", () => "Oldest");
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
const fuse = computed(() => {
if (!allReports.value || allReports.value.length === 0) return null;
return new Fuse(allReports.value, {
keys: [
{
name: "id",
weight: 3,
},
{
name: "body",
weight: 3,
},
{
name: "report_type",
weight: 3,
},
{
name: "item_id",
weight: 2,
},
{
name: "reporter_user.username",
weight: 2,
},
"project.name",
"project.slug",
"user.username",
"version.name",
"target.name",
"target.slug",
],
includeScore: true,
threshold: 0.4,
});
});
const memberRoleMap = computed(() => {
if (!allReports.value?.length) return new Map();
const map = new Map();
for (const report of allReports.value) {
if (report.thread?.members?.length) {
const roleMap = new Map();
for (const member of report.thread.members) {
roleMap.set(member.id, member.role);
}
map.set(report.id, roleMap);
}
}
return map;
});
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null;
return fuse.value.search(query.value).map((result) => result.item);
});
const baseFiltered = computed(() => {
if (!allReports.value) return [];
return query.value && searchResults.value ? searchResults.value : [...allReports.value];
});
const typeFiltered = computed(() => {
if (currentFilterType.value === "All") return baseFiltered.value;
return baseFiltered.value.filter((report) => {
const messages = report.thread?.messages || [];
if (messages.length === 0) {
return currentFilterType.value === "Unread";
}
const lastMessage = messages[messages.length - 1];
if (!lastMessage.author_id) return false;
const roleMap = memberRoleMap.value.get(report.id);
if (!roleMap) return false;
const authorRole = roleMap.get(lastMessage.author_id);
const isModeratorMessage = authorRole === "moderator" || authorRole === "admin";
return currentFilterType.value === "Read" ? isModeratorMessage : !isModeratorMessage;
});
});
const filteredReports = computed(() => {
const filtered = [...typeFiltered.value];
if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
} else {
filtered.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
}
return filtered;
});
const paginatedReports = computed(() => {
if (!filteredReports.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredReports.value.slice(start, end);
});
function goToPage(page: number) {
currentPage.value = page;
}
</script>

View File

@@ -1,304 +0,0 @@
<template>
<section class="universal-card">
<h2>Review projects</h2>
<div class="input-group">
<Chips
v-model="projectType"
:items="projectTypes"
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x) + 's')"
/>
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
<SortDescIcon />
Sorting by oldest
</button>
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
<SortAscIcon />
Sorting by newest
</button>
<button
class="btn btn-highlight"
:disabled="projectsFiltered.length === 0"
@click="goToProjects()"
>
<ScaleIcon />
Start moderating
</button>
</div>
<p v-if="projectType !== 'all'" class="project-count">
Showing {{ projectsFiltered.length }} {{ projectTypePlural }} of {{ projects.length }} total
projects in the queue.
</p>
<p v-else class="project-count">There are {{ projects.length }} projects in the queue.</p>
<p v-if="projectsOver24Hours.length > 0" class="warning project-count">
<IssuesIcon />
{{ projectsOver24Hours.length }} {{ projectTypePlural }}
have been in the queue for over 24 hours.
</p>
<p v-if="projectsOver48Hours.length > 0" class="danger project-count">
<IssuesIcon />
{{ projectsOver48Hours.length }} {{ projectTypePlural }}
have been in the queue for over 48 hours.
</p>
<div
v-for="project in projectsFiltered.sort((a, b) => {
if (oldestFirst) {
return b.age - a.age;
} else {
return a.age - b.age;
}
})"
:key="`project-${project.id}`"
class="universal-card recessed project"
>
<div class="project-title">
<div class="mobile-row">
<nuxt-link :to="`/project/${project.id}`" class="iconified-stacked-link">
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
<span class="stacked">
<span class="title">{{ project.name }}</span>
<span>{{ formatProjectType(project.inferred_project_type) }}</span>
</span>
</nuxt-link>
</div>
<div class="mobile-row">
by
<nuxt-link
v-if="project.owner"
:to="`/user/${project.owner.user.id}`"
class="iconified-link"
>
<Avatar :src="project.owner.user.avatar_url" circle size="xxs" raised />
<span>{{ project.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="project.org"
:to="`/organization/${project.org.id}`"
class="iconified-link"
>
<Avatar :src="project.org.icon_url" circle size="xxs" raised />
<span>{{ project.org.name }}</span>
</nuxt-link>
</div>
<div class="mobile-row">
is requesting to be
<ProjectStatusBadge
:status="project.requested_status ? project.requested_status : 'approved'"
/>
</div>
</div>
<div class="input-group">
<nuxt-link :to="`/project/${project.id}`" class="iconified-button raised-button">
<EyeIcon />
View project
</nuxt-link>
</div>
<span v-if="project.queued" :class="`submitter-info ${project.age_warning}`">
<IssuesIcon v-if="project.age_warning" />
Submitted
<span v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')">{{
formatRelativeTime(project.queued)
}}</span>
</span>
<span v-else class="submitter-info"><UnknownIcon /> Unknown queue date</span>
</div>
</section>
</template>
<script setup>
import { Avatar, ProjectStatusBadge, Chips, useRelativeTime } from "@modrinth/ui";
import {
UnknownIcon,
EyeIcon,
SortAscIcon,
SortDescIcon,
IssuesIcon,
ScaleIcon,
} from "@modrinth/assets";
import { formatProjectType } from "@modrinth/utils";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
useHead({
title: "Review projects - Modrinth",
});
const app = useNuxtApp();
const router = useRouter();
const now = app.$dayjs();
const TIME_24H = 86400000;
const TIME_48H = TIME_24H * 2;
const formatRelativeTime = useRelativeTime();
const { data: projects } = await useAsyncData("moderation/projects?count=1000", () =>
useBaseFetch("moderation/projects?count=1000", { internal: true }),
);
const members = ref([]);
const projectType = ref("all");
const oldestFirst = ref(true);
const projectsFiltered = computed(() =>
projects.value.filter(
(x) =>
projectType.value === "all" ||
app.$getProjectTypeForUrl(x.project_types[0], x.loaders) === projectType.value,
),
);
const projectsOver24Hours = computed(() =>
projectsFiltered.value.filter((project) => project.age >= TIME_24H && project.age < TIME_48H),
);
const projectsOver48Hours = computed(() =>
projectsFiltered.value.filter((project) => project.age >= TIME_48H),
);
const projectTypePlural = computed(() =>
projectType.value === "all"
? "projects"
: (formatProjectType(projectType.value) + "s").toLowerCase(),
);
const projectTypes = computed(() => {
const set = new Set();
set.add("all");
if (projects.value) {
for (const project of projects.value) {
set.add(project.inferred_project_type);
}
}
return [...set];
});
if (projects.value) {
const teamIds = projects.value.map((x) => x.team_id);
const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
const [{ data: teams }, { data: orgs }] = await Promise.all([
useAsyncData(`teams?ids=${asEncodedJsonArray(teamIds)}`, () =>
fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`),
),
useAsyncData(`organizations?ids=${asEncodedJsonArray(orgIds)}`, () =>
fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
}),
),
]);
if (teams.value) {
members.value = teams.value;
projects.value = projects.value.map((project) => {
project.owner = members.value
? members.value.flat().find((x) => x.team_id === project.team_id && x.role === "Owner")
: null;
project.org = orgs.value ? orgs.value.find((x) => x.id === project.organization) : null;
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE;
project.age_warning = "";
if (project.age > TIME_24H * 2) {
project.age_warning = "danger";
} else if (project.age > TIME_24H) {
project.age_warning = "warning";
}
project.inferred_project_type = app.$getProjectTypeForUrl(
project.project_types[0],
project.loaders,
);
return project;
});
}
}
async function goToProjects() {
const project = projectsFiltered.value[0];
const remainingProjectIds = projectsFiltered.value.slice(1).map((p) => p.id);
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingProjectIds));
await router.push({
name: "type-id",
params: {
type: project.project_types[0],
id: project.slug ? project.slug : project.id,
},
state: {
showChecklist: true,
},
});
}
</script>
<style lang="scss" scoped>
.project {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
@media screen and (min-width: 650px) {
display: grid;
grid-template: "title action" "date action";
grid-template-columns: 1fr auto;
}
}
.submitter-info {
margin: 0;
grid-area: date;
svg {
vertical-align: top;
}
}
.warning {
color: var(--color-orange);
}
.danger {
color: var(--color-red);
font-weight: bold;
}
.project-count {
margin-block: var(--spacing-card-md);
svg {
vertical-align: top;
}
}
.input-group {
grid-area: action;
}
.project-title {
display: flex;
gap: var(--spacing-card-xs);
align-items: center;
flex-wrap: wrap;
.mobile-row {
display: contents;
}
@media screen and (max-width: 800px) {
flex-direction: column;
align-items: flex-start;
.mobile-row {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
align-items: center;
flex-wrap: wrap;
}
}
}
:deep(.avatar) {
flex-shrink: 0;
&.size-xs {
margin-right: var(--spacing-card-xs);
}
}
</style>

View File

@@ -0,0 +1,386 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="updateSearchResults()"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="mt-4 flex flex-col gap-2">
<DelphiReportCard
v-for="report in paginatedReports"
:key="report.version.id"
:report="report"
/>
<div
v-if="!paginatedReports || paginatedReports.length === 0"
class="universal-card h-24 animate-pulse"
></div>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { useLocalStorage } from "@vueuse/core";
import type { TeamMember, Organization, DelphiReport, Project, Version } from "@modrinth/utils";
import Fuse from "fuse.js";
import type { OwnershipTarget, ExtendedDelphiReport } from "@modrinth/moderation";
import DelphiReportCard from "~/components/ui/moderation/ModerationDelphiReportCard.vue";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.technical.search.placeholder",
defaultMessage: "Search tech reviews...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
});
async function getProjectQuicklyForMock(projectId: string): Promise<Project> {
return (await useBaseFetch(`project/${projectId}`)) as Project;
}
async function getVersionQuicklyForMock(versionId: string): Promise<Version> {
return (await useBaseFetch(`version/${versionId}`)) as Version;
}
const mockDelphiReports: DelphiReport[] = [
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/ASMEventHandlerHelper.java",
priority_score: 29,
status: "pending",
detected_at: "2025-04-01T12:00:00Z",
} as DelphiReport,
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/SomeOtherFile.java",
priority_score: 48,
status: "rejected",
detected_at: "2025-03-02T12:00:00Z",
} as DelphiReport,
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/YetAnotherFile.java",
priority_score: 15,
status: "approved",
detected_at: "2025-02-03T12:00:00Z",
} as DelphiReport,
];
const { data: allReports } = await useAsyncData("moderation-tech-reviews", async () => {
// TODO: replace with actual API call
const delphiReports = mockDelphiReports;
if (delphiReports.length === 0) {
return [];
}
const teamIds = [...new Set(delphiReports.map((report) => report.project.team).filter(Boolean))];
const orgIds = [
...new Set(delphiReports.map((report) => report.project.organization).filter(Boolean)),
];
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
]);
const orgTeamIds = orgsData.map((org) => org.team_id).filter(Boolean);
const orgTeamsData: TeamMember[][] =
orgTeamIds.length > 0
? await fetchSegmented(orgTeamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: [];
const teamMap = new Map<string, TeamMember[]>();
const orgMap = new Map<string, Organization>();
teamsData.forEach((team) => {
let teamId = null;
for (const member of team) {
teamId = member.team_id;
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team);
break;
}
}
});
orgTeamsData.forEach((team) => {
let teamId = null;
for (const member of team) {
teamId = member.team_id;
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team);
break;
}
}
});
orgsData.forEach((org: Organization) => {
orgMap.set(org.id, org);
});
const extendedReports: ExtendedDelphiReport[] = delphiReports.map((report) => {
let target: OwnershipTarget | undefined;
const project = report.project;
if (project) {
let owner: TeamMember | null = null;
let org: Organization | null = null;
if (project.team) {
const teamMembers = teamMap.get(project.team);
if (teamMembers) {
owner = teamMembers.find((member) => member.role === "Owner") || null;
}
}
if (project.organization) {
org = orgMap.get(project.organization) || null;
}
if (org) {
target = {
name: org.name,
avatar_url: org.icon_url,
type: "organization",
slug: org.slug,
};
} else if (owner) {
target = {
name: owner.user.username,
avatar_url: owner.user.avatar_url,
type: "user",
slug: owner.user.username,
};
}
}
return {
...report,
target,
};
});
extendedReports.sort((a, b) => b.priority_score - a.priority_score);
return extendedReports;
});
const query = ref(route.query.q?.toString() || "");
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
const currentFilterType = useLocalStorage("moderation-tech-reviews-filter-type", () => "Pending");
const filterTypes: readonly string[] = readonly(["All", "Pending", "Approved", "Rejected"]);
const currentSortType = useLocalStorage("moderation-tech-reviews-sort-type", () => "Priority");
const sortTypes: readonly string[] = readonly(["Priority", "Oldest", "Newest"]);
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
const fuse = computed(() => {
if (!allReports.value || allReports.value.length === 0) return null;
return new Fuse(allReports.value, {
keys: [
{
name: "version.id",
weight: 3,
},
{
name: "version.version_number",
weight: 3,
},
{
name: "project.title",
weight: 3,
},
{
name: "project.slug",
weight: 3,
},
{
name: "version.files.filename",
weight: 2,
},
{
name: "trace_type",
weight: 2,
},
{
name: "content",
weight: 0.5,
},
"file_path",
"project.id",
"target.name",
"target.slug",
],
includeScore: true,
threshold: 0.4,
});
});
const filteredReports = computed(() => {
if (!allReports.value) return [];
let filtered;
if (query.value && fuse.value) {
const results = fuse.value.search(query.value);
filtered = results.map((result) => result.item);
} else {
filtered = [...allReports.value];
}
if (currentFilterType.value === "Pending") {
filtered = filtered.filter((report) => report.status === "pending");
} else if (currentFilterType.value === "Approved") {
filtered = filtered.filter((report) => report.status === "approved");
} else if (currentFilterType.value === "Rejected") {
filtered = filtered.filter((report) => report.status === "rejected");
}
if (currentSortType.value === "Priority") {
filtered.sort((a, b) => b.priority_score - a.priority_score);
} else if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime();
const dateB = new Date(b.detected_at).getTime();
return dateA - dateB;
});
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime();
const dateB = new Date(b.detected_at).getTime();
return dateB - dateA;
});
}
return filtered;
});
const paginatedReports = computed(() => {
if (!filteredReports.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredReports.value.slice(start, end);
});
function updateSearchResults() {
currentPage.value = 1;
}
function goToPage(page: number) {
currentPage.value = page;
}
</script>

View File

@@ -0,0 +1,3 @@
<template>
<p>Not yet implemented.</p>
</template>

View File

@@ -516,12 +516,11 @@
</section>
<section
id="plan"
pyro-hash="plan"
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
>
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center gap-8 text-center">
<div id="plan" class="mx-auto flex w-full max-w-7xl flex-col items-center gap-8 text-center">
<h1 class="relative m-0 text-4xl leading-[120%] md:text-7xl">
There's a server for everyone
</h1>
@@ -551,6 +550,8 @@
<span v-else></span>
</div>
<MedalPlanPromotion v-if="flags.enableMedalPromotion" />
<ul class="m-0 flex w-full grid-cols-3 flex-col gap-8 p-0 lg:grid">
<ServerPlanSelector
:capacity="capacityStatuses?.small?.available"
@@ -648,11 +649,13 @@ import { formatPrice } from "@modrinth/utils";
import { useVIntl } from "@vintl/vintl";
import { products } from "~/generated/state.json";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import MedalPlanPromotion from "~/components/ui/servers/marketing/MedalPlanPromotion.vue";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
import OptionGroup from "~/components/ui/OptionGroup.vue";
const { locale } = useVIntl();
const flags = useFeatureFlags();
const billingPeriods = ref(["monthly", "quarterly"]);
const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly");
@@ -929,10 +932,14 @@ const selectProduct = async (product) => {
}
};
const planQuery = () => {
if (route.query.plan) {
document.getElementById("plan").scrollIntoView();
selectProduct(route.query.plan);
const planQuery = async () => {
if ("plan" in route.query) {
await nextTick();
const planElement = document.getElementById("plan");
if (planElement) {
planElement.scrollIntoView({ behavior: "smooth" });
await selectProduct(route.query.plan);
}
}
};

View File

@@ -290,6 +290,8 @@
</div>
</div>
<MedalServerCountdown class="mb-4" />
<div
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
@@ -380,6 +382,7 @@ import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { ModrinthServer, useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
import MedalServerCountdown from "~/components/ui/servers/marketing/MedalServerCountdown.vue";
const app = useNuxtApp() as unknown as { $notify: any };

View File

@@ -42,7 +42,7 @@
</label>
<ButtonStyled>
<button
:disabled="invocation === startupSettings?.original_invocation"
:disabled="invocation === originalInvocation"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
@@ -120,8 +120,9 @@ const props = defineProps<{
server: ModrinthServer;
}>();
await props.server.startup.fetch();
const data = computed(() => props.server.general);
const startupSettings = computed(() => props.server.startup);
const showAllVersions = ref(false);
const jdkVersionMap = [
@@ -137,33 +138,15 @@ const jdkBuildMap = [
{ value: "graal", label: "GraalVM" },
];
const invocation = ref("");
const jdkVersion = ref("");
const jdkBuild = ref("");
const originalInvocation = ref("");
const originalJdkVersion = ref("");
const originalJdkBuild = ref("");
watch(
startupSettings,
(newSettings) => {
if (newSettings) {
invocation.value = newSettings.invocation;
originalInvocation.value = newSettings.invocation;
const jdkVersionLabel =
jdkVersionMap.find((v) => v.value === newSettings.jdk_version)?.label || "";
jdkVersion.value = jdkVersionLabel;
originalJdkVersion.value = jdkVersionLabel;
const jdkBuildLabel = jdkBuildMap.find((v) => v.value === newSettings.jdk_build)?.label || "";
jdkBuild.value = jdkBuildLabel;
originalJdkBuild.value = jdkBuildLabel;
}
},
{ immediate: true },
const invocation = ref(props.server.startup.invocation);
const jdkVersion = ref(
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
);
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label);
const originalInvocation = ref(invocation.value);
const originalJdkVersion = ref(jdkVersion.value);
const originalJdkBuild = ref(jdkBuild.value);
const hasUnsavedChanges = computed(
() =>
@@ -195,7 +178,7 @@ const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
});
const saveStartup = async () => {
async function saveStartup() {
try {
isUpdating.value = true;
const invocationValue = invocation.value ?? "";
@@ -232,17 +215,17 @@ const saveStartup = async () => {
} finally {
isUpdating.value = false;
}
};
}
const resetStartup = () => {
function resetStartup() {
invocation.value = originalInvocation.value;
jdkVersion.value = originalJdkVersion.value;
jdkBuild.value = originalJdkBuild.value;
};
}
const resetToDefault = () => {
invocation.value = startupSettings.value?.original_invocation ?? "";
};
function resetToDefault() {
invocation.value = originalInvocation.value ?? "";
}
</script>
<style scoped>

View File

@@ -94,18 +94,14 @@
class="m-0 flex flex-col gap-4 p-0"
>
<UiServersServerListing
v-for="server in filteredData"
v-for="server in filteredData.filter((s) => !s.is_preview)"
:key="server.server_id"
:server_id="server.server_id"
:name="server.name"
:status="server.status"
:game="server.game"
:loader="server.loader"
:loader_version="server.loader_version"
:mc_version="server.mc_version"
:upstream="server.upstream"
:net="server.net"
:flows="server.flows"
v-bind="server"
/>
<MedalServerListing
v-for="server in filteredData.filter((s) => s.status !== 'suspended')"
:key="server.server_id"
v-bind="server"
/>
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul>
@@ -124,6 +120,7 @@ import { ButtonStyled, CopyCode } from "@modrinth/ui";
import type { Server, ModrinthServersFetchError } from "@modrinth/utils";
import { reloadNuxtApp } from "#app";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import MedalServerListing from "~/components/ui/servers/marketing/MedalServerListing.vue";
definePageMeta({
middleware: "auth",

View File

@@ -208,15 +208,7 @@
<div class="flex flex-col gap-2">
<UiServersServerListing
v-if="subscription.serverInfo"
:server_id="subscription.serverInfo.server_id"
:name="subscription.serverInfo.name"
:status="subscription.serverInfo.status"
:game="subscription.serverInfo.game"
:loader="subscription.serverInfo.loader"
:loader_version="subscription.serverInfo.loader_version"
:mc_version="subscription.serverInfo.mc_version"
:upstream="subscription.serverInfo.upstream"
:net="subscription.serverInfo.net"
v-bind="subscription.serverInfo"
/>
<div v-else class="w-fit">
<p>

View File

@@ -0,0 +1,98 @@
import { defineStore, createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
export interface ModerationQueue {
items: string[];
total: number;
completed: number;
skipped: number;
lastUpdated: Date;
}
const EMPTY_QUEUE: Partial<ModerationQueue> = {
items: [],
// TODO: Consider some form of displaying this in the checklist, maybe at the end
total: 0,
completed: 0,
skipped: 0,
};
function createEmptyQueue(): ModerationQueue {
return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue;
}
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
export const useModerationStore = defineStore("moderation", {
state: () => ({
currentQueue: createEmptyQueue(),
}),
getters: {
queueLength: (state) => state.currentQueue.items.length,
hasItems: (state) => state.currentQueue.items.length > 0,
progress: (state) => {
if (state.currentQueue.total === 0) return 0;
return (state.currentQueue.completed + state.currentQueue.skipped) / state.currentQueue.total;
},
},
actions: {
setQueue(projectIDs: string[]) {
this.currentQueue = {
items: [...projectIDs],
total: projectIDs.length,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
};
},
setSingleProject(projectId: string) {
this.currentQueue = {
items: [projectId],
total: 1,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
};
},
completeCurrentProject(projectId: string, status: "completed" | "skipped" = "completed") {
if (status === "completed") {
this.currentQueue.completed++;
} else {
this.currentQueue.skipped++;
}
this.currentQueue.items = this.currentQueue.items.filter((id: string) => id !== projectId);
this.currentQueue.lastUpdated = new Date();
return this.currentQueue.items.length > 0;
},
getCurrentProjectId(): string | null {
return this.currentQueue.items[0] || null;
},
resetQueue() {
this.currentQueue = createEmptyQueue();
},
},
persist: {
key: "moderation-store",
serializer: {
serialize: JSON.stringify,
deserialize: (value: string) => {
const parsed = JSON.parse(value);
if (parsed.currentQueue?.lastUpdated) {
parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated);
}
return parsed;
},
},
},
});

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21\n )\n ",
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -25,10 +25,11 @@
"Text",
"Text",
"Text",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55"
"hash": "010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n LIMIT $1;\n ",
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n OFFSET $2\n LIMIT $1\n ",
"describe": {
"columns": [
{
@@ -11,6 +11,7 @@
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
@@ -18,5 +19,5 @@
false
]
},
"hash": "29e171bd746ac5dc1fabae4c9f81c3d1df4e69c860b7d0f6a907377664199217"
"hash": "1aea0d5e6936b043cb7727b779d60598aa812c8ef0f5895fa740859321092a1c"
}

View File

@@ -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 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\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": [
{
@@ -122,6 +122,11 @@
"ordinal": 23,
"name": "allow_friend_requests",
"type_info": "Bool"
},
{
"ordinal": 24,
"name": "is_subscribed_to_newsletter",
"type_info": "Bool"
}
],
"parameters": {
@@ -154,8 +159,9 @@
true,
true,
true,
false,
false
]
},
"hash": "b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0"
"hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n LIMIT $2;\n ",
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
@@ -11,6 +11,7 @@
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int8"
]
@@ -19,5 +20,5 @@
false
]
},
"hash": "f17a109913015a7a5ab847bb2e73794d6261a08d450de24b450222755e520881"
"hash": "be8a5dd2b71fdc279a6fa68fe5384da31afd91d4b480527e2dd8402aef36f12c"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE users\n SET is_subscribed_to_newsletter = TRUE\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n LIMIT $2;\n ",
"query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
@@ -12,6 +12,7 @@
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
@@ -19,5 +20,5 @@
false
]
},
"hash": "3baabc9f08401801fa290866888c540746fc50c1d79911f08f3322b605ce5c30"
"hash": "ccb0315ff52ea4402f53508334a7288fc9f8e77ffd7bce665441ff682384cbf9"
}

View File

@@ -1,8 +1,21 @@
# syntax=docker/dockerfile:1
FROM rust:1.88.0 AS build
WORKDIR /usr/src/labrinth
COPY . .
RUN SQLX_OFFLINE=true cargo build --release --package labrinth
RUN --mount=type=cache,target=/usr/src/labrinth/target \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry \
SQLX_OFFLINE=true cargo build --release --package labrinth
FROM build AS artifacts
RUN --mount=type=cache,target=/usr/src/labrinth/target \
mkdir /labrinth \
&& cp /usr/src/labrinth/target/release/labrinth /labrinth/labrinth \
&& cp -r /usr/src/labrinth/apps/labrinth/migrations /labrinth \
&& cp -r /usr/src/labrinth/apps/labrinth/assets /labrinth
FROM debian:bookworm-slim
@@ -14,10 +27,8 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth
COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/
COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets
WORKDIR /labrinth
COPY --from=artifacts /labrinth /labrinth
WORKDIR /labrinth
ENTRYPOINT ["dumb-init", "--"]
CMD ["/labrinth/labrinth"]

View File

@@ -0,0 +1 @@
CREATE INDEX reports_closed ON reports (closed);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN is_subscribed_to_newsletter BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -315,9 +315,13 @@ pub async fn filter_enlisted_version_ids(
pub async fn is_visible_collection(
collection_data: &DBCollection,
user_option: &Option<User>,
hide_unlisted: bool,
) -> Result<bool, ApiError> {
let mut authorized = !collection_data.status.is_hidden()
&& !collection_data.projects.is_empty();
let mut authorized = (if hide_unlisted {
collection_data.status.is_searchable()
} else {
!collection_data.status.is_hidden()
}) && !collection_data.projects.is_empty();
if let Some(user) = &user_option {
if !authorized
&& (user.role.is_mod() || user.id == collection_data.user_id.into())
@@ -331,12 +335,17 @@ pub async fn is_visible_collection(
pub async fn filter_visible_collections(
collections: Vec<DBCollection>,
user_option: &Option<User>,
hide_unlisted: bool,
) -> Result<Vec<crate::models::collections::Collection>, ApiError> {
let mut return_collections = Vec::new();
let mut check_collections = Vec::new();
for collection in collections {
if (!collection.status.is_hidden() && !collection.projects.is_empty())
if ((if hide_unlisted {
collection.status.is_searchable()
} else {
!collection.status.is_hidden()
}) && !collection.projects.is_empty())
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
{
return_collections.push(collection.into());

View File

@@ -1,6 +1,6 @@
use super::AuthProvider;
use crate::auth::AuthenticationError;
use crate::database::models::user_item;
use crate::database::models::{DBUser, user_item};
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::models::users::User;
@@ -44,17 +44,16 @@ where
Ok(Some((scopes, User::from_full(db_user))))
}
pub async fn get_user_from_headers<'a, E>(
pub async fn get_full_user_from_headers<'a, E>(
req: &HttpRequest,
executor: E,
redis: &RedisPool,
session_queue: &AuthQueue,
required_scopes: Scopes,
) -> Result<(Scopes, User), AuthenticationError>
) -> Result<(Scopes, DBUser), AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
// Fetch DB user record and minos user from headers
let (scopes, db_user) = get_user_record_from_bearer_token(
req,
None,
@@ -65,13 +64,33 @@ where
.await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let user = User::from_full(db_user);
if !scopes.contains(required_scopes) {
return Err(AuthenticationError::InvalidCredentials);
}
Ok((scopes, user))
Ok((scopes, db_user))
}
pub async fn get_user_from_headers<'a, E>(
req: &HttpRequest,
executor: E,
redis: &RedisPool,
session_queue: &AuthQueue,
required_scopes: Scopes,
) -> Result<(Scopes, User), AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let (scopes, db_user) = get_full_user_from_headers(
req,
executor,
redis,
session_queue,
required_scopes,
)
.await?;
Ok((scopes, User::from_full(db_user)))
}
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(

View File

@@ -49,6 +49,8 @@ pub struct DBUser {
pub badges: Badges,
pub allow_friend_requests: bool,
pub is_subscribed_to_newsletter: bool,
}
impl DBUser {
@@ -63,13 +65,13 @@ impl DBUser {
avatar_url, raw_avatar_url, bio, created,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, paypal_id, paypal_country, paypal_email,
venmo_handle, stripe_customer_id, allow_friend_requests
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21
$14, $15, $16, $17, $18, $19, $20, $21, $22
)
",
self.id as DBUserId,
@@ -93,6 +95,7 @@ impl DBUser {
self.venmo_handle,
self.stripe_customer_id,
self.allow_friend_requests,
self.is_subscribed_to_newsletter,
)
.execute(&mut **transaction)
.await?;
@@ -178,7 +181,7 @@ impl DBUser {
created, role, badges,
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
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
FROM users
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
",
@@ -212,6 +215,7 @@ impl DBUser {
stripe_customer_id: u.stripe_customer_id,
totp_secret: u.totp_secret,
allow_friend_requests: u.allow_friend_requests,
is_subscribed_to_newsletter: u.is_subscribed_to_newsletter,
};
acc.insert(u.id, (Some(u.username), user));

View File

@@ -92,7 +92,7 @@ impl CollectionStatus {
}
}
// Project pages + info cannot be viewed
// Collection pages + info cannot be viewed
pub fn is_hidden(&self) -> bool {
match self {
CollectionStatus::Rejected => true,
@@ -103,6 +103,11 @@ impl CollectionStatus {
}
}
// Collection can be displayed in on user page
pub fn is_searchable(&self) -> bool {
matches!(self, CollectionStatus::Listed)
}
pub fn is_approved(&self) -> bool {
match self {
CollectionStatus::Listed => true,

View File

@@ -276,7 +276,11 @@ pub async fn refund_charge(
subscription_interval: charge.subscription_interval,
payment_platform: charge.payment_platform,
payment_platform_id: id,
parent_charge_id: Some(charge.id),
parent_charge_id: if refund_amount != 0 {
Some(charge.id)
} else {
None
},
net,
}
.upsert(&mut transaction)

View File

@@ -1,5 +1,7 @@
use crate::auth::email::send_email;
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::auth::validate::{
get_full_user_from_headers, get_user_record_from_bearer_token,
};
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
use crate::database::models::DBUser;
use crate::database::models::flow_item::DBFlow;
@@ -232,6 +234,7 @@ impl TempUser {
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
is_subscribed_to_newsletter: false,
}
.insert(transaction)
.await?;
@@ -1291,37 +1294,6 @@ pub async fn delete_auth_provider(
Ok(HttpResponse::NoContent().finish())
}
pub async fn sign_up_sendy(email: &str) -> Result<(), AuthenticationError> {
let url = dotenvy::var("SENDY_URL")?;
let id = dotenvy::var("SENDY_LIST_ID")?;
let api_key = dotenvy::var("SENDY_API_KEY")?;
let site_url = dotenvy::var("SITE_URL")?;
if url.is_empty() || url == "none" {
tracing::info!("Sendy URL not set, skipping signup");
return Ok(());
}
let mut form = HashMap::new();
form.insert("api_key", &*api_key);
form.insert("email", email);
form.insert("list", &*id);
form.insert("referrer", &*site_url);
let client = reqwest::Client::new();
client
.post(format!("{url}/subscribe"))
.form(&form)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok(())
}
pub async fn check_sendy_subscription(
email: &str,
) -> Result<bool, AuthenticationError> {
@@ -1456,6 +1428,9 @@ pub async fn create_account_with_password(
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
is_subscribed_to_newsletter: new_account
.sign_up_newsletter
.unwrap_or(false),
}
.insert(&mut transaction)
.await?;
@@ -1476,10 +1451,6 @@ pub async fn create_account_with_password(
&format!("Welcome to Modrinth, {}!", new_account.username),
)?;
if new_account.sign_up_newsletter.unwrap_or(false) {
sign_up_sendy(&new_account.email).await?;
}
transaction.commit().await?;
Ok(HttpResponse::Ok().json(res))
@@ -2420,15 +2391,24 @@ pub async fn subscribe_newsletter(
.await?
.1;
if let Some(email) = user.email {
sign_up_sendy(&email).await?;
sqlx::query!(
"
UPDATE users
SET is_subscribed_to_newsletter = TRUE
WHERE id = $1
",
user.id.0 as i64,
)
.execute(&**pool)
.await?;
Ok(HttpResponse::NoContent().finish())
} else {
Err(ApiError::InvalidInput(
"User does not have an email.".to_string(),
))
}
crate::database::models::DBUser::clear_caches(
&[(user.id.into(), None)],
&redis,
)
.await?;
Ok(HttpResponse::NoContent().finish())
}
#[get("email/subscribe")]
@@ -2438,7 +2418,7 @@ pub async fn get_newsletter_subscription_status(
redis: Data<RedisPool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
let user = get_full_user_from_headers(
&req,
&**pool,
&redis,
@@ -2448,16 +2428,16 @@ pub async fn get_newsletter_subscription_status(
.await?
.1;
if let Some(email) = user.email {
let is_subscribed = check_sendy_subscription(&email).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"subscribed": is_subscribed
})))
} else {
Ok(HttpResponse::Ok().json(serde_json::json!({
"subscribed": false
})))
}
let is_subscribed = user.is_subscribed_to_newsletter
|| if let Some(email) = user.email {
check_sendy_subscription(&email).await?
} else {
false
};
Ok(HttpResponse::Ok().json(serde_json::json!({
"subscribed": is_subscribed
})))
}
fn send_email_verify(

View File

@@ -18,12 +18,14 @@ pub fn config(cfg: &mut web::ServiceConfig) {
}
#[derive(Deserialize)]
pub struct ResultCount {
pub struct ProjectsRequestOptions {
#[serde(default = "default_count")]
pub count: i16,
pub count: u16,
#[serde(default)]
pub offset: u32,
}
fn default_count() -> i16 {
fn default_count() -> u16 {
100
}
@@ -31,7 +33,7 @@ pub async fn get_projects(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
count: web::Query<ResultCount>,
request_opts: web::Query<ProjectsRequestOptions>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
@@ -50,10 +52,12 @@ pub async fn get_projects(
SELECT id FROM mods
WHERE status = $1
ORDER BY queued ASC
LIMIT $2;
OFFSET $3
LIMIT $2
",
ProjectStatus::Processing.as_str(),
count.count as i64
request_opts.count as i64,
request_opts.offset as i64
)
.fetch(&**pool)
.map_ok(|m| database::models::DBProjectId(m.id))

View File

@@ -15,10 +15,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
#[derive(Deserialize)]
pub struct ResultCount {
#[serde(default = "default_count")]
pub count: i16,
pub count: u16,
}
fn default_count() -> i16 {
fn default_count() -> u16 {
100
}
@@ -34,7 +34,10 @@ pub async fn get_projects(
req,
pool.clone(),
redis.clone(),
web::Query(internal::moderation::ResultCount { count: count.count }),
web::Query(internal::moderation::ProjectsRequestOptions {
count: count.count,
offset: 0,
}),
session_queue,
)
.await

View File

@@ -43,12 +43,12 @@ pub async fn report_create(
#[derive(Deserialize)]
pub struct ReportsRequestOptions {
#[serde(default = "default_count")]
count: i16,
count: u16,
#[serde(default = "default_all")]
all: bool,
}
fn default_count() -> i16 {
fn default_count() -> u16 {
100
}
fn default_all() -> bool {
@@ -60,7 +60,7 @@ pub async fn reports(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
count: web::Query<ReportsRequestOptions>,
request_opts: web::Query<ReportsRequestOptions>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let response = v3::reports::reports(
@@ -68,8 +68,9 @@ pub async fn reports(
pool,
redis,
web::Query(v3::reports::ReportsRequestOptions {
count: count.count,
all: count.all,
count: request_opts.count,
offset: 0,
all: request_opts.all,
}),
session_queue,
)

View File

@@ -163,7 +163,8 @@ pub async fn collections_get(
.ok();
let collections =
filter_visible_collections(collections_data, &user_option).await?;
filter_visible_collections(collections_data, &user_option, false)
.await?;
Ok(HttpResponse::Ok().json(collections))
}
@@ -192,7 +193,7 @@ pub async fn collection_get(
.ok();
if let Some(data) = collection_data {
if is_visible_collection(&data, &user_option).await? {
if is_visible_collection(&data, &user_option, false).await? {
return Ok(HttpResponse::Ok().json(Collection::from(data)));
}
}

View File

@@ -222,12 +222,14 @@ pub async fn report_create(
#[derive(Deserialize)]
pub struct ReportsRequestOptions {
#[serde(default = "default_count")]
pub count: i16,
pub count: u16,
#[serde(default)]
pub offset: u32,
#[serde(default = "default_all")]
pub all: bool,
}
fn default_count() -> i16 {
fn default_count() -> u16 {
100
}
fn default_all() -> bool {
@@ -238,7 +240,7 @@ pub async fn reports(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
count: web::Query<ReportsRequestOptions>,
request_opts: web::Query<ReportsRequestOptions>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
@@ -253,15 +255,17 @@ pub async fn reports(
use futures::stream::TryStreamExt;
let report_ids = if user.role.is_mod() && count.all {
let report_ids = if user.role.is_mod() && request_opts.all {
sqlx::query!(
"
SELECT id FROM reports
WHERE closed = FALSE
ORDER BY created ASC
LIMIT $1;
OFFSET $2
LIMIT $1
",
count.count as i64
request_opts.count as i64,
request_opts.offset as i64
)
.fetch(&**pool)
.map_ok(|m| crate::database::models::ids::DBReportId(m.id))
@@ -273,10 +277,12 @@ pub async fn reports(
SELECT id FROM reports
WHERE closed = FALSE AND reporter = $1
ORDER BY created ASC
LIMIT $2;
OFFSET $3
LIMIT $2
",
user.id.0 as i64,
count.count as i64
request_opts.count as i64,
request_opts.offset as i64
)
.fetch(&**pool)
.map_ok(|m| crate::database::models::ids::DBReportId(m.id))

View File

@@ -1,14 +1,14 @@
use std::{collections::HashMap, sync::Arc};
use super::{ApiError, oauth_clients::get_user_clients};
use crate::file_hosting::FileHostPublicity;
use crate::util::img::delete_old_images;
use crate::{
auth::{filter_visible_projects, get_user_from_headers},
auth::{
filter_visible_collections, filter_visible_projects,
get_user_from_headers,
},
database::{models::DBUser, redis::RedisPool},
file_hosting::FileHost,
file_hosting::{FileHost, FileHostPublicity},
models::{
collections::{Collection, CollectionStatus},
notifications::Notification,
pats::Scopes,
projects::Project,
@@ -16,7 +16,7 @@ use crate::{
},
queue::session::AuthQueue,
util::{
routes::read_limited_from_payload,
img::delete_old_images, routes::read_limited_from_payload,
validate::validation_errors_to_string,
},
};
@@ -244,27 +244,19 @@ pub async fn collections_list(
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
let user_id: UserId = id.into();
let can_view_private =
user.is_some_and(|y| y.role.is_mod() || y.id == user_id);
let project_data = DBUser::get_collections(id, &**pool).await?;
let collection_data = DBUser::get_collections(id, &**pool).await?;
let response: Vec<_> = crate::database::models::DBCollection::get_many(
&project_data,
&collection_data,
&**pool,
&redis,
)
.await?
.into_iter()
.filter(|x| {
can_view_private || matches!(x.status, CollectionStatus::Listed)
})
.map(Collection::from)
.collect();
.await?;
Ok(HttpResponse::Ok().json(response))
let collections =
filter_visible_collections(response, &user, true).await?;
Ok(HttpResponse::Ok().json(collections))
} else {
Err(ApiError::NotFound)
}

View File

@@ -1,2 +1,10 @@
# SQLite database file location
DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
MODRINTH_URL=http://localhost:3000/
MODRINTH_API_URL=http://127.0.0.1:8000/v2/
MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/
MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -0,0 +1,10 @@
MODRINTH_URL=https://modrinth.com/
MODRINTH_API_URL=https://api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
MODRINTH_SOCKET_URL=wss://api.modrinth.com/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -0,0 +1,10 @@
MODRINTH_URL=https://staging.modrinth.com/
MODRINTH_API_URL=https://staging-api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/
MODRINTH_SOCKET_URL=wss://staging-api.modrinth.com/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -27,6 +27,7 @@ hashlink.workspace = true
png.workspace = true
bytemuck.workspace = true
rgb.workspace = true
phf.workspace = true
chrono = { workspace = true, features = ["serde"] }
daedalus.workspace = true
@@ -82,6 +83,7 @@ ariadne.workspace = true
winreg.workspace = true
[build-dependencies]
dotenvy.workspace = true
dunce.workspace = true
[features]

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