Compare commits

..

34 Commits

Author SHA1 Message Date
Josiah Glosson
2774cdca76 Merge branch 'main' into app-updater-rework
# Conflicts:
#	packages/app-lib/src/state/friends.rs
#	packages/app-lib/src/util/fetch.rs
2025-07-22 15:39:52 -05:00
Josiah Glosson
3b6a9dde0f Run intl:extract 2025-07-21 10:56:08 -05:00
Josiah Glosson
a1e0c134a0 Merge branch 'main' into app-updater-rework 2025-07-21 10:53:00 -05:00
IMB11
0310cc52d0 fix: hide modal header & add "Hide update reminder" button w/ tooltip 2025-07-17 11:37:17 +01:00
Josiah Glosson
071e2b58b3 Fix lint 2025-07-16 19:04:24 -05:00
Prospector
30e93e0880 lint 2025-07-16 15:25:38 -07:00
Prospector
2c90f1c142 UI tweaks 2025-07-16 15:08:21 -07:00
Josiah Glosson
83bd4dde45 Request the update size with HEAD instead of GET 2025-07-15 20:36:21 -05:00
Josiah Glosson
221c26d613 Merge branch 'main' into app-updater-rework 2025-07-15 20:31:11 -05:00
Josiah Glosson
7cc39cb54d Fix build on Mac 2025-07-15 20:28:34 -05:00
Josiah Glosson
80e0f84b62 Use single LAUNCHER_USER_AGENT constant for all user agents 2025-07-15 20:25:37 -05:00
Josiah Glosson
1e934312a4
Update completion toasts (#3978) 2025-07-12 21:05:17 +01:00
Josiah Glosson
6fa0ee487d Run intl:extract 2025-07-11 16:22:20 -05:00
Josiah Glosson
62e2e5ea6f Open changelog with tauri-plugin-opener 2025-07-11 09:30:05 -05:00
Calum
69a461dffc feat: add error handling 2025-07-11 14:06:44 +01:00
Calum
35baa1af5e feat: polish update available modal 2025-07-10 20:00:39 +01:00
Calum
59ab09a275 feat: create AppearingProgressBar component 2025-07-10 17:50:24 +01:00
Josiah Glosson
286ab6d4a0 Make CI also lint updater code 2025-07-10 08:35:41 -05:00
Josiah Glosson
f9a4042f13 Fix lint 2025-07-10 08:28:15 -05:00
Josiah Glosson
3b74e021b5 Fix PendingUpdateData being seen as a unit struct 2025-07-09 23:07:07 -05:00
Josiah Glosson
8922c7ab03 Restore 5 minute update check instead of 30 seconds 2025-07-09 20:42:23 -05:00
Josiah Glosson
5495b01bc5 Turn download progress into an error bar on failure 2025-07-09 20:41:11 -05:00
Josiah Glosson
9b103e063a Implement updating at next exit 2025-07-09 20:41:11 -05:00
Josiah Glosson
7b73aa2908 Implement the Update Now button 2025-07-09 20:41:11 -05:00
Josiah Glosson
ae75292fd0 Implement skipping the update 2025-07-09 20:41:11 -05:00
Josiah Glosson
9a43d49b3b Fix lint 2025-07-09 20:41:11 -05:00
Josiah Glosson
3d4d0afa59 Slight UI tweaks 2025-07-09 20:41:11 -05:00
Josiah Glosson
9aab6c3e08 Fix update not being rechecked if the update modal was directly dismissed 2025-07-09 20:41:10 -05:00
Josiah Glosson
52d6bf3907 Show update size in modal 2025-07-09 20:41:10 -05:00
Josiah Glosson
523800ea39 Fix lint 2025-07-09 20:41:10 -05:00
Josiah Glosson
35aea3cab2 Add in the buttons and body 2025-07-09 20:41:10 -05:00
Josiah Glosson
36cb3f1686 Fix formatjs on Windows and run formatjs 2025-07-09 20:41:10 -05:00
Josiah Glosson
e59dd086fc Move update checking entirely into JS and open a modal if an update is available 2025-07-09 20:41:10 -05:00
Josiah Glosson
607c42cf01 Make theseus capable of logging messages from the log crate 2025-07-09 20:41:10 -05:00
204 changed files with 3830 additions and 6190 deletions

View File

@ -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

View File

@ -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

View File

@ -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
View File

@ -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
View File

@ -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",

View File

@ -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"

View File

@ -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)
try {
await login()
await fetchCredentials() 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 />

View File

@ -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')

View File

@ -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>

View File

@ -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(() => {

View 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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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
Promise.all(
servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[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) {

View File

@ -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)
: world.type == 'singleplayer' && !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact) ? formatMessage(messages.noContact)
: serverIncompatible : serverIncompatible
? formatMessage(messages.incompatibleServer) ? formatMessage(messages.incompatibleServer)
: !supportsQuickPlay
? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null : null
" "
:disabled=" :disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
"
@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) }}

View File

@ -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')
}

View File

@ -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

View File

@ -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':

View File

@ -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(
Object.keys(serverData).map((address) =>
refreshServerData(serverData[address], protocolVersion, 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
} }

View File

@ -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"

View File

@ -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(() => {

View File

@ -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(

View File

@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
println!("A browser window will now open, follow the login flow there."); 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();

View File

@ -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

View File

@ -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,
), ),

View File

@ -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"

View File

@ -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?)

View File

@ -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,
} }

View File

@ -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();
}

View File

@ -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

View File

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

View File

@ -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)
} }

View File

@ -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)
} }

View File

@ -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")]
'updater: {
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..."); tracing::info!("Initializing app state...");
State::init().await?; 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")
} }
} }

View 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();
}

View 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() {}

View File

@ -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,

View File

@ -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

View File

@ -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;
} }

View File

@ -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",

File diff suppressed because it is too large Load Diff

View File

@ -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 () => {
async function checkSubscribed() {
if (auth.value?.user) { if (auth.value?.user) {
try { try {
const { subscribed } = await useBaseFetch("auth/email/subscribe", { const { data } = await useBaseFetch("auth/email/subscribe", {
method: "GET", method: "GET",
}); });
return !subscribed; subscribed.value = data?.subscribed || false;
} catch { } catch {
return true; 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>

View File

@ -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) {

View File

@ -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>

View File

@ -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">&#x2022;</span>
<div class="flex flex-row gap-2 text-sm">
Requesting
<Badge
v-if="props.queueEntry.project.requested_status"
:type="props.queueEntry.project.requested_status"
class="status"
/>
</div>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<span
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
class="truncate text-sm"
:class="{
'text-red': daysInQueue > 4,
'text-orange': daysInQueue > 2,
}"
>
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
<span class="sm:hidden">{{
getSubmittedTime(queueEntry).replace("Submitted ", "")
}}</span>
</span>
</div>
<div class="flex items-center justify-end gap-2 sm:justify-start">
<ButtonStyled circular>
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
<EyeIcon class="size-4" />
</NuxtLink>
</ButtonStyled>
<ButtonStyled circular color="orange" @click="openProjectForReview">
<button>
<ScaleIcon class="size-4" />
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import {
EyeIcon,
PaintbrushIcon,
ScaleIcon,
BoxIcon,
GlassesIcon,
PlugIcon,
PackageOpenIcon,
BracesIcon,
} from "@modrinth/assets";
import { useRelativeTime, Avatar, ButtonStyled, Badge } from "@modrinth/ui";
import {
formatProjectType,
type Organization,
type Project,
type TeamMember,
} from "@modrinth/utils";
import { computed } from "vue";
import { useModerationStore } from "~/store/moderation.ts";
import type { ModerationProject } from "~/helpers/moderation";
const formatRelativeTime = useRelativeTime();
const moderationStore = useModerationStore();
const props = defineProps<{
queueEntry: ModerationProject;
}>();
function getDaysQueued(date: Date): number {
const now = new Date();
const diff = now.getTime() - date.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
const queuedDate = computed(() => {
return dayjs(
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated,
);
});
const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate());
});
function openProjectForReview() {
moderationStore.setSingleProject(props.queueEntry.project.id);
navigateTo({
name: "type-id",
params: {
type: "project",
id: props.queueEntry.project.id,
},
state: {
showChecklist: true,
},
});
}
function getSubmittedTime(project: any): string {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated;
if (!date) return "Unknown";
try {
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`;
} catch {
return "Unknown";
}
}
</script>

View File

@ -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>

View File

@ -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>

View File

@ -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 ({{ moderationStore.queueLength }} left) Next Project
</template> </button>
<template v-else> </ButtonStyled>
<ButtonStyled v-else color="brand">
<button @click="exitModeration">
<CheckIcon aria-hidden="true" /> <CheckIcon aria-hidden="true" />
All Done! Done
</template>
</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(
defineProps<{
project: Project; project: Project;
futureProjectIds?: string[];
collapsed: boolean; 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,16 +1092,15 @@ 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;
} }
} }
}
try { try {
const formattedMessage = await prettier.format(fullMessage, { const formattedMessage = await prettier.format(fullMessage, {
@ -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 (judgement.status === "permanent-no") {
permanentNoMods.push(judgement.file_name);
} else if (judgement.status === "unidentified") {
unidentifiedMods.push(judgement.file_name);
} }
} else if (file.status === "with-attribution" && file.approved === "no") {
attributeMods.push(file.file_name);
} else if (file.status === "no" && file.approved === "no") {
noMods.push(file.file_name);
} else if (file.status === "permanent-no") {
permanentNoMods.push(file.file_name);
} }
});
allFiles.permanentNo.forEach((file) => {
permanentNoMods.push(file.file_name);
});
if ( 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({ const nextProjectId = currentIds[0];
const remainingIds = currentIds.slice(1);
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingIds));
await router.push({
name: "type-id", name: "type-id",
params: { params: {
type: "project", type: "project",
id: moderationStore.getCurrentProjectId(), id: nextProjectId,
}, },
state: { state: {
showChecklist: true, 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(() => {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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");

View File

@ -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>

View File

@ -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"
/> />

View File

@ -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;

View File

@ -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;

View File

@ -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;
});
}

View File

@ -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,
{ {

View File

@ -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"
}, },

View File

@ -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)),
}, },
]; ];
}); });

View File

@ -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();
} }
} }

View File

@ -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"

View File

@ -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",

View File

@ -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;
} }

View File

@ -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 {

View File

@ -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();

View File

@ -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>
<div class="normal-page__content">
<NuxtPage /> <NuxtPage />
</div> </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>

View File

@ -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"
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" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
<ButtonStyled color="orange" class="w-full sm:w-auto">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
@click="moderateAllInFilter()"
>
<ScaleIcon class="size-4 flex-shrink-0" />
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
<span class="sm:hidden">Moderate</span>
</button>
</ButtonStyled>
</div> </div>
</div> </div>
<div class="grid-display__item">
<div v-if="totalPages > 1" class="flex justify-center lg:hidden"> <div class="label">Versions</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" /> <div class="value">
<ConfettiExplosion v-if="visible" /> {{ formatNumber(stats.versions, false) }}
</div> </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>
<div class="grid-display__item">
<div v-if="totalPages > 1" class="mt-4 flex justify-center"> <div class="label">Files</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" /> <div class="value">
{{ formatNumber(stats.files, false) }}
</div> </div>
</div> </div>
<div class="grid-display__item">
<div class="label">Authors</div>
<div class="value">
{{ formatNumber(stats.authors, false) }}
</div>
</div>
</div>
</section>
</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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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>

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
},
},
},
});

View File

@ -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"
} }

View File

@ -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"
} }

View File

@ -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"
} }

View File

@ -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"
} }

View File

@ -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"
}

View File

@ -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"
} }

View File

@ -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"]

View File

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

View File

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

View File

@ -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());

View File

@ -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>(

View File

@ -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));

View File

@ -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,

View File

@ -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)

View File

@ -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(
&[(user.id.into(), None)],
&redis,
)
.await?;
Ok(HttpResponse::NoContent().finish()) Ok(HttpResponse::NoContent().finish())
} else {
Err(ApiError::InvalidInput(
"User does not have an email.".to_string(),
))
}
} }
#[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?
} else {
false
};
Ok(HttpResponse::Ok().json(serde_json::json!({ Ok(HttpResponse::Ok().json(serde_json::json!({
"subscribed": is_subscribed "subscribed": is_subscribed
}))) })))
} else {
Ok(HttpResponse::Ok().json(serde_json::json!({
"subscribed": false
})))
}
} }
fn send_email_verify( fn send_email_verify(

View File

@ -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))

View File

@ -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