Compare commits
34 Commits
main
...
app-update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2774cdca76 | ||
|
|
3b6a9dde0f | ||
|
|
a1e0c134a0 | ||
|
|
0310cc52d0 | ||
|
|
071e2b58b3 | ||
|
|
30e93e0880 | ||
|
|
2c90f1c142 | ||
|
|
83bd4dde45 | ||
|
|
221c26d613 | ||
|
|
7cc39cb54d | ||
|
|
80e0f84b62 | ||
|
|
1e934312a4 | ||
|
|
6fa0ee487d | ||
|
|
62e2e5ea6f | ||
|
|
69a461dffc | ||
|
|
35baa1af5e | ||
|
|
59ab09a275 | ||
|
|
286ab6d4a0 | ||
|
|
f9a4042f13 | ||
|
|
3b74e021b5 | ||
|
|
8922c7ab03 | ||
|
|
5495b01bc5 | ||
|
|
9b103e063a | ||
|
|
7b73aa2908 | ||
|
|
ae75292fd0 | ||
|
|
9a43d49b3b | ||
|
|
3d4d0afa59 | ||
|
|
9aab6c3e08 | ||
|
|
52d6bf3907 | ||
|
|
523800ea39 | ||
|
|
35aea3cab2 | ||
|
|
36cb3f1686 | ||
|
|
e59dd086fc | ||
|
|
607c42cf01 |
13
.github/workflows/daedalus-docker.yml
vendored
13
.github/workflows/daedalus-docker.yml
vendored
@ -22,26 +22,23 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
- name: Fetch docker metadata
|
- name: Fetch docker metadata
|
||||||
id: docker_meta
|
id: docker_meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v3
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/modrinth/daedalus
|
images: ghcr.io/modrinth/daedalus
|
||||||
- name: Login to GitHub Images
|
- name: Login to GitHub Images
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
file: ./apps/daedalus_client/Dockerfile
|
file: ./apps/daedalus_client/Dockerfile
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||||
cache-from: type=registry,ref=ghcr.io/modrinth/daedalus:main
|
|
||||||
cache-to: type=inline
|
|
||||||
|
|||||||
13
.github/workflows/labrinth-docker.yml
vendored
13
.github/workflows/labrinth-docker.yml
vendored
@ -20,26 +20,23 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
- name: Fetch docker metadata
|
- name: Fetch docker metadata
|
||||||
id: docker_meta
|
id: docker_meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v3
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/modrinth/labrinth
|
images: ghcr.io/modrinth/labrinth
|
||||||
- name: Login to GitHub Images
|
- name: Login to GitHub Images
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
file: ./apps/labrinth/Dockerfile
|
file: ./apps/labrinth/Dockerfile
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||||
cache-from: type=registry,ref=ghcr.io/modrinth/labrinth:main
|
|
||||||
cache-to: type=inline
|
|
||||||
|
|||||||
2
.github/workflows/turbo-ci.yml
vendored
2
.github/workflows/turbo-ci.yml
vendored
@ -52,7 +52,7 @@ jobs:
|
|||||||
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
|
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
|
||||||
# back to a cached cargo install
|
# back to a cached cargo install
|
||||||
- name: 🧰 Setup cargo-sqlx
|
- name: 🧰 Setup cargo-sqlx
|
||||||
uses: taiki-e/cache-cargo-install-action@v2
|
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
|
||||||
with:
|
with:
|
||||||
tool: sqlx-cli
|
tool: sqlx-cli
|
||||||
locked: false
|
locked: false
|
||||||
|
|||||||
1
.idea/code.iml
generated
1
.idea/code.iml
generated
@ -10,6 +10,7 @@
|
|||||||
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
|
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
</content>
|
</content>
|
||||||
|
|||||||
46
Cargo.lock
generated
46
Cargo.lock
generated
@ -5731,17 +5731,6 @@ dependencies = [
|
|||||||
"phf_shared 0.11.3",
|
"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]]
|
[[package]]
|
||||||
name = "phf_codegen"
|
name = "phf_codegen"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@ -5792,16 +5781,6 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"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]]
|
[[package]]
|
||||||
name = "phf_macros"
|
name = "phf_macros"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@ -5829,19 +5808,6 @@ dependencies = [
|
|||||||
"syn 2.0.101",
|
"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]]
|
[[package]]
|
||||||
name = "phf_shared"
|
name = "phf_shared"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@ -5869,15 +5835,6 @@ dependencies = [
|
|||||||
"siphasher 1.0.1",
|
"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]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
@ -8989,7 +8946,6 @@ dependencies = [
|
|||||||
"notify-debouncer-mini",
|
"notify-debouncer-mini",
|
||||||
"p256",
|
"p256",
|
||||||
"paste",
|
"paste",
|
||||||
"phf 0.12.1",
|
|
||||||
"png",
|
"png",
|
||||||
"quartz_nbt",
|
"quartz_nbt",
|
||||||
"quick-xml 0.37.5",
|
"quick-xml 0.37.5",
|
||||||
@ -9029,8 +8985,6 @@ dependencies = [
|
|||||||
"dashmap",
|
"dashmap",
|
||||||
"either",
|
"either",
|
||||||
"enumset",
|
"enumset",
|
||||||
"hyper 1.6.0",
|
|
||||||
"hyper-util",
|
|
||||||
"native-dialog",
|
"native-dialog",
|
||||||
"paste",
|
"paste",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@ -67,7 +67,6 @@ heck = "0.5.0"
|
|||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
hickory-resolver = "0.25.2"
|
hickory-resolver = "0.25.2"
|
||||||
hmac = "0.12.1"
|
hmac = "0.12.1"
|
||||||
hyper = "1.6.0"
|
|
||||||
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
||||||
"http1",
|
"http1",
|
||||||
"native-tokio",
|
"native-tokio",
|
||||||
@ -99,7 +98,6 @@ notify = { version = "8.0.0", default-features = false }
|
|||||||
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||||
p256 = "0.13.2"
|
p256 = "0.13.2"
|
||||||
paste = "1.0.15"
|
paste = "1.0.15"
|
||||||
phf = { version = "0.12.1", features = ["macros"] }
|
|
||||||
png = "0.17.16"
|
png = "0.17.16"
|
||||||
prometheus = "0.14.0"
|
prometheus = "0.14.0"
|
||||||
quartz_nbt = "0.2.9"
|
quartz_nbt = "0.2.9"
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
|
import {
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
useTemplateRef,
|
||||||
|
provide,
|
||||||
|
nextTick,
|
||||||
|
} from 'vue'
|
||||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
ArrowBigUpDashIcon,
|
ArrowBigUpDashIcon,
|
||||||
@ -33,7 +42,7 @@ import { useLoading, useTheming } from '@/store/state'
|
|||||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||||
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
|
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
|
||||||
import { get } from '@/helpers/settings.ts'
|
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||||
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
|
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
|
||||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||||
@ -42,7 +51,7 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
|
|||||||
import { handleError, useNotifications } from '@/store/notifications.js'
|
import { handleError, useNotifications } from '@/store/notifications.js'
|
||||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||||
import { type } from '@tauri-apps/plugin-os'
|
import { type } from '@tauri-apps/plugin-os'
|
||||||
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
|
import { areUpdatesEnabled, getOS, isDev } from '@/helpers/utils.js'
|
||||||
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
@ -59,19 +68,20 @@ import { get_opening_command, initialize_state } from '@/helpers/state'
|
|||||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||||
import { renderString } from '@modrinth/utils'
|
import { renderString } from '@modrinth/utils'
|
||||||
import { useFetch } from '@/helpers/fetch.js'
|
import { useFetch } from '@/helpers/fetch.js'
|
||||||
import { check } from '@tauri-apps/plugin-updater'
|
|
||||||
import NavButton from '@/components/ui/NavButton.vue'
|
import NavButton from '@/components/ui/NavButton.vue'
|
||||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||||
import { get_user } from '@/helpers/cache.js'
|
import { get_user } from '@/helpers/cache.js'
|
||||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
|
||||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||||
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
||||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||||
|
import UpdateModal from '@/components/ui/UpdateModal.vue'
|
||||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||||
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||||
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
|
import { createTooltip, destroyTooltip } from 'floating-vue'
|
||||||
|
|
||||||
const themeStore = useTheming()
|
const themeStore = useTheming()
|
||||||
|
|
||||||
@ -110,6 +120,18 @@ onUnmounted(() => {
|
|||||||
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
|
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const messages = defineMessages({
|
||||||
|
updateInstalledToastTitle: {
|
||||||
|
id: 'app.update.complete-toast.title',
|
||||||
|
defaultMessage: 'Version {version} was successfully installed!',
|
||||||
|
},
|
||||||
|
updateInstalledToastText: {
|
||||||
|
id: 'app.update.complete-toast.text',
|
||||||
|
defaultMessage: 'Click here to view the changelog.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
async function setupApp() {
|
async function setupApp() {
|
||||||
stateInitialized.value = true
|
stateInitialized.value = true
|
||||||
const {
|
const {
|
||||||
@ -123,7 +145,8 @@ async function setupApp() {
|
|||||||
toggle_sidebar,
|
toggle_sidebar,
|
||||||
developer_mode,
|
developer_mode,
|
||||||
feature_flags,
|
feature_flags,
|
||||||
} = await get()
|
pending_update_toast_for_version,
|
||||||
|
} = await getSettings()
|
||||||
|
|
||||||
if (default_page === 'Library') {
|
if (default_page === 'Library') {
|
||||||
await router.push('/library')
|
await router.push('/library')
|
||||||
@ -210,7 +233,6 @@ async function setupApp() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
get_opening_command().then(handleCommand)
|
get_opening_command().then(handleCommand)
|
||||||
checkUpdates()
|
|
||||||
fetchCredentials()
|
fetchCredentials()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -220,6 +242,22 @@ async function setupApp() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to generate skin previews in app setup.', error)
|
console.warn('Failed to generate skin previews in app setup.', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pending_update_toast_for_version !== null) {
|
||||||
|
const settings = await getSettings()
|
||||||
|
settings.pending_update_toast_for_version = null
|
||||||
|
await setSettings(settings)
|
||||||
|
|
||||||
|
const version = await getVersion()
|
||||||
|
if (pending_update_toast_for_version === version) {
|
||||||
|
notifications.addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: formatMessage(messages.updateInstalledToastTitle, { version }),
|
||||||
|
text: formatMessage(messages.updateInstalledToastText),
|
||||||
|
clickAction: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateFailed = ref(false)
|
const stateFailed = ref(false)
|
||||||
@ -264,8 +302,6 @@ const incompatibilityWarningModal = ref()
|
|||||||
|
|
||||||
const credentials = ref()
|
const credentials = ref()
|
||||||
|
|
||||||
const modrinthLoginFlowWaitModal = ref()
|
|
||||||
|
|
||||||
async function fetchCredentials() {
|
async function fetchCredentials() {
|
||||||
const creds = await getCreds().catch(handleError)
|
const creds = await getCreds().catch(handleError)
|
||||||
if (creds && creds.user_id) {
|
if (creds && creds.user_id) {
|
||||||
@ -275,24 +311,8 @@ async function fetchCredentials() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function signIn() {
|
async function signIn() {
|
||||||
modrinthLoginFlowWaitModal.value.show()
|
await login().catch(handleError)
|
||||||
|
await fetchCredentials()
|
||||||
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() {
|
async function logOut() {
|
||||||
@ -365,19 +385,95 @@ async function handleCommand(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateAvailable = ref(false)
|
const availableUpdate = ref(null)
|
||||||
|
const updateSkipped = ref(false)
|
||||||
|
const enqueuedUpdate = ref(null)
|
||||||
|
const updateModal = useTemplateRef('updateModal')
|
||||||
async function checkUpdates() {
|
async function checkUpdates() {
|
||||||
const update = await check()
|
if (!(await areUpdatesEnabled())) {
|
||||||
updateAvailable.value = !!update
|
console.log('Skipping update check as updates are disabled in this build')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performCheck() {
|
||||||
|
if (updateModal.value.isOpen) {
|
||||||
|
console.log('Skipping update check because the update modal is already open')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = await invoke('plugin:updater|check')
|
||||||
|
if (!update) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Update ${update.version} is available.`)
|
||||||
|
|
||||||
|
if (update.version === availableUpdate.value?.version) {
|
||||||
|
console.log(
|
||||||
|
'Skipping update modal because the new version is the same as the dismissed update',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
availableUpdate.value = update
|
||||||
|
|
||||||
|
const settings = await getSettings()
|
||||||
|
if (settings.skipped_update === update.version) {
|
||||||
|
updateSkipped.value = true
|
||||||
|
console.log('Skipping update modal because the user chose to skip this update')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSkipped.value = false
|
||||||
|
updateModal.value.show(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
await performCheck()
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => {
|
() => {
|
||||||
checkUpdates()
|
checkUpdates()
|
||||||
},
|
},
|
||||||
5 * 1000 * 60,
|
5 * 60 * 1000,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function skipUpdate(version) {
|
||||||
|
enqueuedUpdate.value = null
|
||||||
|
|
||||||
|
updateSkipped.value = true
|
||||||
|
const settings = await getSettings()
|
||||||
|
settings.skipped_update = version
|
||||||
|
await setSettings(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEnqueuedForLater(version) {
|
||||||
|
enqueuedUpdate.value = version
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceOpenUpdateModal() {
|
||||||
|
if (updateSkipped.value) {
|
||||||
|
updateSkipped.value = false
|
||||||
|
const settings = await getSettings()
|
||||||
|
settings.skipped_update = null
|
||||||
|
await setSettings(settings)
|
||||||
|
}
|
||||||
|
updateModal.value.show(availableUpdate.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateButton = useTemplateRef('updateButton')
|
||||||
|
async function showUpdateButtonTooltip() {
|
||||||
|
await nextTick()
|
||||||
|
const tooltip = createTooltip(updateButton.value.$el, {
|
||||||
|
placement: 'right',
|
||||||
|
content: 'Click here to view the update again.',
|
||||||
|
})
|
||||||
|
tooltip.show()
|
||||||
|
setTimeout(() => {
|
||||||
|
tooltip.hide()
|
||||||
|
destroyTooltip(updateButton.value.$el)
|
||||||
|
}, 3500)
|
||||||
|
}
|
||||||
|
|
||||||
function handleClick(e) {
|
function handleClick(e) {
|
||||||
let target = e.target
|
let target = e.target
|
||||||
while (target != null) {
|
while (target != null) {
|
||||||
@ -418,11 +514,16 @@ function handleAuxClick(e) {
|
|||||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||||
<div id="teleports"></div>
|
<div id="teleports"></div>
|
||||||
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
|
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
|
||||||
<Suspense>
|
<Suspense @resolve="checkUpdates">
|
||||||
<AppSettingsModal ref="settingsModal" />
|
<UpdateModal
|
||||||
|
ref="updateModal"
|
||||||
|
@update-skipped="skipUpdate"
|
||||||
|
@update-enqueued-for-later="updateEnqueuedForLater"
|
||||||
|
@modal-hidden="showUpdateButtonTooltip"
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
|
<AppSettingsModal ref="settingsModal" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<InstanceCreationModal ref="installationModal" />
|
<InstanceCreationModal ref="installationModal" />
|
||||||
@ -471,8 +572,18 @@ function handleAuxClick(e) {
|
|||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</NavButton>
|
</NavButton>
|
||||||
<div class="flex flex-grow"></div>
|
<div class="flex flex-grow"></div>
|
||||||
<NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
|
<NavButton
|
||||||
<DownloadIcon />
|
v-if="!!availableUpdate"
|
||||||
|
ref="updateButton"
|
||||||
|
v-tooltip.right="
|
||||||
|
enqueuedUpdate === availableUpdate?.version
|
||||||
|
? 'Update installation queued for next restart'
|
||||||
|
: 'Update available'
|
||||||
|
"
|
||||||
|
:to="forceOpenUpdateModal"
|
||||||
|
>
|
||||||
|
<DownloadIcon v-if="updateSkipped || enqueuedUpdate === availableUpdate?.version" />
|
||||||
|
<DownloadIcon v-else class="text-brand-green" />
|
||||||
</NavButton>
|
</NavButton>
|
||||||
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
|
|||||||
@ -305,16 +305,12 @@ const [
|
|||||||
get_game_versions().then(shallowRef).catch(handleError),
|
get_game_versions().then(shallowRef).catch(handleError),
|
||||||
get_loaders()
|
get_loaders()
|
||||||
.then((value) =>
|
.then((value) =>
|
||||||
ref(
|
value
|
||||||
value
|
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
.map((item) => item.name.toLowerCase()),
|
||||||
.map((item) => item.name.toLowerCase()),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.catch((err) => {
|
.then(ref)
|
||||||
handleError(err)
|
.catch(handleError),
|
||||||
return ref([])
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
loaders.value.unshift('vanilla')
|
loaders.value.unshift('vanilla')
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
|
<div
|
||||||
|
class="progress-bar__fill"
|
||||||
|
:style="{
|
||||||
|
width: `${progress}%`,
|
||||||
|
'background-color': error ? 'var(--color-red)' : 'var(--color-brand)',
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -13,6 +19,10 @@ defineProps({
|
|||||||
return value >= 0 && value <= 100
|
return value >= 0 && value <= 100
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
error: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -27,7 +37,6 @@ defineProps({
|
|||||||
|
|
||||||
.progress-bar__fill {
|
.progress-bar__fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: var(--color-brand);
|
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -21,11 +21,14 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const featuredCategory = computed(() => {
|
const featuredCategory = computed(() => {
|
||||||
if (props.project.display_categories.includes('optimization')) {
|
if (props.project.categories.includes('optimization')) {
|
||||||
return 'optimization'
|
return 'optimization'
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.project.display_categories[0] ?? props.project.categories[0]
|
if (props.project.categories.length > 0) {
|
||||||
|
return props.project.categories[0]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const toColor = computed(() => {
|
const toColor = computed(() => {
|
||||||
|
|||||||
303
apps/app-frontend/src/components/ui/UpdateModal.vue
Normal file
303
apps/app-frontend/src/components/ui/UpdateModal.vue
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
<template>
|
||||||
|
<ModalWrapper ref="modal" hide-header :closable="false" :on-hide="onHide">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="w-[500px]">
|
||||||
|
<div class="font-extrabold text-contrast text-xl">
|
||||||
|
{{ formatMessage(messages.header) }} Modrinth App v{{ update!.version }}
|
||||||
|
</div>
|
||||||
|
<template v-if="!downloadInProgress && !downloadError">
|
||||||
|
<div class="mb-1 leading-tight">{{ formatMessage(messages.bodyVersion) }}</div>
|
||||||
|
<div class="text-sm text-secondary mb-2">
|
||||||
|
{{ formatMessage(messages.downloadSize, { size: formatBytes(updateSize) }) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<AppearingProgressBar
|
||||||
|
v-if="!downloadError"
|
||||||
|
:max-value="shouldShowProgress ? updateSize || 0 : 0"
|
||||||
|
:current-value="shouldShowProgress ? downloadedBytes : 0"
|
||||||
|
color="green"
|
||||||
|
class="w-full mb-4 mt-2"
|
||||||
|
/>
|
||||||
|
<div v-if="downloadError" class="leading-tight">
|
||||||
|
<div class="text-red font-medium mb-4">
|
||||||
|
{{ formatMessage(messages.downloadError) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button @click="installUpdateNow">
|
||||||
|
<DownloadIcon />
|
||||||
|
{{ formatMessage(messages.tryAgain) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="copyError">
|
||||||
|
<ClipboardCopyIcon />
|
||||||
|
{{
|
||||||
|
copiedError
|
||||||
|
? formatMessage(messages.copiedError)
|
||||||
|
: formatMessage(messages.copyError)
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<a href="https://support.modrinth.com"><ChatIcon /> Get support</a>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!downloadError" class="flex flex-wrap gap-2 w-full">
|
||||||
|
<JoinedButtons
|
||||||
|
:actions="installActions"
|
||||||
|
:disabled="updatingImmediately || downloadInProgress"
|
||||||
|
color="brand"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="() => openUrl('https://modrinth.com/news/changelog?filter=app')">
|
||||||
|
<ExternalIcon /> {{ formatMessage(messages.changelog) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
|
import { useTemplateRef, ref, computed } from 'vue'
|
||||||
|
import { AppearingProgressBar, ButtonStyled, JoinedButtons } from '@modrinth/ui'
|
||||||
|
import type { JoinedButtonAction } from '@modrinth/ui'
|
||||||
|
import { ExternalIcon, DownloadIcon, RedoIcon, ClipboardCopyIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { enqueueUpdateForInstallation, getUpdateSize } from '@/helpers/utils'
|
||||||
|
import { formatBytes } from '@modrinth/utils'
|
||||||
|
import { handleError } from '@/store/notifications'
|
||||||
|
import { loading_listener } from '@/helpers/events'
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
|
import { ChatIcon } from '@/assets/icons'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'updateEnqueuedForLater', version: string | null): Promise<void>
|
||||||
|
(e: 'modalHidden'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const messages = defineMessages({
|
||||||
|
header: {
|
||||||
|
id: 'app.update.modal-header',
|
||||||
|
defaultMessage: 'Update available - ',
|
||||||
|
},
|
||||||
|
copiedError: {
|
||||||
|
id: 'app.update.copied-error',
|
||||||
|
defaultMessage: 'Copied to clipboard!',
|
||||||
|
},
|
||||||
|
bodyVersion: {
|
||||||
|
id: 'app.update.modal-body-version',
|
||||||
|
defaultMessage:
|
||||||
|
'We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements.',
|
||||||
|
},
|
||||||
|
downloadSize: {
|
||||||
|
id: 'app.update.download-size',
|
||||||
|
defaultMessage: 'The update is {size}.',
|
||||||
|
},
|
||||||
|
changelog: {
|
||||||
|
id: 'app.update.changelog',
|
||||||
|
defaultMessage: 'View changelog',
|
||||||
|
},
|
||||||
|
restartNow: {
|
||||||
|
id: 'app.update.restart',
|
||||||
|
defaultMessage: 'Update now',
|
||||||
|
},
|
||||||
|
later: {
|
||||||
|
id: 'app.update.later',
|
||||||
|
defaultMessage: 'Update on exit',
|
||||||
|
},
|
||||||
|
downloadError: {
|
||||||
|
id: 'app.update.download-error',
|
||||||
|
defaultMessage:
|
||||||
|
'An error occurred while downloading the update. Please try again later. Contact support if the issue persists.',
|
||||||
|
},
|
||||||
|
copyError: {
|
||||||
|
id: 'app.update.copy-error',
|
||||||
|
defaultMessage: 'Copy error',
|
||||||
|
},
|
||||||
|
tryAgain: {
|
||||||
|
id: 'app.update.try-again',
|
||||||
|
defaultMessage: 'Try again',
|
||||||
|
},
|
||||||
|
hide: {
|
||||||
|
id: 'app.update.hide',
|
||||||
|
defaultMessage: 'Hide update reminder',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type UpdateData = {
|
||||||
|
rid: number
|
||||||
|
currentVersion: string
|
||||||
|
version: string
|
||||||
|
date?: string
|
||||||
|
body?: string
|
||||||
|
rawJson: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = ref<UpdateData>()
|
||||||
|
const updateSize = ref<number>()
|
||||||
|
|
||||||
|
const updatingImmediately = ref(false)
|
||||||
|
const downloadInProgress = ref(false)
|
||||||
|
const downloadProgress = ref(0)
|
||||||
|
const copiedError = ref(false)
|
||||||
|
const downloadError = ref<Error | null>(null)
|
||||||
|
|
||||||
|
const enqueuedUpdate = ref<string | null>(null)
|
||||||
|
|
||||||
|
const installActions = computed<JoinedButtonAction[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'install-now',
|
||||||
|
label: formatMessage(messages.restartNow),
|
||||||
|
icon: DownloadIcon,
|
||||||
|
action: installUpdateNow,
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'install-later',
|
||||||
|
label: formatMessage(messages.later),
|
||||||
|
icon: RedoIcon,
|
||||||
|
action: updateAtNextExit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hide',
|
||||||
|
label: formatMessage(messages.hide),
|
||||||
|
action: () => {
|
||||||
|
hide()
|
||||||
|
emit('modalHidden')
|
||||||
|
},
|
||||||
|
icon: XIcon,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const downloadedBytes = computed(() => {
|
||||||
|
return updateSize.value ? Math.round((downloadProgress.value / 100) * updateSize.value) : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const shouldShowProgress = computed(() => {
|
||||||
|
return downloadInProgress.value || updatingImmediately.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const modal = useTemplateRef('modal')
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
async function show(newUpdate: UpdateData) {
|
||||||
|
const oldVersion = update.value?.version
|
||||||
|
|
||||||
|
update.value = newUpdate
|
||||||
|
updateSize.value = await getUpdateSize(newUpdate.rid).catch(handleError)
|
||||||
|
|
||||||
|
if (oldVersion !== update.value?.version) {
|
||||||
|
downloadProgress.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.value!.show(new MouseEvent('click'))
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHide() {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value!.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide, isOpen })
|
||||||
|
|
||||||
|
async function copyError() {
|
||||||
|
if (downloadError.value) {
|
||||||
|
copiedError.value = true
|
||||||
|
const errorData = {
|
||||||
|
message: downloadError.value.message,
|
||||||
|
stack: downloadError.value.stack,
|
||||||
|
name: downloadError.value.name,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
updateVersion: update.value?.version,
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
copiedError.value = false
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(JSON.stringify(errorData, null, 2))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to copy error to clipboard:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Migrate to common events.ts helper when events/listeners are refactored
|
||||||
|
interface LoadingListenerEvent {
|
||||||
|
event: {
|
||||||
|
type: 'launcher_update'
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
fraction?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
loading_listener((event: LoadingListenerEvent) => {
|
||||||
|
if (event.event.type === 'launcher_update') {
|
||||||
|
if (event.event.version === update.value!.version) {
|
||||||
|
downloadProgress.value = (event.fraction ?? 1.0) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function installUpdateNow() {
|
||||||
|
updatingImmediately.value = true
|
||||||
|
|
||||||
|
if (enqueuedUpdate.value !== update.value!.version) {
|
||||||
|
downloadUpdate()
|
||||||
|
} else if (!downloadInProgress.value) {
|
||||||
|
// Update already downloaded. Simply close the app
|
||||||
|
getCurrentWindow().close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAtNextExit() {
|
||||||
|
enqueuedUpdate.value = update.value!.version
|
||||||
|
emit('updateEnqueuedForLater', update.value!.version)
|
||||||
|
|
||||||
|
downloadUpdate()
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadUpdate() {
|
||||||
|
downloadError.value = null
|
||||||
|
downloadProgress.value = 0
|
||||||
|
|
||||||
|
const versionToDownload = update.value!.version
|
||||||
|
|
||||||
|
downloadInProgress.value = true
|
||||||
|
try {
|
||||||
|
await enqueueUpdateForInstallation(update.value!.rid)
|
||||||
|
} catch (e) {
|
||||||
|
downloadInProgress.value = false
|
||||||
|
downloadError.value = e instanceof Error ? e : new Error(String(e))
|
||||||
|
|
||||||
|
handleError(e)
|
||||||
|
|
||||||
|
enqueuedUpdate.value = null
|
||||||
|
updatingImmediately.value = false
|
||||||
|
await emit('updateEnqueuedForLater', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
downloadInProgress.value = false
|
||||||
|
|
||||||
|
if (updatingImmediately.value && update.value!.version === versionToDownload) {
|
||||||
|
await getCurrentWindow().close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@ -76,10 +76,10 @@ const installing = ref(false)
|
|||||||
const onInstall = ref(() => {})
|
const onInstall = ref(() => {})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
|
show: (instanceVal, projectVal, projectVersions, callback) => {
|
||||||
instance.value = instanceVal
|
instance.value = instanceVal
|
||||||
versions.value = projectVersions
|
versions.value = projectVersions
|
||||||
selectedVersion.value = selected ?? projectVersions[0]
|
selectedVersion.value = projectVersions[0]
|
||||||
|
|
||||||
project.value = projectVal
|
project.value = projectVal
|
||||||
|
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -11,6 +11,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
hideHeader: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
closable: {
|
closable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
@ -48,7 +52,14 @@ function onModalHide() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
|
<Modal
|
||||||
|
ref="modal"
|
||||||
|
:header="header"
|
||||||
|
:noblur="!themeStore.advancedRendering"
|
||||||
|
:closable="closable"
|
||||||
|
:hide-header="hideHeader"
|
||||||
|
@hide="onModalHide"
|
||||||
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<slot name="title" />
|
<slot name="title" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
type ProtocolVersion,
|
|
||||||
type ServerWorld,
|
type ServerWorld,
|
||||||
type ServerData,
|
type ServerData,
|
||||||
type WorldWithProfile,
|
type WorldWithProfile,
|
||||||
@ -34,7 +33,7 @@ const theme = useTheming()
|
|||||||
|
|
||||||
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
||||||
const serverData = ref<Record<string, ServerData>>({})
|
const serverData = ref<Record<string, ServerData>>({})
|
||||||
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
|
const protocolVersions = ref<Record<string, number | null>>({})
|
||||||
|
|
||||||
const MIN_JUMP_BACK_IN = 3
|
const MIN_JUMP_BACK_IN = 3
|
||||||
const MAX_JUMP_BACK_IN = 6
|
const MAX_JUMP_BACK_IN = 6
|
||||||
@ -122,8 +121,11 @@ async function populateJumpBackIn() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
servers.forEach(({ instancePath, address }) =>
|
// fetch each server's data
|
||||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
Promise.all(
|
||||||
|
servers.map(({ instancePath, address }) =>
|
||||||
|
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,8 +150,8 @@ async function populateJumpBackIn() {
|
|||||||
.slice(0, MAX_JUMP_BACK_IN)
|
.slice(0, MAX_JUMP_BACK_IN)
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshServer(address: string, instancePath: string) {
|
async function refreshServer(address: string, instancePath: string) {
|
||||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function joinWorld(world: WorldWithProfile) {
|
async function joinWorld(world: WorldWithProfile) {
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import type {
|
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
|
||||||
ProtocolVersion,
|
|
||||||
ServerStatus,
|
|
||||||
ServerWorld,
|
|
||||||
SingleplayerWorld,
|
|
||||||
World,
|
|
||||||
} from '@/helpers/worlds.ts'
|
|
||||||
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
|
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
|
||||||
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
||||||
import {
|
import {
|
||||||
@ -60,9 +54,8 @@ const props = withDefaults(
|
|||||||
playingInstance?: boolean
|
playingInstance?: boolean
|
||||||
playingWorld?: boolean
|
playingWorld?: boolean
|
||||||
startingInstance?: boolean
|
startingInstance?: boolean
|
||||||
supportsServerQuickPlay?: boolean
|
supportsQuickPlay?: boolean
|
||||||
supportsWorldQuickPlay?: boolean
|
currentProtocol?: number | null
|
||||||
currentProtocol?: ProtocolVersion | null
|
|
||||||
highlighted?: boolean
|
highlighted?: boolean
|
||||||
|
|
||||||
// Server only
|
// Server only
|
||||||
@ -85,8 +78,7 @@ const props = withDefaults(
|
|||||||
playingInstance: false,
|
playingInstance: false,
|
||||||
playingWorld: false,
|
playingWorld: false,
|
||||||
startingInstance: false,
|
startingInstance: false,
|
||||||
supportsServerQuickPlay: true,
|
supportsQuickPlay: false,
|
||||||
supportsWorldQuickPlay: false,
|
|
||||||
currentProtocol: null,
|
currentProtocol: null,
|
||||||
|
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
@ -110,8 +102,7 @@ const serverIncompatible = computed(
|
|||||||
!!props.serverStatus &&
|
!!props.serverStatus &&
|
||||||
!!props.serverStatus.version?.protocol &&
|
!!props.serverStatus.version?.protocol &&
|
||||||
!!props.currentProtocol &&
|
!!props.currentProtocol &&
|
||||||
(props.serverStatus.version.protocol !== props.currentProtocol.version ||
|
props.serverStatus.version.protocol !== props.currentProtocol,
|
||||||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||||
@ -129,13 +120,9 @@ const messages = defineMessages({
|
|||||||
id: 'instance.worlds.a_minecraft_server',
|
id: 'instance.worlds.a_minecraft_server',
|
||||||
defaultMessage: 'A Minecraft Server',
|
defaultMessage: 'A Minecraft Server',
|
||||||
},
|
},
|
||||||
noServerQuickPlay: {
|
noQuickPlay: {
|
||||||
id: 'instance.worlds.no_server_quick_play',
|
id: 'instance.worlds.no_quick_play',
|
||||||
defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+',
|
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
|
||||||
},
|
|
||||||
noSingleplayerQuickPlay: {
|
|
||||||
id: 'instance.worlds.no_singleplayer_quick_play',
|
|
||||||
defaultMessage: 'You can only jump straight into singleplayer worlds on Minecraft 1.20+',
|
|
||||||
},
|
},
|
||||||
gameAlreadyOpen: {
|
gameAlreadyOpen: {
|
||||||
id: 'instance.worlds.game_already_open',
|
id: 'instance.worlds.game_already_open',
|
||||||
@ -157,6 +144,10 @@ const messages = defineMessages({
|
|||||||
id: 'instance.worlds.view_instance',
|
id: 'instance.worlds.view_instance',
|
||||||
defaultMessage: 'View instance',
|
defaultMessage: 'View instance',
|
||||||
},
|
},
|
||||||
|
playAnyway: {
|
||||||
|
id: 'instance.worlds.play_anyway',
|
||||||
|
defaultMessage: 'Play anyway',
|
||||||
|
},
|
||||||
playInstance: {
|
playInstance: {
|
||||||
id: 'instance.worlds.play_instance',
|
id: 'instance.worlds.play_instance',
|
||||||
defaultMessage: 'Play instance',
|
defaultMessage: 'Play instance',
|
||||||
@ -331,24 +322,17 @@ const messages = defineMessages({
|
|||||||
<ButtonStyled v-else>
|
<ButtonStyled v-else>
|
||||||
<button
|
<button
|
||||||
v-tooltip="
|
v-tooltip="
|
||||||
world.type == 'server' && !supportsServerQuickPlay
|
!serverStatus
|
||||||
? formatMessage(messages.noServerQuickPlay)
|
? formatMessage(messages.noContact)
|
||||||
: world.type == 'singleplayer' && !supportsWorldQuickPlay
|
: serverIncompatible
|
||||||
? formatMessage(messages.noSingleplayerQuickPlay)
|
? formatMessage(messages.incompatibleServer)
|
||||||
: playingOtherWorld || locked
|
: !supportsQuickPlay
|
||||||
? formatMessage(messages.gameAlreadyOpen)
|
? formatMessage(messages.noQuickPlay)
|
||||||
: !serverStatus
|
: playingOtherWorld || locked
|
||||||
? formatMessage(messages.noContact)
|
? formatMessage(messages.gameAlreadyOpen)
|
||||||
: serverIncompatible
|
: null
|
||||||
? formatMessage(messages.incompatibleServer)
|
|
||||||
: null
|
|
||||||
"
|
|
||||||
:disabled="
|
|
||||||
playingOtherWorld ||
|
|
||||||
startingInstance ||
|
|
||||||
(world.type == 'server' && !supportsServerQuickPlay) ||
|
|
||||||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
|
|
||||||
"
|
"
|
||||||
|
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||||
@click="emit('play')"
|
@click="emit('play')"
|
||||||
>
|
>
|
||||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||||
@ -365,6 +349,11 @@ const messages = defineMessages({
|
|||||||
disabled: playingInstance,
|
disabled: playingInstance,
|
||||||
action: () => emit('play-instance'),
|
action: () => emit('play-instance'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'play-anyway',
|
||||||
|
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
|
||||||
|
action: () => emit('play'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'open-instance',
|
id: 'open-instance',
|
||||||
shown: !!instancePath,
|
shown: !!instancePath,
|
||||||
@ -430,6 +419,10 @@ const messages = defineMessages({
|
|||||||
<PlayIcon aria-hidden="true" />
|
<PlayIcon aria-hidden="true" />
|
||||||
{{ formatMessage(messages.playInstance) }}
|
{{ formatMessage(messages.playInstance) }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #play-anyway>
|
||||||
|
<PlayIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.playAnyway) }}
|
||||||
|
</template>
|
||||||
<template #open-instance>
|
<template #open-instance>
|
||||||
<EyeIcon aria-hidden="true" />
|
<EyeIcon aria-hidden="true" />
|
||||||
{{ formatMessage(messages.viewInstance) }}
|
{{ formatMessage(messages.viewInstance) }}
|
||||||
|
|||||||
@ -16,7 +16,3 @@ export async function logout() {
|
|||||||
export async function get() {
|
export async function get() {
|
||||||
return await invoke('plugin:mr-auth|get')
|
return await invoke('plugin:mr-auth|get')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelLogin() {
|
|
||||||
return await invoke('plugin:mr-auth|cancel_modrinth_login')
|
|
||||||
}
|
|
||||||
|
|||||||
@ -62,6 +62,9 @@ export type AppSettings = {
|
|||||||
|
|
||||||
developer_mode: boolean
|
developer_mode: boolean
|
||||||
feature_flags: Record<FeatureFlag, boolean>
|
feature_flags: Record<FeatureFlag, boolean>
|
||||||
|
|
||||||
|
skipped_update: string | null
|
||||||
|
pending_update_toast_for_version: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get full settings object
|
// Get full settings object
|
||||||
|
|||||||
@ -5,6 +5,22 @@ export async function isDev() {
|
|||||||
return await invoke('is_dev')
|
return await invoke('is_dev')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function areUpdatesEnabled() {
|
||||||
|
return await invoke('are_updates_enabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUpdateSize(updateRid) {
|
||||||
|
return await invoke('get_update_size', { rid: updateRid })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueUpdateForInstallation(updateRid) {
|
||||||
|
return await invoke('enqueue_update_for_installation', { rid: updateRid })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeEnqueuedUpdate() {
|
||||||
|
return await invoke('remove_enqueued_update')
|
||||||
|
}
|
||||||
|
|
||||||
// One of 'Windows', 'Linux', 'MacOS'
|
// One of 'Windows', 'Linux', 'MacOS'
|
||||||
export async function getOS() {
|
export async function getOS() {
|
||||||
return await invoke('plugin:utils|get_os')
|
return await invoke('plugin:utils|get_os')
|
||||||
@ -37,13 +53,6 @@ export async function restartApp() {
|
|||||||
return await invoke('restart_app')
|
return await invoke('restart_app')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated This method is no longer needed, and just returns its parameter
|
|
||||||
*/
|
|
||||||
export function sanitizePotentialFileUrl(url) {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
export const releaseColor = (releaseType) => {
|
export const releaseColor = (releaseType) => {
|
||||||
switch (releaseType) {
|
switch (releaseType) {
|
||||||
case 'release':
|
case 'release':
|
||||||
|
|||||||
@ -51,7 +51,6 @@ export type ServerStatus = {
|
|||||||
version?: {
|
version?: {
|
||||||
name: string
|
name: string
|
||||||
protocol: number
|
protocol: number
|
||||||
legacy: boolean
|
|
||||||
}
|
}
|
||||||
favicon?: string
|
favicon?: string
|
||||||
enforces_secure_chat: boolean
|
enforces_secure_chat: boolean
|
||||||
@ -71,17 +70,11 @@ export interface Chat {
|
|||||||
|
|
||||||
export type ServerData = {
|
export type ServerData = {
|
||||||
refreshing: boolean
|
refreshing: boolean
|
||||||
lastSuccessfulRefresh?: number
|
|
||||||
status?: ServerStatus
|
status?: ServerStatus
|
||||||
rawMotd?: string | Chat
|
rawMotd?: string | Chat
|
||||||
renderedMotd?: string
|
renderedMotd?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProtocolVersion = {
|
|
||||||
version: number
|
|
||||||
legacy: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get_recent_worlds(
|
export async function get_recent_worlds(
|
||||||
limit: number,
|
limit: number,
|
||||||
displayStatuses?: DisplayStatus[],
|
displayStatuses?: DisplayStatus[],
|
||||||
@ -163,13 +156,13 @@ export async function remove_server_from_profile(path: string, index: number): P
|
|||||||
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
|
export async function get_profile_protocol_version(path: string): Promise<number | null> {
|
||||||
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get_server_status(
|
export async function get_server_status(
|
||||||
address: string,
|
address: string,
|
||||||
protocolVersion: ProtocolVersion | null = null,
|
protocolVersion: number | null = null,
|
||||||
): Promise<ServerStatus> {
|
): Promise<ServerStatus> {
|
||||||
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
||||||
}
|
}
|
||||||
@ -213,39 +206,30 @@ export function isServerWorld(world: World): world is ServerWorld {
|
|||||||
|
|
||||||
export async function refreshServerData(
|
export async function refreshServerData(
|
||||||
serverData: ServerData,
|
serverData: ServerData,
|
||||||
protocolVersion: ProtocolVersion | null,
|
protocolVersion: number | null,
|
||||||
address: string,
|
address: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const refreshTime = Date.now()
|
|
||||||
serverData.refreshing = true
|
serverData.refreshing = true
|
||||||
await get_server_status(address, protocolVersion)
|
await get_server_status(address, protocolVersion)
|
||||||
.then((status) => {
|
.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
|
serverData.status = status
|
||||||
if (status.description) {
|
if (status.description) {
|
||||||
serverData.rawMotd = status.description
|
serverData.rawMotd = status.description
|
||||||
serverData.renderedMotd = autoToHTML(status.description)
|
serverData.renderedMotd = autoToHTML(status.description)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(`Refreshing addr: ${address}`, err)
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
serverData.refreshing = false
|
serverData.refreshing = false
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
|
||||||
console.error(`Refreshing addr ${address}`, protocolVersion, err)
|
|
||||||
if (!protocolVersion?.legacy) {
|
|
||||||
refreshServerData(serverData, { version: 74, legacy: true }, address)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshServers(
|
export async function refreshServers(
|
||||||
worlds: World[],
|
worlds: World[],
|
||||||
serverData: Record<string, ServerData>,
|
serverData: Record<string, ServerData>,
|
||||||
protocolVersion: ProtocolVersion | null,
|
protocolVersion: number | null,
|
||||||
) {
|
) {
|
||||||
const servers = worlds.filter(isServerWorld)
|
const servers = worlds.filter(isServerWorld)
|
||||||
servers.forEach((server) => {
|
servers.forEach((server) => {
|
||||||
@ -259,8 +243,10 @@ export function refreshServers(
|
|||||||
})
|
})
|
||||||
|
|
||||||
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
|
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
|
||||||
Object.keys(serverData).forEach((address) =>
|
Promise.all(
|
||||||
refreshServerData(serverData[address], protocolVersion, address),
|
Object.keys(serverData).map((address) =>
|
||||||
|
refreshServerData(serverData[address], protocolVersion, address),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,24 +297,15 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
|
|||||||
return worlds ?? []
|
return worlds ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
const FIRST_QUICK_PLAY_VERSION = '23w14a'
|
||||||
if (!gameVersions.length) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
||||||
const targetIndex = gameVersions.findIndex((v) => v.version === 'a1.0.5_01')
|
|
||||||
|
|
||||||
return versionIndex === -1 || targetIndex === -1 || versionIndex <= targetIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
|
||||||
if (!gameVersions.length) {
|
if (!gameVersions.length) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
||||||
const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
|
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
|
||||||
|
|
||||||
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
|
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,45 @@
|
|||||||
"app.settings.tabs.resource-management": {
|
"app.settings.tabs.resource-management": {
|
||||||
"message": "Resource management"
|
"message": "Resource management"
|
||||||
},
|
},
|
||||||
|
"app.update.changelog": {
|
||||||
|
"message": "View changelog"
|
||||||
|
},
|
||||||
|
"app.update.complete-toast.text": {
|
||||||
|
"message": "Click here to view the changelog."
|
||||||
|
},
|
||||||
|
"app.update.complete-toast.title": {
|
||||||
|
"message": "Version {version} was successfully installed!"
|
||||||
|
},
|
||||||
|
"app.update.copied-error": {
|
||||||
|
"message": "Copied to clipboard!"
|
||||||
|
},
|
||||||
|
"app.update.copy-error": {
|
||||||
|
"message": "Copy error"
|
||||||
|
},
|
||||||
|
"app.update.download-error": {
|
||||||
|
"message": "An error occurred while downloading the update. Please try again later. Contact support if the issue persists."
|
||||||
|
},
|
||||||
|
"app.update.download-size": {
|
||||||
|
"message": "The update is {size}."
|
||||||
|
},
|
||||||
|
"app.update.hide": {
|
||||||
|
"message": "Hide update reminder"
|
||||||
|
},
|
||||||
|
"app.update.later": {
|
||||||
|
"message": "Update on exit"
|
||||||
|
},
|
||||||
|
"app.update.modal-body-version": {
|
||||||
|
"message": "We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements."
|
||||||
|
},
|
||||||
|
"app.update.modal-header": {
|
||||||
|
"message": "Update available - "
|
||||||
|
},
|
||||||
|
"app.update.restart": {
|
||||||
|
"message": "Update now"
|
||||||
|
},
|
||||||
|
"app.update.try-again": {
|
||||||
|
"message": "Try again"
|
||||||
|
},
|
||||||
"instance.add-server.add-and-play": {
|
"instance.add-server.add-and-play": {
|
||||||
"message": "Add and play"
|
"message": "Add and play"
|
||||||
},
|
},
|
||||||
@ -383,11 +422,11 @@
|
|||||||
"instance.worlds.no_contact": {
|
"instance.worlds.no_contact": {
|
||||||
"message": "Server couldn't be contacted"
|
"message": "Server couldn't be contacted"
|
||||||
},
|
},
|
||||||
"instance.worlds.no_server_quick_play": {
|
"instance.worlds.no_quick_play": {
|
||||||
"message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
|
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||||
},
|
},
|
||||||
"instance.worlds.no_singleplayer_quick_play": {
|
"instance.worlds.play_anyway": {
|
||||||
"message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
|
"message": "Play anyway"
|
||||||
},
|
},
|
||||||
"instance.worlds.play_instance": {
|
"instance.worlds.play_instance": {
|
||||||
"message": "Play instance"
|
"message": "Play instance"
|
||||||
|
|||||||
@ -67,8 +67,7 @@
|
|||||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||||
:world="world"
|
:world="world"
|
||||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||||
:supports-server-quick-play="supportsServerQuickPlay"
|
:supports-quick-play="supportsQuickPlay"
|
||||||
:supports-world-quick-play="supportsWorldQuickPlay"
|
|
||||||
:current-protocol="protocolVersion"
|
:current-protocol="protocolVersion"
|
||||||
:playing-instance="playing"
|
:playing-instance="playing"
|
||||||
:playing-world="worldsMatch(world, worldPlaying)"
|
:playing-world="worldsMatch(world, worldPlaying)"
|
||||||
@ -135,7 +134,6 @@ import {
|
|||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
type ProtocolVersion,
|
|
||||||
type SingleplayerWorld,
|
type SingleplayerWorld,
|
||||||
type World,
|
type World,
|
||||||
type ServerWorld,
|
type ServerWorld,
|
||||||
@ -151,11 +149,10 @@ import {
|
|||||||
refreshWorld,
|
refreshWorld,
|
||||||
sortWorlds,
|
sortWorlds,
|
||||||
refreshServers,
|
refreshServers,
|
||||||
hasWorldQuickPlaySupport,
|
hasQuickPlaySupport,
|
||||||
refreshWorlds,
|
refreshWorlds,
|
||||||
handleDefaultProfileUpdateEvent,
|
handleDefaultProfileUpdateEvent,
|
||||||
showWorldInFolder,
|
showWorldInFolder,
|
||||||
hasServerQuickPlaySupport,
|
|
||||||
} from '@/helpers/worlds.ts'
|
} from '@/helpers/worlds.ts'
|
||||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||||
@ -213,9 +210,7 @@ const worldPlaying = ref<World>()
|
|||||||
const worlds = ref<World[]>([])
|
const worlds = ref<World[]>([])
|
||||||
const serverData = ref<Record<string, ServerData>>({})
|
const serverData = ref<Record<string, ServerData>>({})
|
||||||
|
|
||||||
const protocolVersion = ref<ProtocolVersion | null>(
|
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
|
||||||
await get_profile_protocol_version(instance.value.path),
|
|
||||||
)
|
|
||||||
|
|
||||||
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
||||||
if (e.profile_path_id !== instance.value.path) return
|
if (e.profile_path_id !== instance.value.path) return
|
||||||
@ -251,7 +246,7 @@ async function refreshAllWorlds() {
|
|||||||
worlds.value = await refreshWorlds(instance.value.path).finally(
|
worlds.value = await refreshWorlds(instance.value.path).finally(
|
||||||
() => (refreshingAll.value = false),
|
() => (refreshingAll.value = false),
|
||||||
)
|
)
|
||||||
refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||||
|
|
||||||
const hasNoWorlds = worlds.value.length === 0
|
const hasNoWorlds = worlds.value.length === 0
|
||||||
|
|
||||||
@ -357,11 +352,8 @@ function worldsMatch(world: World, other: World | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
||||||
const supportsServerQuickPlay = computed(() =>
|
const supportsQuickPlay = computed(() =>
|
||||||
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||||
)
|
|
||||||
const supportsWorldQuickPlay = computed(() =>
|
|
||||||
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const filterOptions = computed(() => {
|
const filterOptions = computed(() => {
|
||||||
|
|||||||
@ -29,8 +29,8 @@ export const useInstall = defineStore('installStore', {
|
|||||||
setIncompatibilityWarningModal(ref) {
|
setIncompatibilityWarningModal(ref) {
|
||||||
this.incompatibilityWarningModal = ref
|
this.incompatibilityWarningModal = ref
|
||||||
},
|
},
|
||||||
showIncompatibilityWarningModal(instance, project, versions, selected, onInstall) {
|
showIncompatibilityWarningModal(instance, project, versions, onInstall) {
|
||||||
this.incompatibilityWarningModal.show(instance, project, versions, selected, onInstall)
|
this.incompatibilityWarningModal.show(instance, project, versions, onInstall)
|
||||||
},
|
},
|
||||||
setModInstallModal(ref) {
|
setModInstallModal(ref) {
|
||||||
this.modInstallModal = ref
|
this.modInstallModal = ref
|
||||||
@ -133,13 +133,7 @@ export const install = async (
|
|||||||
callback(version.id)
|
callback(version.id)
|
||||||
} else {
|
} else {
|
||||||
const install = useInstall()
|
const install = useInstall()
|
||||||
install.showIncompatibilityWarningModal(
|
install.showIncompatibilityWarningModal(instance, project, projectVersions, callback)
|
||||||
instance,
|
|
||||||
project,
|
|
||||||
projectVersions,
|
|
||||||
version,
|
|
||||||
callback,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const versions = (await get_version_many(project.versions).catch(handleError)).sort(
|
const versions = (await get_version_many(project.versions).catch(handleError)).sort(
|
||||||
|
|||||||
@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
|||||||
println!("A browser window will now open, follow the login flow there.");
|
println!("A browser window will now open, follow the login flow there.");
|
||||||
let login = minecraft_auth::begin_login().await?;
|
let login = minecraft_auth::begin_login().await?;
|
||||||
|
|
||||||
println!("Open URL {} in a browser", login.auth_request_uri.as_str());
|
println!("Open URL {} in a browser", login.redirect_uri.as_str());
|
||||||
|
|
||||||
println!("Please enter URL code: ");
|
println!("Please enter URL code: ");
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
|
|||||||
@ -31,8 +31,6 @@ thiserror.workspace = true
|
|||||||
daedalus.workspace = true
|
daedalus.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
either.workspace = true
|
either.workspace = true
|
||||||
hyper = { workspace = true, features = ["server"] }
|
|
||||||
hyper-util.workspace = true
|
|
||||||
|
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
urlencoding.workspace = true
|
urlencoding.workspace = true
|
||||||
|
|||||||
@ -120,12 +120,7 @@ fn main() {
|
|||||||
.plugin(
|
.plugin(
|
||||||
"mr-auth",
|
"mr-auth",
|
||||||
InlinedPlugin::new()
|
InlinedPlugin::new()
|
||||||
.commands(&[
|
.commands(&["modrinth_login", "logout", "get"])
|
||||||
"modrinth_login",
|
|
||||||
"logout",
|
|
||||||
"get",
|
|
||||||
"cancel_modrinth_login",
|
|
||||||
])
|
|
||||||
.default_permission(
|
.default_permission(
|
||||||
DefaultPermissionRule::AllowAllCommands,
|
DefaultPermissionRule::AllowAllCommands,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
"build": "tauri build",
|
"build": "tauri build",
|
||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
"test": "cargo nextest run --all-targets --no-fail-fast",
|
"test": "cargo nextest run --all-targets --no-fail-fast",
|
||||||
"lint": "cargo fmt --check && cargo clippy --all-targets",
|
"lint": "cargo fmt --check && cargo clippy --all-targets && cargo clippy --all-targets --features updater",
|
||||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
|
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo clippy --all-targets --features updater --fix --allow-dirty && cargo fmt"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.5.0"
|
"@tauri-apps/cli": "2.5.0"
|
||||||
|
|||||||
@ -33,7 +33,7 @@ pub async fn login<R: Runtime>(
|
|||||||
let window = tauri::WebviewWindowBuilder::new(
|
let window = tauri::WebviewWindowBuilder::new(
|
||||||
&app,
|
&app,
|
||||||
"signin",
|
"signin",
|
||||||
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err(
|
||||||
|_| {
|
|_| {
|
||||||
theseus::ErrorKind::OtherError(
|
theseus::ErrorKind::OtherError(
|
||||||
"Error parsing auth redirect URL".to_string(),
|
"Error parsing auth redirect URL".to_string(),
|
||||||
@ -77,7 +77,6 @@ pub async fn login<R: Runtime>(
|
|||||||
window.close()?;
|
window.close()?;
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
||||||
Ok(minecraft_auth::remove_user(user).await?)
|
Ok(minecraft_auth::remove_user(user).await?)
|
||||||
|
|||||||
@ -22,8 +22,6 @@ pub mod cache;
|
|||||||
pub mod friends;
|
pub mod friends;
|
||||||
pub mod worlds;
|
pub mod worlds;
|
||||||
|
|
||||||
mod oauth_utils;
|
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
||||||
|
|
||||||
// // Main returnable Theseus GUI error
|
// // Main returnable Theseus GUI error
|
||||||
@ -47,8 +45,12 @@ pub enum TheseusSerializableError {
|
|||||||
Tauri(#[from] tauri::Error),
|
Tauri(#[from] tauri::Error),
|
||||||
|
|
||||||
#[cfg(feature = "updater")]
|
#[cfg(feature = "updater")]
|
||||||
#[error("Tauri updater error: {0}")]
|
#[error("Updater error: {0}")]
|
||||||
TauriUpdater(#[from] tauri_plugin_updater::Error),
|
Updater(#[from] tauri_plugin_updater::Error),
|
||||||
|
|
||||||
|
#[cfg(feature = "updater")]
|
||||||
|
#[error("HTTP error: {0}")]
|
||||||
|
Http(#[from] tauri_plugin_http::reqwest::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic implementation of From<T> for ErrorTypeA
|
// Generic implementation of From<T> for ErrorTypeA
|
||||||
@ -106,5 +108,6 @@ impl_serialize! {
|
|||||||
impl_serialize! {
|
impl_serialize! {
|
||||||
IO,
|
IO,
|
||||||
Tauri,
|
Tauri,
|
||||||
TauriUpdater,
|
Updater,
|
||||||
|
Http,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,70 +1,79 @@
|
|||||||
use crate::api::Result;
|
use crate::api::Result;
|
||||||
use crate::api::TheseusSerializableError;
|
use chrono::{Duration, Utc};
|
||||||
use crate::api::oauth_utils;
|
|
||||||
use tauri::Manager;
|
|
||||||
use tauri::Runtime;
|
|
||||||
use tauri::plugin::TauriPlugin;
|
use tauri::plugin::TauriPlugin;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri::{Manager, Runtime, UserAttentionType};
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
use tokio::sync::oneshot;
|
|
||||||
|
|
||||||
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||||
tauri::plugin::Builder::new("mr-auth")
|
tauri::plugin::Builder::new("mr-auth")
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,])
|
||||||
modrinth_login,
|
|
||||||
logout,
|
|
||||||
get,
|
|
||||||
cancel_modrinth_login,
|
|
||||||
])
|
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn modrinth_login<R: Runtime>(
|
pub async fn modrinth_login<R: Runtime>(
|
||||||
app: tauri::AppHandle<R>,
|
app: tauri::AppHandle<R>,
|
||||||
) -> Result<ModrinthCredentials> {
|
) -> Result<Option<ModrinthCredentials>> {
|
||||||
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
|
let redirect_uri = mr_auth::authenticate_begin_flow();
|
||||||
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
|
|
||||||
auth_code_recv_socket_tx,
|
|
||||||
));
|
|
||||||
|
|
||||||
let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
|
let start = Utc::now();
|
||||||
|
|
||||||
let auth_request_uri = format!(
|
if let Some(window) = app.get_webview_window("modrinth-signin") {
|
||||||
"{}?launcher=true&ipver={}&port={}",
|
window.close()?;
|
||||||
mr_auth::authenticate_begin_flow(),
|
|
||||||
if auth_code_recv_socket.is_ipv4() {
|
|
||||||
"4"
|
|
||||||
} else {
|
|
||||||
"6"
|
|
||||||
},
|
|
||||||
auth_code_recv_socket.port()
|
|
||||||
);
|
|
||||||
|
|
||||||
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(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let Some(auth_code) = auth_code.await.unwrap()? else {
|
|
||||||
return Err(TheseusSerializableError::Theseus(
|
|
||||||
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
|
|
||||||
|
|
||||||
if let Some(main_window) = app.get_window("main") {
|
|
||||||
main_window.set_focus().ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(credentials)
|
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(),
|
||||||
|
)
|
||||||
|
.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))?;
|
||||||
|
|
||||||
|
while (Utc::now() - start) < Duration::minutes(10) {
|
||||||
|
if window.title().is_err() {
|
||||||
|
// user closed window, cancelling flow
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.close()?;
|
||||||
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@ -76,8 +85,3 @@ pub async fn logout() -> Result<()> {
|
|||||||
pub async fn get() -> Result<Option<ModrinthCredentials>> {
|
pub async fn get() -> Result<Option<ModrinthCredentials>> {
|
||||||
Ok(theseus::mr_auth::get_credentials().await?)
|
Ok(theseus::mr_auth::get_credentials().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn cancel_modrinth_login() {
|
|
||||||
oauth_utils::auth_code_reply::stop_listeners();
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,159 +0,0 @@
|
|||||||
//! 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
@ -1,3 +0,0 @@
|
|||||||
//! Assorted utilities for OAuth 2.0 authorization flows.
|
|
||||||
|
|
||||||
pub mod auth_code_reply;
|
|
||||||
@ -250,7 +250,7 @@ pub async fn profile_get_pack_export_candidates(
|
|||||||
// invoke('plugin:profile|profile_run', path)
|
// invoke('plugin:profile|profile_run', path)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
||||||
let process = profile::run(path, QuickPlayType::None).await?;
|
let process = profile::run(path, &QuickPlayType::None).await?;
|
||||||
|
|
||||||
Ok(process)
|
Ok(process)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,9 @@ use enumset::EnumSet;
|
|||||||
use tauri::{AppHandle, Manager, Runtime};
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
use theseus::prelude::ProcessMetadata;
|
use theseus::prelude::ProcessMetadata;
|
||||||
use theseus::profile::{QuickPlayType, get_full_path};
|
use theseus::profile::{QuickPlayType, get_full_path};
|
||||||
use theseus::server_address::ServerAddress;
|
|
||||||
use theseus::worlds::{
|
use theseus::worlds::{
|
||||||
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
|
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
|
||||||
WorldType, WorldWithProfile,
|
WorldWithProfile,
|
||||||
};
|
};
|
||||||
use theseus::{profile, worlds};
|
use theseus::{profile, worlds};
|
||||||
|
|
||||||
@ -184,16 +183,14 @@ pub async fn remove_server_from_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_profile_protocol_version(
|
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> {
|
||||||
path: &str,
|
|
||||||
) -> Result<Option<ProtocolVersion>> {
|
|
||||||
Ok(worlds::get_profile_protocol_version(path).await?)
|
Ok(worlds::get_profile_protocol_version(path).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_server_status(
|
pub async fn get_server_status(
|
||||||
address: &str,
|
address: &str,
|
||||||
protocol_version: Option<ProtocolVersion>,
|
protocol_version: Option<i32>,
|
||||||
) -> Result<ServerStatus> {
|
) -> Result<ServerStatus> {
|
||||||
Ok(worlds::get_server_status(address, protocol_version).await?)
|
Ok(worlds::get_server_status(address, protocol_version).await?)
|
||||||
}
|
}
|
||||||
@ -204,7 +201,7 @@ pub async fn start_join_singleplayer_world(
|
|||||||
world: String,
|
world: String,
|
||||||
) -> Result<ProcessMetadata> {
|
) -> Result<ProcessMetadata> {
|
||||||
let process =
|
let process =
|
||||||
profile::run(path, QuickPlayType::Singleplayer(world)).await?;
|
profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
|
||||||
|
|
||||||
Ok(process)
|
Ok(process)
|
||||||
}
|
}
|
||||||
@ -214,11 +211,8 @@ pub async fn start_join_server(
|
|||||||
path: &str,
|
path: &str,
|
||||||
address: &str,
|
address: &str,
|
||||||
) -> Result<ProcessMetadata> {
|
) -> Result<ProcessMetadata> {
|
||||||
let process = profile::run(
|
let process =
|
||||||
path,
|
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
|
||||||
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(process)
|
Ok(process)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,11 @@ mod error;
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod macos;
|
mod macos;
|
||||||
|
|
||||||
|
#[cfg(feature = "updater")]
|
||||||
|
mod updater_impl;
|
||||||
|
#[cfg(not(feature = "updater"))]
|
||||||
|
mod updater_impl_noop;
|
||||||
|
|
||||||
// Should be called in launcher initialization
|
// Should be called in launcher initialization
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@ -21,75 +26,9 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
|||||||
tracing::info!("Initializing app event state...");
|
tracing::info!("Initializing app event state...");
|
||||||
theseus::EventState::init(app.clone()).await?;
|
theseus::EventState::init(app.clone()).await?;
|
||||||
|
|
||||||
#[cfg(feature = "updater")]
|
tracing::info!("Initializing app state...");
|
||||||
'updater: {
|
State::init().await?;
|
||||||
if env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_ok() {
|
|
||||||
State::init().await?;
|
|
||||||
break 'updater;
|
|
||||||
}
|
|
||||||
|
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
|
||||||
|
|
||||||
let updater = app.updater_builder().build()?;
|
|
||||||
|
|
||||||
let update_fut = updater.check();
|
|
||||||
|
|
||||||
tracing::info!("Initializing app state...");
|
|
||||||
State::init().await?;
|
|
||||||
|
|
||||||
let check_bar = theseus::init_loading(
|
|
||||||
theseus::LoadingBarType::CheckingForUpdates,
|
|
||||||
1.0,
|
|
||||||
"Checking for updates...",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tracing::info!("Checking for updates...");
|
|
||||||
let update = update_fut.await;
|
|
||||||
|
|
||||||
drop(check_bar);
|
|
||||||
|
|
||||||
if let Some(update) = update.ok().flatten() {
|
|
||||||
tracing::info!("Update found: {:?}", update.download_url);
|
|
||||||
let loader_bar_id = theseus::init_loading(
|
|
||||||
theseus::LoadingBarType::LauncherUpdate {
|
|
||||||
version: update.version.clone(),
|
|
||||||
current_version: update.current_version.clone(),
|
|
||||||
},
|
|
||||||
1.0,
|
|
||||||
"Updating Modrinth App...",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 100 MiB
|
|
||||||
const DEFAULT_CONTENT_LENGTH: u64 = 1024 * 1024 * 100;
|
|
||||||
|
|
||||||
update
|
|
||||||
.download_and_install(
|
|
||||||
|chunk_length, content_length| {
|
|
||||||
let _ = theseus::emit_loading(
|
|
||||||
&loader_bar_id,
|
|
||||||
(chunk_length as f64)
|
|
||||||
/ (content_length
|
|
||||||
.unwrap_or(DEFAULT_CONTENT_LENGTH)
|
|
||||||
as f64),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|| {},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
app.restart();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "updater"))]
|
|
||||||
{
|
|
||||||
State::init().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("Finished checking for updates!");
|
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
app.asset_protocol_scope()
|
app.asset_protocol_scope()
|
||||||
.allow_directory(state.directories.caches_dir(), true)?;
|
.allow_directory(state.directories.caches_dir(), true)?;
|
||||||
@ -125,6 +64,17 @@ fn is_dev() -> bool {
|
|||||||
cfg!(debug_assertions)
|
cfg!(debug_assertions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn are_updates_enabled() -> bool {
|
||||||
|
cfg!(feature = "updater")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "updater")]
|
||||||
|
pub use updater_impl::*;
|
||||||
|
|
||||||
|
#[cfg(not(feature = "updater"))]
|
||||||
|
pub use updater_impl_noop::*;
|
||||||
|
|
||||||
// Toggles decorations
|
// Toggles decorations
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
|
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
|
||||||
@ -166,7 +116,17 @@ fn main() {
|
|||||||
|
|
||||||
#[cfg(feature = "updater")]
|
#[cfg(feature = "updater")]
|
||||||
{
|
{
|
||||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
use tauri_plugin_http::reqwest::header::{HeaderValue, USER_AGENT};
|
||||||
|
use theseus::LAUNCHER_USER_AGENT;
|
||||||
|
builder = builder.plugin(
|
||||||
|
tauri_plugin_updater::Builder::new()
|
||||||
|
.header(
|
||||||
|
USER_AGENT,
|
||||||
|
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
builder = builder
|
builder = builder
|
||||||
@ -261,9 +221,14 @@ fn main() {
|
|||||||
.plugin(api::ads::init())
|
.plugin(api::ads::init())
|
||||||
.plugin(api::friends::init())
|
.plugin(api::friends::init())
|
||||||
.plugin(api::worlds::init())
|
.plugin(api::worlds::init())
|
||||||
|
.manage(PendingUpdateData::default())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
initialize_state,
|
initialize_state,
|
||||||
is_dev,
|
is_dev,
|
||||||
|
are_updates_enabled,
|
||||||
|
get_update_size,
|
||||||
|
enqueue_update_for_installation,
|
||||||
|
remove_enqueued_update,
|
||||||
toggle_decorations,
|
toggle_decorations,
|
||||||
show_window,
|
show_window,
|
||||||
restart_app,
|
restart_app,
|
||||||
@ -275,8 +240,41 @@ fn main() {
|
|||||||
match app {
|
match app {
|
||||||
Ok(app) => {
|
Ok(app) => {
|
||||||
app.run(|app, event| {
|
app.run(|app, event| {
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(any(feature = "updater", target_os = "macos")))]
|
||||||
drop((app, event));
|
drop((app, event));
|
||||||
|
|
||||||
|
#[cfg(feature = "updater")]
|
||||||
|
if matches!(event, tauri::RunEvent::Exit) {
|
||||||
|
let update_data = app.state::<PendingUpdateData>().inner();
|
||||||
|
if let Some((update, data)) = &*update_data.0.lock().unwrap() {
|
||||||
|
fn set_changelog_toast(version: Option<String>) {
|
||||||
|
let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move {
|
||||||
|
let mut settings = settings::get().await?;
|
||||||
|
settings.pending_update_toast_for_version = version;
|
||||||
|
settings::set(settings).await?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
if let Err(e) = toast_result {
|
||||||
|
tracing::warn!("Failed to set pending_update_toast: {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_changelog_toast(Some(update.version.clone()));
|
||||||
|
if let Err(e) = update.install(data) {
|
||||||
|
tracing::error!("Error while updating: {e}");
|
||||||
|
set_changelog_toast(None);
|
||||||
|
|
||||||
|
DialogBuilder::message()
|
||||||
|
.set_level(MessageLevel::Error)
|
||||||
|
.set_title("Update error")
|
||||||
|
.set_text(format!("Failed to install update due to an error:\n{e}"))
|
||||||
|
.alert()
|
||||||
|
.show()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if let tauri::RunEvent::Opened { urls } = event {
|
if let tauri::RunEvent::Opened { urls } = event {
|
||||||
tracing::info!("Handling webview open {urls:?}");
|
tracing::info!("Handling webview open {urls:?}");
|
||||||
@ -304,6 +302,8 @@ fn main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
tracing::error!("Error while running tauri application: {:?}", e);
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
|
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
|
||||||
@ -332,7 +332,6 @@ fn main() {
|
|||||||
.show()
|
.show()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
tracing::error!("Error while running tauri application: {:?}", e);
|
|
||||||
panic!("{1}: {:?}", e, "error while running tauri application")
|
panic!("{1}: {:?}", e, "error while running tauri application")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
121
apps/app/src/updater_impl.rs
Normal file
121
apps/app/src/updater_impl.rs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
use crate::api::Result;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tauri::http::HeaderValue;
|
||||||
|
use tauri::http::header::ACCEPT;
|
||||||
|
use tauri::{Manager, ResourceId, Runtime, Webview};
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
use tauri_plugin_http::reqwest::ClientBuilder;
|
||||||
|
use tauri_plugin_updater::Error;
|
||||||
|
use tauri_plugin_updater::Update;
|
||||||
|
use theseus::{
|
||||||
|
LAUNCHER_USER_AGENT, LoadingBarType, emit_loading, init_loading,
|
||||||
|
};
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct PendingUpdateData(pub Mutex<Option<(Arc<Update>, Vec<u8>)>>);
|
||||||
|
|
||||||
|
// Reimplementation of Update::download mostly, minus the actual download part
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_update_size<R: Runtime>(
|
||||||
|
webview: Webview<R>,
|
||||||
|
rid: ResourceId,
|
||||||
|
) -> Result<Option<u64>> {
|
||||||
|
let update = webview.resources_table().get::<Update>(rid)?;
|
||||||
|
|
||||||
|
let mut headers = update.headers.clone();
|
||||||
|
if !headers.contains_key(ACCEPT) {
|
||||||
|
headers.insert(
|
||||||
|
ACCEPT,
|
||||||
|
HeaderValue::from_static("application/octet-stream"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut request = ClientBuilder::new().user_agent(LAUNCHER_USER_AGENT);
|
||||||
|
if let Some(timeout) = update.timeout {
|
||||||
|
request = request.timeout(timeout);
|
||||||
|
}
|
||||||
|
if let Some(ref proxy) = update.proxy {
|
||||||
|
let proxy = reqwest::Proxy::all(proxy.as_str())?;
|
||||||
|
request = request.proxy(proxy);
|
||||||
|
}
|
||||||
|
let response = request
|
||||||
|
.build()?
|
||||||
|
.head(update.download_url.clone())
|
||||||
|
.headers(headers)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(Error::Network(format!(
|
||||||
|
"Download request failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_length = response
|
||||||
|
.headers()
|
||||||
|
.get("Content-Length")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.parse().ok());
|
||||||
|
|
||||||
|
Ok(content_length)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn enqueue_update_for_installation<R: Runtime>(
|
||||||
|
webview: Webview<R>,
|
||||||
|
rid: ResourceId,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pending_data = webview.state::<PendingUpdateData>().inner();
|
||||||
|
|
||||||
|
let update = webview.resources_table().get::<Update>(rid)?;
|
||||||
|
|
||||||
|
let progress = init_loading(
|
||||||
|
LoadingBarType::LauncherUpdate {
|
||||||
|
version: update.version.clone(),
|
||||||
|
current_version: update.current_version.clone(),
|
||||||
|
},
|
||||||
|
1.0,
|
||||||
|
"Downloading update...",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let download_start = Instant::now();
|
||||||
|
let update_data = update
|
||||||
|
.download(
|
||||||
|
|chunk_size, total_size| {
|
||||||
|
let Some(total_size) = total_size else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(e) = emit_loading(
|
||||||
|
&progress,
|
||||||
|
chunk_size as f64 / total_size as f64,
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to update download progress bar: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|| {},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let download_duration = download_start.elapsed();
|
||||||
|
tracing::info!("Downloaded update in {download_duration:?}");
|
||||||
|
|
||||||
|
pending_data
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.replace((update, update_data));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_enqueued_update<R: Runtime>(webview: Webview<R>) {
|
||||||
|
let pending_data = webview.state::<PendingUpdateData>().inner();
|
||||||
|
pending_data.0.lock().unwrap().take();
|
||||||
|
}
|
||||||
26
apps/app/src/updater_impl_noop.rs
Normal file
26
apps/app/src/updater_impl_noop.rs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
use crate::api::Result;
|
||||||
|
use theseus::ErrorKind;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct PendingUpdateData(());
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_update_size() -> Result<()> {
|
||||||
|
updates_are_disabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn enqueue_update_for_installation() -> Result<()> {
|
||||||
|
updates_are_disabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn updates_are_disabled() -> Result<()> {
|
||||||
|
let error: theseus::Error = ErrorKind::OtherError(
|
||||||
|
"Updates are disabled in this build.".to_string(),
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
Err(error.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_enqueued_update() {}
|
||||||
@ -63,7 +63,6 @@
|
|||||||
"height": 800,
|
"height": 800,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"title": "Modrinth App",
|
"title": "Modrinth App",
|
||||||
"label": "main",
|
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"minHeight": 700,
|
"minHeight": 700,
|
||||||
"minWidth": 1100,
|
"minWidth": 1100,
|
||||||
|
|||||||
@ -1,19 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
FROM rust:1.88.0 AS build
|
FROM rust:1.88.0 AS build
|
||||||
|
|
||||||
WORKDIR /usr/src/daedalus
|
WORKDIR /usr/src/daedalus
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/usr/src/daedalus/target \
|
RUN cargo build --release --package daedalus_client
|
||||||
--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
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
@ -21,7 +11,7 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=artifacts /daedalus /daedalus
|
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||||
|
|
||||||
WORKDIR /daedalus_client
|
WORKDIR /daedalus_client
|
||||||
CMD ["/daedalus/daedalus_client"]
|
|
||||||
|
CMD /daedalus/daedalus_client
|
||||||
|
|||||||
@ -143,13 +143,8 @@ export default defineNuxtConfig({
|
|||||||
state.lastGenerated &&
|
state.lastGenerated &&
|
||||||
new Date(state.lastGenerated).getTime() + TTL > new Date().getTime() &&
|
new Date(state.lastGenerated).getTime() + TTL > new Date().getTime() &&
|
||||||
// ...but only if the API URL is the same
|
// ...but only if the API URL is the same
|
||||||
state.apiUrl === API_URL &&
|
state.apiUrl === API_URL
|
||||||
// ...and if no errors were caught during the last generation
|
|
||||||
(state.errors ?? []).length === 0
|
|
||||||
) {
|
) {
|
||||||
console.log(
|
|
||||||
"Tags already recently generated. Delete apps/frontend/generated/state.json to force regeneration.",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
"postinstall": "nuxi prepare",
|
"postinstall": "nuxi prepare",
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"fix": "eslint . --fix && prettier --write .",
|
"fix": "eslint . --fix && prettier --write .",
|
||||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||||
"test": "nuxi build"
|
"test": "nuxi build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -59,12 +59,10 @@
|
|||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"pathe": "^1.1.2",
|
"pathe": "^1.1.2",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pinia-plugin-persistedstate": "^4.4.1",
|
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"qrcode.vue": "^3.4.0",
|
"qrcode.vue": "^3.4.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"three": "^0.172.0",
|
"three": "^0.172.0",
|
||||||
"vue-confetti-explosion": "^1.0.2",
|
|
||||||
"vue-multiselect": "3.0.0-alpha.2",
|
"vue-multiselect": "3.0.0-alpha.2",
|
||||||
"vue-typed-virtual-list": "^1.0.10",
|
"vue-typed-virtual-list": "^1.0.10",
|
||||||
"vue3-ace-editor": "^2.2.4",
|
"vue3-ace-editor": "^2.2.4",
|
||||||
|
|||||||
1133
apps/frontend/src/components/ui/ModerationChecklist.vue
Normal file
1133
apps/frontend/src/components/ui/ModerationChecklist.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,28 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
import { MailIcon, CheckIcon } from "@modrinth/assets";
|
import { MailIcon, CheckIcon } from "@modrinth/assets";
|
||||||
import { ref } from "vue";
|
import { ref, watchEffect } from "vue";
|
||||||
import { useBaseFetch } from "~/composables/fetch.js";
|
import { useBaseFetch } from "~/composables/fetch.js";
|
||||||
|
|
||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
const showSubscriptionConfirmation = ref(false);
|
const showSubscriptionConfirmation = ref(false);
|
||||||
const showSubscribeButton = useAsyncData(
|
const subscribed = ref(false);
|
||||||
async () => {
|
|
||||||
if (auth.value?.user) {
|
async function checkSubscribed() {
|
||||||
try {
|
if (auth.value?.user) {
|
||||||
const { subscribed } = await useBaseFetch("auth/email/subscribe", {
|
try {
|
||||||
method: "GET",
|
const { data } = await useBaseFetch("auth/email/subscribe", {
|
||||||
});
|
method: "GET",
|
||||||
return !subscribed;
|
});
|
||||||
} catch {
|
subscribed.value = data?.subscribed || false;
|
||||||
return true;
|
} catch {
|
||||||
}
|
subscribed.value = false;
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{ watch: [auth], server: false },
|
}
|
||||||
);
|
|
||||||
|
watchEffect(() => {
|
||||||
|
checkSubscribed();
|
||||||
|
});
|
||||||
|
|
||||||
async function subscribe() {
|
async function subscribe() {
|
||||||
try {
|
try {
|
||||||
@ -34,19 +35,14 @@ async function subscribe() {
|
|||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showSubscriptionConfirmation.value = false;
|
showSubscriptionConfirmation.value = false;
|
||||||
showSubscribeButton.status.value = "success";
|
subscribed.value = true;
|
||||||
showSubscribeButton.data.value = false;
|
|
||||||
}, 2500);
|
}, 2500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ButtonStyled
|
<ButtonStyled v-if="auth?.user && !subscribed" color="brand" type="outlined">
|
||||||
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
|
|
||||||
color="brand"
|
|
||||||
type="outlined"
|
|
||||||
>
|
|
||||||
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
|
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
|
||||||
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
|
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
|
||||||
<template v-else> <CheckIcon /> Subscribed! </template>
|
<template v-else> <CheckIcon /> Subscribed! </template>
|
||||||
|
|||||||
@ -29,7 +29,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
||||||
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isMac() {
|
function isMac() {
|
||||||
return navigator.platform.toUpperCase().includes("MAC");
|
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(event?: MouseEvent) {
|
function show(event?: MouseEvent) {
|
||||||
@ -1,179 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
<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">•</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">•</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>
|
|
||||||
@ -1,275 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -8,7 +8,7 @@
|
|||||||
<div v-if="!modPackData">Loading data...</div>
|
<div v-if="!modPackData">Loading data...</div>
|
||||||
|
|
||||||
<div v-else-if="modPackData.length === 0">
|
<div v-else-if="modPackData.length === 0">
|
||||||
<p>All permissions already obtained.</p>
|
<p>All permissions obtained. You may skip this step!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!modPackData[currentIndex]">
|
<div v-else-if="!modPackData[currentIndex]">
|
||||||
@ -157,7 +157,7 @@ import type {
|
|||||||
} from "@modrinth/utils";
|
} from "@modrinth/utils";
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
import { ref, computed, watch, onMounted } from "vue";
|
import { ref, computed, watch, onMounted } from "vue";
|
||||||
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -182,26 +182,7 @@ const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
|
|||||||
|
|
||||||
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
|
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
|
||||||
|
|
||||||
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
|
const modPackData = ref<ModerationModpackItem[] | null>(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 currentIndex = ref(0);
|
||||||
|
|
||||||
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
|
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
|
||||||
@ -270,45 +251,7 @@ async function fetchModPackData(): Promise<void> {
|
|||||||
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
|
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
|
||||||
internal: true,
|
internal: true,
|
||||||
})) as ModerationModpackResponse;
|
})) 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[] = [
|
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 || {})
|
...Object.entries(data.unknown_files || {})
|
||||||
.map(
|
.map(
|
||||||
([sha1, fileName]): ModerationUnknownModpackItem => ({
|
([sha1, fileName]): ModerationUnknownModpackItem => ({
|
||||||
@ -367,7 +310,6 @@ async function fetchModPackData(): Promise<void> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch modpack data:", error);
|
console.error("Failed to fetch modpack data:", error);
|
||||||
modPackData.value = [];
|
modPackData.value = [];
|
||||||
permanentNoFiles.value = [];
|
|
||||||
persistAll();
|
persistAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -379,14 +321,6 @@ function goToPrevious(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
|
||||||
modPackData,
|
|
||||||
(newValue) => {
|
|
||||||
persistedModPackData.value = newValue;
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
function goToNext(): void {
|
function goToNext(): void {
|
||||||
if (modPackData.value && currentIndex.value < modPackData.value.length) {
|
if (modPackData.value && currentIndex.value < modPackData.value.length) {
|
||||||
currentIndex.value++;
|
currentIndex.value++;
|
||||||
@ -462,17 +396,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
|
||||||
modPackData,
|
|
||||||
(newValue) => {
|
|
||||||
if (newValue && newValue.length === 0) {
|
|
||||||
emit("complete");
|
|
||||||
clearPersistedData();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.projectId,
|
() => props.projectId,
|
||||||
() => {
|
() => {
|
||||||
@ -483,20 +406,6 @@ watch(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function getModpackFiles(): {
|
|
||||||
interactive: ModerationModpackItem[];
|
|
||||||
permanentNo: ModerationModpackItem[];
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
interactive: modPackData.value || [],
|
|
||||||
permanentNo: permanentNoFiles.value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
getModpackFiles,
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -42,9 +42,9 @@
|
|||||||
<div v-if="done">
|
<div v-if="done">
|
||||||
<p>
|
<p>
|
||||||
You are done moderating this project!
|
You are done moderating this project!
|
||||||
<template v-if="moderationStore.hasItems">
|
<template v-if="futureProjectCount > 0">
|
||||||
There are
|
There are
|
||||||
{{ moderationStore.queueLength }} left.
|
{{ futureProjectCount }} left.
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -98,7 +98,7 @@
|
|||||||
<div v-if="toggleActions.length > 0" class="toggle-actions-group space-y-3">
|
<div v-if="toggleActions.length > 0" class="toggle-actions-group space-y-3">
|
||||||
<template v-for="action in toggleActions" :key="getActionKey(action)">
|
<template v-for="action in toggleActions" :key="getActionKey(action)">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="isActionSelected(action)"
|
:model-value="actionStates[getActionId(action)]?.selected ?? false"
|
||||||
:label="action.label"
|
:label="action.label"
|
||||||
:description="action.description"
|
:description="action.description"
|
||||||
:disabled="false"
|
:disabled="false"
|
||||||
@ -215,31 +215,49 @@
|
|||||||
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
|
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">
|
<div class="flex items-center gap-2">
|
||||||
<ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
|
<ButtonStyled v-if="!done && !generatedMessage && futureProjectCount > 0">
|
||||||
<button @click="skipCurrentProject">
|
<button @click="goToNextProject">
|
||||||
<XIcon aria-hidden="true" />
|
<XIcon aria-hidden="true" />
|
||||||
Skip ({{ moderationStore.queueLength }} left)
|
Skip
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div v-if="done">
|
<div v-if="done">
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled v-if="futureProjectCount > 0" color="brand">
|
||||||
<button @click="endChecklist(undefined)">
|
<button @click="goToNextProject">
|
||||||
<template v-if="hasNextProject">
|
<RightArrowIcon aria-hidden="true" />
|
||||||
<RightArrowIcon aria-hidden="true" />
|
Next Project
|
||||||
Next Project ({{ moderationStore.queueLength }} left)
|
</button>
|
||||||
</template>
|
</ButtonStyled>
|
||||||
<template v-else>
|
<ButtonStyled v-else color="brand">
|
||||||
<CheckIcon aria-hidden="true" />
|
<button @click="exitModeration">
|
||||||
All Done!
|
<CheckIcon aria-hidden="true" />
|
||||||
</template>
|
Done
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="generatedMessage" class="flex items-center gap-2">
|
<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>
|
<ButtonStyled>
|
||||||
<button @click="goBackToStages">
|
<button @click="goBackToStages">
|
||||||
<LeftArrowIcon aria-hidden="true" />
|
<LeftArrowIcon aria-hidden="true" />
|
||||||
@ -259,7 +277,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled color="green">
|
<ButtonStyled color="green">
|
||||||
<button @click="sendMessage(project.requested_status ?? 'approved')">
|
<button @click="sendMessage('approved')">
|
||||||
<CheckIcon aria-hidden="true" />
|
<CheckIcon aria-hidden="true" />
|
||||||
Approve
|
Approve
|
||||||
</button>
|
</button>
|
||||||
@ -350,42 +368,44 @@ import {
|
|||||||
DropdownSelect,
|
DropdownSelect,
|
||||||
MarkdownEditor,
|
MarkdownEditor,
|
||||||
} from "@modrinth/ui";
|
} from "@modrinth/ui";
|
||||||
import {
|
import { type Project, renderHighlightedString, type ModerationJudgements } from "@modrinth/utils";
|
||||||
type Project,
|
|
||||||
renderHighlightedString,
|
|
||||||
type ModerationJudgements,
|
|
||||||
type ModerationModpackItem,
|
|
||||||
type ProjectStatus,
|
|
||||||
} from "@modrinth/utils";
|
|
||||||
import { computedAsync, useLocalStorage } from "@vueuse/core";
|
import { computedAsync, useLocalStorage } from "@vueuse/core";
|
||||||
import {
|
import type {
|
||||||
type Action,
|
Action,
|
||||||
type MultiSelectChipsAction,
|
MultiSelectChipsAction,
|
||||||
type DropdownAction,
|
DropdownAction,
|
||||||
type ButtonAction,
|
ButtonAction,
|
||||||
type ToggleAction,
|
ToggleAction,
|
||||||
type ConditionalButtonAction,
|
ConditionalButtonAction,
|
||||||
type Stage,
|
Stage,
|
||||||
finalPermissionMessages,
|
|
||||||
} from "@modrinth/moderation";
|
} from "@modrinth/moderation";
|
||||||
import * as prettier from "prettier";
|
|
||||||
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
|
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
|
||||||
import KeybindsModal from "./ChecklistKeybindsModal.vue";
|
import KeybindsModal from "./ChecklistKeybindsModal.vue";
|
||||||
import { useModerationStore } from "~/store/moderation.ts";
|
import { finalPermissionMessages } from "@modrinth/moderation/data/modpack-permissions-stage";
|
||||||
|
import prettier from "prettier";
|
||||||
|
|
||||||
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
|
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
project: Project;
|
defineProps<{
|
||||||
collapsed: boolean;
|
project: Project;
|
||||||
}>();
|
futureProjectIds?: string[];
|
||||||
|
collapsed: boolean;
|
||||||
const moderationStore = useModerationStore();
|
}>(),
|
||||||
|
{
|
||||||
|
futureProjectIds: () => [] as string[],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const variables = computed(() => {
|
const variables = computed(() => {
|
||||||
return flattenProjectVariables(props.project);
|
return flattenProjectVariables(props.project);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const futureProjectCount = computed(() => {
|
||||||
|
const ids = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
|
||||||
|
return ids.length;
|
||||||
|
});
|
||||||
|
|
||||||
const modpackPermissionsComplete = ref(false);
|
const modpackPermissionsComplete = ref(false);
|
||||||
const modpackJudgements = ref<ModerationJudgements>({});
|
const modpackJudgements = ref<ModerationJudgements>({});
|
||||||
const isModpackPermissionsStage = computed(() => {
|
const isModpackPermissionsStage = computed(() => {
|
||||||
@ -399,6 +419,7 @@ const done = ref(false);
|
|||||||
|
|
||||||
function handleModpackPermissionsComplete() {
|
function handleModpackPermissionsComplete() {
|
||||||
modpackPermissionsComplete.value = true;
|
modpackPermissionsComplete.value = true;
|
||||||
|
nextStage();
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -509,7 +530,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
|||||||
isLoadingMessage: loadingMessage.value,
|
isLoadingMessage: loadingMessage.value,
|
||||||
isModpackPermissionsStage: isModpackPermissionsStage.value,
|
isModpackPermissionsStage: isModpackPermissionsStage.value,
|
||||||
|
|
||||||
futureProjectCount: moderationStore.queueLength,
|
futureProjectCount: futureProjectCount.value,
|
||||||
visibleActionsCount: visibleActions.value.length,
|
visibleActionsCount: visibleActions.value.length,
|
||||||
|
|
||||||
focusedActionIndex: focusedActionIndex.value,
|
focusedActionIndex: focusedActionIndex.value,
|
||||||
@ -522,13 +543,13 @@ function handleKeybinds(event: KeyboardEvent) {
|
|||||||
tryGoNext: nextStage,
|
tryGoNext: nextStage,
|
||||||
tryGoBack: previousStage,
|
tryGoBack: previousStage,
|
||||||
tryGenerateMessage: generateMessage,
|
tryGenerateMessage: generateMessage,
|
||||||
trySkipProject: skipCurrentProject,
|
trySkipProject: goToNextProject,
|
||||||
|
|
||||||
tryToggleCollapse: () => emit("toggleCollapsed"),
|
tryToggleCollapse: () => emit("toggleCollapsed"),
|
||||||
tryResetProgress: resetProgress,
|
tryResetProgress: resetProgress,
|
||||||
tryExitModeration: () => emit("exit"),
|
tryExitModeration: () => emit("exit"),
|
||||||
|
|
||||||
tryApprove: () => sendMessage(props.project.requested_status),
|
tryApprove: () => sendMessage("approved"),
|
||||||
tryReject: () => sendMessage("rejected"),
|
tryReject: () => sendMessage("rejected"),
|
||||||
tryWithhold: () => sendMessage("withheld"),
|
tryWithhold: () => sendMessage("withheld"),
|
||||||
tryEditMessage: goBackToStages,
|
tryEditMessage: goBackToStages,
|
||||||
@ -645,17 +666,12 @@ function initializeStageActions(stage: Stage, stageIndex: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActionId(action: Action, index?: number): string {
|
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);
|
return getActionIdForStage(action, currentStage.value, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActionKey(action: Action): string {
|
function getActionKey(action: Action): string {
|
||||||
// Find the actual index of this action in the current stage's actions array
|
const index = visibleActions.value.indexOf(action);
|
||||||
const index = currentStageObj.value.actions.indexOf(action);
|
return `${currentStage.value}-${index}-${getActionId(action)}`;
|
||||||
return `${currentStage.value}-${index}-${getActionId(action, index)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleActions = computed(() => {
|
const visibleActions = computed(() => {
|
||||||
@ -725,8 +741,7 @@ const multiSelectActions = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
function getDropdownValue(action: DropdownAction) {
|
function getDropdownValue(action: DropdownAction) {
|
||||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
const actionId = getActionId(action);
|
||||||
const actionId = getActionId(action, actionIndex);
|
|
||||||
const visibleOptions = getVisibleDropdownOptions(action);
|
const visibleOptions = getVisibleDropdownOptions(action);
|
||||||
const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0;
|
const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0;
|
||||||
|
|
||||||
@ -741,14 +756,12 @@ function getDropdownValue(action: DropdownAction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isActionSelected(action: Action): boolean {
|
function isActionSelected(action: Action): boolean {
|
||||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
const actionId = getActionId(action);
|
||||||
const actionId = getActionId(action, actionIndex);
|
|
||||||
return actionStates.value[actionId]?.selected || false;
|
return actionStates.value[actionId]?.selected || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAction(action: Action) {
|
function toggleAction(action: Action) {
|
||||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
const actionId = getActionId(action);
|
||||||
const actionId = getActionId(action, actionIndex);
|
|
||||||
const state = actionStates.value[actionId];
|
const state = actionStates.value[actionId];
|
||||||
if (state) {
|
if (state) {
|
||||||
state.selected = !state.selected;
|
state.selected = !state.selected;
|
||||||
@ -757,8 +770,7 @@ function toggleAction(action: Action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectDropdownOption(action: DropdownAction, selected: any) {
|
function selectDropdownOption(action: DropdownAction, selected: any) {
|
||||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
const actionId = getActionId(action);
|
||||||
const actionId = getActionId(action, actionIndex);
|
|
||||||
const state = actionStates.value[actionId];
|
const state = actionStates.value[actionId];
|
||||||
if (state && selected !== undefined && selected !== null) {
|
if (state && selected !== undefined && selected !== null) {
|
||||||
const optionIndex = action.options.findIndex(
|
const optionIndex = action.options.findIndex(
|
||||||
@ -774,8 +786,7 @@ function selectDropdownOption(action: DropdownAction, selected: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean {
|
function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean {
|
||||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
const actionId = getActionId(action);
|
||||||
const actionId = getActionId(action, actionIndex);
|
|
||||||
const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined;
|
const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined;
|
||||||
|
|
||||||
const visibleOptions = getVisibleMultiSelectOptions(action);
|
const visibleOptions = getVisibleMultiSelectOptions(action);
|
||||||
@ -786,8 +797,7 @@ function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleChip(action: MultiSelectChipsAction, optionIndex: number) {
|
function toggleChip(action: MultiSelectChipsAction, optionIndex: number) {
|
||||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
const actionId = getActionId(action);
|
||||||
const actionId = getActionId(action, actionIndex);
|
|
||||||
const state = actionStates.value[actionId];
|
const state = actionStates.value[actionId];
|
||||||
if (state && state.value instanceof Set) {
|
if (state && state.value instanceof Set) {
|
||||||
const visibleOptions = getVisibleMultiSelectOptions(action);
|
const visibleOptions = getVisibleMultiSelectOptions(action);
|
||||||
@ -813,31 +823,6 @@ 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() {
|
async function assembleFullMessage() {
|
||||||
const messageParts: MessagePart[] = [];
|
const messageParts: MessagePart[] = [];
|
||||||
|
|
||||||
@ -1060,7 +1045,7 @@ function nextStage() {
|
|||||||
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
|
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: "Modpack permissions stage unfinished",
|
title: "Modpack permissions stage unfinished",
|
||||||
text: "Please complete the modpack permissions stage before proceeding.",
|
message: "Please complete the modpack permissions stage before proceeding.",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1107,14 +1092,13 @@ async function generateMessage() {
|
|||||||
const baseMessage = await assembleFullMessage();
|
const baseMessage = await assembleFullMessage();
|
||||||
let fullMessage = baseMessage;
|
let fullMessage = baseMessage;
|
||||||
|
|
||||||
if (props.project.project_type === "modpack") {
|
if (
|
||||||
const modpackFilesData = getModpackFilesFromStorage();
|
props.project.project_type === "modpack" &&
|
||||||
|
Object.keys(modpackJudgements.value).length > 0
|
||||||
if (modpackFilesData.interactive.length > 0 || modpackFilesData.permanentNo.length > 0) {
|
) {
|
||||||
const modpackMessage = generateModpackMessage(modpackFilesData);
|
const modpackMessage = generateModpackMessage(modpackJudgements.value);
|
||||||
if (modpackMessage) {
|
if (modpackMessage) {
|
||||||
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
|
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1137,7 +1121,7 @@ async function generateMessage() {
|
|||||||
console.error("Error generating message:", error);
|
console.error("Error generating message:", error);
|
||||||
addNotification({
|
addNotification({
|
||||||
title: "Error generating message",
|
title: "Error generating message",
|
||||||
text: "Failed to generate moderation message. Please try again.",
|
message: "Failed to generate moderation message. Please try again.",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@ -1145,34 +1129,25 @@ async function generateMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateModpackMessage(allFiles: {
|
function generateModpackMessage(judgements: ModerationJudgements) {
|
||||||
interactive: ModerationModpackItem[];
|
|
||||||
permanentNo: ModerationModpackItem[];
|
|
||||||
}) {
|
|
||||||
const issues = [];
|
const issues = [];
|
||||||
|
|
||||||
const attributeMods: string[] = [];
|
const attributeMods = [];
|
||||||
const noMods: string[] = [];
|
const noMods = [];
|
||||||
const permanentNoMods: string[] = [];
|
const permanentNoMods = [];
|
||||||
const unidentifiedMods: string[] = [];
|
const unidentifiedMods = [];
|
||||||
|
|
||||||
allFiles.interactive.forEach((file) => {
|
for (const [, judgement] of Object.entries(judgements)) {
|
||||||
if (file.status === "unidentified") {
|
if (judgement.status === "with-attribution") {
|
||||||
if (file.approved === "no") {
|
attributeMods.push(judgement.file_name);
|
||||||
unidentifiedMods.push(file.file_name);
|
} else if (judgement.status === "no") {
|
||||||
}
|
noMods.push(judgement.file_name);
|
||||||
} else if (file.status === "with-attribution" && file.approved === "no") {
|
} else if (judgement.status === "permanent-no") {
|
||||||
attributeMods.push(file.file_name);
|
permanentNoMods.push(judgement.file_name);
|
||||||
} else if (file.status === "no" && file.approved === "no") {
|
} else if (judgement.status === "unidentified") {
|
||||||
noMods.push(file.file_name);
|
unidentifiedMods.push(judgement.file_name);
|
||||||
} else if (file.status === "permanent-no") {
|
|
||||||
permanentNoMods.push(file.file_name);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
allFiles.permanentNo.forEach((file) => {
|
|
||||||
permanentNoMods.push(file.file_name);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
attributeMods.length > 0 ||
|
attributeMods.length > 0 ||
|
||||||
@ -1182,12 +1157,6 @@ function generateModpackMessage(allFiles: {
|
|||||||
) {
|
) {
|
||||||
issues.push("## Copyrighted content");
|
issues.push("## Copyrighted content");
|
||||||
|
|
||||||
if (unidentifiedMods.length > 0) {
|
|
||||||
issues.push(
|
|
||||||
`${finalPermissionMessages.unidentified}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attributeMods.length > 0) {
|
if (attributeMods.length > 0) {
|
||||||
issues.push(
|
issues.push(
|
||||||
`${finalPermissionMessages["with-attribution"]}\n${attributeMods.map((mod) => `- ${mod}`).join("\n")}`,
|
`${finalPermissionMessages["with-attribution"]}\n${attributeMods.map((mod) => `- ${mod}`).join("\n")}`,
|
||||||
@ -1203,13 +1172,18 @@ function generateModpackMessage(allFiles: {
|
|||||||
`${finalPermissionMessages["permanent-no"]}\n${permanentNoMods.map((mod) => `- ${mod}`).join("\n")}`,
|
`${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");
|
return issues.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNextProject = ref(false);
|
async function sendMessage(status: "approved" | "rejected" | "withheld") {
|
||||||
async function sendMessage(status: ProjectStatus) {
|
|
||||||
try {
|
try {
|
||||||
await useBaseFetch(`project/${props.project.id}`, {
|
await useBaseFetch(`project/${props.project.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@ -1243,73 +1217,55 @@ async function sendMessage(status: ProjectStatus) {
|
|||||||
|
|
||||||
done.value = true;
|
done.value = true;
|
||||||
|
|
||||||
hasNextProject.value = await moderationStore.completeCurrentProject(
|
// Clear local storage for future reviews
|
||||||
props.project.id,
|
localStorage.removeItem(`modpack-permissions-${props.project.id}`);
|
||||||
"completed",
|
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",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting moderation:", error);
|
console.error("Error submitting moderation:", error);
|
||||||
addNotification({
|
addNotification({
|
||||||
title: "Error submitting moderation",
|
title: "Error submitting moderation",
|
||||||
text: "Failed to submit moderation decision. Please try again.",
|
message: "Failed to submit moderation decision. Please try again.",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function endChecklist(status?: string) {
|
async function goToNextProject() {
|
||||||
clearProjectLocalStorage();
|
const currentIds = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
|
||||||
|
|
||||||
if (!hasNextProject.value) {
|
if (currentIds.length === 0) {
|
||||||
await navigateTo({
|
await navigateTo("/moderation/review");
|
||||||
name: "moderation",
|
return;
|
||||||
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 skipCurrentProject() {
|
async function exitModeration() {
|
||||||
hasNextProject.value = await moderationStore.completeCurrentProject(props.project.id, "skipped");
|
await navigateTo("/moderation/review");
|
||||||
|
|
||||||
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(() => {
|
const isLastVisibleStage = computed(() => {
|
||||||
@ -1,21 +1,13 @@
|
|||||||
<template>
|
<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
|
<ReportInfo
|
||||||
v-for="report in filteredReports"
|
v-for="report in reports.filter(
|
||||||
|
(x) =>
|
||||||
|
(moderation || x.reporterUser.id === auth.user.id) &&
|
||||||
|
(viewMode === 'open' ? x.open : !x.open),
|
||||||
|
)"
|
||||||
:key="report.id"
|
:key="report.id"
|
||||||
:report="report"
|
:report="report"
|
||||||
:thread="report.thread"
|
:thread="report.thread"
|
||||||
:show-message="false"
|
|
||||||
:moderation="moderation"
|
:moderation="moderation"
|
||||||
raised
|
raised
|
||||||
:auth="auth"
|
:auth="auth"
|
||||||
@ -24,12 +16,11 @@
|
|||||||
<p v-if="reports.length === 0">You don't have any active reports.</p>
|
<p v-if="reports.length === 0">You don't have any active reports.</p>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Chips } from "@modrinth/ui";
|
|
||||||
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
||||||
import { addReportMessage } from "~/helpers/threads.js";
|
import { addReportMessage } from "~/helpers/threads.js";
|
||||||
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
moderation: {
|
moderation: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@ -41,14 +32,9 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const viewMode = ref("open");
|
const viewMode = ref("open");
|
||||||
const reasonFilter = ref("All");
|
|
||||||
const reports = ref([]);
|
const reports = ref([]);
|
||||||
|
|
||||||
const MAX_REPORTS = 1500;
|
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
|
||||||
|
|
||||||
let { data: rawReports } = await useAsyncData("report", () =>
|
|
||||||
useBaseFetch(`report?count=${MAX_REPORTS}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
rawReports = rawReports.value.map((report) => {
|
rawReports = rawReports.value.map((report) => {
|
||||||
report.item_id = report.item_id.replace(/"/g, "");
|
report.item_id = report.item_id.replace(/"/g, "");
|
||||||
@ -65,7 +51,6 @@ const userIds = [...new Set(reporterUsers.concat(reportedUsers))];
|
|||||||
const threadIds = [
|
const threadIds = [
|
||||||
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
|
...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([
|
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
|
||||||
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
|
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
|
||||||
@ -108,13 +93,4 @@ reports.value = rawReports.map((report) => {
|
|||||||
report.open = true;
|
report.open = true;
|
||||||
return report;
|
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>
|
</script>
|
||||||
|
|||||||
@ -1,36 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
|
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
|
||||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||||
<Transition
|
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
|
||||||
enter-from-class="opacity-0 max-h-0"
|
|
||||||
enter-to-class="opacity-100 max-h-20"
|
|
||||||
leave-active-class="transition-all duration-200 ease-in"
|
|
||||||
leave-from-class="opacity-100 max-h-20"
|
|
||||||
leave-to-class="opacity-0 max-h-0"
|
|
||||||
>
|
|
||||||
<div v-if="isLoading" class="w-full">
|
|
||||||
<div class="mb-2 flex justify-between text-sm">
|
|
||||||
<Transition name="phrase-fade" mode="out-in">
|
|
||||||
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
|
|
||||||
currentPhrase
|
|
||||||
}}</span>
|
|
||||||
</Transition>
|
|
||||||
<div class="flex flex-col items-end">
|
|
||||||
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
|
|
||||||
<span class="text-xs text-secondary"
|
|
||||||
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="h-2 w-full rounded-full bg-divider">
|
|
||||||
<div
|
|
||||||
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
|
|
||||||
:style="{ width: `${uploadProgress}%` }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
@ -144,7 +115,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
import { BackupWarning, ButtonStyled, NewModal, AppearingProgressBar } from "@modrinth/ui";
|
||||||
import {
|
import {
|
||||||
UploadIcon,
|
UploadIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
@ -187,50 +158,9 @@ const hardReset = ref(false);
|
|||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const loadingServerCheck = ref(false);
|
const loadingServerCheck = ref(false);
|
||||||
const mrpackFile = ref<File | null>(null);
|
const mrpackFile = ref<File | null>(null);
|
||||||
const uploadProgress = ref(0);
|
|
||||||
const uploadedBytes = ref(0);
|
const uploadedBytes = ref(0);
|
||||||
const totalBytes = ref(0);
|
const totalBytes = ref(0);
|
||||||
|
|
||||||
const uploadPhrases = [
|
|
||||||
"Removing Herobrine...",
|
|
||||||
"Feeding parrots...",
|
|
||||||
"Teaching villagers new trades...",
|
|
||||||
"Convincing creepers to be friendly...",
|
|
||||||
"Polishing diamonds...",
|
|
||||||
"Training wolves to fetch...",
|
|
||||||
"Building pixel art...",
|
|
||||||
"Explaining redstone to beginners...",
|
|
||||||
"Collecting all the cats...",
|
|
||||||
"Negotiating with endermen...",
|
|
||||||
"Planting suspicious stew ingredients...",
|
|
||||||
"Calibrating TNT blast radius...",
|
|
||||||
"Teaching chickens to fly...",
|
|
||||||
"Sorting inventory alphabetically...",
|
|
||||||
"Convincing iron golems to smile...",
|
|
||||||
];
|
|
||||||
|
|
||||||
const currentPhrase = ref("Uploading...");
|
|
||||||
let phraseInterval: NodeJS.Timeout | null = null;
|
|
||||||
const usedPhrases = ref(new Set<number>());
|
|
||||||
|
|
||||||
const getNextPhrase = () => {
|
|
||||||
if (usedPhrases.value.size >= uploadPhrases.length) {
|
|
||||||
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
|
|
||||||
usedPhrases.value.clear();
|
|
||||||
if (currentPhraseIndex !== -1) {
|
|
||||||
usedPhrases.value.add(currentPhraseIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const availableIndices = uploadPhrases
|
|
||||||
.map((_, index) => index)
|
|
||||||
.filter((index) => !usedPhrases.value.has(index));
|
|
||||||
|
|
||||||
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
|
|
||||||
usedPhrases.value.add(randomIndex);
|
|
||||||
|
|
||||||
return uploadPhrases[randomIndex];
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDangerous = computed(() => hardReset.value);
|
const isDangerous = computed(() => hardReset.value);
|
||||||
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
|
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
|
||||||
|
|
||||||
@ -259,31 +189,17 @@ const handleReinstall = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
uploadProgress.value = 0;
|
|
||||||
uploadProgress.value = 0;
|
|
||||||
uploadedBytes.value = 0;
|
uploadedBytes.value = 0;
|
||||||
totalBytes.value = mrpackFile.value.size;
|
totalBytes.value = mrpackFile.value.size;
|
||||||
|
|
||||||
currentPhrase.value = getNextPhrase();
|
|
||||||
phraseInterval = setInterval(() => {
|
|
||||||
currentPhrase.value = getNextPhrase();
|
|
||||||
}, 4500);
|
|
||||||
|
|
||||||
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
|
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
|
||||||
mrpackFile.value,
|
mrpackFile.value,
|
||||||
hardReset.value,
|
hardReset.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
onProgress(({ loaded, total, progress }) => {
|
onProgress(({ loaded, total }) => {
|
||||||
uploadProgress.value = progress;
|
|
||||||
uploadedBytes.value = loaded;
|
uploadedBytes.value = loaded;
|
||||||
totalBytes.value = total;
|
totalBytes.value = total;
|
||||||
|
|
||||||
if (phraseInterval && progress >= 100) {
|
|
||||||
clearInterval(phraseInterval);
|
|
||||||
phraseInterval = null;
|
|
||||||
currentPhrase.value = "Installing modpack...";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -316,10 +232,6 @@ const handleReinstall = async () => {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
if (phraseInterval) {
|
|
||||||
clearInterval(phraseInterval);
|
|
||||||
phraseInterval = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onShow = () => {
|
const onShow = () => {
|
||||||
@ -328,15 +240,8 @@ const onShow = () => {
|
|||||||
loadingServerCheck.value = false;
|
loadingServerCheck.value = false;
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
mrpackFile.value = null;
|
mrpackFile.value = null;
|
||||||
uploadProgress.value = 0;
|
|
||||||
uploadedBytes.value = 0;
|
uploadedBytes.value = 0;
|
||||||
totalBytes.value = 0;
|
totalBytes.value = 0;
|
||||||
currentPhrase.value = "Uploading...";
|
|
||||||
usedPhrases.value.clear();
|
|
||||||
if (phraseInterval) {
|
|
||||||
clearInterval(phraseInterval);
|
|
||||||
phraseInterval = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const show = () => mrpackModal.value?.show();
|
const show = () => mrpackModal.value?.show();
|
||||||
@ -349,14 +254,4 @@ defineExpose({ show, hide });
|
|||||||
.stylized-toggle:checked::after {
|
.stylized-toggle:checked::after {
|
||||||
background: var(--color-accent-contrast) !important;
|
background: var(--color-accent-contrast) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phrase-fade-enter-active,
|
|
||||||
.phrase-fade-leave-active {
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phrase-fade-enter-from,
|
|
||||||
.phrase-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -66,27 +66,6 @@
|
|||||||
<UiServersPanelSpinner />
|
<UiServersPanelSpinner />
|
||||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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-3xl 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
|
<div
|
||||||
v-else-if="status === 'suspended'"
|
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-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||||
@ -108,8 +87,7 @@ import { Avatar, CopyCode } from "@modrinth/ui";
|
|||||||
|
|
||||||
const props = defineProps<Partial<Server>>();
|
const props = defineProps<Partial<Server>>();
|
||||||
|
|
||||||
if (props.server_id && props.status === "available") {
|
if (props.server_id) {
|
||||||
// Necessary only to get server icon
|
|
||||||
await useModrinthServers(props.server_id, ["general"]);
|
await useModrinthServers(props.server_id, ["general"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,6 +109,11 @@ if (props.upstream) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
|
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 iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
||||||
const isConfiguring = computed(() => props.flows?.intro);
|
const isConfiguring = computed(() => props.flows?.intro);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -2,10 +2,7 @@
|
|||||||
<div class="static w-full grid-cols-1 md:relative md:flex">
|
<div class="static w-full grid-cols-1 md:relative md:flex">
|
||||||
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
|
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
|
||||||
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
|
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
|
||||||
<div
|
<div v-for="link in navLinks" :key="link.label">
|
||||||
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
|
|
||||||
:key="link.label"
|
|
||||||
>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="link.href"
|
:to="link.href"
|
||||||
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
|
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
|
||||||
@ -43,7 +40,7 @@ import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
|||||||
const emit = defineEmits(["reinstall"]);
|
const emit = defineEmits(["reinstall"]);
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[];
|
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
||||||
route: RouteLocationNormalized;
|
route: RouteLocationNormalized;
|
||||||
server: ModrinthServer;
|
server: ModrinthServer;
|
||||||
backupInProgress?: BackupInProgressReason;
|
backupInProgress?: BackupInProgressReason;
|
||||||
|
|||||||
@ -34,38 +34,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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">
|
<div v-if="flags.developerMode" class="thread-id">
|
||||||
Thread ID:
|
Thread ID:
|
||||||
<CopyCode :text="thread.id" />
|
<CopyCode :text="thread.id" />
|
||||||
@ -103,17 +71,12 @@
|
|||||||
v-if="sortedMessages.length > 0"
|
v-if="sortedMessages.length > 0"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="!replyBody"
|
:disabled="!replyBody"
|
||||||
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
|
@click="sendReply()"
|
||||||
>
|
>
|
||||||
<ReplyIcon aria-hidden="true" />
|
<ReplyIcon aria-hidden="true" />
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button v-else class="btn btn-primary" :disabled="!replyBody" @click="sendReply()">
|
||||||
v-else
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="!replyBody"
|
|
||||||
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
|
|
||||||
>
|
|
||||||
<SendIcon aria-hidden="true" />
|
<SendIcon aria-hidden="true" />
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
@ -326,7 +289,6 @@ const sortedMessages = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const modalSubmit = ref(null);
|
const modalSubmit = ref(null);
|
||||||
const modalReply = ref(null);
|
|
||||||
|
|
||||||
async function updateThreadLocal() {
|
async function updateThreadLocal() {
|
||||||
let threadId = null;
|
let threadId = null;
|
||||||
@ -354,11 +316,6 @@ async function onUploadImage(file) {
|
|||||||
return response.url;
|
return response.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendReplyFromModal(status = null, privateMessage = false) {
|
|
||||||
modalReply.value.hide();
|
|
||||||
await sendReply(status, privateMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendReply(status = null, privateMessage = false) {
|
async function sendReply(status = null, privateMessage = false) {
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
@ -441,7 +398,6 @@ async function reopenReport() {
|
|||||||
|
|
||||||
const replyWithSubmission = ref(false);
|
const replyWithSubmission = ref(false);
|
||||||
const submissionConfirmation = ref(false);
|
const submissionConfirmation = ref(false);
|
||||||
const replyConfirmation = ref(false);
|
|
||||||
|
|
||||||
function openResubmitModal(reply) {
|
function openResubmitModal(reply) {
|
||||||
submissionConfirmation.value = false;
|
submissionConfirmation.value = false;
|
||||||
@ -449,11 +405,6 @@ function openResubmitModal(reply) {
|
|||||||
modalSubmit.value.show();
|
modalSubmit.value.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReplyModal(reply) {
|
|
||||||
replyConfirmation.value = false;
|
|
||||||
modalReply.value.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resubmit() {
|
async function resubmit() {
|
||||||
if (replyWithSubmission.value) {
|
if (replyWithSubmission.value) {
|
||||||
await sendReply("processing");
|
await sendReply("processing");
|
||||||
|
|||||||
@ -1,282 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -36,7 +36,7 @@
|
|||||||
v-tooltip="'Modrinth Team'"
|
v-tooltip="'Modrinth Team'"
|
||||||
/>
|
/>
|
||||||
<MicrophoneIcon
|
<MicrophoneIcon
|
||||||
v-if="report && message.author_id === report.reporter_user?.id"
|
v-if="report && message.author_id === report.reporterUser.id"
|
||||||
v-tooltip="'Reporter'"
|
v-tooltip="'Reporter'"
|
||||||
class="reporter-icon"
|
class="reporter-icon"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { ServerModule } from "./base.ts";
|
|||||||
export class GeneralModule extends ServerModule implements ServerGeneral {
|
export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||||
server_id!: string;
|
server_id!: string;
|
||||||
name!: string;
|
name!: string;
|
||||||
owner_id!: string;
|
|
||||||
net!: { ip: string; port: number; domain: string };
|
net!: { ip: string; port: number; domain: string };
|
||||||
game!: string;
|
game!: string;
|
||||||
backup_quota!: number;
|
backup_quota!: number;
|
||||||
|
|||||||
@ -147,7 +147,7 @@ export async function useServersFetch<T>(
|
|||||||
404: "Not Found",
|
404: "Not Found",
|
||||||
405: "Method Not Allowed",
|
405: "Method Not Allowed",
|
||||||
408: "Request Timeout",
|
408: "Request Timeout",
|
||||||
429: "You're making requests too quickly. Please wait a moment and try again.",
|
429: "Too Many Requests",
|
||||||
500: "Internal Server Error",
|
500: "Internal Server Error",
|
||||||
502: "Bad Gateway",
|
502: "Bad Gateway",
|
||||||
503: "Service Unavailable",
|
503: "Service Unavailable",
|
||||||
@ -167,17 +167,11 @@ export async function useServersFetch<T>(
|
|||||||
console.error("Fetch error:", error);
|
console.error("Fetch error:", error);
|
||||||
|
|
||||||
const fetchError = new ModrinthServersFetchError(
|
const fetchError = new ModrinthServersFetchError(
|
||||||
`[Modrinth Servers] ${error.message}`,
|
`[Modrinth Servers] ${message}`,
|
||||||
statusCode,
|
statusCode,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
throw new ModrinthServerError(
|
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
|
||||||
`[Modrinth Servers] ${message}`,
|
|
||||||
statusCode,
|
|
||||||
fetchError,
|
|
||||||
module,
|
|
||||||
v1Error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;
|
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;
|
||||||
|
|||||||
@ -1,236 +0,0 @@
|
|||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -295,7 +295,7 @@
|
|||||||
{
|
{
|
||||||
id: 'review-projects',
|
id: 'review-projects',
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
link: '/moderation/',
|
link: '/moderation/review',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'review-reports',
|
id: 'review-reports',
|
||||||
@ -981,6 +981,23 @@ 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 = [
|
||||||
...options,
|
...options,
|
||||||
{
|
{
|
||||||
|
|||||||
@ -182,6 +182,9 @@
|
|||||||
"collection.button.unfollow-project": {
|
"collection.button.unfollow-project": {
|
||||||
"message": "Unfollow project"
|
"message": "Unfollow project"
|
||||||
},
|
},
|
||||||
|
"collection.button.upload-icon": {
|
||||||
|
"message": "Upload icon"
|
||||||
|
},
|
||||||
"collection.delete-modal.description": {
|
"collection.delete-modal.description": {
|
||||||
"message": "This will remove this collection forever. This action cannot be undone."
|
"message": "This will remove this collection forever. This action cannot be undone."
|
||||||
},
|
},
|
||||||
@ -476,30 +479,6 @@
|
|||||||
"layout.nav.search": {
|
"layout.nav.search": {
|
||||||
"message": "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": {
|
"profile.button.billing": {
|
||||||
"message": "Manage user billing"
|
"message": "Manage user billing"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -689,10 +689,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'moderation-checklist',
|
id: 'moderation-checklist',
|
||||||
action: () => {
|
action: () => (showModerationChecklist = true),
|
||||||
moderationStore.setSingleProject(project.id);
|
|
||||||
showModerationChecklist = true;
|
|
||||||
},
|
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
hoverOnly: true,
|
hoverOnly: true,
|
||||||
shown:
|
shown:
|
||||||
@ -873,6 +870,19 @@
|
|||||||
@delete-version="deleteVersion"
|
@delete-version="deleteVersion"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -880,8 +890,9 @@
|
|||||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||||
class="moderation-checklist"
|
class="moderation-checklist"
|
||||||
>
|
>
|
||||||
<ModerationChecklist
|
<NewModerationChecklist
|
||||||
:project="project"
|
:project="project"
|
||||||
|
:future-project-ids="futureProjectIds"
|
||||||
:collapsed="collapsedModerationChecklist"
|
:collapsed="collapsedModerationChecklist"
|
||||||
@exit="showModerationChecklist = false"
|
@exit="showModerationChecklist = false"
|
||||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||||
@ -940,7 +951,14 @@ import {
|
|||||||
useRelativeTime,
|
useRelativeTime,
|
||||||
} from "@modrinth/ui";
|
} from "@modrinth/ui";
|
||||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||||
import { formatCategory, formatProjectType, renderString } from "@modrinth/utils";
|
import {
|
||||||
|
formatCategory,
|
||||||
|
formatProjectType,
|
||||||
|
isRejected,
|
||||||
|
isStaff,
|
||||||
|
isUnderReview,
|
||||||
|
renderString,
|
||||||
|
} from "@modrinth/utils";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { Tooltip } from "floating-vue";
|
import { Tooltip } from "floating-vue";
|
||||||
import { useLocalStorage } from "@vueuse/core";
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
@ -958,13 +976,11 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
|||||||
import { userCollectProject } from "~/composables/user.js";
|
import { userCollectProject } from "~/composables/user.js";
|
||||||
import { reportProject } from "~/utils/report-helpers.ts";
|
import { reportProject } from "~/utils/report-helpers.ts";
|
||||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||||
import ModerationChecklist from "~/components/ui/moderation/checklist/ModerationChecklist.vue";
|
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
|
||||||
import { useModerationStore } from "~/store/moderation.ts";
|
|
||||||
|
|
||||||
const data = useNuxtApp();
|
const data = useNuxtApp();
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const moderationStore = useModerationStore();
|
|
||||||
|
|
||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
const user = await useUser();
|
const user = await useUser();
|
||||||
@ -1552,6 +1568,12 @@ const showModerationChecklist = useLocalStorage(
|
|||||||
);
|
);
|
||||||
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
|
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
|
||||||
|
|
||||||
|
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
|
||||||
|
|
||||||
|
watch(futureProjectIds, (newValue) => {
|
||||||
|
console.log("Future project IDs updated:", newValue);
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
showModerationChecklist,
|
showModerationChecklist,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
@ -1624,7 +1646,9 @@ const navLinks = computed(() => {
|
|||||||
{
|
{
|
||||||
label: formatMessage(messages.moderationTab),
|
label: formatMessage(messages.moderationTab),
|
||||||
href: `${projectUrl}/moderation`,
|
href: `${projectUrl}/moderation`,
|
||||||
shown: !!currentMember.value,
|
shown:
|
||||||
|
!!currentMember.value &&
|
||||||
|
(isRejected(project.value) || isUnderReview(project.value) || isStaff(auth.value.user)),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@ -365,10 +365,8 @@ export default defineNuxtComponent({
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
this.expandedGalleryItem = null;
|
this.expandedGalleryItem = null;
|
||||||
} else if (e.key === "ArrowLeft") {
|
} else if (e.key === "ArrowLeft") {
|
||||||
e.stopPropagation();
|
|
||||||
this.previousImage();
|
this.previousImage();
|
||||||
} else if (e.key === "ArrowRight") {
|
} else if (e.key === "ArrowRight") {
|
||||||
e.stopPropagation();
|
|
||||||
this.nextImage();
|
this.nextImage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,15 +76,8 @@
|
|||||||
<p>
|
<p>
|
||||||
This is a private conversation thread with the Modrinth moderators. They may message you
|
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
|
with issues concerning this project. This thread is only checked when you submit your
|
||||||
project for review. For additional inquiries, please go to the
|
project for review. For additional inquiries, contact
|
||||||
<a class="text-link" href="https://support.modrinth.com" target="_blank">
|
<a href="https://support.modrinth.com">Modrinth Support</a>.
|
||||||
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>
|
</p>
|
||||||
<ConversationThread
|
<ConversationThread
|
||||||
v-if="thread"
|
v-if="thread"
|
||||||
|
|||||||
@ -58,41 +58,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NewModal>
|
</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="page experimental-styles-within">
|
||||||
<div
|
<div
|
||||||
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
|
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
|
||||||
@ -236,12 +201,6 @@
|
|||||||
Refund options
|
Refund options
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -275,6 +234,7 @@ import { products } from "~/generated/state.json";
|
|||||||
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const data = useNuxtApp();
|
||||||
const vintl = useVIntl();
|
const vintl = useVIntl();
|
||||||
|
|
||||||
const { formatMessage } = vintl;
|
const { formatMessage } = vintl;
|
||||||
@ -344,10 +304,6 @@ const refundTypes = ref(["full", "partial", "none"]);
|
|||||||
const refundAmount = ref(0);
|
const refundAmount = ref(0);
|
||||||
const unprovision = ref(true);
|
const unprovision = ref(true);
|
||||||
|
|
||||||
const modifying = ref(false);
|
|
||||||
const modifyModal = ref();
|
|
||||||
const cancel = ref(false);
|
|
||||||
|
|
||||||
function showRefundModal(charge) {
|
function showRefundModal(charge) {
|
||||||
selectedCharge.value = charge;
|
selectedCharge.value = charge;
|
||||||
refundType.value = "full";
|
refundType.value = "full";
|
||||||
@ -356,12 +312,6 @@ function showRefundModal(charge) {
|
|||||||
refundModal.value.show();
|
refundModal.value.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showModifyModal(charge) {
|
|
||||||
selectedCharge.value = charge;
|
|
||||||
cancel.value = false;
|
|
||||||
modifyModal.value.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refundCharge() {
|
async function refundCharge() {
|
||||||
refunding.value = true;
|
refunding.value = true;
|
||||||
try {
|
try {
|
||||||
@ -377,7 +327,8 @@ async function refundCharge() {
|
|||||||
await refreshCharges();
|
await refreshCharges();
|
||||||
refundModal.value.hide();
|
refundModal.value.hide();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addNotification({
|
data.$notify({
|
||||||
|
group: "main",
|
||||||
title: "Error refunding",
|
title: "Error refunding",
|
||||||
text: err.data?.description ?? err,
|
text: err.data?.description ?? err,
|
||||||
type: "error",
|
type: "error",
|
||||||
@ -386,32 +337,6 @@ async function refundCharge() {
|
|||||||
refunding.value = false;
|
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 = {
|
const chargeStatuses = {
|
||||||
open: {
|
open: {
|
||||||
color: "bg-blue",
|
color: "bg-blue",
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="subtleLauncherRedirectUri">
|
<div>
|
||||||
<iframe
|
<template v-if="flow">
|
||||||
: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">
|
<label for="two-factor-code">
|
||||||
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
||||||
<span class="label__description">
|
<span class="label__description">
|
||||||
@ -195,7 +189,6 @@ const auth = await useAuth();
|
|||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
|
|
||||||
const redirectTarget = route.query.redirect || "";
|
const redirectTarget = route.query.redirect || "";
|
||||||
const subtleLauncherRedirectUri = ref();
|
|
||||||
|
|
||||||
if (route.query.code && !route.fullPath.includes("new_account=true")) {
|
if (route.query.code && !route.fullPath.includes("new_account=true")) {
|
||||||
await finishSignIn();
|
await finishSignIn();
|
||||||
@ -269,32 +262,7 @@ async function begin2FASignIn() {
|
|||||||
|
|
||||||
async function finishSignIn(token) {
|
async function finishSignIn(token) {
|
||||||
if (route.query.launcher) {
|
if (route.query.launcher) {
|
||||||
if (!token) {
|
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true });
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -218,7 +218,7 @@ const username = ref("");
|
|||||||
const password = ref("");
|
const password = ref("");
|
||||||
const confirmPassword = ref("");
|
const confirmPassword = ref("");
|
||||||
const token = ref("");
|
const token = ref("");
|
||||||
const subscribe = ref(false);
|
const subscribe = ref(true);
|
||||||
|
|
||||||
async function createAccount() {
|
async function createAccount() {
|
||||||
startLoading();
|
startLoading();
|
||||||
@ -247,14 +247,16 @@ async function createAccount() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await useAuth(res.session);
|
|
||||||
await useUser();
|
|
||||||
|
|
||||||
if (route.query.launcher) {
|
if (route.query.launcher) {
|
||||||
await navigateTo({ path: "/auth/sign-in", query: route.query });
|
await navigateTo(`https://launcher-files.modrinth.com/?code=${res.session}`, {
|
||||||
|
external: true,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await useAuth(res.session);
|
||||||
|
await useUser();
|
||||||
|
|
||||||
if (route.query.redirect) {
|
if (route.query.redirect) {
|
||||||
await navigateTo(route.query.redirect);
|
await navigateTo(route.query.redirect);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -40,6 +40,7 @@
|
|||||||
@change="showPreviewImage"
|
@change="showPreviewImage"
|
||||||
>
|
>
|
||||||
<UploadIcon aria-hidden="true" />
|
<UploadIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.uploadIconButton) }}
|
||||||
</FileInput>
|
</FileInput>
|
||||||
<Button
|
<Button
|
||||||
v-if="!deletedIcon && (previewImage || collection.icon_url)"
|
v-if="!deletedIcon && (previewImage || collection.icon_url)"
|
||||||
@ -478,6 +479,10 @@ const messages = defineMessages({
|
|||||||
id: "collection.label.updated-at",
|
id: "collection.label.updated-at",
|
||||||
defaultMessage: "Updated {ago}",
|
defaultMessage: "Updated {ago}",
|
||||||
},
|
},
|
||||||
|
uploadIconButton: {
|
||||||
|
id: "collection.button.upload-icon",
|
||||||
|
defaultMessage: "Upload icon",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = useNuxtApp();
|
const data = useNuxtApp();
|
||||||
|
|||||||
@ -1,84 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="normal-page">
|
||||||
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
<div class="normal-page__sidebar">
|
||||||
>
|
<aside class="universal-card">
|
||||||
<h1>Moderation</h1>
|
<h1>Moderation</h1>
|
||||||
<NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
|
<NavStack>
|
||||||
<div class="mb-4 sm:hidden">
|
<NavStackItem link="/moderation" label="Overview">
|
||||||
<Chips
|
<ModrinthIcon aria-hidden="true" />
|
||||||
v-model="selectedChip"
|
</NavStackItem>
|
||||||
:items="mobileNavOptions"
|
<NavStackItem link="/moderation/review" label="Review projects">
|
||||||
:never-empty="true"
|
<ScaleIcon aria-hidden="true" />
|
||||||
@change="navigateToPage"
|
</NavStackItem>
|
||||||
/>
|
<NavStackItem link="/moderation/reports" label="Reports">
|
||||||
|
<ReportIcon aria-hidden="true" />
|
||||||
|
</NavStackItem>
|
||||||
|
</NavStack>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<div class="normal-page__content">
|
||||||
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
<NuxtPage />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import { defineMessages, useVIntl } from "@vintl/vintl";
|
import { ModrinthIcon, ScaleIcon, ReportIcon } from "@modrinth/assets";
|
||||||
import { Chips } from "@modrinth/ui";
|
import NavStack from "~/components/ui/NavStack.vue";
|
||||||
import NavTabs from "@/components/ui/NavTabs.vue";
|
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: "auth",
|
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>
|
</script>
|
||||||
|
|||||||
@ -1,339 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-3">
|
<div>
|
||||||
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
<section class="universal-card">
|
||||||
<div class="iconified-input flex-1 lg:max-w-md">
|
<h2>Statistics</h2>
|
||||||
<SearchIcon aria-hidden="true" class="text-lg" />
|
<div class="grid-display">
|
||||||
<input
|
<div class="grid-display__item">
|
||||||
v-model="query"
|
<div class="label">Projects</div>
|
||||||
class="h-[40px]"
|
<div class="value">
|
||||||
autocomplete="off"
|
{{ formatNumber(stats.projects, false) }}
|
||||||
spellcheck="false"
|
</div>
|
||||||
type="text"
|
</div>
|
||||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
<div class="grid-display__item">
|
||||||
@input="goToPage(1)"
|
<div class="label">Versions</div>
|
||||||
/>
|
<div class="value">
|
||||||
<Button v-if="query" class="r-btn" @click="() => (query = '')">
|
{{ formatNumber(stats.versions, false) }}
|
||||||
<XIcon />
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
<div class="grid-display__item">
|
||||||
|
<div class="label">Files</div>
|
||||||
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
<div class="value">
|
||||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
{{ formatNumber(stats.files, false) }}
|
||||||
<ConfettiExplosion v-if="visible" />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid-display__item">
|
||||||
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
<div class="label">Authors</div>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
<div class="value">
|
||||||
<DropdownSelect
|
{{ formatNumber(stats.authors, false) }}
|
||||||
v-slot="{ selected }"
|
</div>
|
||||||
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>
|
</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>
|
</section>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import { DropdownSelect, Button, ButtonStyled, Pagination } from "@modrinth/ui";
|
import { formatNumber } from "@modrinth/utils";
|
||||||
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";
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl();
|
useHead({
|
||||||
const moderationStore = useModerationStore();
|
title: "Staff overview - Modrinth",
|
||||||
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: allProjects } = await useLazyAsyncData("moderation-projects", async () => {
|
const { data: stats } = await useAsyncData("statistics", () => useBaseFetch("statistics"));
|
||||||
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>
|
</script>
|
||||||
|
|||||||
17
apps/frontend/src/pages/moderation/report/[id].vue
Normal file
17
apps/frontend/src/pages/moderation/report/[id].vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<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>
|
||||||
16
apps/frontend/src/pages/moderation/reports.vue
Normal file
16
apps/frontend/src/pages/moderation/reports.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<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>
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,290 +0,0 @@
|
|||||||
<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>
|
|
||||||
304
apps/frontend/src/pages/moderation/review.vue
Normal file
304
apps/frontend/src/pages/moderation/review.vue
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<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>
|
||||||
@ -1,386 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
<p>Not yet implemented.</p>
|
|
||||||
</template>
|
|
||||||
@ -16,15 +16,12 @@ import {
|
|||||||
CardIcon,
|
CardIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
ModrinthIcon,
|
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { isAdmin as isUserAdmin, type User } from "@modrinth/utils";
|
|
||||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const serverId = route.params.id as string;
|
const serverId = route.params.id as string;
|
||||||
const auth = await useAuth();
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: ModrinthServer;
|
server: ModrinthServer;
|
||||||
@ -35,11 +32,7 @@ useHead({
|
|||||||
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
|
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ownerId = computed(() => props.server.general?.owner_id ?? "Ghost");
|
const navLinks = [
|
||||||
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value);
|
|
||||||
const isAdmin = computed(() => isUserAdmin(auth.value?.user));
|
|
||||||
|
|
||||||
const navLinks = computed(() => [
|
|
||||||
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
|
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
|
||||||
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
|
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
|
||||||
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
|
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
|
||||||
@ -55,15 +48,7 @@ const navLinks = computed(() => [
|
|||||||
label: "Billing",
|
label: "Billing",
|
||||||
href: `/settings/billing#server-${serverId}`,
|
href: `/settings/billing#server-${serverId}`,
|
||||||
external: true,
|
external: true,
|
||||||
shown: isOwner.value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ModrinthIcon,
|
|
||||||
label: "Admin Billing",
|
|
||||||
href: `/admin/billing/${ownerId.value}`,
|
|
||||||
external: true,
|
|
||||||
shown: isAdmin.value,
|
|
||||||
},
|
},
|
||||||
{ icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` },
|
{ icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` },
|
||||||
]);
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -42,7 +42,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
<button
|
<button
|
||||||
:disabled="invocation === originalInvocation"
|
:disabled="invocation === startupSettings?.original_invocation"
|
||||||
class="!w-full sm:!w-auto"
|
class="!w-full sm:!w-auto"
|
||||||
@click="resetToDefault"
|
@click="resetToDefault"
|
||||||
>
|
>
|
||||||
@ -120,9 +120,8 @@ const props = defineProps<{
|
|||||||
server: ModrinthServer;
|
server: ModrinthServer;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
await props.server.startup.fetch();
|
|
||||||
|
|
||||||
const data = computed(() => props.server.general);
|
const data = computed(() => props.server.general);
|
||||||
|
const startupSettings = computed(() => props.server.startup);
|
||||||
const showAllVersions = ref(false);
|
const showAllVersions = ref(false);
|
||||||
|
|
||||||
const jdkVersionMap = [
|
const jdkVersionMap = [
|
||||||
@ -138,15 +137,33 @@ const jdkBuildMap = [
|
|||||||
{ value: "graal", label: "GraalVM" },
|
{ value: "graal", label: "GraalVM" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const invocation = ref(props.server.startup.invocation);
|
const invocation = ref("");
|
||||||
const jdkVersion = ref(
|
const jdkVersion = ref("");
|
||||||
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
|
const jdkBuild = ref("");
|
||||||
);
|
|
||||||
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label);
|
|
||||||
|
|
||||||
const originalInvocation = ref(invocation.value);
|
const originalInvocation = ref("");
|
||||||
const originalJdkVersion = ref(jdkVersion.value);
|
const originalJdkVersion = ref("");
|
||||||
const originalJdkBuild = ref(jdkBuild.value);
|
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 hasUnsavedChanges = computed(
|
const hasUnsavedChanges = computed(
|
||||||
() =>
|
() =>
|
||||||
@ -178,7 +195,7 @@ const displayedJavaVersions = computed(() => {
|
|||||||
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
|
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function saveStartup() {
|
const saveStartup = async () => {
|
||||||
try {
|
try {
|
||||||
isUpdating.value = true;
|
isUpdating.value = true;
|
||||||
const invocationValue = invocation.value ?? "";
|
const invocationValue = invocation.value ?? "";
|
||||||
@ -215,17 +232,17 @@ async function saveStartup() {
|
|||||||
} finally {
|
} finally {
|
||||||
isUpdating.value = false;
|
isUpdating.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function resetStartup() {
|
const resetStartup = () => {
|
||||||
invocation.value = originalInvocation.value;
|
invocation.value = originalInvocation.value;
|
||||||
jdkVersion.value = originalJdkVersion.value;
|
jdkVersion.value = originalJdkVersion.value;
|
||||||
jdkBuild.value = originalJdkBuild.value;
|
jdkBuild.value = originalJdkBuild.value;
|
||||||
}
|
};
|
||||||
|
|
||||||
function resetToDefault() {
|
const resetToDefault = () => {
|
||||||
invocation.value = originalInvocation.value ?? "";
|
invocation.value = startupSettings.value?.original_invocation ?? "";
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -96,7 +96,16 @@
|
|||||||
<UiServersServerListing
|
<UiServersServerListing
|
||||||
v-for="server in filteredData"
|
v-for="server in filteredData"
|
||||||
:key="server.server_id"
|
:key="server.server_id"
|
||||||
v-bind="server"
|
: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"
|
||||||
/>
|
/>
|
||||||
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
|
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -208,7 +208,15 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<UiServersServerListing
|
<UiServersServerListing
|
||||||
v-if="subscription.serverInfo"
|
v-if="subscription.serverInfo"
|
||||||
v-bind="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"
|
||||||
/>
|
/>
|
||||||
<div v-else class="w-fit">
|
<div v-else class="w-fit">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n OFFSET $2\n LIMIT $1\n ",
|
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n LIMIT $1;\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@ -11,7 +11,6 @@
|
|||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Int8",
|
|
||||||
"Int8"
|
"Int8"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -19,5 +18,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "1aea0d5e6936b043cb7727b779d60598aa812c8ef0f5895fa740859321092a1c"
|
"hash": "29e171bd746ac5dc1fabae4c9f81c3d1df4e69c860b7d0f6a907377664199217"
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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, 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 ",
|
"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 ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@ -25,11 +25,10 @@
|
|||||||
"Text",
|
"Text",
|
||||||
"Text",
|
"Text",
|
||||||
"Text",
|
"Text",
|
||||||
"Bool",
|
|
||||||
"Bool"
|
"Bool"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e"
|
"hash": "32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55"
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
|
"query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n LIMIT $2;\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@ -12,7 +12,6 @@
|
|||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Text",
|
"Text",
|
||||||
"Int8",
|
|
||||||
"Int8"
|
"Int8"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -20,5 +19,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "ccb0315ff52ea4402f53508334a7288fc9f8e77ffd7bce665441ff682384cbf9"
|
"hash": "3baabc9f08401801fa290866888c540746fc50c1d79911f08f3322b605ce5c30"
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@ -122,11 +122,6 @@
|
|||||||
"ordinal": 23,
|
"ordinal": 23,
|
||||||
"name": "allow_friend_requests",
|
"name": "allow_friend_requests",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 24,
|
|
||||||
"name": "is_subscribed_to_newsletter",
|
|
||||||
"type_info": "Bool"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@ -159,9 +154,8 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4"
|
"hash": "b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0"
|
||||||
}
|
}
|
||||||
@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"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 ",
|
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n LIMIT $2;\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@ -11,7 +11,6 @@
|
|||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Int8",
|
|
||||||
"Int8",
|
"Int8",
|
||||||
"Int8"
|
"Int8"
|
||||||
]
|
]
|
||||||
@ -20,5 +19,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "be8a5dd2b71fdc279a6fa68fe5384da31afd91d4b480527e2dd8402aef36f12c"
|
"hash": "f17a109913015a7a5ab847bb2e73794d6261a08d450de24b450222755e520881"
|
||||||
}
|
}
|
||||||
@ -1,21 +1,8 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
FROM rust:1.88.0 AS build
|
FROM rust:1.88.0 AS build
|
||||||
|
|
||||||
WORKDIR /usr/src/labrinth
|
WORKDIR /usr/src/labrinth
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/usr/src/labrinth/target \
|
RUN SQLX_OFFLINE=true cargo build --release --package labrinth
|
||||||
--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
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
@ -27,8 +14,10 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
|
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=artifacts /labrinth /labrinth
|
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
|
WORKDIR /labrinth
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["/labrinth/labrinth"]
|
CMD ["/labrinth/labrinth"]
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
CREATE INDEX reports_closed ON reports (closed);
|
|
||||||
@ -1 +0,0 @@
|
|||||||
ALTER TABLE users ADD COLUMN is_subscribed_to_newsletter BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
@ -315,13 +315,9 @@ pub async fn filter_enlisted_version_ids(
|
|||||||
pub async fn is_visible_collection(
|
pub async fn is_visible_collection(
|
||||||
collection_data: &DBCollection,
|
collection_data: &DBCollection,
|
||||||
user_option: &Option<User>,
|
user_option: &Option<User>,
|
||||||
hide_unlisted: bool,
|
|
||||||
) -> Result<bool, ApiError> {
|
) -> Result<bool, ApiError> {
|
||||||
let mut authorized = (if hide_unlisted {
|
let mut authorized = !collection_data.status.is_hidden()
|
||||||
collection_data.status.is_searchable()
|
&& !collection_data.projects.is_empty();
|
||||||
} else {
|
|
||||||
!collection_data.status.is_hidden()
|
|
||||||
}) && !collection_data.projects.is_empty();
|
|
||||||
if let Some(user) = &user_option {
|
if let Some(user) = &user_option {
|
||||||
if !authorized
|
if !authorized
|
||||||
&& (user.role.is_mod() || user.id == collection_data.user_id.into())
|
&& (user.role.is_mod() || user.id == collection_data.user_id.into())
|
||||||
@ -335,17 +331,12 @@ pub async fn is_visible_collection(
|
|||||||
pub async fn filter_visible_collections(
|
pub async fn filter_visible_collections(
|
||||||
collections: Vec<DBCollection>,
|
collections: Vec<DBCollection>,
|
||||||
user_option: &Option<User>,
|
user_option: &Option<User>,
|
||||||
hide_unlisted: bool,
|
|
||||||
) -> Result<Vec<crate::models::collections::Collection>, ApiError> {
|
) -> Result<Vec<crate::models::collections::Collection>, ApiError> {
|
||||||
let mut return_collections = Vec::new();
|
let mut return_collections = Vec::new();
|
||||||
let mut check_collections = Vec::new();
|
let mut check_collections = Vec::new();
|
||||||
|
|
||||||
for collection in collections {
|
for collection in collections {
|
||||||
if ((if hide_unlisted {
|
if (!collection.status.is_hidden() && !collection.projects.is_empty())
|
||||||
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())
|
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
|
||||||
{
|
{
|
||||||
return_collections.push(collection.into());
|
return_collections.push(collection.into());
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use super::AuthProvider;
|
use super::AuthProvider;
|
||||||
use crate::auth::AuthenticationError;
|
use crate::auth::AuthenticationError;
|
||||||
use crate::database::models::{DBUser, user_item};
|
use crate::database::models::user_item;
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
use crate::models::pats::Scopes;
|
use crate::models::pats::Scopes;
|
||||||
use crate::models::users::User;
|
use crate::models::users::User;
|
||||||
@ -44,33 +44,6 @@ where
|
|||||||
Ok(Some((scopes, User::from_full(db_user))))
|
Ok(Some((scopes, User::from_full(db_user))))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_full_user_from_headers<'a, E>(
|
|
||||||
req: &HttpRequest,
|
|
||||||
executor: E,
|
|
||||||
redis: &RedisPool,
|
|
||||||
session_queue: &AuthQueue,
|
|
||||||
required_scopes: Scopes,
|
|
||||||
) -> Result<(Scopes, DBUser), AuthenticationError>
|
|
||||||
where
|
|
||||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
|
||||||
{
|
|
||||||
let (scopes, db_user) = get_user_record_from_bearer_token(
|
|
||||||
req,
|
|
||||||
None,
|
|
||||||
executor,
|
|
||||||
redis,
|
|
||||||
session_queue,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
|
||||||
|
|
||||||
if !scopes.contains(required_scopes) {
|
|
||||||
return Err(AuthenticationError::InvalidCredentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((scopes, db_user))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_user_from_headers<'a, E>(
|
pub async fn get_user_from_headers<'a, E>(
|
||||||
req: &HttpRequest,
|
req: &HttpRequest,
|
||||||
executor: E,
|
executor: E,
|
||||||
@ -81,16 +54,24 @@ pub async fn get_user_from_headers<'a, E>(
|
|||||||
where
|
where
|
||||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||||
{
|
{
|
||||||
let (scopes, db_user) = get_full_user_from_headers(
|
// Fetch DB user record and minos user from headers
|
||||||
|
let (scopes, db_user) = get_user_record_from_bearer_token(
|
||||||
req,
|
req,
|
||||||
|
None,
|
||||||
executor,
|
executor,
|
||||||
redis,
|
redis,
|
||||||
session_queue,
|
session_queue,
|
||||||
required_scopes,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?
|
||||||
|
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||||
|
|
||||||
Ok((scopes, User::from_full(db_user)))
|
let user = User::from_full(db_user);
|
||||||
|
|
||||||
|
if !scopes.contains(required_scopes) {
|
||||||
|
return Err(AuthenticationError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((scopes, user))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
|
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
|
||||||
|
|||||||
@ -49,8 +49,6 @@ pub struct DBUser {
|
|||||||
pub badges: Badges,
|
pub badges: Badges,
|
||||||
|
|
||||||
pub allow_friend_requests: bool,
|
pub allow_friend_requests: bool,
|
||||||
|
|
||||||
pub is_subscribed_to_newsletter: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DBUser {
|
impl DBUser {
|
||||||
@ -65,13 +63,13 @@ impl DBUser {
|
|||||||
avatar_url, raw_avatar_url, bio, created,
|
avatar_url, raw_avatar_url, bio, created,
|
||||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
||||||
email_verified, password, paypal_id, paypal_country, paypal_email,
|
email_verified, password, paypal_id, paypal_country, paypal_email,
|
||||||
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
|
venmo_handle, stripe_customer_id, allow_friend_requests
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
$6, $7,
|
$6, $7,
|
||||||
$8, $9, $10, $11, $12, $13,
|
$8, $9, $10, $11, $12, $13,
|
||||||
$14, $15, $16, $17, $18, $19, $20, $21, $22
|
$14, $15, $16, $17, $18, $19, $20, $21
|
||||||
)
|
)
|
||||||
",
|
",
|
||||||
self.id as DBUserId,
|
self.id as DBUserId,
|
||||||
@ -95,7 +93,6 @@ impl DBUser {
|
|||||||
self.venmo_handle,
|
self.venmo_handle,
|
||||||
self.stripe_customer_id,
|
self.stripe_customer_id,
|
||||||
self.allow_friend_requests,
|
self.allow_friend_requests,
|
||||||
self.is_subscribed_to_newsletter,
|
|
||||||
)
|
)
|
||||||
.execute(&mut **transaction)
|
.execute(&mut **transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@ -181,7 +178,7 @@ impl DBUser {
|
|||||||
created, role, badges,
|
created, role, badges,
|
||||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
||||||
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
|
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
|
||||||
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
|
venmo_handle, stripe_customer_id, allow_friend_requests
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
|
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
|
||||||
",
|
",
|
||||||
@ -215,7 +212,6 @@ impl DBUser {
|
|||||||
stripe_customer_id: u.stripe_customer_id,
|
stripe_customer_id: u.stripe_customer_id,
|
||||||
totp_secret: u.totp_secret,
|
totp_secret: u.totp_secret,
|
||||||
allow_friend_requests: u.allow_friend_requests,
|
allow_friend_requests: u.allow_friend_requests,
|
||||||
is_subscribed_to_newsletter: u.is_subscribed_to_newsletter,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
acc.insert(u.id, (Some(u.username), user));
|
acc.insert(u.id, (Some(u.username), user));
|
||||||
|
|||||||
@ -92,7 +92,7 @@ impl CollectionStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collection pages + info cannot be viewed
|
// Project pages + info cannot be viewed
|
||||||
pub fn is_hidden(&self) -> bool {
|
pub fn is_hidden(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
CollectionStatus::Rejected => true,
|
CollectionStatus::Rejected => true,
|
||||||
@ -103,11 +103,6 @@ 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 {
|
pub fn is_approved(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
CollectionStatus::Listed => true,
|
CollectionStatus::Listed => true,
|
||||||
|
|||||||
@ -276,11 +276,7 @@ pub async fn refund_charge(
|
|||||||
subscription_interval: charge.subscription_interval,
|
subscription_interval: charge.subscription_interval,
|
||||||
payment_platform: charge.payment_platform,
|
payment_platform: charge.payment_platform,
|
||||||
payment_platform_id: id,
|
payment_platform_id: id,
|
||||||
parent_charge_id: if refund_amount != 0 {
|
parent_charge_id: Some(charge.id),
|
||||||
Some(charge.id)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
net,
|
net,
|
||||||
}
|
}
|
||||||
.upsert(&mut transaction)
|
.upsert(&mut transaction)
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
use crate::auth::email::send_email;
|
use crate::auth::email::send_email;
|
||||||
use crate::auth::validate::{
|
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||||
get_full_user_from_headers, get_user_record_from_bearer_token,
|
|
||||||
};
|
|
||||||
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
|
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
|
||||||
use crate::database::models::DBUser;
|
use crate::database::models::DBUser;
|
||||||
use crate::database::models::flow_item::DBFlow;
|
use crate::database::models::flow_item::DBFlow;
|
||||||
@ -234,7 +232,6 @@ impl TempUser {
|
|||||||
role: Role::Developer.to_string(),
|
role: Role::Developer.to_string(),
|
||||||
badges: Badges::default(),
|
badges: Badges::default(),
|
||||||
allow_friend_requests: true,
|
allow_friend_requests: true,
|
||||||
is_subscribed_to_newsletter: false,
|
|
||||||
}
|
}
|
||||||
.insert(transaction)
|
.insert(transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@ -1294,6 +1291,37 @@ pub async fn delete_auth_provider(
|
|||||||
Ok(HttpResponse::NoContent().finish())
|
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(
|
pub async fn check_sendy_subscription(
|
||||||
email: &str,
|
email: &str,
|
||||||
) -> Result<bool, AuthenticationError> {
|
) -> Result<bool, AuthenticationError> {
|
||||||
@ -1428,9 +1456,6 @@ pub async fn create_account_with_password(
|
|||||||
role: Role::Developer.to_string(),
|
role: Role::Developer.to_string(),
|
||||||
badges: Badges::default(),
|
badges: Badges::default(),
|
||||||
allow_friend_requests: true,
|
allow_friend_requests: true,
|
||||||
is_subscribed_to_newsletter: new_account
|
|
||||||
.sign_up_newsletter
|
|
||||||
.unwrap_or(false),
|
|
||||||
}
|
}
|
||||||
.insert(&mut transaction)
|
.insert(&mut transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@ -1451,6 +1476,10 @@ pub async fn create_account_with_password(
|
|||||||
&format!("Welcome to Modrinth, {}!", new_account.username),
|
&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?;
|
transaction.commit().await?;
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(res))
|
Ok(HttpResponse::Ok().json(res))
|
||||||
@ -2391,24 +2420,15 @@ pub async fn subscribe_newsletter(
|
|||||||
.await?
|
.await?
|
||||||
.1;
|
.1;
|
||||||
|
|
||||||
sqlx::query!(
|
if let Some(email) = user.email {
|
||||||
"
|
sign_up_sendy(&email).await?;
|
||||||
UPDATE users
|
|
||||||
SET is_subscribed_to_newsletter = TRUE
|
|
||||||
WHERE id = $1
|
|
||||||
",
|
|
||||||
user.id.0 as i64,
|
|
||||||
)
|
|
||||||
.execute(&**pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
crate::database::models::DBUser::clear_caches(
|
Ok(HttpResponse::NoContent().finish())
|
||||||
&[(user.id.into(), None)],
|
} else {
|
||||||
&redis,
|
Err(ApiError::InvalidInput(
|
||||||
)
|
"User does not have an email.".to_string(),
|
||||||
.await?;
|
))
|
||||||
|
}
|
||||||
Ok(HttpResponse::NoContent().finish())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("email/subscribe")]
|
#[get("email/subscribe")]
|
||||||
@ -2418,7 +2438,7 @@ pub async fn get_newsletter_subscription_status(
|
|||||||
redis: Data<RedisPool>,
|
redis: Data<RedisPool>,
|
||||||
session_queue: Data<AuthQueue>,
|
session_queue: Data<AuthQueue>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
let user = get_full_user_from_headers(
|
let user = get_user_from_headers(
|
||||||
&req,
|
&req,
|
||||||
&**pool,
|
&**pool,
|
||||||
&redis,
|
&redis,
|
||||||
@ -2428,16 +2448,16 @@ pub async fn get_newsletter_subscription_status(
|
|||||||
.await?
|
.await?
|
||||||
.1;
|
.1;
|
||||||
|
|
||||||
let is_subscribed = user.is_subscribed_to_newsletter
|
if let Some(email) = user.email {
|
||||||
|| if let Some(email) = user.email {
|
let is_subscribed = check_sendy_subscription(&email).await?;
|
||||||
check_sendy_subscription(&email).await?
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
} else {
|
"subscribed": is_subscribed
|
||||||
false
|
})))
|
||||||
};
|
} else {
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
"subscribed": false
|
||||||
"subscribed": is_subscribed
|
})))
|
||||||
})))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_email_verify(
|
fn send_email_verify(
|
||||||
|
|||||||
@ -18,14 +18,12 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ProjectsRequestOptions {
|
pub struct ResultCount {
|
||||||
#[serde(default = "default_count")]
|
#[serde(default = "default_count")]
|
||||||
pub count: u16,
|
pub count: i16,
|
||||||
#[serde(default)]
|
|
||||||
pub offset: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_count() -> u16 {
|
fn default_count() -> i16 {
|
||||||
100
|
100
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +31,7 @@ pub async fn get_projects(
|
|||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
redis: web::Data<RedisPool>,
|
redis: web::Data<RedisPool>,
|
||||||
request_opts: web::Query<ProjectsRequestOptions>,
|
count: web::Query<ResultCount>,
|
||||||
session_queue: web::Data<AuthQueue>,
|
session_queue: web::Data<AuthQueue>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
check_is_moderator_from_headers(
|
check_is_moderator_from_headers(
|
||||||
@ -52,12 +50,10 @@ pub async fn get_projects(
|
|||||||
SELECT id FROM mods
|
SELECT id FROM mods
|
||||||
WHERE status = $1
|
WHERE status = $1
|
||||||
ORDER BY queued ASC
|
ORDER BY queued ASC
|
||||||
OFFSET $3
|
LIMIT $2;
|
||||||
LIMIT $2
|
|
||||||
",
|
",
|
||||||
ProjectStatus::Processing.as_str(),
|
ProjectStatus::Processing.as_str(),
|
||||||
request_opts.count as i64,
|
count.count as i64
|
||||||
request_opts.offset as i64
|
|
||||||
)
|
)
|
||||||
.fetch(&**pool)
|
.fetch(&**pool)
|
||||||
.map_ok(|m| database::models::DBProjectId(m.id))
|
.map_ok(|m| database::models::DBProjectId(m.id))
|
||||||
|
|||||||
@ -15,10 +15,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ResultCount {
|
pub struct ResultCount {
|
||||||
#[serde(default = "default_count")]
|
#[serde(default = "default_count")]
|
||||||
pub count: u16,
|
pub count: i16,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_count() -> u16 {
|
fn default_count() -> i16 {
|
||||||
100
|
100
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,10 +34,7 @@ pub async fn get_projects(
|
|||||||
req,
|
req,
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
redis.clone(),
|
redis.clone(),
|
||||||
web::Query(internal::moderation::ProjectsRequestOptions {
|
web::Query(internal::moderation::ResultCount { count: count.count }),
|
||||||
count: count.count,
|
|
||||||
offset: 0,
|
|
||||||
}),
|
|
||||||
session_queue,
|
session_queue,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user