Compare commits

...

12 Commits

Author SHA1 Message Date
Prospector
4180544e0a Bump version 2025-05-01 17:49:31 -07:00
Prospector
9168d349fc Fix filter bar showing up with no options 2025-05-01 16:27:18 -07:00
Prospector
3dad6b317f MR App 0.9.5 - Big bugfix update (#3585)
* Add launcher_feature_version to Profile

* Misc fixes

- Add typing to theme and settings stuff
- Push instance route on creation from installing a modpack
- Fixed servers not reloading properly when first added

* Make old instances scan the logs folder for joined servers on launcher startup

* Create AttachedWorldData

* Change AttachedWorldData interface

* Rename WorldType::World to WorldType::Singleplayer

* Implement world display status system

* Fix Minecraft font

* Fix set_world_display_status Tauri error

* Add 'Play instance' option

* Add option to disable worlds showing in Home

* Fixes

- Fix available server filter only showing if there are some available
- Fixed server and singleplayer filters sometimes showing when there are only servers or singleplayer worlds
- Fixed new worlds not being automatically added when detected
- Rephrased Jump back into worlds option description

* Fixed sometimes more than 6 items showing up in Jump back in

* Fix servers.dat issue with instances you haven't played before

* Fix too large of bulk requests being made, limit max to 800 #3430

* Add hiding from home page, add types to Mods.vue

* Make recent worlds go into grid when display is huge

* Fix lint

* Remove redundant media query

* Fix protocol version on home page, and home page being blocked by pinging servers

* Clippy fix

* More Clippy fixes

* Fix Prettier lints

* Undo `from_string` changes

---------

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Co-authored-by: Alejandro González <me@alegon.dev>
2025-05-01 16:13:13 -07:00
Prospector
4a2605bc1e Add ability to switch payment interval for Modrinth+ (#3581) 2025-05-01 17:36:51 +00:00
jade
41543e3af0 Use project/user/org permalinks in moderation queue page (#3586) 2025-05-01 00:46:54 +00:00
Prospector
6003f1a10e Update changelog 2025-04-29 08:13:36 -07:00
Prospector
3d9be0cc3f Fix duplicate hidden entries in analytics (#3576) 2025-04-29 08:12:38 -07:00
Prospector
5e7444f115 intl:extract 2025-04-28 19:42:16 -07:00
Prospector
20fcf70e90 Update changelog, fix overflowing maven coords 2025-04-28 19:41:08 -07:00
Emma Alexia
0508f13cb6 Quick moderation fixes (#3556)
* Quick moderation fixes

* Fix Odyssey mods linking
* Add "Copy permanent link" button to orgs, users, projects
* Use permanent links for Slack webhooks

* Update apps/frontend/src/pages/organization/[id].vue

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>

* Run Prettier

---------

Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>
Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Co-authored-by: Alejandro González <me@alegon.dev>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-04-29 01:18:43 +00:00
Emma Alexia
2f68c62b3a Improve wording about unprovisioned servers (#3574) 2025-04-29 01:13:44 +00:00
Prospector
ea64e08791 Add support for snapshots with Modrinth Servers (#3570)
* Add support for snapshots with Modrinth Servers

* Fix snapshots without dots

* Fix loader version not resetting when no longer valid

* Fix collapsing margins on Report page
2025-04-28 18:14:04 -07:00
141 changed files with 1879 additions and 852 deletions

30
Cargo.lock generated
View File

@@ -2604,6 +2604,28 @@ dependencies = [
"syn 2.0.90",
]
[[package]]
name = "enumset"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d07a4b049558765cef5f0c1a273c3fc57084d768b44d2f98127aef4cceb17293"
dependencies = [
"enumset_derive",
"serde",
]
[[package]]
name = "enumset_derive"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59c3b24c345d8c314966bdc1832f6c2635bfcce8e7cf363bd115987bba2ee242"
dependencies = [
"darling 0.20.10",
"proc-macro2",
"quote",
"syn 2.0.90",
]
[[package]]
name = "env_filter"
version = "0.1.3"
@@ -9236,9 +9258,10 @@ dependencies = [
[[package]]
name = "theseus"
version = "0.9.4"
version = "0.9.5"
dependencies = [
"ariadne",
"async-compression",
"async-recursion",
"async-tungstenite",
"async-walkdir",
@@ -9253,6 +9276,7 @@ dependencies = [
"discord-rich-presence",
"dunce",
"either",
"enumset",
"flate2",
"fs4",
"futures",
@@ -9293,13 +9317,14 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "0.9.4"
version = "0.9.5"
dependencies = [
"chrono",
"cocoa 0.25.0",
"daedalus",
"dashmap 6.1.0",
"either",
"enumset",
"native-dialog",
"objc",
"opener",
@@ -9333,6 +9358,7 @@ dependencies = [
name = "theseus_playground"
version = "0.0.0"
dependencies = [
"enumset",
"theseus",
"tokio",
"tracing",

View File

@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "0.9.4",
"version": "0.9.5",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -24,7 +24,7 @@ import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings'
import { get } from '@/helpers/settings.ts'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'

View File

@@ -7,7 +7,7 @@
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('https://cdn.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
}
@font-face {
@@ -15,7 +15,7 @@
font-style: italic;
font-display: swap;
font-weight: 400;
src: url('https://cdn.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
}
@font-face {
@@ -23,7 +23,7 @@
font-style: normal;
font-display: swap;
font-weight: 600;
src: url('https://cdn.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
}
@font-face {
@@ -31,7 +31,7 @@
font-style: italic;
font-display: swap;
font-weight: 600;
src: url('https://cdn.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
}
.font-minecraft {

View File

@@ -14,7 +14,7 @@ import { ref, computed } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js'
import { cancel_directory_change } from '@/helpers/settings.js'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { install } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'

View File

@@ -1,5 +1,5 @@
<template>
<ModalWrapper ref="modal" header="Create instance">
<ModalWrapper ref="modal" header="Creating an instance">
<div class="modal-header">
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
</div>

View File

@@ -124,8 +124,11 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { ref, computed } from 'vue'
import { install as installVersion } from '@/store/install.js'
import { useRouter } from 'vue-router'
dayjs.extend(relativeTime)
const router = useRouter()
const props = defineProps({
backgroundImage: {
type: String,
@@ -168,6 +171,9 @@ async function install() {
installing.value = false
emit('install', props.project.project_id ?? props.project.id)
},
(profile) => {
router.push(`/instance/${profile}`)
},
)
}

View File

@@ -13,15 +13,17 @@ const confirmModal = ref(null)
const installing = ref(false)
const onInstall = ref(() => {})
const onCreateInstance = ref(() => {})
defineExpose({
show: (projectVal, versionIdVal, callback) => {
show: (projectVal, versionIdVal, callback, createInstanceCallback) => {
project.value = projectVal
versionId.value = versionIdVal
installing.value = false
confirmModal.value.show()
onInstall.value = callback
onCreateInstance.value = createInstanceCallback
trackEvent('PackInstallStart')
},
@@ -36,6 +38,7 @@ async function install() {
versionId.value,
project.value.title,
project.value.icon_url,
onCreateInstance.value,
).catch(handleError)
trackEvent('PackInstall', {
id: project.value.id,

View File

@@ -3,7 +3,7 @@ import { Checkbox } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings'
import { get } from '@/helpers/settings.ts'
import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'

View File

@@ -7,7 +7,7 @@ import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings'
import { get } from '@/helpers/settings.ts'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
const { formatMessage } = useVIntl()

View File

@@ -3,7 +3,7 @@ import { Checkbox, Toggle } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings'
import { get } from '@/helpers/settings.ts'
import { edit } from '@/helpers/profile'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'

View File

@@ -22,7 +22,7 @@ import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/
import { useTheming } from '@/store/state'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get, set } from '@/helpers/settings'
import { get, set } from '@/helpers/settings.ts'
const themeStore = useTheming()

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue'
import { ConfirmModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming()

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue'
import { NewModal as Modal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming()

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue'
import { ShareModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming()

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings'
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue'
import { getOS } from '@/helpers/utils'
import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming()
@@ -24,13 +25,13 @@ watch(
<ThemeSelector
:update-color-theme="
(theme) => {
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeStore.themeOptions"
:theme-options="themeStore.getThemeOptions()"
system-theme-color="system"
/>
@@ -80,10 +81,28 @@ watch(
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="w-40"
:options="['Home', 'Library']"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag('worlds_in_home')"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag('worlds_in_home')
themeStore.featureFlags['worlds_in_home'] = newValue
settings.feature_flags['worlds_in_home'] = newValue
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { get, set } from '@/helpers/settings'
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications'

View File

@@ -2,18 +2,15 @@
import { Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const settings = ref(await get())
const options = ref(['project_background', 'page_path', 'worlds_tab'])
const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
function getStoreValue(key: string) {
return themeStore.featureFlags[key] ?? false
}
function setStoreValue(key: string, value: boolean) {
function setFeatureFlag(key: string, value: boolean) {
themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value
}
@@ -21,7 +18,7 @@ function setStoreValue(key: string, value: boolean) {
watch(
settings,
async () => {
await set(settings.value)
await setSettings(settings.value)
},
{ deep: true },
)
@@ -36,8 +33,8 @@ watch(
<Toggle
id="advanced-rendering"
:model-value="getStoreValue(option)"
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings'
import { get, set } from '@/helpers/settings.ts'
import { Toggle } from '@modrinth/ui'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'

View File

@@ -1,7 +1,7 @@
<script setup>
import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.js'
import { get, set } from '@/helpers/settings.ts'
import { purge_cache_types } from '@/helpers/cache.js'
import { handleError } from '@/store/notifications.js'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'

View File

@@ -153,7 +153,7 @@ onUnmounted(() => {
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
<router-link
class="inline-flex items-center gap-1 truncate hover:underline text-secondary"
class="inline-flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/project/${modpack.id}`"
>
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />

View File

@@ -13,16 +13,17 @@ import {
import { HeadingLink, GAME_MODES } from '@modrinth/ui'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import { watch, onMounted, onUnmounted, ref } from 'vue'
import { watch, onMounted, onUnmounted, ref, computed } from 'vue'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { useTheming } from '@/store/theme'
import { kill } from '@/helpers/profile'
import { useTheming } from '@/store/theme.ts'
import { kill, run } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
const props = defineProps<{
recentInstances: GameInstance[]
@@ -54,7 +55,9 @@ type WorldJumpBackInItem = BaseJumpBackInItem & {
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
watch(props.recentInstances, async () => {
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home'))
watch([() => props.recentInstances, () => showWorlds.value], async () => {
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
@@ -66,66 +69,71 @@ await populateJumpBackIn().catch(() => {
async function populateJumpBackIn() {
console.info('Repopulating jump back in...')
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN)
const worldItems: WorldJumpBackInItem[] = []
worlds.forEach((world) => {
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
if (!instance || !world.last_played) {
return
}
if (showWorlds.value) {
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite'])
worldItems.push({
type: 'world',
last_played: dayjs(world.last_played),
world: world,
instance: instance,
})
})
worlds.forEach((world) => {
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
const servers: {
instancePath: string
address: string
}[] = worldItems
.filter((item) => item.world.type === 'server' && item.instance)
.map((item) => ({
instancePath: item.instance.path,
address: (item.world as ServerWorld).address,
}))
// fetch protocol versions for all unique MC versions with server worlds
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
await Promise.all(
[...uniqueServerInstances].map((path) => {
get_profile_protocol_version(path)
.then((protoVer) => (protocolVersions.value[path] = protoVer))
.catch(() => {
console.error(`Failed to get profile protocol for: ${path} `)
})
}),
)
// initialize server data
servers.forEach(({ address }) => {
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
if (!instance || !world.last_played) {
return
}
}
})
// fetch each server's data
await Promise.all(
servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
),
)
worldItems.push({
type: 'world',
last_played: dayjs(world.last_played),
world: world,
instance: instance,
})
})
const servers: {
instancePath: string
address: string
}[] = worldItems
.filter((item) => item.world.type === 'server' && item.instance)
.map((item) => ({
instancePath: item.instance.path,
address: (item.world as ServerWorld).address,
}))
// fetch protocol versions for all unique MC versions with server worlds
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
await Promise.all(
[...uniqueServerInstances].map((path) =>
get_profile_protocol_version(path)
.then((protoVer) => (protocolVersions.value[path] = protoVer))
.catch(() => {
console.error(`Failed to get profile protocol for: ${path} `)
}),
),
)
// initialize server data
servers.forEach(({ address }) => {
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
}
}
})
// fetch each server's data
Promise.all(
servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
),
)
}
const instanceItems: InstanceJumpBackInItem[] = []
props.recentInstances.forEach((instance) => {
if (worldItems.some((item) => item.instance.path === instance.path) || !instance.last_played) {
return
for (const instance of props.recentInstances) {
const worldItem = worldItems.find((item) => item.instance.path === instance.path)
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) {
continue
}
instanceItems.push({
@@ -133,13 +141,13 @@ async function populateJumpBackIn() {
last_played: dayjs(instance.last_played),
instance: instance,
})
})
}
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
jumpBackInItems.value = items.filter(
(item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO),
)
jumpBackInItems.value = items
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
.slice(0, MAX_JUMP_BACK_IN)
}
async function refreshServer(address: string, instancePath: string) {
@@ -155,6 +163,18 @@ async function joinWorld(world: WorldWithProfile) {
}
}
async function playInstance(instance: GameInstance) {
await run(instance.path)
.catch((err) => handleSevereError(err, { profilePath: instance.path }))
.finally(() => {
trackEvent('InstancePlay', {
loader: instance.loader,
game_version: instance.game_version,
source: 'WorldItem',
})
})
}
async function stopInstance(path: string) {
await kill(path).catch(handleError)
trackEvent('InstanceStop', {
@@ -209,11 +229,7 @@ onUnmounted(() => {
<template>
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink
v-if="(theme.featureFlags as Record<string, boolean>)['worlds_tab']"
to="/worlds"
class="mt-1"
>
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in
</HeadingLink>
<span
@@ -222,7 +238,7 @@ onUnmounted(() => {
>
Jump back in
</span>
<div class="flex flex-col w-full gap-2">
<div class="grid-when-huge flex flex-col w-full gap-2">
<template
v-for="item in jumpBackInItems"
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
@@ -246,7 +262,7 @@ onUnmounted(() => {
:rendered-motd="
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
"
:current-protocol="protocolVersions[item.instance.game_version]"
:current-protocol="protocolVersions[item.instance.path]"
:game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
"
@@ -259,6 +275,7 @@ onUnmounted(() => {
? refreshServer(item.world.address, item.instance.path)
: {}
"
@update="() => populateJumpBackIn()"
@play="
() => {
currentProfile = item.instance.path
@@ -266,6 +283,12 @@ onUnmounted(() => {
joinWorld(item.world)
}
"
@play-instance="
() => {
currentProfile = item.instance.path
playInstance(item.instance)
}
"
@stop="() => stopInstance(item.instance.path)"
/>
<InstanceItem v-else :instance="item.instance" />
@@ -273,3 +296,9 @@ onUnmounted(() => {
</div>
</div>
</template>
<style scoped lang="scss">
.grid-when-huge {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
}
</style>

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, World } from '@/helpers/worlds.ts'
import { getWorldIdentifier, showWorldInFolder } from '@/helpers/worlds.ts'
import {
set_world_display_status,
getWorldIdentifier,
showWorldInFolder,
} from '@/helpers/worlds.ts'
import { formatNumber } from '@modrinth/utils'
import {
IssuesIcon,
@@ -19,6 +23,7 @@ import {
TrashIcon,
UpdatedIcon,
UserIcon,
XIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
import type { MessageDescriptor } from '@vintl/vintl'
@@ -35,7 +40,7 @@ const { formatMessage } = useVIntl()
const router = useRouter()
const emit = defineEmits<{
(e: 'play' | 'stop' | 'refresh' | 'edit' | 'delete'): void
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
}>()
const props = withDefaults(
@@ -69,6 +74,7 @@ const props = withDefaults(
playingWorld: false,
startingInstance: false,
supportsQuickPlay: false,
currentProtocol: null,
refreshing: false,
serverStatus: undefined,
@@ -143,10 +149,18 @@ const messages = defineMessages({
id: 'instance.worlds.play_anyway',
defaultMessage: 'Play anyway',
},
playInstance: {
id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance',
},
worldInUse: {
id: 'instance.worlds.world_in_use',
defaultMessage: 'World is in use',
},
dontShowOnHome: {
id: 'instance.worlds.dont_show_on_home',
defaultMessage: `Don't show on Home`,
},
})
</script>
<template>
@@ -336,6 +350,12 @@ const messages = defineMessages({
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'play-instance',
shown: !!instancePath,
disabled: playingInstance,
action: () => emit('play-instance'),
},
{
id: 'play-anyway',
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
@@ -344,7 +364,7 @@ const messages = defineMessages({
{
id: 'open-instance',
shown: !!instancePath,
action: () => router.push(encodeURI(`/instance/${instancePath}/worlds`)),
action: () => router.push(encodeURI(`/instance/${instancePath}`)),
},
{
id: 'refresh',
@@ -369,6 +389,24 @@ const messages = defineMessages({
action: () =>
world.type === 'singleplayer' ? showWorldInFolder(instancePath, world.path) : {},
},
{
divider: true,
shown: !!instancePath,
},
{
id: 'dont-show-on-home',
shown: !!instancePath,
action: () => {
set_world_display_status(
instancePath,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
},
},
{
divider: true,
shown: !instancePath,
@@ -385,6 +423,10 @@ const messages = defineMessages({
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #play-anyway>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }}
@@ -406,6 +448,10 @@ const messages = defineMessages({
<template #refresh>
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
{{

View File

@@ -1,13 +1,20 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import { edit_server_in_profile, type ServerWorld } from '@/helpers/worlds.ts'
import {
type ServerPackStatus,
edit_server_in_profile,
type ServerWorld,
set_world_display_status,
type DisplayStatus,
} from '@/helpers/worlds.ts'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { formatMessage } = useVIntl()
@@ -21,10 +28,14 @@ const props = defineProps<{
const modal = ref()
const name = ref()
const address = ref()
const resourcePack = ref('enabled')
const index = ref()
const name = ref<string>('')
const address = ref<string>('')
const resourcePack = ref<ServerPackStatus>('enabled')
const index = ref<number>(0)
const displayStatus = ref<DisplayStatus>('normal')
const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveServer() {
const serverName = name.value ? name.value : address.value
@@ -36,12 +47,23 @@ async function saveServer() {
address.value,
resourcePackStatus,
).catch(handleError)
if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status(
props.instance.path,
'server',
address.value,
newDisplayStatus.value,
)
}
emit('submit', {
name: serverName,
type: 'server',
index: index.value,
address: address.value,
pack_status: resourcePackStatus,
display_status: newDisplayStatus.value,
})
hide()
}
@@ -51,6 +73,8 @@ function show(server: ServerWorld) {
address.value = server.address
resourcePack.value = server.pack_status
index.value = server.index
displayStatus.value = server.display_status
hideFromHome.value = server.display_status === 'hidden'
modal.value.show()
}
@@ -75,6 +99,7 @@ const titleMessage = defineMessage({
v-model:address="address"
v-model:resource-pack="resourcePack"
/>
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button :disabled="!address" @click="saveServer">

View File

@@ -1,18 +1,19 @@
<script setup lang="ts">
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import type { SingleplayerWorld } from '@/helpers/worlds.ts'
import { rename_world, reset_world_icon } from '@/helpers/worlds.ts'
import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts'
import { set_world_display_status, rename_world, reset_world_icon } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
submit: [path: string, name: string, removeIcon: boolean]
submit: [path: string, name: string, removeIcon: boolean, displayStatus: DisplayStatus]
}>()
const props = defineProps<{
@@ -25,6 +26,10 @@ const icon = ref()
const name = ref()
const path = ref()
const removeIcon = ref(false)
const displayStatus = ref<DisplayStatus>('normal')
const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveWorld() {
await rename_world(props.instance.path, path.value, name.value).catch(handleError)
@@ -32,8 +37,16 @@ async function saveWorld() {
if (removeIcon.value) {
await reset_world_icon(props.instance.path, path.value).catch(handleError)
}
if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status(
props.instance.path,
'singleplayer',
path.value,
newDisplayStatus.value,
)
}
emit('submit', path.value, name.value, removeIcon.value)
emit('submit', path.value, name.value, removeIcon.value, newDisplayStatus.value)
hide()
}
@@ -41,6 +54,8 @@ function show(world: SingleplayerWorld) {
name.value = world.name
path.value = world.path
icon.value = world.icon
displayStatus.value = world.display_status
hideFromHome.value = world.display_status === 'hidden'
modal.value.show()
}
@@ -87,6 +102,7 @@ const messages = defineMessages({
class="w-full"
autocomplete="off"
/>
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
</div>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { Checkbox } from '@modrinth/ui'
const { formatMessage } = useVIntl()
const value = defineModel<boolean>({ required: true })
const labelMessage = defineMessage({
id: 'instance.edit-world.hide-from-home',
defaultMessage: `Hide from the Home page`,
})
const label = computed(() => formatMessage(labelMessage))
</script>
<template>
<Checkbox v-model="value" :label="label" />
</template>

View File

@@ -7,7 +7,13 @@ import { invoke } from '@tauri-apps/api/core'
import { create } from './profile'
// Installs pack from a version ID
export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
export async function create_profile_and_install(
projectId,
versionId,
packTitle,
iconUrl,
createInstanceCallback = () => {},
) {
const location = {
type: 'fromVersionId',
project_id: projectId,
@@ -24,6 +30,7 @@ export async function create_profile_and_install(projectId, versionId, packTitle
null,
true,
)
createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile })
}

View File

@@ -1,43 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
// Get full settings object
export async function get() {
return await invoke('plugin:settings|settings_get')
}
// Set full settings object
export async function set(settings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change() {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@@ -0,0 +1,78 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
export type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: ColorTheme
default_page: 'home' | 'library'
collapsed_navigation: boolean
advanced_rendering: boolean
native_decorations: boolean
toggle_sidebar: boolean
telemetry: boolean
discord_rpc: boolean
personalized_ads: boolean
onboarded: boolean
extra_launch_args: string[]
custom_env_vars: [string, string][]
memory: MemorySettings
force_fullscreen: boolean
game_resolution: WindowSize
hide_on_process_start: boolean
hooks: Hooks
custom_dir?: string | null
prev_custom_dir?: string | null
migrated: boolean
developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean>
}
// Get full settings object
export async function get() {
return (await invoke('plugin:settings|settings_get')) as AppSettings
}
// Set full settings object
export async function set(settings: AppSettings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change(): Promise<void> {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@@ -48,6 +48,32 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
hash: string
file_name: string
size: number
metadata?: FileMetadata
update_version_id?: string
project_type: ContentFileProjectType
}
type FileMetadata = {
project_id: string
version_id: string
}
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
type CacheBehaviour =
// Serve expired data. If fetch fails / launcher is offline, errors are ignored
| 'stale_while_revalidate_skip_offline'
// Serve expired data, revalidate in background
| 'stale_while_revalidate'
// Must revalidate if data is expired
| 'must_revalidate'
// Ignore cache- always fetch updated data from origin
| 'bypass'
type MemorySettings = {
maximum: number
}
@@ -88,6 +114,7 @@ type AppSettings = {
collapsed_navigation: boolean
advanced_rendering: boolean
native_decorations: boolean
worlds_in_home: boolean
telemetry: boolean
discord_rpc: boolean

View File

@@ -9,8 +9,13 @@ type BaseWorld = {
name: string
last_played?: string
icon?: string
display_status: DisplayStatus
type: WorldType
}
export type WorldType = 'singleplayer' | 'server'
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
export type SingleplayerWorld = BaseWorld & {
type: 'singleplayer'
path: string
@@ -70,8 +75,11 @@ export type ServerData = {
renderedMotd?: string
}
export async function get_recent_worlds(limit: number): Promise<WorldWithProfile[]> {
return await invoke('plugin:worlds|get_recent_worlds', { limit })
export async function get_recent_worlds(
limit: number,
displayStatuses?: DisplayStatus[],
): Promise<WorldWithProfile[]> {
return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
}
export async function get_profile_worlds(path: string): Promise<World[]> {
@@ -85,6 +93,20 @@ export async function get_singleplayer_world(
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
}
export async function set_world_display_status(
instance: string,
worldType: WorldType,
worldId: string,
displayStatus: DisplayStatus,
): Promise<void> {
return await invoke('plugin:worlds|set_world_display_status', {
instance,
worldType,
worldId,
displayStatus,
})
}
export async function rename_world(
instance: string,
world: string,
@@ -230,12 +252,14 @@ export async function refreshServers(
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
const newWorld = await get_singleplayer_world(instancePath, worldPath)
if (index !== -1) {
worlds[index] = await get_singleplayer_world(instancePath, worldPath)
sortWorlds(worlds)
worlds[index] = newWorld
} else {
console.error(`Error refreshing world, could not find world at path ${worldPath}.`)
console.info(`Adding new world at path: ${worldPath}.`)
worlds.push(newWorld)
}
sortWorlds(worlds)
}
export async function handleDefaultProfileUpdateEvent(

View File

@@ -22,7 +22,7 @@
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
<button
v-for="filter in filterOptions"
:key="filter"
:key="`content-filter-${filter.id}`"
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
@click="toggleArray(selectedFilters, filter.id)"
>
@@ -47,7 +47,7 @@
path: x.path,
disabled: x.disabled,
filename: x.file_name,
icon: x.icon,
icon: x.icon ?? undefined,
title: x.name,
data: x,
}
@@ -156,7 +156,7 @@
color-fill="text"
hover-color-fill="text"
>
<button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
<button class="w-max" :disabled="installing" @click="modpackVersionModal?.show()">
<DownloadIcon /> Update pack
</button>
</ButtonStyled>
@@ -170,7 +170,7 @@
>
<button
v-tooltip="`Update`"
:disabled="(item.data as any).updating"
:disabled="(item.data as ProjectListEntry).updating"
@click="updateProject(item.data)"
>
<DownloadIcon />
@@ -276,6 +276,7 @@ import {
RadialHeader,
Toggle,
} from '@modrinth/ui'
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
import { formatProjectType } from '@modrinth/utils'
import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
@@ -305,43 +306,18 @@ import { profile_listener } from '@/helpers/events.js'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import dayjs from 'dayjs'
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
const props = defineProps({
instance: {
type: Object,
default() {
return {}
},
},
options: {
type: Object,
default() {
return {}
},
},
offline: {
type: Boolean,
default() {
return false
},
},
playing: {
type: Boolean,
default() {
return false
},
},
versions: {
type: Array,
required: true,
},
installed: {
type: Boolean,
default() {
return false
},
},
})
const props = defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu>
offline: boolean
playing: boolean
versions: Version[]
installed: boolean
}>()
type ProjectListEntryAuthor = {
name: string
@@ -356,13 +332,15 @@ type ProjectListEntry = {
author: ProjectListEntryAuthor | null
version: string | null
file_name: string
icon: string | null
icon: string | undefined
disabled: boolean
updateVersion?: string
outdated: boolean
updated: dayjs.Dayjs
project_type: string
id?: string
updating?: boolean
selected?: boolean
}
const isPackLocked = computed(() => {
@@ -375,17 +353,20 @@ const canUpdatePack = computed(() => {
const exportModal = ref(null)
const projects = ref<ProjectListEntry[]>([])
const selectedFiles = ref([])
const selectedFiles = ref<string[]>([])
const selectedProjects = computed(() =>
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
)
const selectionMap = ref(new Map())
const initProjects = async (cacheBehaviour?) => {
const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
const newProjects: ProjectListEntry[] = []
const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
const profileProjects = (await get_projects(props.instance.path, cacheBehaviour)) as Record<
string,
ContentFile
>
const fetchProjects = []
const fetchVersions = []
@@ -397,21 +378,21 @@ const initProjects = async (cacheBehaviour?) => {
}
const [modrinthProjects, modrinthVersions] = await Promise.all([
await get_project_many(fetchProjects).catch(handleError),
await get_version_many(fetchVersions).catch(handleError),
(await get_project_many(fetchProjects).catch(handleError)) as Project[],
(await get_version_many(fetchVersions).catch(handleError)) as Version[],
])
const [modrinthTeams, modrinthOrganizations] = await Promise.all([
await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError),
await get_organization_many(
(await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError)) as TeamMember[][],
(await get_organization_many(
modrinthProjects.map((x) => x.organization).filter((x) => !!x),
).catch(handleError),
).catch(handleError)) as Organization[],
])
for (const [path, file] of Object.entries(profileProjects)) {
if (file.metadata) {
const project = modrinthProjects.find((x) => file.metadata.project_id === x.id)
const version = modrinthVersions.find((x) => file.metadata.version_id === x.id)
const project = modrinthProjects.find((x) => file.metadata?.project_id === x.id)
const version = modrinthVersions.find((x) => file.metadata?.version_id === x.id)
if (project && version) {
const org = project.organization
@@ -420,7 +401,7 @@ const initProjects = async (cacheBehaviour?) => {
const team = modrinthTeams.find((x) => x[0].team_id === project.team)
let author: ProjectListEntryAuthor | null
let author: ProjectListEntryAuthor | null = null
if (org) {
author = {
name: org.name,
@@ -429,13 +410,13 @@ const initProjects = async (cacheBehaviour?) => {
}
} else if (team) {
const teamMember = team.find((x) => x.is_owner)
author = {
name: teamMember.user.username,
slug: teamMember.user.username,
type: 'user',
if (teamMember) {
author = {
name: teamMember.user.username,
slug: teamMember.user.username,
type: 'user',
}
}
} else {
author = null
}
newProjects.push({
@@ -464,7 +445,7 @@ const initProjects = async (cacheBehaviour?) => {
author: null,
version: null,
file_name: file.file_name,
icon: null,
icon: undefined,
disabled: file.file_name.endsWith('.disabled'),
outdated: false,
updated: dayjs(0),
@@ -488,7 +469,7 @@ const initProjects = async (cacheBehaviour?) => {
}
await initProjects()
const modpackVersionModal = ref(null)
const modpackVersionModal = ref<InstanceType<typeof ModpackVersionModal> | null>()
const installing = computed(() => props.instance.install_stage !== 'installed')
const vintl = useVIntl()
@@ -513,7 +494,7 @@ const messages = defineMessages({
const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
const options: FilterOption[] = []
const frequency = projects.value.reduce((map, item) => {
const frequency = projects.value.reduce((map: Record<string, number>, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1
return map
}, {})
@@ -544,7 +525,7 @@ const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
return options
})
const selectedFilters = ref([])
const selectedFilters = ref<string[]>([])
const filteredProjects = computed(() => {
const updatesFilter = selectedFilters.value.includes('updates')
const disabledFilter = selectedFilters.value.includes('disabled')
@@ -571,7 +552,7 @@ watch(filterOptions, () => {
}
})
function toggleArray(array, value) {
function toggleArray<T>(array: T[], value: T) {
if (array.includes(value)) {
array.splice(array.indexOf(value), 1)
} else {
@@ -581,7 +562,7 @@ function toggleArray(array, value) {
const searchFilter = ref('')
const selectAll = ref(false)
const shareModal = ref(null)
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
const ascending = ref(true)
const sortColumn = ref('Name')
const currentPage = ref(1)
@@ -622,7 +603,7 @@ const search = computed(() => {
watch([sortColumn, ascending, selectedFilters.value, searchFilter], () => (currentPage.value = 1))
const sortProjects = (filter) => {
const sortProjects = (filter: string) => {
if (sortColumn.value === filter) {
ascending.value = !ascending.value
} else {
@@ -640,7 +621,7 @@ const updateAll = async () => {
}
}
const paths = await update_all(props.instance.path).catch(handleError)
const paths = (await update_all(props.instance.path).catch(handleError)) as Record<string, string>
for (const [oldVal, newVal] of Object.entries(paths)) {
const index = projects.value.findIndex((x) => x.path === oldVal)
@@ -649,7 +630,7 @@ const updateAll = async () => {
if (projects.value[index].updateVersion) {
projects.value[index].version = projects.value[index].updateVersion.version_number
projects.value[index].updateVersion = null
projects.value[index].updateVersion = undefined
}
}
for (const project of setProjects) {
@@ -664,15 +645,15 @@ const updateAll = async () => {
})
}
const updateProject = async (mod) => {
const updateProject = async (mod: ProjectListEntry) => {
mod.updating = true
await new Promise((resolve) => setTimeout(resolve, 0))
mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
mod.updating = false
mod.outdated = false
mod.version = mod.updateVersion.version_number
mod.updateVersion = null
mod.version = mod.updateVersion?.version_number
mod.updateVersion = undefined
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
@@ -683,15 +664,15 @@ const updateProject = async (mod) => {
})
}
const locks = {}
const locks: Record<string, string | null> = {}
const toggleDisableMod = async (mod) => {
const toggleDisableMod = async (mod: ProjectListEntry) => {
// Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property.
const lock = locks[mod.file_name]
while (lock) {
await new Promise((resolve) => {
setTimeout((_) => resolve(), 100)
setTimeout((value: unknown) => resolve(value), 100)
})
}
@@ -716,20 +697,20 @@ const toggleDisableMod = async (mod) => {
locks[mod.file_name] = null
}
const removeMod = async (mod) => {
const removeMod = async (mod: ContentItem<ProjectListEntry>) => {
await remove_project(props.instance.path, mod.path).catch(handleError)
projects.value = projects.value.filter((x) => mod.path !== x.path)
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.id,
name: mod.name,
project_type: mod.project_type,
id: mod.data.id,
name: mod.data.name,
project_type: mod.data.project_type,
})
}
const copyModLink = async (mod) => {
const copyModLink = async (mod: ContentItem<ProjectListEntry>) => {
await navigator.clipboard.writeText(
`https://modrinth.com/${mod.data.project_type}/${mod.data.slug}`,
)
@@ -744,15 +725,15 @@ const deleteSelected = async () => {
}
const shareNames = async () => {
await shareModal.value.show(functionValues.value.map((x) => x.name).join('\n'))
await shareModal.value?.show(functionValues.value.map((x) => x.name).join('\n'))
}
const shareFileNames = async () => {
await shareModal.value.show(functionValues.value.map((x) => x.file_name).join('\n'))
await shareModal.value?.show(functionValues.value.map((x) => x.file_name).join('\n'))
}
const shareUrls = async () => {
await shareModal.value.show(
await shareModal.value?.show(
functionValues.value
.filter((x) => x.slug)
.map((x) => `https://modrinth.com/${x.project_type}/${x.slug}`)
@@ -761,7 +742,7 @@ const shareUrls = async () => {
}
const shareMarkdown = async () => {
await shareModal.value.show(
await shareModal.value?.show(
functionValues.value
.map((x) => {
if (x.slug) {
@@ -826,15 +807,17 @@ const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
await initProjects()
})
const unlistenProfiles = await profile_listener(async (event) => {
if (
event.profile_path_id === props.instance.path &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing'
) {
await initProjects()
}
})
const unlistenProfiles = await profile_listener(
async (event: { event: string; profile_path_id: string }) => {
if (
event.profile_path_id === props.instance.path &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing'
) {
await initProjects()
}
},
)
onUnmounted(() => {
unlisten()

View File

@@ -60,7 +60,7 @@
</button>
</ButtonStyled>
</div>
<FilterBar v-model="filters" :options="filterOptions" />
<FilterBar v-model="filters" :options="filterOptions" show-all-options />
<div class="flex flex-col w-full gap-2">
<WorldItem
v-for="world in filteredWorlds"
@@ -225,6 +225,11 @@ const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
await refreshAllWorlds()
async function refreshServer(address: string) {
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
}
}
await refreshServerData(serverData.value[address], protocolVersion.value, address)
}
@@ -263,9 +268,12 @@ async function addServer(server: ServerWorld) {
async function editServer(server: ServerWorld) {
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
if (index !== -1) {
const oldServer = worlds.value[index] as ServerWorld
worlds.value[index] = server
sortWorlds(worlds.value)
await refreshServer(server.address)
if (oldServer.address !== server.address) {
await refreshServer(server.address)
}
} else {
handleError(`Error refreshing server, refreshing all worlds`)
await refreshAllWorlds()
@@ -349,26 +357,34 @@ const supportsQuickPlay = computed(() =>
const filterOptions = computed(() => {
const options: FilterBarOption[] = []
if (worlds.value.some((x) => x.type === 'singleplayer')) {
const hasServer = worlds.value.some((x) => x.type === 'server')
if (worlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
options.push({
id: 'singleplayer',
message: messages.singleplayer,
})
}
if (worlds.value.some((x) => x.type === 'server')) {
options.push({
id: 'server',
message: messages.server,
})
}
// add available filter if there's any offline ("unavailable") servers
if (hasServer) {
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
if (
worlds.value.some(
(x) =>
x.type === 'server' &&
!serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing,
) &&
worlds.value.some(
(x) =>
x.type === 'singleplayer' ||
(x.type === 'server' &&
serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing),
)
) {
options.push({

View File

@@ -155,7 +155,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ref, shallowRef, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
@@ -170,6 +170,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'
dayjs.extend(relativeTime)
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
@@ -247,6 +248,9 @@ async function install(version) {
installedVersion.value = version
}
},
(profile) => {
router.push(`/instance/${profile}`)
},
)
}

View File

@@ -23,8 +23,8 @@ export const useInstall = defineStore('installStore', {
setInstallConfirmModal(ref) {
this.installConfirmModal = ref
},
showInstallConfirmModal(project, version_id, onInstall) {
this.installConfirmModal.show(project, version_id, onInstall)
showInstallConfirmModal(project, version_id, onInstall, createInstanceCallback) {
this.installConfirmModal.show(project, version_id, onInstall, createInstanceCallback)
},
setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref
@@ -41,7 +41,14 @@ export const useInstall = defineStore('installStore', {
},
})
export const install = async (projectId, versionId, instancePath, source, callback = () => {}) => {
export const install = async (
projectId,
versionId,
instancePath,
source,
callback = () => {},
createInstanceCallback = () => {},
) => {
const project = await get_project(projectId, 'must_revalidate').catch(handleError)
if (project.project_type === 'modpack') {
@@ -49,7 +56,13 @@ export const install = async (projectId, versionId, instancePath, source, callba
const packs = await list().catch(handleError)
if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) {
await packInstall(project.id, version, project.title, project.icon_url).catch(handleError)
await packInstall(
project.id,
version,
project.title,
project.icon_url,
createInstanceCallback,
).catch(handleError)
trackEvent('PackInstall', {
id: project.id,
@@ -61,7 +74,7 @@ export const install = async (projectId, versionId, instancePath, source, callba
callback(version)
} else {
const install = useInstall()
install.showInstallConfirmModal(project, version, callback)
install.showInstallConfirmModal(project, version, callback, createInstanceCallback)
}
} else {
if (instancePath) {

View File

@@ -1,4 +1,4 @@
import { useTheming } from './theme'
import { useTheming } from './theme.ts'
import { useBreadcrumbs } from './breadcrumbs'
import { useLoading } from './loading'
import { useNotifications, handleError } from './notifications'

View File

@@ -1,38 +0,0 @@
import { defineStore } from 'pinia'
export const useTheming = defineStore('themeStore', {
state: () => ({
themeOptions: ['dark', 'light', 'oled', 'system'],
advancedRendering: true,
selectedTheme: 'dark',
toggleSidebar: false,
devMode: false,
featureFlags: {},
}),
actions: {
setThemeState(newTheme) {
if (this.themeOptions.includes(newTheme)) this.selectedTheme = newTheme
else console.warn('Selected theme is not present. Check themeOptions.')
this.setThemeClass()
},
setThemeClass() {
for (const theme of this.themeOptions) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
},
},
})

View File

@@ -0,0 +1,70 @@
import { defineStore } from 'pinia'
export const DEFAULT_FEATURE_FLAGS = {
project_background: false,
page_path: false,
worlds_tab: false,
worlds_in_home: true,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
export type FeatureFlags = Record<FeatureFlag, boolean>
export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type ThemeStore = {
selectedTheme: ColorTheme
advancedRendering: boolean
toggleSidebar: boolean
devMode: boolean
featureFlags: FeatureFlags
}
export const DEFAULT_THEME_STORE: ThemeStore = {
selectedTheme: 'dark',
advancedRendering: true,
toggleSidebar: false,
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
}
export const useTheming = defineStore('themeStore', {
state: () => DEFAULT_THEME_STORE,
actions: {
setThemeState(newTheme: ColorTheme) {
if (THEME_OPTIONS.includes(newTheme)) {
this.selectedTheme = newTheme
} else {
console.warn('Selected theme is not present. Check themeOptions.')
}
this.setThemeClass()
},
setThemeClass() {
for (const theme of THEME_OPTIONS) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
},
getFeatureFlag(key: FeatureFlag) {
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
},
getThemeOptions() {
return THEME_OPTIONS
},
},
})

View File

@@ -9,5 +9,6 @@ edition = "2021"
theseus = { path = "../../packages/app-lib", features = ["cli"] }
tokio = { version = "1", features = ["full"] }
webbrowser = "0.8.13"
enumset = "1.1"
tracing = "0.1.37"

View File

@@ -3,6 +3,7 @@
windows_subsystem = "windows"
)]
use enumset::EnumSet;
use theseus::prelude::*;
use theseus::worlds::get_recent_worlds;
@@ -40,7 +41,7 @@ async fn main() -> theseus::Result<()> {
// Initialize state
State::init().await?;
let worlds = get_recent_worlds(4).await?;
let worlds = get_recent_worlds(4, EnumSet::all()).await?;
for world in worlds {
println!(
"World: {:?}/{:?} played at {:?}: {:#?}",

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_gui"
version = "0.9.4"
version = "0.9.5"
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/"
@@ -42,6 +42,7 @@ tracing-error = "0.2.0"
dashmap = "6.0.1"
paste = "1.0.15"
enumset = { version = "1.1", features = ["serde"] }
opener = { version = "0.7.2", features = ["reveal", "dbus-vendored"] }

View File

@@ -248,6 +248,7 @@ fn main() {
"get_recent_worlds",
"get_profile_worlds",
"get_singleplayer_world",
"set_world_display_status",
"rename_world",
"reset_world_icon",
"backup_world",

View File

@@ -1,10 +1,12 @@
use crate::api::Result;
use either::Either;
use enumset::EnumSet;
use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata;
use theseus::profile::{get_full_path, QuickPlayType};
use theseus::worlds::{
ServerPackStatus, ServerStatus, World, WorldWithProfile,
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
WorldWithProfile,
};
use theseus::{profile, worlds};
@@ -14,6 +16,7 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
get_recent_worlds,
get_profile_worlds,
get_singleplayer_world,
set_world_display_status,
rename_world,
reset_world_icon,
backup_world,
@@ -33,8 +36,13 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
pub async fn get_recent_worlds<R: Runtime>(
app_handle: AppHandle<R>,
limit: usize,
display_statuses: Option<EnumSet<DisplayStatus>>,
) -> Result<Vec<WorldWithProfile>> {
let mut result = worlds::get_recent_worlds(limit).await?;
let mut result = worlds::get_recent_worlds(
limit,
display_statuses.unwrap_or(EnumSet::all()),
)
.await?;
for world in result.iter_mut() {
adapt_world_icon(&app_handle, &mut world.world);
}
@@ -59,8 +67,7 @@ pub async fn get_singleplayer_world<R: Runtime>(
instance: &str,
world: &str,
) -> Result<World> {
let instance = get_full_path(instance).await?;
let mut world = worlds::get_singleplayer_world(&instance, world).await?;
let mut world = worlds::get_singleplayer_world(instance, world).await?;
adapt_world_icon(&app_handle, &mut world);
Ok(world)
}
@@ -90,6 +97,22 @@ fn adapt_world_icon<R: Runtime>(app_handle: &AppHandle<R>, world: &mut World) {
}
}
#[tauri::command]
pub async fn set_world_display_status(
instance: &str,
world_type: WorldType,
world_id: &str,
display_status: DisplayStatus,
) -> Result<()> {
Ok(worlds::set_world_display_status(
instance,
world_type,
world_id,
display_status,
)
.await?)
}
#[tauri::command]
pub async fn rename_world(
instance: &str,

View File

@@ -117,8 +117,7 @@ fn show_window(app: tauri::AppHandle) {
.set_type(MessageType::Error)
.set_title("Initialization error")
.set_text(&format!(
"Cannot display application window due to an error:\n{}",
e
"Cannot display application window due to an error:\n{e}"
))
.show_alert()
.unwrap();
@@ -138,8 +137,7 @@ fn is_dev() -> bool {
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
window.set_decorations(b).map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to toggle decorations: {}",
e
"Failed to toggle decorations: {e}"
)))
})?;
Ok(())
@@ -320,7 +318,7 @@ fn main() {
#[cfg(target_os = "windows")]
{
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
if format!("{:?}", e).contains(
if format!("{e:?}").contains(
"Runtime(CreateWebview(WebView2Error(WindowsError",
) {
MessageDialog::new()
@@ -338,8 +336,7 @@ fn main() {
.set_type(MessageType::Error)
.set_title("Initialization error")
.set_text(&format!(
"Cannot initialize application due to an error:\n{:?}",
e
"Cannot initialize application due to an error:\n{e:?}"
))
.show_alert()
.unwrap();

View File

@@ -44,7 +44,7 @@
]
},
"productName": "Modrinth App",
"version": "0.9.4",
"version": "0.9.5",
"mainBinaryName": "Modrinth App",
"identifier": "ModrinthApp",
"plugins": {
@@ -83,7 +83,7 @@
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
"font-src": ["https://cdn-raw.modrinth.com/fonts/inter/"],
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
"style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com 'self'",

View File

@@ -57,7 +57,7 @@ pub async fn fetch_forge(
ForgeVersion {
format_version,
installer_url: format!("https://maven.minecraftforge.net/net/minecraftforge/forge/{0}/forge-{0}-installer.jar", loader_version),
installer_url: format!("https://maven.minecraftforge.net/net/minecraftforge/forge/{loader_version}/forge-{loader_version}-installer.jar"),
raw: loader_version,
loader_version: version_split,
game_version: game_version.clone(),
@@ -137,7 +137,7 @@ pub async fn fetch_neo(
Ok(ForgeVersion {
format_version: 2,
installer_url: format!("https://maven.neoforged.net/net/neoforged/forge/{0}/forge-{0}-installer.jar", loader_version),
installer_url: format!("https://maven.neoforged.net/net/neoforged/forge/{loader_version}/forge-{loader_version}-installer.jar"),
raw: loader_version,
loader_version: version_split,
game_version: "1.20.1".to_string(), // All NeoForge Forge versions are for 1.20.1
@@ -163,7 +163,7 @@ pub async fn fetch_neo(
Ok(ForgeVersion {
format_version: 2,
installer_url: format!("https://maven.neoforged.net/net/neoforged/neoforge/{0}/neoforge-{0}-installer.jar", loader_version),
installer_url: format!("https://maven.neoforged.net/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar"),
loader_version: loader_version.clone(),
raw: loader_version,
game_version,
@@ -502,7 +502,7 @@ async fn fetch(
)?;
artifact.url =
format_url(&format!("maven/{}", artifact_path));
format_url(&format!("maven/{artifact_path}"));
return Ok(lib);
}

View File

@@ -86,8 +86,8 @@
<UpdatedIcon />
</Button>
<DropdownSelect
class="range-dropdown"
v-model="selectedRange"
class="range-dropdown"
:options="ranges"
name="Time range"
:display-name="
@@ -197,11 +197,11 @@
>
<div class="country-flag-container">
<template v-if="name.toLowerCase() === 'xx' || !name">
<img
src="https://cdn.modrinth.com/placeholder-banner.svg"
alt="Placeholder flag"
class="country-flag"
/>
<div
class="country-flag flex select-none items-center justify-center bg-bg-raised font-extrabold text-secondary"
>
?
</div>
</template>
<template v-else>
<img
@@ -213,7 +213,7 @@
</div>
<div class="country-text">
<strong class="country-name"
><template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
><template v-if="name.toLowerCase() === 'xx' || !name">Other</template>
<template v-else>{{ countryCodeToName(name) }}</template>
</strong>
<span class="data-point">{{ formatNumber(count) }}</span>

View File

@@ -53,7 +53,7 @@
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Minecraft version</div>
<div class="text-lg font-bold text-contrast">Minecraft version</div>
<UiServersTeleportDropdownMenu
v-model="selectedMCVersion"
name="mcVersion"
@@ -61,6 +61,20 @@
class="w-full max-w-[100%]"
placeholder="Select Minecraft version..."
/>
<div class="mt-2 flex items-center justify-between gap-2">
<label for="toggle-snapshots" class="font-semibold"> Show snapshot versions </label>
<div
v-tooltip="
isSnapshotSelected ? 'A snapshot version is currently selected.' : undefined
"
>
<Toggle
id="toggle-snapshots"
v-model="showSnapshots"
:disabled="isSnapshotSelected"
/>
</div>
</div>
</div>
<div
@@ -74,7 +88,7 @@
}"
>
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-contrast">{{ selectedLoader }} version</div>
<div class="text-lg font-bold text-contrast">{{ selectedLoader }} version</div>
<template v-if="!selectedMCVersion">
<div
@@ -177,8 +191,9 @@
</template>
<script setup lang="ts">
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { RightArrowIcon, XIcon, ServerIcon, DropdownIcon } from "@modrinth/assets";
import { BackupWarning, ButtonStyled, NewModal, Toggle } from "@modrinth/ui";
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from "@modrinth/assets";
import { $fetch } from "ofetch";
import type { Server } from "~/composables/pyroServers";
import type { Loaders } from "~/types/servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
@@ -214,6 +229,7 @@ const hardReset = ref(false);
const isLoading = ref(false);
const loadingServerCheck = ref(false);
const serverCheckError = ref("");
const showSnapshots = ref(false);
const selectedLoader = ref<Loaders>("Vanilla");
const selectedMCVersion = ref("");
@@ -226,6 +242,22 @@ const cachedVersions = ref<VersionCache>({});
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const;
const isSnapshotSelected = computed(() => {
if (selectedMCVersion.value) {
const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value);
if (selected?.version_type !== "release") {
return true;
}
}
return false;
});
const getLoaderVersions = async (loader: string) => {
return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
);
};
const fetchLoaderVersions = async () => {
const versions = await Promise.all(
versionStrings.map(async (loader) => {
@@ -234,7 +266,7 @@ const fetchLoaderVersions = async () => {
throw new Error("Failed to fetch loader versions");
}
try {
const res = await $fetch(`/loader-versions?loader=${loader}`);
const res = await getLoaderVersions(loader);
return { [loader]: (res as any).gameVersions };
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
@@ -277,11 +309,11 @@ const fetchPurpurVersions = async (mcVersion: string) => {
}
};
const selectedLoaderVersions = computed(() => {
const selectedLoaderVersions = computed<string[]>(() => {
const loader = selectedLoader.value.toLowerCase();
if (loader === "paper") {
return paperVersions.value[selectedMCVersion.value] || [];
return paperVersions.value[selectedMCVersion.value].map((x) => `${x}`) || [];
}
if (loader === "purpur") {
@@ -325,13 +357,22 @@ watch(selectedLoader, async () => {
watch(
selectedLoaderVersions,
(newVersions) => {
if (newVersions.length > 0 && !selectedLoaderVersion.value) {
if (
newVersions.length > 0 &&
(!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value))
) {
selectedLoaderVersion.value = String(newVersions[0]);
}
},
{ immediate: true },
);
const getLoaderVersion = async (loader: string, version: string) => {
return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
);
};
const checkVersionAvailability = async (version: string) => {
if (!version || version.trim().length < 3) return;
@@ -339,9 +380,7 @@ const checkVersionAvailability = async (version: string) => {
loadingServerCheck.value = true;
try {
const mcRes =
cachedVersions.value[version] ||
(await $fetch(`/loader-versions?loader=minecraft&version=${version}`));
const mcRes = cachedVersions.value[version] || (await getLoaderVersion("minecraft", version));
cachedVersions.value[version] = mcRes;
@@ -377,13 +416,15 @@ onMounted(() => {
});
const tags = useTags();
const mcVersions = tags.value.gameVersions
.filter((x) => x.version_type === "release")
.map((x) => x.version)
.filter((x) => {
const segment = parseInt(x.split(".")[1], 10);
return !isNaN(segment) && segment > 2;
});
const mcVersions = computed(() =>
tags.value.gameVersions
.filter((x) =>
showSnapshots.value
? x.version_type === "snapshot" || x.version_type === "release"
: x.version_type === "release",
)
.map((x) => x.version),
);
const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => {
@@ -448,6 +489,9 @@ const handleReinstall = async () => {
const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || "";
if (isSnapshotSelected.value) {
showSnapshots.value = true;
}
};
const onHide = () => {

View File

@@ -638,6 +638,7 @@
shown: !isMember,
},
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
]"
aria-label="More options"
:dropdown-id="`${baseId}-more-options`"
@@ -659,6 +660,10 @@
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
Copy permanent link
</template>
</OverflowMenu>
</ButtonStyled>
</template>
@@ -888,6 +893,7 @@ import { reportProject } from "~/utils/report-helpers.ts";
const data = useNuxtApp();
const route = useNativeRoute();
const config = useRuntimeConfig();
const auth = await useAuth();
const user = await useUser();
@@ -1458,6 +1464,10 @@ async function copyId() {
await navigator.clipboard.writeText(project.value.id);
}
async function copyPermalink() {
await navigator.clipboard.writeText(`${config.public.siteUrl}/project/${project.value.id}`);
}
const collapsedChecklist = ref(false);
const showModerationChecklist = ref(false);

View File

@@ -622,7 +622,7 @@
<CopyCode :text="version.id" />
</div>
<div v-if="!isEditing && flags.developerMode">
<h4>Modrinth Maven</h4>
<h4>Maven coordinates</h4>
<div class="maven-section">
<CopyCode :text="`maven.modrinth:${project.id}:${version.id}`" />
</div>
@@ -1555,6 +1555,10 @@ export default defineNuxtComponent({
display: flex;
align-items: center;
gap: 0.5rem;
button {
max-width: 100%;
}
}
.team-member {

View File

@@ -169,7 +169,7 @@
</template>
<template #copy-maven>
<ClipboardCopyIcon aria-hidden="true" />
Copy Modrinth Maven
Copy Maven coordinates
</template>
</OverflowMenu>
</ButtonStyled>

View File

@@ -52,10 +52,7 @@
>
<div class="project-title">
<div class="mobile-row">
<nuxt-link
:to="`/${project.inferred_project_type}/${project.slug}`"
class="iconified-stacked-link"
>
<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>
@@ -67,7 +64,7 @@
by
<nuxt-link
v-if="project.owner"
:to="`/user/${project.owner.user.username}`"
:to="`/user/${project.owner.user.id}`"
class="iconified-link"
>
<Avatar :src="project.owner.user.avatar_url" circle size="xxs" raised />
@@ -75,7 +72,7 @@
</nuxt-link>
<nuxt-link
v-else-if="project.org"
:to="`/organization/${project.org.slug}`"
:to="`/organization/${project.org.id}`"
class="iconified-link"
>
<Avatar :src="project.org.icon_url" circle size="xxs" raised />
@@ -88,10 +85,7 @@
</div>
</div>
<div class="input-group">
<nuxt-link
:to="`/${project.inferred_project_type}/${project.slug}`"
class="iconified-button raised-button"
>
<nuxt-link :to="`/project/${project.id}`" class="iconified-button raised-button">
<EyeIcon />
View project
</nuxt-link>

View File

@@ -123,6 +123,7 @@
},
{ divider: true, shown: auth.user && currentMember },
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
]"
aria-label="More options"
>
@@ -135,6 +136,10 @@
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
@@ -287,6 +292,7 @@ const cosmetics = useCosmetics();
const route = useNativeRoute();
const tags = useTags();
const flags = useFeatureFlags();
const config = useRuntimeConfig();
let orgId = useRouteId();
@@ -502,6 +508,12 @@ const navLinks = computed(() => [
async function copyId() {
await navigator.clipboard.writeText(organization.value.id);
}
async function copyPermalink() {
await navigator.clipboard.writeText(
`${config.public.siteUrl}/organization/${organization.value.id}`,
);
}
</script>
<style scoped lang="scss">

View File

@@ -1,7 +1,7 @@
<template>
<div class="page">
<div class="experimental-styles-within flex flex-col gap-2">
<RadialHeader class="top-box mb-2 text-center" color="orange">
<RadialHeader class="top-box mb-2 flex flex-col items-center justify-center" color="orange">
<ScaleIcon class="h-12 w-12 text-brand-orange" />
<h1 class="m-3 gap-2 text-3xl font-extrabold">
{{

View File

@@ -64,6 +64,21 @@
</template>
</span>
<template v-if="midasCharge">
<span
v-if="
midasCharge.status === 'open' && midasCharge.subscription_interval === 'monthly'
"
class="text-sm text-purple"
>
Save
{{
formatPrice(
vintl.locale,
midasCharge.amount * 12 - oppositePrice,
midasCharge.currency_code,
)
}}/year by switching to yearly billing!
</span>
<span class="text-sm text-secondary">
Since {{ $dayjs(midasSubscription.created).format("MMMM D, YYYY") }}
</span>
@@ -118,19 +133,46 @@
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-else-if="midasCharge && midasCharge.status !== 'cancelled'">
<button
class="ml-auto"
@click="
() => {
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
}
"
<div
v-else-if="midasCharge && midasCharge.status !== 'cancelled'"
class="ml-auto flex gap-2"
>
<ButtonStyled>
<button
:disabled="changingInterval"
@click="
() => {
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
}
"
>
<XIcon /> Cancel
</button>
</ButtonStyled>
<ButtonStyled
:color="midasCharge.subscription_interval === 'yearly' ? 'standard' : 'purple'"
color-fill="text"
>
<XIcon /> Cancel
</button>
</ButtonStyled>
<button
v-tooltip="
midasCharge.subscription_interval === 'yearly'
? `Monthly billing will cost you an additional ${formatPrice(
vintl.locale,
oppositePrice * 12 - midasCharge.amount,
midasCharge.currency_code,
)} per year`
: undefined
"
:disabled="changingInterval"
@click="switchMidasInterval(oppositeInterval)"
>
<SpinnerIcon v-if="changingInterval" class="animate-spin" />
<TransferIcon v-else /> {{ changingInterval ? "Switching" : "Switch" }} to
{{ oppositeInterval }}
</button>
</ButtonStyled>
</div>
<ButtonStyled
v-else-if="midasCharge && midasCharge.status === 'cancelled'"
color="purple"
@@ -178,8 +220,12 @@
/>
<div v-else class="w-fit">
<p>
A linked server couldn't be found with this subscription. It may have been deleted
or suspended. Please contact Modrinth support with the following information:
A linked server couldn't be found for this subscription. There are a few possible
explanations for this. If you just purchased your server, this is normal. It could
take up to an hour for your server to be provisioned. Otherwise, if you purchased
this server a while ago, it has likely since been suspended. If this is not what
you were expecting, please contact Modrinth support with the following
information:
</p>
<div class="flex w-full flex-col gap-2">
<CopyCode
@@ -547,6 +593,8 @@ import {
} from "@modrinth/ui";
import {
PlusIcon,
TransferIcon,
SpinnerIcon,
ArrowBigUpDashIcon,
XIcon,
CardIcon,
@@ -750,6 +798,13 @@ const midasCharge = computed(() =>
: null,
);
const oppositePrice = computed(() =>
midasSubscription.value
? midasProduct.value?.prices?.find((price) => price.id === midasSubscription.value.price_id)
?.prices?.intervals?.[oppositeInterval.value]
: undefined,
);
const pyroSubscriptions = computed(() => {
const pyroSubs = subscriptions.value?.filter((s) => s?.metadata?.type === "pyro") || [];
const servers = serversData.value?.servers || [];
@@ -847,6 +902,31 @@ async function submit() {
const removePaymentMethodIndex = ref();
const changingInterval = ref(false);
const oppositeInterval = computed(() =>
midasCharge.value?.subscription_interval === "yearly" ? "monthly" : "yearly",
);
async function switchMidasInterval(interval) {
changingInterval.value = true;
startLoading();
try {
await useBaseFetch(`billing/subscription/${midasSubscription.value.id}`, {
internal: true,
method: "PATCH",
body: {
interval,
},
});
await refresh();
} catch (error) {
console.error("Error switching Modrinth+ payment interval:", error);
}
stopLoading();
changingInterval.value = false;
}
async function editPaymentMethod(index, primary) {
startLoading();
try {

View File

@@ -125,6 +125,7 @@
shown: auth.user?.id !== user.id,
},
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
{
id: 'open-billing',
action: () => navigateTo(`/admin/billing/${user.id}`),
@@ -151,6 +152,10 @@
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
<template #open-billing>
<CurrencyIcon aria-hidden="true" />
{{ formatMessage(messages.billingButton) }}
@@ -381,6 +386,7 @@ const auth = await useAuth();
const cosmetics = useCosmetics();
const tags = useTags();
const flags = useFeatureFlags();
const config = useRuntimeConfig();
const vintl = useVIntl();
const { formatMessage } = vintl;
@@ -616,6 +622,10 @@ async function copyId() {
await navigator.clipboard.writeText(user.value.id);
}
async function copyPermalink() {
await navigator.clipboard.writeText(`${config.public.siteUrl}/user/${user.value.id}`);
}
const navLinks = computed(() => [
{
label: formatMessage(commonMessages.allProjectType),

View File

@@ -1,36 +0,0 @@
const getLoaderVersions = async (loader: string) => {
const loaderVersions = await fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
);
return loaderVersions.json();
};
const getLoaderVersion = async (loader: string, version: string) => {
const loaderVersion = await fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
);
return loaderVersion.json();
};
export default defineEventHandler(async (e) => {
const params = new URLSearchParams(e._path?.split("?")[1] ?? "");
if (!params.has("loader"))
return new Response(
JSON.stringify({
error: "Missing loader",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
const loader = params.get("loader");
const version = params.get("version");
if (version) {
const loaderVersion = await getLoaderVersion(loader!, version);
return new Response(JSON.stringify(loaderVersion), {
headers: { "Content-Type": "application/json" },
});
}
const loaderVersions = await getLoaderVersions(loader!);
return new Response(JSON.stringify(loaderVersions), {
headers: { "Content-Type": "application/json" },
});
});

View File

@@ -262,8 +262,9 @@ export const processAnalyticsByCountry = (category, projects, sortFn) => {
loadedProjectData.forEach((data) => {
Object.entries(data).forEach(([country, value]) => {
const current = countrySums.get(country) || 0;
countrySums.set(country, current + value);
const countryCode = country || "XX";
const current = countrySums.get(countryCode) || 0;
countrySums.set(countryCode, current + value);
});
});

View File

@@ -95,7 +95,7 @@ impl actix_web::ResponseError for OAuthError {
);
if let Some(state) = self.state.as_ref() {
redirect_uri = format!("{}&state={}", redirect_uri, state);
redirect_uri = format!("{redirect_uri}&state={state}");
}
HttpResponse::Ok()

View File

@@ -414,7 +414,7 @@ fn generate_access_token() -> String {
.take(60)
.map(char::from)
.collect::<String>();
format!("mro_{}", random)
format!("mro_{random}")
}
async fn init_oauth_code_flow(

View File

@@ -32,7 +32,7 @@ impl Display for ErrorPage {
let html = include_str!("error.html")
.replace("{{ code }}", &self.code.to_string())
.replace("{{ message }}", &self.message);
write!(f, "{}", html)?;
write!(f, "{html}")?;
Ok(())
}

View File

@@ -103,8 +103,7 @@ impl MinecraftGameVersion {
}
_ => {
return Err(DatabaseError::SchemaError(format!(
"Game version requires field value to be an enum: {:?}",
version_field
"Game version requires field value to be an enum: {version_field:?}"
)));
}
};

View File

@@ -1080,8 +1080,7 @@ impl VersionFieldValue {
let field_name = field_type.to_str();
let did_not_exist_error = |field_name: &str, desired_field: &str| {
DatabaseError::SchemaError(format!(
"Field name {} for field {} in does not exist",
desired_field, field_name
"Field name {desired_field} for field {field_name} in does not exist"
))
};
@@ -1103,8 +1102,7 @@ impl VersionFieldValue {
.collect::<Vec<_>>();
if field_id.len() > 1 {
return Err(DatabaseError::SchemaError(format!(
"Multiple field ids for field {}",
field_name
"Multiple field ids for field {field_name}"
)));
}

View File

@@ -912,7 +912,7 @@ impl Version {
file.hashes.iter().map(|(algo, hash)| {
(
VERSION_FILES_NAMESPACE,
Some(format!("{}_{}", algo, hash)),
Some(format!("{algo}_{hash}")),
)
})
},

View File

@@ -80,10 +80,9 @@ impl From<DBNotification> for Notification {
} => (
"A project you follow has been updated!".to_string(),
format!(
"The project {} has released a new version: {}",
project_id, version_id
"The project {project_id} has released a new version: {version_id}"
),
format!("/project/{}/version/{}", project_id, version_id),
format!("/project/{project_id}/version/{version_id}"),
vec![],
),
NotificationBody::TeamInvite {
@@ -93,8 +92,8 @@ impl From<DBNotification> for Notification {
..
} => (
"You have been invited to join a team!".to_string(),
format!("An invite has been sent for you to be {} of a team", role),
format!("/project/{}", project_id),
format!("An invite has been sent for you to be {role} of a team"),
format!("/project/{project_id}"),
vec![
NotificationAction {
name: "Accept".to_string(),
@@ -117,10 +116,9 @@ impl From<DBNotification> for Notification {
} => (
"You have been invited to join an organization!".to_string(),
format!(
"An invite has been sent for you to be {} of an organization",
role
"An invite has been sent for you to be {role} of an organization"
),
format!("/organization/{}", organization_id),
format!("/organization/{organization_id}"),
vec![
NotificationAction {
name: "Accept".to_string(),
@@ -149,7 +147,7 @@ impl From<DBNotification> for Notification {
old_status.as_friendly_str(),
new_status.as_friendly_str()
),
format!("/project/{}", project_id),
format!("/project/{project_id}"),
vec![],
),
NotificationBody::ModeratorMessage {
@@ -160,9 +158,9 @@ impl From<DBNotification> for Notification {
"A moderator has sent you a message!".to_string(),
"Click on the link to read more.".to_string(),
if let Some(project_id) = project_id {
format!("/project/{}", project_id)
format!("/project/{project_id}")
} else if let Some(report_id) = report_id {
format!("/project/{}", report_id)
format!("/project/{report_id}")
} else {
"#".to_string()
},

View File

@@ -168,19 +168,19 @@ impl ModerationMessage {
for project in &projects {
let additional_text = if project.contains("ftb-quests") {
Some("Heracles")
Some(("Odyssey Quests", "lo90fZoB"))
} else if project.contains("ftb-ranks") || project.contains("ftb-essentials") {
Some("Prometheus")
Some(("Odyssey Roles", "iYcNKH7W"))
} else if project.contains("ftb-teams") {
Some("Argonauts")
Some(("Odyssey Guilds", "bb2EpKpx"))
} else if project.contains("ftb-chunks") {
Some("Cadmus")
Some(("Odyssey Claims", "fEWKxVzh"))
} else {
None
};
val.push_str(&if let Some(additional_text) = additional_text {
format!("- {project}(consider using [{additional_text}](https://modrinth.com/mod/{}) instead)\n", additional_text.to_lowercase())
format!("- {project} (consider using [{}](https://modrinth.com/project/{}) instead)\n", additional_text.0, additional_text.1)
} else {
format!("- {project}\n")
})

View File

@@ -222,8 +222,7 @@ pub async fn delphi_result_ingest(
for (issue, trace) in &body.issues {
for (path, code) in trace {
header.push_str(&format!(
"\n issue {issue} found at file {}: \n ```\n{}\n```",
path, code
"\n issue {issue} found at file {path}: \n ```\n{code}\n```"
));
}
}
@@ -242,10 +241,8 @@ pub async fn delphi_result_ingest(
for (issue, trace) in &body.issues {
for path in trace.keys() {
thread_header.push_str(&format!(
"\n\n- issue {issue} found at file {}",
path
));
thread_header
.push_str(&format!("\n\n- issue {issue} found at file {path}"));
}
if trace.is_empty() {

View File

@@ -247,7 +247,7 @@ impl AuthProvider {
state: String,
) -> Result<String, AuthenticationError> {
let self_addr = dotenvy::var("SELF_ADDR")?;
let raw_redirect_uri = format!("{}/v2/auth/callback", self_addr);
let raw_redirect_uri = format!("{self_addr}/v2/auth/callback");
let redirect_uri = urlencoding::encode(&raw_redirect_uri);
Ok(match self {
@@ -255,30 +255,24 @@ impl AuthProvider {
let client_id = dotenvy::var("GITHUB_CLIENT_ID")?;
format!(
"https://github.com/login/oauth/authorize?client_id={}&prompt=select_account&state={}&scope=read%3Auser%20user%3Aemail&redirect_uri={}",
client_id,
state,
redirect_uri,
"https://github.com/login/oauth/authorize?client_id={client_id}&prompt=select_account&state={state}&scope=read%3Auser%20user%3Aemail&redirect_uri={redirect_uri}",
)
}
AuthProvider::Discord => {
let client_id = dotenvy::var("DISCORD_CLIENT_ID")?;
format!("https://discord.com/api/oauth2/authorize?client_id={}&state={}&response_type=code&scope=identify%20email&redirect_uri={}", client_id, state, redirect_uri)
format!("https://discord.com/api/oauth2/authorize?client_id={client_id}&state={state}&response_type=code&scope=identify%20email&redirect_uri={redirect_uri}")
}
AuthProvider::Microsoft => {
let client_id = dotenvy::var("MICROSOFT_CLIENT_ID")?;
format!("https://login.live.com/oauth20_authorize.srf?client_id={}&response_type=code&scope=user.read&state={}&prompt=select_account&redirect_uri={}", client_id, state, redirect_uri)
format!("https://login.live.com/oauth20_authorize.srf?client_id={client_id}&response_type=code&scope=user.read&state={state}&prompt=select_account&redirect_uri={redirect_uri}")
}
AuthProvider::GitLab => {
let client_id = dotenvy::var("GITLAB_CLIENT_ID")?;
format!(
"https://gitlab.com/oauth/authorize?client_id={}&state={}&scope=read_user+profile+email&response_type=code&redirect_uri={}",
client_id,
state,
redirect_uri,
"https://gitlab.com/oauth/authorize?client_id={client_id}&state={state}&scope=read_user+profile+email&response_type=code&redirect_uri={redirect_uri}",
)
}
AuthProvider::Google => {
@@ -342,8 +336,7 @@ impl AuthProvider {
let client_secret = dotenvy::var("GITHUB_CLIENT_SECRET")?;
let url = format!(
"https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}&redirect_uri={}",
client_id, client_secret, code, redirect_uri
"https://github.com/login/oauth/access_token?client_id={client_id}&client_secret={client_secret}&code={code}&redirect_uri={redirect_uri}"
);
let token: AccessToken = reqwest::Client::new()
@@ -482,9 +475,8 @@ impl AuthProvider {
form.insert("openid.mode".to_string(), "check_authentication");
for val in signed.split(',') {
if let Some(arr_val) = query.get(&format!("openid.{}", val))
{
form.insert(format!("openid.{}", val), &**arr_val);
if let Some(arr_val) = query.get(&format!("openid.{val}")) {
form.insert(format!("openid.{val}"), &**arr_val);
}
}
@@ -621,8 +613,7 @@ impl AuthProvider {
email: discord_user.email,
avatar_url: discord_user.avatar.map(|x| {
format!(
"https://cdn.discordapp.com/avatars/{}/{}.webp",
id, x
"https://cdn.discordapp.com/avatars/{id}/{x}.webp"
)
}),
bio: None,
@@ -741,9 +732,7 @@ impl AuthProvider {
let response: String = reqwest::get(
&format!(
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key={}&steamids={}",
api_key,
token
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key={api_key}&steamids={token}"
)
)
.await?
@@ -1367,7 +1356,7 @@ pub async fn create_account_with_password(
if let Some(feedback) =
score.feedback().clone().and_then(|x| x.warning())
{
format!("Password too weak: {}", feedback)
format!("Password too weak: {feedback}")
} else {
"Specified password is too weak! Please improve its strength."
.to_string()
@@ -2030,7 +2019,7 @@ pub async fn change_password(
if let Some(feedback) =
score.feedback().clone().and_then(|x| x.warning())
{
format!("Password too weak: {}", feedback)
format!("Password too weak: {feedback}")
} else {
"Specified password is too weak! Please improve its strength.".to_string()
},
@@ -2085,8 +2074,8 @@ pub async fn change_password(
send_email(
email,
&format!("Password {}", changed),
&format!("Your password has been {} on your account.", changed),
&format!("Password {changed}"),
&format!("Your password has been {changed} on your account."),
"If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).",
None,
)?;

View File

@@ -113,7 +113,7 @@ pub async fn create_pat(
.take(60)
.map(char::from)
.collect::<String>();
let token = format!("mrp_{}", token);
let token = format!("mrp_{token}");
let name = info.name.clone();
database::models::pat_item::PersonalAccessToken {

View File

@@ -116,7 +116,7 @@ pub async fn forge_updates(
for game_version in &game_versions {
response
.promos
.entry(format!("{}-recommended", game_version))
.entry(format!("{game_version}-recommended"))
.or_insert_with(|| version.version_number.clone());
}
}
@@ -124,7 +124,7 @@ pub async fn forge_updates(
for game_version in &game_versions {
response
.promos
.entry(format!("{}-latest", game_version))
.entry(format!("{game_version}-latest"))
.or_insert_with(|| version.version_number.clone());
}
}

View File

@@ -144,7 +144,7 @@ where
match (
"Content-Type",
format!("multipart/form-data; boundary={}", boundary).as_str(),
format!("multipart/form-data; boundary={boundary}").as_str(),
)
.try_into_pair()
{
@@ -153,8 +153,7 @@ where
}
Err(err) => {
CreateError::InvalidInput(format!(
"Error inserting test header: {:?}.",
err
"Error inserting test header: {err:?}."
));
}
};

View File

@@ -424,7 +424,7 @@ pub async fn collection_icon_edit(
let collection_id: CollectionId = collection_item.id.into();
let upload_result = crate::util::img::upload_image_optimized(
&format!("data/{}", collection_id),
&format!("data/{collection_id}"),
bytes.freeze(),
&ext.ext,
Some(96),

View File

@@ -393,7 +393,7 @@ pub async fn oauth_client_icon_edit(
)
.await?;
let upload_result = upload_image_optimized(
&format!("data/{}", client_id),
&format!("data/{client_id}"),
bytes.freeze(),
&ext.ext,
Some(96),

View File

@@ -1096,7 +1096,7 @@ pub async fn organization_icon_edit(
let organization_id: OrganizationId = organization_item.id.into();
let upload_result = crate::util::img::upload_image_optimized(
&format!("data/{}", organization_id),
&format!("data/{organization_id}"),
bytes.freeze(),
&ext.ext,
Some(96),

View File

@@ -676,8 +676,7 @@ pub async fn cancel_payout(
.make_paypal_request::<(), ()>(
Method::POST,
&format!(
"payments/payouts-item/{}/cancel",
platform_id
"payments/payouts-item/{platform_id}/cancel"
),
None,
None,
@@ -689,7 +688,7 @@ pub async fn cancel_payout(
payouts
.make_tremendous_request::<(), ()>(
Method::POST,
&format!("rewards/{}/cancel", platform_id),
&format!("rewards/{platform_id}/cancel"),
None,
)
.await?;

View File

@@ -810,8 +810,7 @@ async fn project_create_inner(
|| image.context.inner_id().is_some()
{
return Err(CreateError::InvalidInput(format!(
"Image {} is not unused and in the 'project' context",
image_id
"Image {image_id} is not unused and in the 'project' context"
)));
}
@@ -830,8 +829,7 @@ async fn project_create_inner(
image_item::Image::clear_cache(image.id.into(), redis).await?;
} else {
return Err(CreateError::InvalidInput(format!(
"Image {} does not exist",
image_id
"Image {image_id} does not exist"
)));
}
}

View File

@@ -710,10 +710,8 @@ pub async fn project_edit(
));
}
let ids_to_delete = links
.iter()
.map(|(name, _)| name.clone())
.collect::<Vec<String>>();
let ids_to_delete =
links.keys().cloned().collect::<Vec<String>>();
// Deletes all links from hashmap- either will be deleted or be replaced
sqlx::query!(
"
@@ -1270,10 +1268,7 @@ pub async fn projects_edit(
.await?;
if let Some(links) = &bulk_edit_project.link_urls {
let ids_to_delete = links
.iter()
.map(|(name, _)| name.clone())
.collect::<Vec<String>>();
let ids_to_delete = links.keys().cloned().collect::<Vec<String>>();
// Deletes all links from hashmap- either will be deleted or be replaced
sqlx::query!(
"
@@ -1482,7 +1477,7 @@ pub async fn project_icon_edit(
let project_id: ProjectId = project_item.inner.id.into();
let upload_result = upload_image_optimized(
&format!("data/{}", project_id),
&format!("data/{project_id}"),
bytes.freeze(),
&ext.ext,
Some(96),
@@ -1700,7 +1695,7 @@ pub async fn add_gallery_item(
let id: ProjectId = project_item.inner.id.into();
let upload_result = upload_image_optimized(
&format!("data/{}/images", id),
&format!("data/{id}/images"),
bytes.freeze(),
&ext.ext,
Some(350),

View File

@@ -178,8 +178,7 @@ pub async fn report_create(
|| image.context.inner_id().is_some()
{
return Err(ApiError::InvalidInput(format!(
"Image {} is not unused and in the 'report' context",
image_id
"Image {image_id} is not unused and in the 'report' context"
)));
}
@@ -198,8 +197,7 @@ pub async fn report_create(
image_item::Image::clear_cache(image.id.into(), &redis).await?;
} else {
return Err(ApiError::InvalidInput(format!(
"Image {} could not be found",
image_id
"Image {image_id} could not be found"
)));
}
}

View File

@@ -527,8 +527,7 @@ pub async fn thread_send_message(
) || image.context.inner_id().is_some()
{
return Err(ApiError::InvalidInput(format!(
"Image {} is not unused and in the 'thread_message' context",
image_id
"Image {image_id} is not unused and in the 'thread_message' context"
)));
}
@@ -548,8 +547,7 @@ pub async fn thread_send_message(
.await?;
} else {
return Err(ApiError::InvalidInput(format!(
"Image {} does not exist",
image_id
"Image {image_id} does not exist"
)));
}
}

View File

@@ -595,7 +595,7 @@ pub async fn user_icon_edit(
let user_id: UserId = actual_user.id.into();
let upload_result = crate::util::img::upload_image_optimized(
&format!("data/{}", user_id),
&format!("data/{user_id}"),
bytes.freeze(),
&ext.ext,
Some(96),

View File

@@ -486,8 +486,7 @@ async fn version_create_inner(
|| image.context.inner_id().is_some()
{
return Err(CreateError::InvalidInput(format!(
"Image {} is not unused and in the 'version' context",
image_id
"Image {image_id} is not unused and in the 'version' context"
)));
}
@@ -506,8 +505,7 @@ async fn version_create_inner(
image_item::Image::clear_cache(image.id.into(), redis).await?;
} else {
return Err(CreateError::InvalidInput(format!(
"Image {} does not exist",
image_id
"Image {image_id} does not exist"
)));
}
}
@@ -810,7 +808,7 @@ pub async fn upload_file(
) -> Result<(), CreateError> {
let (file_name, file_extension) = get_name_ext(content_disposition)?;
if other_file_names.contains(&format!("{}.{}", file_name, file_extension)) {
if other_file_names.contains(&format!("{file_name}.{file_extension}")) {
return Err(CreateError::InvalidInput(
"Duplicate files are not allowed to be uploaded to Modrinth!"
.to_string(),

View File

@@ -67,5 +67,5 @@ pub async fn push_back_user_expiry(
}
fn get_field_name(user: UserId) -> String {
format!("user_status:{}", user)
format!("user_status:{user}")
}

View File

@@ -34,7 +34,7 @@ impl AppendsMultipart for TestRequest {
let (boundary, payload) = generate_multipart(data);
self.append_header((
"Content-Type",
format!("multipart/form-data; boundary={}", boundary),
format!("multipart/form-data; boundary={boundary}"),
))
.set_payload(payload)
}
@@ -62,17 +62,12 @@ pub fn generate_multipart(
if let Some(filename) = &segment.filename {
payload.extend_from_slice(
format!("; filename=\"{filename}\"", filename = filename)
.as_bytes(),
format!("; filename=\"{filename}\"").as_bytes(),
);
}
if let Some(content_type) = &segment.content_type {
payload.extend_from_slice(
format!(
"\r\nContent-Type: {content_type}",
content_type = content_type
)
.as_bytes(),
format!("\r\nContent-Type: {content_type}").as_bytes(),
);
}
payload.extend_from_slice(b"\r\n\r\n");
@@ -87,9 +82,7 @@ pub fn generate_multipart(
}
payload.extend_from_slice(b"\r\n");
}
payload.extend_from_slice(
format!("--{boundary}--\r\n", boundary = boundary).as_bytes(),
);
payload.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
(boundary, Bytes::from(payload))
}

View File

@@ -51,8 +51,7 @@ pub async fn upload_image_optimized(
let content_type = crate::util::ext::get_image_content_type(file_extension)
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Invalid format for image: {}",
file_extension
"Invalid format for image: {file_extension}"
))
})?;
@@ -91,7 +90,7 @@ pub async fn upload_image_optimized(
let upload_data = file_host
.upload_file(
content_type,
&format!("{}/{}.{}", upload_folder, hash, file_extension),
&format!("{upload_folder}/{hash}.{file_extension}"),
bytes,
)
.await?;

View File

@@ -71,7 +71,7 @@ async fn get_webhook_metadata(
url: format!(
"{}/organization/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
organization.slug
to_base62(organization.id.0 as u64)
),
icon_url: organization.icon_url,
});
@@ -97,7 +97,7 @@ async fn get_webhook_metadata(
url: format!(
"{}/user/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
user.username
to_base62(user.id.0 as u64)
),
name: user.username,
icon_url: user.avatar_url,
@@ -145,11 +145,7 @@ async fn get_webhook_metadata(
"{}/{}/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
project_type,
project
.inner
.slug
.clone()
.unwrap_or_else(|| to_base62(project.inner.id.0 as u64))
to_base62(project.inner.id.0 as u64)
),
project_title: project.inner.name,
project_summary: project.inner.summary,

View File

@@ -71,7 +71,7 @@ impl ApiV2 {
};
let req = test::TestRequest::get()
.uri(&format!("/v2/search?{}{}", query_field, facets_field))
.uri(&format!("/v2/search?{query_field}{facets_field}"))
.append_pat(pat)
.to_request();
let resp = self.call(req).await;
@@ -99,7 +99,7 @@ impl ApiProject for ApiV2 {
// Approve as a moderator.
let req = TestRequest::patch()
.uri(&format!("/v2/project/{}", slug))
.uri(&format!("/v2/project/{slug}"))
.append_pat(MOD_USER_PAT)
.set_json(json!(
{
@@ -114,7 +114,7 @@ impl ApiProject for ApiV2 {
// Get project's versions
let req = TestRequest::get()
.uri(&format!("/v2/project/{}/version", slug))
.uri(&format!("/v2/project/{slug}/version"))
.append_pat(pat)
.to_request();
let resp = self.call(req).await;
@@ -217,7 +217,7 @@ impl ApiProject for ApiV2 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::get()
.uri(&format!("/v2/user/{}/projects", user_id_or_username))
.uri(&format!("/v2/user/{user_id_or_username}/projects"))
.append_pat(pat)
.to_request();
self.call(req).await
@@ -260,7 +260,7 @@ impl ApiProject for ApiV2 {
) -> ServiceResponse {
let projects_str = ids_or_slugs
.iter()
.map(|s| format!("\"{}\"", s))
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(",");
let req = test::TestRequest::patch()
@@ -490,13 +490,13 @@ impl ApiProject for ApiV2 {
featured = featured
);
if let Some(title) = title {
url.push_str(&format!("&title={}", title));
url.push_str(&format!("&title={title}"));
}
if let Some(description) = description {
url.push_str(&format!("&description={}", description));
url.push_str(&format!("&description={description}"));
}
if let Some(ordering) = ordering {
url.push_str(&format!("&ordering={}", ordering));
url.push_str(&format!("&ordering={ordering}"));
}
let req = test::TestRequest::post()
@@ -542,10 +542,7 @@ impl ApiProject for ApiV2 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::delete()
.uri(&format!(
"/v2/project/{id_or_slug}/gallery?url={url}",
url = url
))
.uri(&format!("/v2/project/{id_or_slug}/gallery?url={url}"))
.append_pat(pat)
.to_request();

View File

@@ -11,7 +11,7 @@ impl ApiUser for ApiV2 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::get()
.uri(&format!("/v2/user/{}", user_id_or_username))
.uri(&format!("/v2/user/{user_id_or_username}"))
.append_pat(pat)
.to_request();
self.call(req).await
@@ -32,7 +32,7 @@ impl ApiUser for ApiV2 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::patch()
.uri(&format!("/v2/user/{}", user_id_or_username))
.uri(&format!("/v2/user/{user_id_or_username}"))
.append_pat(pat)
.set_json(patch)
.to_request();
@@ -46,7 +46,7 @@ impl ApiUser for ApiV2 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::delete()
.uri(&format!("/v2/user/{}", user_id_or_username))
.uri(&format!("/v2/user/{user_id_or_username}"))
.append_pat(pat)
.to_request();

View File

@@ -399,18 +399,18 @@ impl ApiVersion for ApiV2 {
));
}
if let Some(featured) = featured {
query_string.push_str(&format!("&featured={}", featured));
query_string.push_str(&format!("&featured={featured}"));
}
if let Some(version_type) = version_type {
query_string.push_str(&format!("&version_type={}", version_type));
query_string.push_str(&format!("&version_type={version_type}"));
}
if let Some(limit) = limit {
let limit = limit.to_string();
query_string.push_str(&format!("&limit={}", limit));
query_string.push_str(&format!("&limit={limit}"));
}
if let Some(offset) = offset {
let offset = offset.to_string();
query_string.push_str(&format!("&offset={}", offset));
query_string.push_str(&format!("&offset={offset}"));
}
let req = test::TestRequest::get()
@@ -480,7 +480,7 @@ impl ApiVersion for ApiV2 {
) -> ServiceResponse {
let ids = url_encode_json_serialized_vec(&version_ids);
let request = test::TestRequest::get()
.uri(&format!("/v2/versions?ids={}", ids))
.uri(&format!("/v2/versions?ids={ids}"))
.append_pat(pat)
.to_request();
self.call(request).await

View File

@@ -157,7 +157,7 @@ impl ApiV3 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::get()
.uri(&format!("/v3/user/{}/collections", user_id_or_username))
.uri(&format!("/v3/user/{user_id_or_username}/collections"))
.append_pat(pat)
.to_request();
self.call(req).await

View File

@@ -104,8 +104,7 @@ impl ApiV3 {
code: auth_code,
redirect_uri: original_redirect_uri,
client_id: serde_json::from_str(&format!(
"\"{}\"",
client_id
"\"{client_id}\""
))
.unwrap(),
})

View File

@@ -47,7 +47,7 @@ impl ApiV3 {
pat: Option<&str>,
) -> Vec<OAuthClient> {
let req = TestRequest::get()
.uri(&format!("/v3/user/{}/oauth_apps", user_id))
.uri(&format!("/v3/user/{user_id}/oauth_apps"))
.append_pat(pat)
.to_request();
let resp = self.call(req).await;
@@ -62,7 +62,7 @@ impl ApiV3 {
pat: Option<&str>,
) -> ServiceResponse {
let req = TestRequest::get()
.uri(&format!("/_internal/oauth/app/{}", client_id))
.uri(&format!("/_internal/oauth/app/{client_id}"))
.append_pat(pat)
.to_request();
@@ -93,7 +93,7 @@ impl ApiV3 {
pat: Option<&str>,
) -> ServiceResponse {
let req = TestRequest::delete()
.uri(&format!("/_internal/oauth/app/{}", client_id))
.uri(&format!("/_internal/oauth/app/{client_id}"))
.append_pat(pat)
.to_request();

View File

@@ -53,7 +53,7 @@ impl ApiProject for ApiV3 {
// Approve as a moderator.
let req = TestRequest::patch()
.uri(&format!("/v3/project/{}", slug))
.uri(&format!("/v3/project/{slug}"))
.append_pat(MOD_USER_PAT)
.set_json(json!(
{
@@ -69,7 +69,7 @@ impl ApiProject for ApiV3 {
// Get project's versions
let req = TestRequest::get()
.uri(&format!("/v3/project/{}/version", slug))
.uri(&format!("/v3/project/{slug}/version"))
.append_pat(pat)
.to_request();
let resp = self.call(req).await;
@@ -172,7 +172,7 @@ impl ApiProject for ApiV3 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::get()
.uri(&format!("/v3/user/{}/projects", user_id_or_username))
.uri(&format!("/v3/user/{user_id_or_username}/projects"))
.append_pat(pat)
.to_request();
self.call(req).await
@@ -215,7 +215,7 @@ impl ApiProject for ApiV3 {
) -> ServiceResponse {
let projects_str = ids_or_slugs
.iter()
.map(|s| format!("\"{}\"", s))
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(",");
let req = test::TestRequest::patch()
@@ -363,13 +363,13 @@ impl ApiProject for ApiV3 {
featured = featured
);
if let Some(title) = title {
url.push_str(&format!("&title={}", title));
url.push_str(&format!("&title={title}"));
}
if let Some(description) = description {
url.push_str(&format!("&description={}", description));
url.push_str(&format!("&description={description}"));
}
if let Some(ordering) = ordering {
url.push_str(&format!("&ordering={}", ordering));
url.push_str(&format!("&ordering={ordering}"));
}
let req = test::TestRequest::post()
@@ -416,10 +416,7 @@ impl ApiProject for ApiV3 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::delete()
.uri(&format!(
"/v3/project/{id_or_slug}/gallery?url={url}",
url = url
))
.uri(&format!("/v3/project/{id_or_slug}/gallery?url={url}"))
.append_pat(pat)
.to_request();
@@ -562,7 +559,7 @@ impl ApiV3 {
};
let req = test::TestRequest::get()
.uri(&format!("/v3/search?{}{}", query_field, facets_field))
.uri(&format!("/v3/search?{query_field}{facets_field}"))
.append_pat(pat)
.to_request();
let resp = self.call(req).await;
@@ -583,12 +580,12 @@ impl ApiV3 {
let version_string: String =
serde_json::to_string(&id_or_slugs).unwrap();
let version_string = urlencoding::encode(&version_string);
format!("version_ids={}", version_string)
format!("version_ids={version_string}")
} else {
let projects_string: String =
serde_json::to_string(&id_or_slugs).unwrap();
let projects_string = urlencoding::encode(&projects_string);
format!("project_ids={}", projects_string)
format!("project_ids={projects_string}")
};
let mut extra_args = String::new();
@@ -605,10 +602,8 @@ impl ApiV3 {
extra_args.push_str(&format!("&end_date={end_date}"));
}
if let Some(resolution_minutes) = resolution_minutes {
extra_args.push_str(&format!(
"&resolution_minutes={}",
resolution_minutes
));
extra_args
.push_str(&format!("&resolution_minutes={resolution_minutes}"));
}
let req = test::TestRequest::get()

View File

@@ -76,7 +76,7 @@ impl ApiV3 {
loader_field: &str,
) -> ServiceResponse {
let req = TestRequest::get()
.uri(&format!("/v3/loader_field?loader_field={}", loader_field))
.uri(&format!("/v3/loader_field?loader_field={loader_field}"))
.append_pat(ADMIN_USER_PAT)
.to_request();
self.call(req).await

View File

@@ -13,7 +13,7 @@ impl ApiUser for ApiV3 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::get()
.uri(&format!("/v3/user/{}", user_id_or_username))
.uri(&format!("/v3/user/{user_id_or_username}"))
.append_pat(pat)
.to_request();
self.call(req).await
@@ -34,7 +34,7 @@ impl ApiUser for ApiV3 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::patch()
.uri(&format!("/v3/user/{}", user_id_or_username))
.uri(&format!("/v3/user/{user_id_or_username}"))
.append_pat(pat)
.set_json(patch)
.to_request();
@@ -48,7 +48,7 @@ impl ApiUser for ApiV3 {
pat: Option<&str>,
) -> ServiceResponse {
let req = test::TestRequest::delete()
.uri(&format!("/v3/user/{}", user_id_or_username))
.uri(&format!("/v3/user/{user_id_or_username}"))
.append_pat(pat)
.to_request();
self.call(req).await

View File

@@ -432,18 +432,18 @@ impl ApiVersion for ApiV3 {
));
}
if let Some(featured) = featured {
query_string.push_str(&format!("&featured={}", featured));
query_string.push_str(&format!("&featured={featured}"));
}
if let Some(version_type) = version_type {
query_string.push_str(&format!("&version_type={}", version_type));
query_string.push_str(&format!("&version_type={version_type}"));
}
if let Some(limit) = limit {
let limit = limit.to_string();
query_string.push_str(&format!("&limit={}", limit));
query_string.push_str(&format!("&limit={limit}"));
}
if let Some(offset) = offset {
let offset = offset.to_string();
query_string.push_str(&format!("&offset={}", offset));
query_string.push_str(&format!("&offset={offset}"));
}
let req = test::TestRequest::get()
@@ -513,7 +513,7 @@ impl ApiVersion for ApiV3 {
) -> ServiceResponse {
let ids = url_encode_json_serialized_vec(&version_ids);
let request = test::TestRequest::get()
.uri(&format!("/v3/versions?ids={}", ids))
.uri(&format!("/v3/versions?ids={ids}"))
.append_pat(pat)
.to_request();
self.call(request).await
@@ -546,10 +546,7 @@ impl ApiVersion for ApiV3 {
Some(file),
);
let request = test::TestRequest::post()
.uri(&format!(
"/v3/version/{version_id}/file",
version_id = version_id
))
.uri(&format!("/v3/version/{version_id}/file"))
.append_pat(pat)
.set_multipart(m)
.to_request();
@@ -562,10 +559,7 @@ impl ApiVersion for ApiV3 {
pat: Option<&str>,
) -> ServiceResponse {
let request = test::TestRequest::delete()
.uri(&format!(
"/v3/version/{version_id}",
version_id = version_id
))
.uri(&format!("/v3/version/{version_id}"))
.append_pat(pat)
.to_request();
self.call(request).await

View File

@@ -137,7 +137,7 @@ impl TemporaryDatabase {
dotenvy::var("DATABASE_URL").expect("No database URL");
let mut template_url =
Url::parse(&url).expect("Invalid database URL");
template_url.set_path(&format!("/{}", TEMPLATE_DATABASE_NAME));
template_url.set_path(&format!("/{TEMPLATE_DATABASE_NAME}"));
let pool = PgPool::connect(template_url.as_str())
.await

View File

@@ -470,8 +470,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != ProjectPermissions::empty() {
return Err(format!(
"Test 1 failed. Expected no permissions, got {:?}",
p
"Test 1 failed. Expected no permissions, got {p:?}"
));
}
@@ -511,8 +510,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != ProjectPermissions::empty() {
return Err(format!(
"Test 2 failed. Expected no permissions, got {:?}",
p
"Test 2 failed. Expected no permissions, got {p:?}"
));
}
@@ -561,8 +559,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != failure_project_permissions {
return Err(format!(
"Test 3 failed. Expected {:?}, got {:?}",
failure_project_permissions, p
"Test 3 failed. Expected {failure_project_permissions:?}, got {p:?}"
));
}
@@ -607,8 +604,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != success_permissions {
return Err(format!(
"Test 4 failed. Expected {:?}, got {:?}",
success_permissions, p
"Test 4 failed. Expected {success_permissions:?}, got {p:?}"
));
}
@@ -666,8 +662,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != failure_project_permissions {
return Err(format!(
"Test 5 failed. Expected {:?}, got {:?}",
failure_project_permissions, p
"Test 5 failed. Expected {failure_project_permissions:?}, got {p:?}"
));
}
@@ -721,8 +716,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != success_permissions {
return Err(format!(
"Test 6 failed. Expected {:?}, got {:?}",
success_permissions, p
"Test 6 failed. Expected {success_permissions:?}, got {p:?}"
));
}
@@ -790,8 +784,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != failure_project_permissions {
return Err(format!(
"Test 7 failed. Expected {:?}, got {:?}",
failure_project_permissions, p
"Test 7 failed. Expected {failure_project_permissions:?}, got {p:?}"
));
}
@@ -856,8 +849,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != success_permissions {
return Err(format!(
"Test 8 failed. Expected {:?}, got {:?}",
success_permissions, p
"Test 8 failed. Expected {success_permissions:?}, got {p:?}"
));
}
@@ -927,8 +919,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != OrganizationPermissions::empty() {
return Err(format!(
"Test 1 failed. Expected no permissions, got {:?}",
p
"Test 1 failed. Expected no permissions, got {p:?}"
));
}
Ok(())
@@ -976,8 +967,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != failure_organization_permissions {
return Err(format!(
"Test 2 failed. Expected {:?}, got {:?}",
failure_organization_permissions, p
"Test 2 failed. Expected {failure_organization_permissions:?}, got {p:?}"
));
}
Ok(())
@@ -1021,8 +1011,7 @@ impl<'a, A: Api> PermissionsTest<'a, A> {
.await;
if p != success_permissions {
return Err(format!(
"Test 3 failed. Expected {:?}, got {:?}",
success_permissions, p
"Test 3 failed. Expected {success_permissions:?}, got {p:?}"
));
}
Ok(())

View File

@@ -487,7 +487,7 @@ async fn test_multi_get_redis_cache() {
// Create 5 modpacks
let mut modpacks = Vec::new();
for i in 0..5 {
let slug = format!("test-modpack-{}", i);
let slug = format!("test-modpack-{i}");
let creation_data = get_public_project_creation_data(
&slug,
@@ -503,7 +503,7 @@ async fn test_multi_get_redis_cache() {
// Create 5 mods
let mut mods = Vec::new();
for i in 0..5 {
let slug = format!("test-mod-{}", i);
let slug = format!("test-mod-{i}");
let creation_data = get_public_project_creation_data(
&slug,

View File

@@ -28,7 +28,7 @@ async fn oauth_flow_happy_path() {
} = &env.dummy.oauth_client_alpha;
// Initiate authorization
let redirect_uri = format!("{}?foo=bar", base_redirect_uri);
let redirect_uri = format!("{base_redirect_uri}?foo=bar");
let original_state = "1234";
let resp = env
.api

View File

@@ -81,7 +81,7 @@ pub async fn pat_full_test() {
// Change scopes and test again
let req = test::TestRequest::patch()
.uri(&format!("/_internal/pat/{}", id))
.uri(&format!("/_internal/pat/{id}"))
.append_pat(USER_USER_PAT)
.set_json(json!({
"scopes": 0,
@@ -93,7 +93,7 @@ pub async fn pat_full_test() {
// Change scopes back, and set expiry to the past, and test again
let req = test::TestRequest::patch()
.uri(&format!("/_internal/pat/{}", id))
.uri(&format!("/_internal/pat/{id}"))
.append_pat(USER_USER_PAT)
.set_json(json!({
"scopes": Scopes::COLLECTION_CREATE,
@@ -109,21 +109,21 @@ pub async fn pat_full_test() {
// Change everything back to normal and test again
let req = test::TestRequest::patch()
.uri(&format!("/_internal/pat/{}", id))
.uri(&format!("/_internal/pat/{id}"))
.append_pat(USER_USER_PAT)
.set_json(json!({
"expires": Utc::now() + Duration::days(1), // no longer expired!
}))
.to_request();
println!("PAT ID FOR TEST: {}", id);
println!("PAT ID FOR TEST: {id}");
let resp = test_env.call(req).await;
assert_status!(&resp, StatusCode::NO_CONTENT);
assert_eq!(mock_pat_test(access_token).await, 200); // Works again
// Patching to a bad expiry should fail
let req = test::TestRequest::patch()
.uri(&format!("/_internal/pat/{}", id))
.uri(&format!("/_internal/pat/{id}"))
.append_pat(USER_USER_PAT)
.set_json(json!({
"expires": Utc::now() - Duration::days(1), // Past
@@ -140,7 +140,7 @@ pub async fn pat_full_test() {
}
let req = test::TestRequest::patch()
.uri(&format!("/_internal/pat/{}", id))
.uri(&format!("/_internal/pat/{id}"))
.append_pat(USER_USER_PAT)
.set_json(json!({
"scopes": scope.bits(),
@@ -156,7 +156,7 @@ pub async fn pat_full_test() {
// Delete PAT
let req = test::TestRequest::delete()
.append_pat(USER_USER_PAT)
.uri(&format!("/_internal/pat/{}", id))
.uri(&format!("/_internal/pat/{id}"))
.to_request();
let resp = test_env.call(req).await;
assert_status!(&resp, StatusCode::NO_CONTENT);
@@ -260,7 +260,7 @@ pub async fn bad_pats() {
// Patching to a bad expiry should fail
let req = test::TestRequest::patch()
.uri(&format!("/_internal/pat/{}", id))
.uri(&format!("/_internal/pat/{id}"))
.append_pat(USER_USER_PAT)
.set_json(json!({
"expires": Utc::now() - Duration::days(1), // Past
@@ -277,7 +277,7 @@ pub async fn bad_pats() {
}
let req = test::TestRequest::patch()
.uri(&format!("/_internal/pat/{}", id))
.uri(&format!("/_internal/pat/{id}"))
.append_pat(USER_USER_PAT)
.set_json(json!({
"scopes": scope.bits(),

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