Compare commits

..

34 Commits

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

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ jobs:
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install
- name: 🧰 Setup cargo-sqlx
uses: taiki-e/cache-cargo-install-action@v2
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
with:
tool: sqlx-cli
locked: false

3
.idea/code.iml generated
View File

@@ -10,10 +10,11 @@
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
</module>

View File

@@ -4,7 +4,6 @@
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.detectIndentation": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "always",
"source.fixAll.eslint": "explicit"
}
}

46
Cargo.lock generated
View File

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

View File

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

View File

@@ -1,35 +1,15 @@
<script setup>
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { useFetch } from '@/helpers/fetch.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
import { useError } from '@/store/error.js'
import { useInstall } from '@/store/install.js'
import { useLoading, useTheming } from '@/store/state'
import {
computed,
onMounted,
onUnmounted,
ref,
watch,
useTemplateRef,
provide,
nextTick,
} from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
@@ -42,44 +22,69 @@ import {
LogOutIcon,
MaximizeIcon,
MinimizeIcon,
NewspaperIcon,
PlusIcon,
RestoreIcon,
RightArrowIcon,
SettingsIcon,
WorldIcon,
XIcon,
NewspaperIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
ButtonStyled,
NewsArticleCard,
NotificationPanel,
Notifications,
OverflowMenu,
provideNotificationManager,
NewsArticleCard,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { openUrl } from '@tauri-apps/plugin-opener'
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 as getSettings, set as setSettings } 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'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import { handleError, useNotifications } from '@/store/notifications.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { type } from '@tauri-apps/plugin-os'
import { check } from '@tauri-apps/plugin-updater'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { areUpdatesEnabled, getOS, isDev } from '@/helpers/utils.js'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { getVersion } from '@tauri-apps/api/app'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { create_profile_and_install_from_file } from './helpers/pack'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { useError } from '@/store/error.js'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import { useInstall } from '@/store/install.js'
import { invoke } from '@tauri-apps/api/core'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js'
import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import UpdateModal from '@/components/ui/UpdateModal.vue'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { createTooltip, destroyTooltip } from 'floating-vue'
const themeStore = useTheming()
const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
const { handleError, addNotification } = notificationManager
const news = ref([])
const urlModal = ref(null)
@@ -115,6 +120,18 @@ onUnmounted(() => {
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
updateInstalledToastTitle: {
id: 'app.update.complete-toast.title',
defaultMessage: 'Version {version} was successfully installed!',
},
updateInstalledToastText: {
id: 'app.update.complete-toast.text',
defaultMessage: 'Click here to view the changelog.',
},
})
async function setupApp() {
stateInitialized.value = true
const {
@@ -128,7 +145,8 @@ async function setupApp() {
toggle_sidebar,
developer_mode,
feature_flags,
} = await get()
pending_update_toast_for_version,
} = await getSettings()
if (default_page === 'Library') {
await router.push('/library')
@@ -172,7 +190,7 @@ async function setupApp() {
}
await warning_listener((e) =>
addNotification({
notificationsWrapper.value.addNotification({
title: 'Warning',
text: e.message,
type: 'warn',
@@ -215,7 +233,6 @@ async function setupApp() {
})
get_opening_command().then(handleCommand)
checkUpdates()
fetchCredentials()
try {
@@ -225,6 +242,22 @@ async function setupApp() {
} catch (error) {
console.warn('Failed to generate skin previews in app setup.', error)
}
if (pending_update_toast_for_version !== null) {
const settings = await getSettings()
settings.pending_update_toast_for_version = null
await setSettings(settings)
const version = await getVersion()
if (pending_update_toast_for_version === version) {
notifications.addNotification({
type: 'success',
title: formatMessage(messages.updateInstalledToastTitle, { version }),
text: formatMessage(messages.updateInstalledToastText),
clickAction: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
})
}
}
}
const stateFailed = ref(false)
@@ -256,6 +289,9 @@ const route = useRoute()
const loading = useLoading()
loading.setEnabled(false)
const notifications = useNotifications()
const notificationsWrapper = ref()
const error = useError()
const errorModal = ref()
@@ -266,8 +302,6 @@ const incompatibilityWarningModal = ref()
const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
@@ -277,24 +311,8 @@ async function fetchCredentials() {
}
async function signIn() {
modrinthLoginFlowWaitModal.value.show()
try {
await login()
await fetchCredentials()
} catch (error) {
if (
typeof error === 'object' &&
typeof error['message'] === 'string' &&
error.message.includes('Login canceled')
) {
// Not really an error due to being a result of user interaction, show nothing
} else {
handleError(error)
}
} finally {
modrinthLoginFlowWaitModal.value.hide()
}
await login().catch(handleError)
await fetchCredentials()
}
async function logOut() {
@@ -337,6 +355,8 @@ watch(
onMounted(() => {
invoke('show_window')
notifications.setNotifs(notificationsWrapper.value)
error.setErrorModal(errorModal.value)
install.setIncompatibilityWarningModal(incompatibilityWarningModal)
@@ -365,19 +385,95 @@ async function handleCommand(e) {
}
}
const updateAvailable = ref(false)
const availableUpdate = ref(null)
const updateSkipped = ref(false)
const enqueuedUpdate = ref(null)
const updateModal = useTemplateRef('updateModal')
async function checkUpdates() {
const update = await check()
updateAvailable.value = !!update
if (!(await areUpdatesEnabled())) {
console.log('Skipping update check as updates are disabled in this build')
return
}
async function performCheck() {
if (updateModal.value.isOpen) {
console.log('Skipping update check because the update modal is already open')
return
}
const update = await invoke('plugin:updater|check')
if (!update) {
return
}
console.log(`Update ${update.version} is available.`)
if (update.version === availableUpdate.value?.version) {
console.log(
'Skipping update modal because the new version is the same as the dismissed update',
)
return
}
availableUpdate.value = update
const settings = await getSettings()
if (settings.skipped_update === update.version) {
updateSkipped.value = true
console.log('Skipping update modal because the user chose to skip this update')
return
}
updateSkipped.value = false
updateModal.value.show(update)
}
await performCheck()
setTimeout(
() => {
checkUpdates()
},
5 * 1000 * 60,
5 * 60 * 1000,
)
}
async function skipUpdate(version) {
enqueuedUpdate.value = null
updateSkipped.value = true
const settings = await getSettings()
settings.skipped_update = version
await setSettings(settings)
}
async function updateEnqueuedForLater(version) {
enqueuedUpdate.value = version
}
async function forceOpenUpdateModal() {
if (updateSkipped.value) {
updateSkipped.value = false
const settings = await getSettings()
settings.skipped_update = null
await setSettings(settings)
}
updateModal.value.show(availableUpdate.value)
}
const updateButton = useTemplateRef('updateButton')
async function showUpdateButtonTooltip() {
await nextTick()
const tooltip = createTooltip(updateButton.value.$el, {
placement: 'right',
content: 'Click here to view the update again.',
})
tooltip.show()
setTimeout(() => {
tooltip.hide()
destroyTooltip(updateButton.value.$el)
}, 3500)
}
function handleClick(e) {
let target = e.target
while (target != null) {
@@ -418,11 +514,16 @@ function handleAuxClick(e) {
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<div id="teleports"></div>
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
<Suspense>
<AppSettingsModal ref="settingsModal" />
<Suspense @resolve="checkUpdates">
<UpdateModal
ref="updateModal"
@update-skipped="skipUpdate"
@update-enqueued-for-later="updateEnqueuedForLater"
@modal-hidden="showUpdateButtonTooltip"
/>
</Suspense>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
<AppSettingsModal ref="settingsModal" />
</Suspense>
<Suspense>
<InstanceCreationModal ref="installationModal" />
@@ -471,8 +572,18 @@ function handleAuxClick(e) {
<PlusIcon />
</NavButton>
<div class="flex flex-grow"></div>
<NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
<DownloadIcon />
<NavButton
v-if="!!availableUpdate"
ref="updateButton"
v-tooltip.right="
enqueuedUpdate === availableUpdate?.version
? 'Update installation queued for next restart'
: 'Update available'
"
:to="forceOpenUpdateModal"
>
<DownloadIcon v-if="updateSkipped || enqueuedUpdate === availableUpdate?.version" />
<DownloadIcon v-else class="text-brand-green" />
</NavButton>
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
<SettingsIcon />
@@ -657,7 +768,7 @@ function handleAuxClick(e) {
</div>
</div>
<URLConfirmModal ref="urlModal" />
<NotificationPanel has-sidebar />
<Notifications ref="notificationsWrapper" sidebar />
<ErrorModal ref="errorModal" />
<ModInstallModal ref="modInstallModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />

View File

@@ -1,25 +1,24 @@
<script setup>
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { duplicate, remove } from '@/helpers/profile.js'
import { computed, ref } from 'vue'
import {
ClipboardCopyIcon,
EyeIcon,
FolderOpenIcon,
PlayIcon,
PlusIcon,
SearchIcon,
StopCircleIcon,
TrashIcon,
StopCircleIcon,
EyeIcon,
SearchIcon,
XIcon,
} from '@modrinth/assets'
import { Button, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import { Button, DropdownSelect } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
const { handleError } = injectNotificationManager()
import { duplicate, remove } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
const props = defineProps({
instances: {

View File

@@ -1,32 +1,31 @@
<script setup>
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ProjectCard from '@/components/ui/ProjectCard.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_by_profile_path } from '@/helpers/process.js'
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { install as installVersion } from '@/store/install.js'
import {
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
TrashIcon,
DownloadIcon,
GlobeIcon,
StopCircleIcon,
ExternalIcon,
EyeIcon,
} from '@modrinth/assets'
import { HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import Instance from '@/components/ui/Instance.vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ProjectCard from '@/components/ui/ProjectCard.vue'
import { get_by_profile_path } from '@/helpers/process.js'
import { handleError } from '@/store/notifications.js'
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager()
import { showProfileInFolder } from '@/helpers/utils.js'
import { trackEvent } from '@/helpers/analytics'
import { handleSevereError } from '@/store/error.js'
import { install as installVersion } from '@/store/install.js'
import { openUrl } from '@tauri-apps/plugin-opener'
import { HeadingLink } from '@modrinth/ui'
const router = useRouter()

View File

@@ -73,23 +73,22 @@
</template>
<script setup>
import { trackEvent } from '@/helpers/analytics'
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {
get_default_user,
login as login_flow,
users,
remove_user,
set_default_user,
users,
login as login_flow,
get_default_user,
} from '@/helpers/auth'
import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js'
import { DropdownIcon, LogInIcon, PlusIcon, SpinnerIcon, TrashIcon } from '@modrinth/assets'
import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue'
const { handleError } = injectNotificationManager()
import { get_available_skins } from '@/helpers/skins'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
defineProps({
mode: {

View File

@@ -1,12 +1,11 @@
<script setup lang="ts">
import { add_project_from_path } from '@/helpers/profile.js'
import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, OverflowMenu } from '@modrinth/ui'
import { DropdownIcon, PlusIcon, FolderOpenIcon } from '@modrinth/assets'
import { ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { add_project_from_path } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager()
const props = defineProps({
instance: {
type: Object,

View File

@@ -1,24 +1,23 @@
<script setup>
import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install } from '@/helpers/profile.js'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { handleSevereError } from '@/store/error.js'
import {
CheckIcon,
CopyIcon,
DropdownIcon,
XIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
XIcon,
CopyIcon,
} from '@modrinth/assets'
import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui'
import { computed, ref } from 'vue'
const { handleError } = injectNotificationManager()
import { ChatIcon } from '@/assets/icons'
import { ButtonStyled, Collapsible } from '@modrinth/ui'
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.ts'
import { install } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const errorModal = ref()
const error = ref()

View File

@@ -1,13 +1,12 @@
<script setup>
import { XIcon, PlusIcon } from '@modrinth/assets'
import { Button, Checkbox } from '@modrinth/ui'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
import { PlusIcon, XIcon } from '@modrinth/assets'
import { Button, Checkbox, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
import { open } from '@tauri-apps/plugin-dialog'
import { handleError } from '@/store/notifications.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const props = defineProps({
instance: {

View File

@@ -1,10 +1,6 @@
<script setup>
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, kill, run } from '@/helpers/profile'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
DownloadIcon,
GameIcon,
@@ -13,13 +9,17 @@ import {
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { finish_install, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener } from '@/helpers/events'
import { handleError } from '@/store/state.js'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const props = defineProps({

View File

@@ -198,17 +198,6 @@
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { trackEvent } from '@/helpers/analytics'
import {
get_default_launcher_path,
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
import { create } from '@/helpers/profile'
import { get_loaders } from '@/helpers/tags'
import {
CodeIcon,
FolderOpenIcon,
@@ -219,14 +208,24 @@ import {
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { Avatar, Button, Checkbox, Chips, injectNotificationManager } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog'
import { Avatar, Button, Checkbox, Chips } from '@modrinth/ui'
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import { get_loaders } from '@/helpers/tags'
import { create } from '@/helpers/profile'
import { open } from '@tauri-apps/plugin-dialog'
import { convertFileSrc } from '@tauri-apps/api/core'
import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
import { handleError } from '@/store/notifications.js'
import Multiselect from 'vue-multiselect'
const { handleError } = injectNotificationManager()
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
import {
get_default_launcher_path,
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { getCurrentWebview } from '@tauri-apps/api/webview'
const profile_name = ref('')
const game_version = ref('')
@@ -306,16 +305,12 @@ const [
get_game_versions().then(shallowRef).catch(handleError),
get_loaders()
.then((value) =>
ref(
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
),
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
)
.catch((err) => {
handleError(err)
return ref([])
}),
.then(ref)
.catch(handleError),
])
loaders.value.unshift('vanilla')

View File

@@ -35,14 +35,13 @@
</ModalWrapper>
</template>
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { find_filtered_jres } from '@/helpers/jre.js'
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { PlusIcon, CheckIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
import { find_filtered_jres } from '@/helpers/jre.js'
import { handleError } from '@/store/notifications.js'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const chosenInstallOptions = ref([])
const detectJavaModal = ref(null)

View File

@@ -52,22 +52,21 @@
</template>
<script setup>
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
import {
CheckIcon,
DownloadIcon,
FolderSearchIcon,
PlayIcon,
SearchIcon,
PlayIcon,
CheckIcon,
XIcon,
FolderSearchIcon,
DownloadIcon,
} from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { Button } from '@modrinth/ui'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
import { open } from '@tauri-apps/plugin-dialog'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics'
const props = defineProps({
version: {

View File

@@ -1,6 +1,12 @@
<template>
<div class="progress-bar">
<div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
<div
class="progress-bar__fill"
:style="{
width: `${progress}%`,
'background-color': error ? 'var(--color-red)' : 'var(--color-brand)',
}"
></div>
</div>
</template>
@@ -13,6 +19,10 @@ defineProps({
return value >= 0 && value <= 100
},
},
error: {
type: Boolean,
default: false,
},
})
</script>
@@ -27,7 +37,6 @@ defineProps({
.progress-bar__fill {
height: 100%;
background-color: var(--color-brand);
transition: width 0.3s;
}
</style>

View File

@@ -21,11 +21,14 @@ const props = defineProps({
})
const featuredCategory = computed(() => {
if (props.project.display_categories.includes('optimization')) {
if (props.project.categories.includes('optimization')) {
return 'optimization'
}
return props.project.display_categories[0] ?? props.project.categories[0]
if (props.project.categories.length > 0) {
return props.project.categories[0]
}
return undefined
})
const toColor = computed(() => {

View File

@@ -1,14 +1,13 @@
<script setup>
import NavButton from '@/components/ui/NavButton.vue'
import { profile_listener } from '@/helpers/events.js'
import { list } from '@/helpers/profile'
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, injectNotificationManager } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { handleError } from '@/store/notifications'
import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue'
const { handleError } = injectNotificationManager()
import { profile_listener } from '@/helpers/events.js'
import NavButton from '@/components/ui/NavButton.vue'
import { Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { SpinnerIcon } from '@modrinth/assets'
const recentInstances = ref([])
const getInstances = async () => {

View File

@@ -94,24 +94,23 @@
</template>
<script setup>
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import { get_many } from '@/helpers/profile.js'
import { progress_bars_list } from '@/helpers/state.js'
import {
DownloadIcon,
DropdownIcon,
StopCircleIcon,
TerminalSquareIcon,
DropdownIcon,
UnplugIcon,
} from '@modrinth/assets'
import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui'
import { Button, ButtonStyled, Card } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import { loading_listener, process_listener } from '@/helpers/events'
import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager()
import { progress_bars_list } from '@/helpers/state.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { handleError } from '@/store/notifications.js'
import { get_many } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics'
const router = useRouter()
const card = ref(null)

View File

@@ -1,13 +1,12 @@
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_project, get_version } from '@/helpers/cache.js'
import { get_categories } from '@/helpers/tags.js'
import { install as installVersion } from '@/store/install.js'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { Button } from '@modrinth/ui'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_categories } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js'
import { get_version, get_project } from '@/helpers/cache.js'
import { install as installVersion } from '@/store/install.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const confirmModal = ref(null)
const project = ref(null)

View File

@@ -0,0 +1,303 @@
<template>
<ModalWrapper ref="modal" hide-header :closable="false" :on-hide="onHide">
<div class="flex flex-col gap-4">
<div class="w-[500px]">
<div class="font-extrabold text-contrast text-xl">
{{ formatMessage(messages.header) }} Modrinth App v{{ update!.version }}
</div>
<template v-if="!downloadInProgress && !downloadError">
<div class="mb-1 leading-tight">{{ formatMessage(messages.bodyVersion) }}</div>
<div class="text-sm text-secondary mb-2">
{{ formatMessage(messages.downloadSize, { size: formatBytes(updateSize) }) }}
</div>
</template>
<AppearingProgressBar
v-if="!downloadError"
:max-value="shouldShowProgress ? updateSize || 0 : 0"
:current-value="shouldShowProgress ? downloadedBytes : 0"
color="green"
class="w-full mb-4 mt-2"
/>
<div v-if="downloadError" class="leading-tight">
<div class="text-red font-medium mb-4">
{{ formatMessage(messages.downloadError) }}
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled color="brand">
<button @click="installUpdateNow">
<DownloadIcon />
{{ formatMessage(messages.tryAgain) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyError">
<ClipboardCopyIcon />
{{
copiedError
? formatMessage(messages.copiedError)
: formatMessage(messages.copyError)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<a href="https://support.modrinth.com"><ChatIcon /> Get support</a>
</ButtonStyled>
</div>
</div>
<div v-if="!downloadError" class="flex flex-wrap gap-2 w-full">
<JoinedButtons
:actions="installActions"
:disabled="updatingImmediately || downloadInProgress"
color="brand"
/>
<div>
<ButtonStyled>
<button @click="() => openUrl('https://modrinth.com/news/changelog?filter=app')">
<ExternalIcon /> {{ formatMessage(messages.changelog) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { useTemplateRef, ref, computed } from 'vue'
import { AppearingProgressBar, ButtonStyled, JoinedButtons } from '@modrinth/ui'
import type { JoinedButtonAction } from '@modrinth/ui'
import { ExternalIcon, DownloadIcon, RedoIcon, ClipboardCopyIcon, XIcon } from '@modrinth/assets'
import { enqueueUpdateForInstallation, getUpdateSize } from '@/helpers/utils'
import { formatBytes } from '@modrinth/utils'
import { handleError } from '@/store/notifications'
import { loading_listener } from '@/helpers/events'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ChatIcon } from '@/assets/icons'
const emit = defineEmits<{
(e: 'updateEnqueuedForLater', version: string | null): Promise<void>
(e: 'modalHidden'): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.update.modal-header',
defaultMessage: 'Update available - ',
},
copiedError: {
id: 'app.update.copied-error',
defaultMessage: 'Copied to clipboard!',
},
bodyVersion: {
id: 'app.update.modal-body-version',
defaultMessage:
'We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements.',
},
downloadSize: {
id: 'app.update.download-size',
defaultMessage: 'The update is {size}.',
},
changelog: {
id: 'app.update.changelog',
defaultMessage: 'View changelog',
},
restartNow: {
id: 'app.update.restart',
defaultMessage: 'Update now',
},
later: {
id: 'app.update.later',
defaultMessage: 'Update on exit',
},
downloadError: {
id: 'app.update.download-error',
defaultMessage:
'An error occurred while downloading the update. Please try again later. Contact support if the issue persists.',
},
copyError: {
id: 'app.update.copy-error',
defaultMessage: 'Copy error',
},
tryAgain: {
id: 'app.update.try-again',
defaultMessage: 'Try again',
},
hide: {
id: 'app.update.hide',
defaultMessage: 'Hide update reminder',
},
})
type UpdateData = {
rid: number
currentVersion: string
version: string
date?: string
body?: string
rawJson: Record<string, unknown>
}
const update = ref<UpdateData>()
const updateSize = ref<number>()
const updatingImmediately = ref(false)
const downloadInProgress = ref(false)
const downloadProgress = ref(0)
const copiedError = ref(false)
const downloadError = ref<Error | null>(null)
const enqueuedUpdate = ref<string | null>(null)
const installActions = computed<JoinedButtonAction[]>(() => [
{
id: 'install-now',
label: formatMessage(messages.restartNow),
icon: DownloadIcon,
action: installUpdateNow,
color: 'green',
},
{
id: 'install-later',
label: formatMessage(messages.later),
icon: RedoIcon,
action: updateAtNextExit,
},
{
id: 'hide',
label: formatMessage(messages.hide),
action: () => {
hide()
emit('modalHidden')
},
icon: XIcon,
},
])
const downloadedBytes = computed(() => {
return updateSize.value ? Math.round((downloadProgress.value / 100) * updateSize.value) : 0
})
const shouldShowProgress = computed(() => {
return downloadInProgress.value || updatingImmediately.value
})
const modal = useTemplateRef('modal')
const isOpen = ref(false)
async function show(newUpdate: UpdateData) {
const oldVersion = update.value?.version
update.value = newUpdate
updateSize.value = await getUpdateSize(newUpdate.rid).catch(handleError)
if (oldVersion !== update.value?.version) {
downloadProgress.value = 0
}
modal.value!.show(new MouseEvent('click'))
isOpen.value = true
}
function onHide() {
isOpen.value = false
}
function hide() {
modal.value!.hide()
}
defineExpose({ show, hide, isOpen })
async function copyError() {
if (downloadError.value) {
copiedError.value = true
const errorData = {
message: downloadError.value.message,
stack: downloadError.value.stack,
name: downloadError.value.name,
timestamp: new Date().toISOString(),
updateVersion: update.value?.version,
}
setTimeout(() => {
copiedError.value = false
}, 3000)
try {
await navigator.clipboard.writeText(JSON.stringify(errorData, null, 2))
} catch (e) {
console.error('Failed to copy error to clipboard:', e)
}
}
}
// TODO: Migrate to common events.ts helper when events/listeners are refactored
interface LoadingListenerEvent {
event: {
type: 'launcher_update'
version: string
}
fraction?: number
}
loading_listener((event: LoadingListenerEvent) => {
if (event.event.type === 'launcher_update') {
if (event.event.version === update.value!.version) {
downloadProgress.value = (event.fraction ?? 1.0) * 100
}
}
})
function installUpdateNow() {
updatingImmediately.value = true
if (enqueuedUpdate.value !== update.value!.version) {
downloadUpdate()
} else if (!downloadInProgress.value) {
// Update already downloaded. Simply close the app
getCurrentWindow().close()
}
}
function updateAtNextExit() {
enqueuedUpdate.value = update.value!.version
emit('updateEnqueuedForLater', update.value!.version)
downloadUpdate()
hide()
}
async function downloadUpdate() {
downloadError.value = null
downloadProgress.value = 0
const versionToDownload = update.value!.version
downloadInProgress.value = true
try {
await enqueueUpdateForInstallation(update.value!.rid)
} catch (e) {
downloadInProgress.value = false
downloadError.value = e instanceof Error ? e : new Error(String(e))
handleError(e)
enqueuedUpdate.value = null
updatingImmediately.value = false
await emit('updateEnqueuedForLater', null)
return
}
downloadInProgress.value = false
if (updatingImmediately.value && update.value!.version === versionToDownload) {
await getCurrentWindow().close()
}
}
</script>
<style scoped lang="scss"></style>

View File

@@ -1,29 +1,23 @@
<script setup lang="ts">
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_user_many } from '@/helpers/cache'
import { friend_listener } from '@/helpers/events'
import { add_friend, friend_statuses, friends, remove_friend } from '@/helpers/friends'
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui'
import {
MailIcon,
UserPlusIcon,
MoreVerticalIcon,
MailIcon,
SettingsIcon,
TrashIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
injectNotificationManager,
OverflowMenu,
useRelativeTime,
} from '@modrinth/ui'
import { ref, onUnmounted, watch, computed } from 'vue'
import { friend_listener } from '@/helpers/events'
import { friends, friend_statuses, add_friend, remove_friend } from '@/helpers/friends'
import { get_user_many } from '@/helpers/cache'
import { handleError } from '@/store/notifications.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const props = defineProps<{

View File

@@ -57,16 +57,15 @@
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { add_project_from_version as installMod } from '@/helpers/profile'
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { XIcon, DownloadIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { add_project_from_version as installMod } from '@/helpers/profile'
import { ref } from 'vue'
import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics'
import Multiselect from 'vue-multiselect'
const { handleError } = injectNotificationManager()
const instance = ref(null)
const project = ref(null)
const versions = ref(null)

View File

@@ -1,12 +1,11 @@
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { Button } from '@modrinth/ui'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
import { trackEvent } from '@/helpers/analytics'
import { handleError } from '@/store/state.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const versionId = ref()
const project = ref()

View File

@@ -1,29 +1,29 @@
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import {
check_installed,
create,
get,
add_project_from_version as installMod,
list,
} from '@/helpers/profile'
import { installVersionDependencies } from '@/store/install.js'
import {
CheckIcon,
DownloadIcon,
PlusIcon,
RightArrowIcon,
UploadIcon,
XIcon,
RightArrowIcon,
CheckIcon,
} from '@modrinth/assets'
import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { Avatar, Button, Card } from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
add_project_from_version as installMod,
check_installed,
get,
list,
create,
} from '@/helpers/profile'
import { open } from '@tauri-apps/plugin-dialog'
import { installVersionDependencies } from '@/store/install.js'
import { handleError } from '@/store/notifications.js'
import { useRouter } from 'vue-router'
import { convertFileSrc } from '@tauri-apps/api/core'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const { handleError } = injectNotificationManager()
const router = useRouter()
const versions = ref()

View File

@@ -1,23 +1,17 @@
<script setup lang="ts">
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import { CopyIcon, EditIcon, PlusIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Checkbox,
injectNotificationManager,
OverflowMenu,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { SpinnerIcon, TrashIcon, UploadIcon, PlusIcon, EditIcon, CopyIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, OverflowMenu, Checkbox } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics'
import { open } from '@tauri-apps/plugin-dialog'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, type Ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { GameInstance, InstanceSettingsTabProps } from '../../../helpers/types'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import type { InstanceSettingsTabProps, GameInstance } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const router = useRouter()

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { Checkbox, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { Checkbox } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import type { AppSettings, Hooks, InstanceSettingsTabProps } from '../../../helpers/types'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts'
import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()

View File

@@ -1,30 +1,23 @@
<script setup lang="ts">
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache'
import { get_loader_versions } from '@/helpers/metadata'
import { edit, install, update_repair_modrinth } from '@/helpers/profile'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import {
DownloadIcon,
HammerIcon,
IssuesIcon,
SpinnerIcon,
TransferIcon,
UndoIcon,
UnlinkIcon,
UnplugIcon,
IssuesIcon,
HammerIcon,
DownloadIcon,
WrenchIcon,
UndoIcon,
SpinnerIcon,
UnplugIcon,
UnlinkIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Checkbox,
Chips,
TeleportDropdownMenu,
injectNotificationManager,
} from '@modrinth/ui'
import { Avatar, Checkbox, Chips, ButtonStyled, TeleportDropdownMenu } from '@modrinth/ui'
import { computed, type ComputedRef, type Ref, ref, shallowRef, watch } from 'vue'
import { edit, install, update_repair_modrinth } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import {
formatCategory,
type GameVersionTag,
@@ -32,16 +25,16 @@ import {
type Project,
type Version,
} from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { get_project, get_version_many } from '@/helpers/cache'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import dayjs from 'dayjs'
import { computed, ref, shallowRef, watch, type ComputedRef, type Ref } from 'vue'
import type {
InstanceSettingsTabProps,
Manifest,
ManifestLoaderVersion,
Manifest,
} from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const repairConfirmModal = ref()

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import JavaSelector from '@/components/ui/JavaSelector.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { Checkbox, Slider } from '@modrinth/ui'
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import { Checkbox, injectNotificationManager, Slider } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, readonly, ref, watch } from 'vue'
import type { AppSettings, InstanceSettingsTabProps, MemorySettings } from '../../../helpers/types'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get } from '@/helpers/settings.ts'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
import useMemorySlider from '@/composables/useMemorySlider'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { Checkbox, injectNotificationManager, Toggle } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
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.ts'
import { edit } from '@/helpers/profile'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()

View File

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

View File

@@ -11,6 +11,10 @@ const props = defineProps({
type: String,
default: null,
},
hideHeader: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: true,
@@ -48,7 +52,14 @@ function onModalHide() {
</script>
<template>
<Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
<Modal
ref="modal"
:header="header"
:noblur="!themeStore.advancedRendering"
:closable="closable"
:hide-header="hideHeader"
@hide="onModalHide"
>
<template #title>
<slot name="title" />
</template>

View File

@@ -1,10 +1,8 @@
<script setup>
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
import { injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
import { get_java_versions, set_java_version } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
import JavaSelector from '@/components/ui/JavaSelector.vue'
const javaVersions = ref(await get_java_versions().catch(handleError))
async function updateJavaVersion(version) {

View File

@@ -1,13 +1,13 @@
<script setup>
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { purge_cache_types } from '@/helpers/cache.js'
import { get, set } from '@/helpers/settings.ts'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { Button, Slider, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue'
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'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { open } from '@tauri-apps/plugin-dialog'
const { handleError } = injectNotificationManager()
const settings = ref(await get())
watch(

View File

@@ -100,39 +100,37 @@
</template>
<script setup lang="ts">
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { ref, computed, watch, useTemplateRef } from 'vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import {
add_and_equip_custom_skin,
determineModelType,
get_normalized_skin_texture,
remove_custom_skin,
unequip_skin,
type Cape,
type Skin,
type SkinModel,
} from '@/helpers/skins.ts'
import {
CheckIcon,
ChevronRightIcon,
SaveIcon,
SpinnerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
SkinPreviewRenderer,
Button,
ButtonStyled,
RadioButtons,
CapeButton,
CapeLikeTextButton,
injectNotificationManager,
RadioButtons,
SkinPreviewRenderer,
ButtonStyled,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef, watch } from 'vue'
const { handleError } = injectNotificationManager()
import {
add_and_equip_custom_skin,
remove_custom_skin,
unequip_skin,
type Skin,
type Cape,
type SkinModel,
get_normalized_skin_texture,
determineModelType,
} from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import {
UploadIcon,
CheckIcon,
SaveIcon,
XIcon,
ChevronRightIcon,
SpinnerIcon,
} from '@modrinth/assets'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
const modal = useTemplateRef('modal')
const selectCapeModal = useTemplateRef('selectCapeModal')

View File

@@ -27,14 +27,14 @@
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount, watch } from 'vue'
import { UploadIcon } from '@modrinth/assets'
import { useNotifications } from '@/store/state'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins'
import { UploadIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { onBeforeUnmount, ref, watch } from 'vue'
const { addNotification } = injectNotificationManager()
const notifications = useNotifications()
const modal = ref()
const fileInput = ref<HTMLInputElement>()
@@ -99,7 +99,7 @@ async function setupDragDropListener() {
const data = await get_dragged_skin_data(filePath)
await processData(data.buffer)
} catch (error) {
addNotification({
notifications.addNotification({
title: 'Error processing file',
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
type: 'error',

View File

@@ -1,12 +1,6 @@
<script setup lang="ts">
import { trackEvent } from '@/helpers/analytics'
import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils'
import { handleSevereError } from '@/store/error'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import {
EyeIcon,
FolderOpenIcon,
@@ -19,20 +13,25 @@ import {
Avatar,
ButtonStyled,
commonMessages,
injectNotificationManager,
OverflowMenu,
SmartClickable,
useRelativeTime,
} from '@modrinth/ui'
import { capitalizeString } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useVIntl } from '@vintl/vintl'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
import { showProfileInFolder } from '@/helpers/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useRouter } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
import { get_project } from '@/helpers/cache'
import { capitalizeString } from '@modrinth/utils'
import { kill, run } from '@/helpers/profile'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import { get_by_profile_path } from '@/helpers/process'
import { handleError } from '@/store/notifications'
import { process_listener } from '@/helpers/events'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()

View File

@@ -1,31 +1,29 @@
<script setup lang="ts">
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { trackEvent } from '@/helpers/analytics'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process'
import { kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import {
type ProtocolVersion,
type ServerData,
type ServerWorld,
type ServerData,
type WorldWithProfile,
get_recent_worlds,
getWorldIdentifier,
get_profile_protocol_version,
get_recent_worlds,
refreshServerData,
start_join_server,
start_join_singleplayer_world,
} from '@/helpers/worlds.ts'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/theme.ts'
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
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, computed } from 'vue'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const { handleError } = injectNotificationManager()
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[]
@@ -35,7 +33,7 @@ const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
const protocolVersions = ref<Record<string, number | null>>({})
const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6
@@ -123,8 +121,11 @@ async function populateJumpBackIn() {
}
})
servers.forEach(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
// fetch each server's data
Promise.all(
servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
),
)
}
@@ -149,8 +150,8 @@ async function populateJumpBackIn() {
.slice(0, MAX_JUMP_BACK_IN)
}
function refreshServer(address: string, instancePath: string) {
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
async function refreshServer(address: string, instancePath: string) {
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
}
async function joinWorld(world: WorldWithProfile) {

View File

@@ -1,12 +1,6 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type {
ProtocolVersion,
ServerStatus,
ServerWorld,
SingleplayerWorld,
World,
} from '@/helpers/worlds.ts'
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import {
@@ -60,9 +54,8 @@ const props = withDefaults(
playingInstance?: boolean
playingWorld?: boolean
startingInstance?: boolean
supportsServerQuickPlay?: boolean
supportsWorldQuickPlay?: boolean
currentProtocol?: ProtocolVersion | null
supportsQuickPlay?: boolean
currentProtocol?: number | null
highlighted?: boolean
// Server only
@@ -85,8 +78,7 @@ const props = withDefaults(
playingInstance: false,
playingWorld: false,
startingInstance: false,
supportsServerQuickPlay: true,
supportsWorldQuickPlay: false,
supportsQuickPlay: false,
currentProtocol: null,
refreshing: false,
@@ -110,8 +102,7 @@ const serverIncompatible = computed(
!!props.serverStatus &&
!!props.serverStatus.version?.protocol &&
!!props.currentProtocol &&
(props.serverStatus.version.protocol !== props.currentProtocol.version ||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
props.serverStatus.version.protocol !== props.currentProtocol,
)
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
@@ -129,13 +120,9 @@ const messages = defineMessages({
id: 'instance.worlds.a_minecraft_server',
defaultMessage: 'A Minecraft Server',
},
noServerQuickPlay: {
id: 'instance.worlds.no_server_quick_play',
defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+',
},
noSingleplayerQuickPlay: {
id: 'instance.worlds.no_singleplayer_quick_play',
defaultMessage: 'You can only jump straight into singleplayer worlds on Minecraft 1.20+',
noQuickPlay: {
id: 'instance.worlds.no_quick_play',
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
},
gameAlreadyOpen: {
id: 'instance.worlds.game_already_open',
@@ -157,6 +144,10 @@ const messages = defineMessages({
id: 'instance.worlds.view_instance',
defaultMessage: 'View instance',
},
playAnyway: {
id: 'instance.worlds.play_anyway',
defaultMessage: 'Play anyway',
},
playInstance: {
id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance',
@@ -331,24 +322,17 @@ const messages = defineMessages({
<ButtonStyled v-else>
<button
v-tooltip="
world.type == 'server' && !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: world.type == 'singleplayer' && !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
"
:disabled="
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
!serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: !supportsQuickPlay
? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
@@ -365,6 +349,11 @@ const messages = defineMessages({
disabled: playingInstance,
action: () => emit('play-instance'),
},
{
id: 'play-anyway',
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
action: () => emit('play'),
},
{
id: 'open-instance',
shown: !!instancePath,
@@ -430,6 +419,10 @@ const messages = defineMessages({
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #play-anyway>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const emit = defineEmits<{

View File

@@ -1,21 +1,21 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import type { GameInstance } from '@/helpers/types'
import {
type ServerPackStatus,
edit_server_in_profile,
type ServerWorld,
set_world_display_status,
type DisplayStatus,
type ServerPackStatus,
type ServerWorld,
} from '@/helpers/worlds.ts'
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed, ref } from 'vue'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const emit = defineEmits<{

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
import type { GameInstance } from '@/helpers/types'
import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts'
import { rename_world, reset_world_icon, set_world_display_status } from '@/helpers/worlds.ts'
import { ChevronRightIcon, SaveIcon, UndoIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { set_world_display_status, rename_world, reset_world_icon } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref } from 'vue'
import { handleError } from '@/store/notifications'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const emit = defineEmits<{

View File

@@ -1,9 +1,8 @@
import { ref, computed } from 'vue'
import { get_max_memory } from '@/helpers/jre.js'
import { injectNotificationManager } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { handleError } from '@/store/notifications.js'
export default async function () {
const { handleError } = injectNotificationManager()
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
const snapPoints = computed(() => {

View File

@@ -1,6 +1,6 @@
import { injectNotificationManager } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { fetch } from '@tauri-apps/plugin-http'
import { handleError } from '@/store/state.js'
import { getVersion } from '@tauri-apps/api/app'
export const useFetch = async (url, item, isSilent) => {
try {
@@ -11,7 +11,6 @@ export const useFetch = async (url, item, isSilent) => {
})
} catch (err) {
if (!isSilent) {
const { handleError } = injectNotificationManager()
handleError({ message: `Error fetching ${item}` })
}
console.error(err)

View File

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

View File

@@ -3,9 +3,9 @@
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { install_to_existing_profile } from '@/helpers/pack.js'
import { injectNotificationManager } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import { install_to_existing_profile } from '@/helpers/pack.js'
import { handleError } from '@/store/notifications.js'
/// Add instance
/*
@@ -190,7 +190,6 @@ export async function edit_icon(path, iconPath) {
}
export async function finish_install(instance) {
const { handleError } = injectNotificationManager()
if (instance.install_stage !== 'pack_installed') {
let linkedData = instance.linked_data
await install_to_existing_profile(

View File

@@ -62,6 +62,9 @@ export type AppSettings = {
developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean>
skipped_update: string | null
pending_update_toast_for_version: string | null
}
// Get full settings object

View File

@@ -1,6 +1,6 @@
import { injectNotificationManager } from '@modrinth/ui'
import { arrayBufferToBase64 } from '@modrinth/utils'
import { invoke } from '@tauri-apps/api/core'
import { handleError } from '@/store/notifications'
import { arrayBufferToBase64 } from '@modrinth/utils'
export interface Cape {
id: string
@@ -39,7 +39,6 @@ export const DEFAULT_MODELS: Record<string, SkinModel> = {
export function filterSavedSkins(list: Skin[]) {
const customSkins = list.filter((s) => s.source !== 'default')
const { handleError } = injectNotificationManager()
fixUnknownSkins(customSkins).catch(handleError)
return customSkins
}

View File

@@ -5,6 +5,22 @@ export async function isDev() {
return await invoke('is_dev')
}
export async function areUpdatesEnabled() {
return await invoke('are_updates_enabled')
}
export async function getUpdateSize(updateRid) {
return await invoke('get_update_size', { rid: updateRid })
}
export async function enqueueUpdateForInstallation(updateRid) {
return await invoke('enqueue_update_for_installation', { rid: updateRid })
}
export async function removeEnqueuedUpdate() {
return await invoke('remove_enqueued_update')
}
// One of 'Windows', 'Linux', 'MacOS'
export async function getOS() {
return await invoke('plugin:utils|get_os')
@@ -37,13 +53,6 @@ export async function restartApp() {
return await invoke('restart_app')
}
/**
* @deprecated This method is no longer needed, and just returns its parameter
*/
export function sanitizePotentialFileUrl(url) {
return url
}
export const releaseColor = (releaseType) => {
switch (releaseType) {
case 'release':

View File

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

View File

@@ -20,6 +20,45 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.update.changelog": {
"message": "View changelog"
},
"app.update.complete-toast.text": {
"message": "Click here to view the changelog."
},
"app.update.complete-toast.title": {
"message": "Version {version} was successfully installed!"
},
"app.update.copied-error": {
"message": "Copied to clipboard!"
},
"app.update.copy-error": {
"message": "Copy error"
},
"app.update.download-error": {
"message": "An error occurred while downloading the update. Please try again later. Contact support if the issue persists."
},
"app.update.download-size": {
"message": "The update is {size}."
},
"app.update.hide": {
"message": "Hide update reminder"
},
"app.update.later": {
"message": "Update on exit"
},
"app.update.modal-body-version": {
"message": "We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements."
},
"app.update.modal-header": {
"message": "Update available - "
},
"app.update.restart": {
"message": "Update now"
},
"app.update.try-again": {
"message": "Try again"
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},
@@ -383,11 +422,11 @@
"instance.worlds.no_contact": {
"message": "Server couldn't be contacted"
},
"instance.worlds.no_server_quick_play": {
"message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
"instance.worlds.no_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+"
},
"instance.worlds.no_singleplayer_quick_play": {
"message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
"instance.worlds.play_anyway": {
"message": "Play anyway"
},
"instance.worlds.play_instance": {
"message": "Play instance"

View File

@@ -1,12 +1,12 @@
import App from '@/App.vue'
import { createApp } from 'vue'
import router from '@/routes'
import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker'
import { createPlugin } from '@vintl/vintl/plugin'
import App from '@/App.vue'
import { createPinia } from 'pinia'
import FloatingVue from 'floating-vue'
import 'floating-vue/dist/style.css'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import { createPlugin } from '@vintl/vintl/plugin'
import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker'
const VIntlPlugin = createPlugin({
controllerOpts: {

View File

@@ -1,34 +1,33 @@
<script setup lang="ts">
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type Instance from '@/components/ui/Instance.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_search_results } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, SearchIcon, XIcon } from '@modrinth/assets'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import type { Ref } from 'vue'
import { SearchIcon, XIcon, ClipboardCopyIcon, GlobeIcon, ExternalIcon } from '@modrinth/assets'
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
import {
SearchFilterControl,
SearchSidebarFilter,
Button,
Checkbox,
DropdownSelect,
injectNotificationManager,
LoadingIndicator,
Pagination,
SearchFilterControl,
SearchSidebarFilter,
useSearch,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { defineMessages, useVIntl } from '@vintl/vintl'
import type { Ref } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { handleError } from '@/store/state'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
import { get_search_results } from '@/helpers/cache.js'
import NavTabs from '@/components/ui/NavTabs.vue'
import type Instance from '@/components/ui/Instance.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const router = useRouter()

View File

@@ -1,18 +1,17 @@
<script setup lang="ts">
import RowDisplay from '@/components/RowDisplay.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import { get_search_results } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events'
import { list } from '@/helpers/profile.js'
import type { GameInstance } from '@/helpers/types'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { injectNotificationManager } from '@modrinth/ui'
import type { SearchResult } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref } from 'vue'
import { ref, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue'
import { list } from '@/helpers/profile.js'
import { profile_listener } from '@/helpers/events'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
import dayjs from 'dayjs'
import { get_search_results } from '@/helpers/cache.js'
import type { SearchResult } from '@modrinth/utils'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import type { GameInstance } from '@/helpers/types'
const { handleError } = injectNotificationManager()
const route = useRoute()
const breadcrumbs = useBreadcrumbs()

View File

@@ -1,26 +1,4 @@
<script setup lang="ts">
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import type { Cape, Skin } from '@/helpers/skins.ts'
import {
equip_skin,
filterDefaultSkins,
filterSavedSkins,
get_available_capes,
get_available_skins,
get_normalized_skin_texture,
normalize_skin_texture,
remove_custom_skin,
set_default_cape,
} from '@/helpers/skins.ts'
import { handleSevereError } from '@/store/error'
import {
EditIcon,
ExcitedRinthbot,
@@ -34,21 +12,42 @@ import {
Button,
ButtonStyled,
ConfirmModal,
injectNotificationManager,
SkinButton,
SkinLikeTextButton,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { arrayBufferToBase64 } from '@modrinth/utils'
import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import { handleError, useNotifications } from '@/store/notifications'
import type { Cape, Skin } from '@/helpers/skins.ts'
import {
normalize_skin_texture,
equip_skin,
filterDefaultSkins,
filterSavedSkins,
get_available_capes,
get_available_skins,
get_normalized_skin_texture,
remove_custom_skin,
set_default_cape,
} from '@/helpers/skins.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import { arrayBufferToBase64 } from '@modrinth/utils'
const editSkinModal = useTemplateRef('editSkinModal')
const selectCapeModal = useTemplateRef('selectCapeModal')
const uploadSkinModal = useTemplateRef('uploadSkinModal')
const notifications = injectNotificationManager()
const { handleError } = notifications
const notifications = useNotifications()
const settings = ref(await getSettings())
const skins = ref<Skin[]>([])
@@ -114,7 +113,7 @@ async function loadCapes() {
defaultCape.value = capes.value.find((c) => c.is_equipped)
originalDefaultCape.value = defaultCape.value
} catch (error) {
if (currentUser.value && error instanceof Error) {
if (currentUser.value) {
handleError(error)
}
}
@@ -127,7 +126,7 @@ async function loadSkins() {
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
originalSelectedSkin.value = selectedSkin.value
} catch (error) {
if (currentUser.value && error instanceof Error) {
if (currentUser.value) {
handleError(error)
}
}
@@ -162,7 +161,7 @@ async function changeSkin(newSkin: Skin) {
text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
})
} else {
handleError(error as Error)
handleError(error)
}
}
}
@@ -191,7 +190,7 @@ async function handleCapeSelected(cape: Cape | undefined) {
text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
})
} else {
handleError(error as Error)
handleError(error)
}
}
}
@@ -208,7 +207,7 @@ async function loadCurrentUser() {
const allAccounts = await users()
currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId)
} catch (e) {
handleError(e as Error)
handleError(e)
currentUser.value = undefined
currentUserId.value = undefined
}
@@ -277,7 +276,7 @@ async function checkUserChanges() {
await loadSkins()
}
} catch (error) {
if (currentUser.value && error instanceof Error) {
if (currentUser.value) {
handleError(error)
}
}
@@ -377,7 +376,7 @@ await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
color="green"
aria-label="Edit skin"
class="pointer-events-auto"
@click.stop="(e: MouseEvent) => editSkinModal?.show(e, skin)"
@click.stop="(e) => editSkinModal?.show(e, skin)"
>
<EditIcon /> Edit
</Button>

View File

@@ -157,18 +157,13 @@
</div>
</template>
<script setup>
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useLoading } from '@/store/state'
import {
Avatar,
ButtonStyled,
ContentPageHeader,
LoadingIndicator,
OverflowMenu,
} from '@modrinth/ui'
import {
CheckCircleIcon,
ClipboardCopyIcon,
@@ -192,25 +187,28 @@ import {
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ContentPageHeader,
injectNotificationManager,
LoadingIndicator,
OverflowMenu,
} from '@modrinth/ui'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener, profile_listener } from '@/helpers/events'
import { useRoute, useRouter } from 'vue-router'
import { computed, onUnmounted, ref, watch } from 'vue'
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics'
import { convertFileSrc } from '@tauri-apps/api/core'
import { handleSevereError } from '@/store/error.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const route = useRoute()
const router = useRouter()

View File

@@ -88,30 +88,30 @@
</template>
<script setup>
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { process_listener } from '@/helpers/events.js'
import { CheckIcon, ClipboardCopyIcon, ShareIcon, TrashIcon } from '@modrinth/assets'
import { Button, Card, Checkbox, DropdownSelect } from '@modrinth/ui'
import {
delete_logs_by_filename,
get_latest_log_cursor,
get_logs,
get_output_by_filename,
get_latest_log_cursor,
} from '@/helpers/logs.js'
import { get_by_profile_path } from '@/helpers/process.js'
import { CheckIcon, ClipboardCopyIcon, ShareIcon, TrashIcon } from '@modrinth/assets'
import { Button, Card, Checkbox, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import dayjs from 'dayjs'
import isToday from 'dayjs/plugin/isToday'
import isYesterday from 'dayjs/plugin/isYesterday'
import { ofetch } from 'ofetch'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import { get_by_profile_path } from '@/helpers/process.js'
import { useRoute } from 'vue-router'
import { process_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js'
import { ofetch } from 'ofetch'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
dayjs.extend(isToday)
dayjs.extend(isYesterday)
const { handleError } = injectNotificationManager()
const route = useRoute()
const props = defineProps({

View File

@@ -249,30 +249,6 @@
</div>
</template>
<script setup lang="ts">
import { TextInputIcon } from '@/assets/icons'
import AddContentButton from '@/components/ui/AddContentButton.vue'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import {
get_organization_many,
get_project_many,
get_team_many,
get_version_many,
} from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import {
add_project_from_path,
get_projects,
remove_project,
toggle_disable_project,
update_all,
update_project,
} from '@/helpers/profile.js'
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
import { highlightModInProfile } from '@/helpers/utils.js'
import {
CheckCircleIcon,
ClipboardCopyIcon,
@@ -295,22 +271,44 @@ import {
Button,
ButtonStyled,
ContentListPanel,
injectNotificationManager,
OverflowMenu,
Pagination,
RadialHeader,
Toggle,
} from '@modrinth/ui'
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
import { formatProjectType } from '@modrinth/utils'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
const { handleError } = injectNotificationManager()
import { defineMessages, useVIntl } from '@vintl/vintl'
import {
add_project_from_path,
get_projects,
remove_project,
toggle_disable_project,
update_all,
update_project,
} from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import { trackEvent } from '@/helpers/analytics'
import { highlightModInProfile } from '@/helpers/utils.js'
import { TextInputIcon } from '@/assets/icons'
import ExportModal from '@/components/ui/ExportModal.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import AddContentButton from '@/components/ui/AddContentButton.vue'
import {
get_organization_many,
get_project_many,
get_team_many,
get_version_many,
} from '@/helpers/cache.js'
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: GameInstance

View File

@@ -67,8 +67,7 @@
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
:world="world"
:highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-server-quick-play="supportsServerQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
:supports-quick-play="supportsQuickPlay"
:current-protocol="protocolVersion"
:playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)"
@@ -121,55 +120,53 @@
</div>
</template>
<script setup lang="ts">
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { ref, computed, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
import {
Button,
ButtonStyled,
RadialHeader,
FilterBar,
type FilterBarOption,
type GameVersion,
GAME_MODES,
} from '@modrinth/ui'
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
import {
type SingleplayerWorld,
type World,
type ServerWorld,
type ServerData,
type ProfileEvent,
get_profile_protocol_version,
remove_server_from_profile,
delete_world,
start_join_server,
start_join_singleplayer_world,
getWorldIdentifier,
refreshServerData,
refreshWorld,
sortWorlds,
refreshServers,
hasQuickPlaySupport,
refreshWorlds,
handleDefaultProfileUpdateEvent,
showWorldInFolder,
} from '@/helpers/worlds.ts'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { handleError } from '@/store/notifications'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { Version } from '@modrinth/utils'
import { profile_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import {
type ProfileEvent,
type ProtocolVersion,
type ServerData,
type ServerWorld,
type SingleplayerWorld,
type World,
delete_world,
getWorldIdentifier,
get_profile_protocol_version,
handleDefaultProfileUpdateEvent,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
refreshServerData,
refreshServers,
refreshWorld,
refreshWorlds,
remove_server_from_profile,
showWorldInFolder,
sortWorlds,
start_join_server,
start_join_singleplayer_world,
} from '@/helpers/worlds.ts'
import { PlusIcon, SearchIcon, SpinnerIcon, UpdatedIcon, XIcon } from '@modrinth/assets'
import {
Button,
ButtonStyled,
FilterBar,
type FilterBarOption,
GAME_MODES,
type GameVersion,
RadialHeader,
injectNotificationManager,
} from '@modrinth/ui'
import type { Version } from '@modrinth/utils'
import { defineMessages } from '@vintl/vintl'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const { handleError } = injectNotificationManager()
const route = useRoute()
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
@@ -213,9 +210,7 @@ const worldPlaying = ref<World>()
const worlds = ref<World[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersion = ref<ProtocolVersion | null>(
await get_profile_protocol_version(instance.value.path),
)
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
@@ -251,7 +246,7 @@ async function refreshAllWorlds() {
worlds.value = await refreshWorlds(instance.value.path).finally(
() => (refreshingAll.value = false),
)
refreshServers(worlds.value, serverData.value, protocolVersion.value)
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
const hasNoWorlds = worlds.value.length === 0
@@ -282,7 +277,7 @@ async function editServer(server: ServerWorld) {
await refreshServer(server.address)
}
} else {
handleError(new Error(`Error refreshing server, refreshing all worlds`))
handleError(`Error refreshing server, refreshing all worlds`)
await refreshAllWorlds()
}
}
@@ -301,7 +296,7 @@ async function editWorld(path: string, name: string, removeIcon: boolean) {
}
sortWorlds(worlds.value)
} else {
handleError(new Error(`Error finding world in list, refreshing all worlds`))
handleError(`Error finding world in list, refreshing all worlds`)
await refreshAllWorlds()
}
}
@@ -311,7 +306,7 @@ async function deleteWorld(world: SingleplayerWorld) {
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
}
function handleJoinError(err: Error) {
function handleJoinError(err: unknown) {
handleError(err)
startingInstance.value = false
worldPlaying.value = undefined
@@ -357,11 +352,8 @@ function worldsMatch(world: World, other: World | undefined) {
}
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
const supportsServerQuickPlay = computed(() =>
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const supportsWorldQuickPlay = computed(() =>
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
const supportsQuickPlay = computed(() =>
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const filterOptions = computed(() => {
@@ -436,7 +428,7 @@ function promptToRemoveWorld(world: World): boolean {
async function proceedRemoveServer() {
if (!serverToRemove.value) {
handleError(new Error(`Error removing server, no server marked for removal.`))
handleError(`Error removing server, no server marked for removal.`)
return
}
await removeServer(serverToRemove.value)
@@ -445,7 +437,7 @@ async function proceedRemoveServer() {
async function proceedDeleteWorld() {
if (!worldToDelete.value) {
handleError(new Error(`Error deleting world, no world marked for removal.`))
handleError(`Error deleting world, no world marked for removal.`)
return
}
await deleteWorld(worldToDelete.value)

View File

@@ -1,16 +1,16 @@
<script setup>
import { NewInstanceImage } from '@/assets/icons'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { profile_listener } from '@/helpers/events.js'
import { list } from '@/helpers/profile.js'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { PlusIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { onUnmounted, ref, shallowRef } from 'vue'
import { list } from '@/helpers/profile.js'
import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { profile_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js'
import { Button } from '@modrinth/ui'
import { PlusIcon } from '@modrinth/assets'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { NewInstanceImage } from '@/assets/icons'
import NavTabs from '@/components/ui/NavTabs.vue'
const { handleError } = injectNotificationManager()
const route = useRoute()
const breadcrumbs = useBreadcrumbs()

View File

@@ -129,46 +129,46 @@
</template>
<script setup>
import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { get_project, get_team, get_version_many } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { install as installVersion } from '@/store/install.js'
import { useTheming } from '@/store/state.js'
import {
BookmarkIcon,
CheckIcon,
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
GlobeIcon,
HeartIcon,
MoreVerticalIcon,
DownloadIcon,
ReportIcon,
HeartIcon,
ExternalIcon,
CheckIcon,
GlobeIcon,
ClipboardCopyIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
OverflowMenu,
ProjectBackgroundGradient,
ProjectHeader,
ProjectSidebarCompatibility,
ButtonStyled,
OverflowMenu,
ProjectSidebarLinks,
ProjectSidebarCreators,
ProjectSidebarDetails,
ProjectSidebarLinks,
injectNotificationManager,
ProjectBackgroundGradient,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
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 { ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, shallowRef, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { install as installVersion } from '@/store/install.js'
import { get_project, get_team, get_version_many } from '@/helpers/cache.js'
import NavTabs from '@/components/ui/NavTabs.vue'
import { useTheming } from '@/store/state.js'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()

View File

@@ -65,16 +65,12 @@
</template>
<script setup>
import { ProjectPageVersions, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import {
ButtonStyled,
OverflowMenu,
ProjectPageVersions,
injectNotificationManager,
} from '@modrinth/ui'
import { ref } from 'vue'
import { handleError } from '@/store/notifications.js'
defineProps({
project: {
@@ -107,8 +103,6 @@ defineProps({
},
})
const { handleError } = injectNotificationManager()
const [loaders, gameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),

View File

@@ -1,48 +0,0 @@
import {
AbstractWebNotificationManager,
type NotificationPanelLocation,
type WebNotification,
} from '@modrinth/ui'
import { ref, type Ref } from 'vue'
export class AppNotificationManager extends AbstractWebNotificationManager {
private readonly state: Ref<WebNotification[]>
private readonly locationState: Ref<NotificationPanelLocation>
public constructor() {
super()
this.state = ref<WebNotification[]>([])
this.locationState = ref<NotificationPanelLocation>('right')
}
public getNotificationLocation(): NotificationPanelLocation {
return this.locationState.value
}
public setNotificationLocation(location: NotificationPanelLocation): void {
this.locationState.value = location
}
public getNotifications(): WebNotification[] {
return this.state.value
}
protected addNotificationToStorage(notification: WebNotification): void {
this.state.value.push(notification)
}
protected removeNotificationFromStorage(id: string | number): void {
const index = this.state.value.findIndex((n) => n.id === id)
if (index > -1) {
this.state.value.splice(index, 1)
}
}
protected removeNotificationFromStorageByIndex(index: number): void {
this.state.value.splice(index, 1)
}
protected clearAllNotificationsFromStorage(): void {
this.state.value.splice(0)
}
}

View File

@@ -1,6 +1,4 @@
import { trackEvent } from '@/helpers/analytics.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import { defineStore } from 'pinia'
import {
add_project_from_version,
check_installed,
@@ -9,9 +7,11 @@ import {
list,
remove_project,
} from '@/helpers/profile.js'
import { injectNotificationManager } from '@modrinth/ui'
import { handleError } from '@/store/notifications.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import { trackEvent } from '@/helpers/analytics.js'
import dayjs from 'dayjs'
import { defineStore } from 'pinia'
export const useInstall = defineStore('installStore', {
state: () => ({
@@ -49,7 +49,6 @@ export const install = async (
callback = () => {},
createInstanceCallback = () => {},
) => {
const { handleError } = injectNotificationManager()
const project = await get_project(projectId, 'must_revalidate').catch(handleError)
if (project.project_type === 'modpack') {
@@ -161,7 +160,6 @@ export const install = async (
}
export const installVersionDependencies = async (profile, version) => {
const { handleError } = injectNotificationManager()
for (const dep of version.dependencies) {
if (dep.dependency_type !== 'required') continue
// disallow fabric api install on quilt

View File

@@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
export const useNotifications = defineStore('notificationsStore', {
state: () => ({
notificationsWrapper: null,
}),
actions: {
setNotifs(notifs) {
this.notificationsWrapper = notifs
},
addNotification(notif) {
this.notificationsWrapper.addNotification(notif)
},
},
})
export const handleError = (err) => {
const notifs = useNotifications()
notifs.addNotification({
title: 'An error occurred',
text: err.message ?? err,
type: 'error',
})
console.error(err)
}

View File

@@ -1,6 +1,7 @@
import { useBreadcrumbs } from './breadcrumbs'
import { useInstall } from './install'
import { useLoading } from './loading'
import { useTheming } from './theme.ts'
import { useBreadcrumbs } from './breadcrumbs'
import { useLoading } from './loading'
import { useNotifications, handleError } from './notifications'
import { useInstall } from './install'
export { useBreadcrumbs, useInstall, useLoading, useTheming }
export { useTheming, useBreadcrumbs, useLoading, useNotifications, handleError, useInstall }

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,8 @@
"build": "tauri build",
"dev": "tauri dev",
"test": "cargo nextest run --all-targets --no-fail-fast",
"lint": "cargo fmt --check && cargo clippy --all-targets",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
"lint": "cargo fmt --check && cargo clippy --all-targets && cargo clippy --all-targets --features updater",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo clippy --all-targets --features updater --fix --allow-dirty && cargo fmt"
},
"devDependencies": {
"@tauri-apps/cli": "2.5.0"

View File

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

View File

@@ -22,8 +22,6 @@ pub mod cache;
pub mod friends;
pub mod worlds;
mod oauth_utils;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
// // Main returnable Theseus GUI error
@@ -47,8 +45,12 @@ pub enum TheseusSerializableError {
Tauri(#[from] tauri::Error),
#[cfg(feature = "updater")]
#[error("Tauri updater error: {0}")]
TauriUpdater(#[from] tauri_plugin_updater::Error),
#[error("Updater error: {0}")]
Updater(#[from] tauri_plugin_updater::Error),
#[cfg(feature = "updater")]
#[error("HTTP error: {0}")]
Http(#[from] tauri_plugin_http::reqwest::Error),
}
// Generic implementation of From<T> for ErrorTypeA
@@ -106,5 +108,6 @@ impl_serialize! {
impl_serialize! {
IO,
Tauri,
TauriUpdater,
Updater,
Http,
}

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -250,7 +250,7 @@ pub async fn profile_get_pack_export_candidates(
// invoke('plugin:profile|profile_run', path)
#[tauri::command]
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
let process = profile::run(path, QuickPlayType::None).await?;
let process = profile::run(path, &QuickPlayType::None).await?;
Ok(process)
}

View File

@@ -4,10 +4,9 @@ use enumset::EnumSet;
use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata;
use theseus::profile::{QuickPlayType, get_full_path};
use theseus::server_address::ServerAddress;
use theseus::worlds::{
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
WorldType, WorldWithProfile,
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
WorldWithProfile,
};
use theseus::{profile, worlds};
@@ -184,16 +183,14 @@ pub async fn remove_server_from_profile(
}
#[tauri::command]
pub async fn get_profile_protocol_version(
path: &str,
) -> Result<Option<ProtocolVersion>> {
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> {
Ok(worlds::get_profile_protocol_version(path).await?)
}
#[tauri::command]
pub async fn get_server_status(
address: &str,
protocol_version: Option<ProtocolVersion>,
protocol_version: Option<i32>,
) -> Result<ServerStatus> {
Ok(worlds::get_server_status(address, protocol_version).await?)
}
@@ -204,7 +201,7 @@ pub async fn start_join_singleplayer_world(
world: String,
) -> Result<ProcessMetadata> {
let process =
profile::run(path, QuickPlayType::Singleplayer(world)).await?;
profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
Ok(process)
}
@@ -214,11 +211,8 @@ pub async fn start_join_server(
path: &str,
address: &str,
) -> Result<ProcessMetadata> {
let process = profile::run(
path,
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
)
.await?;
let process =
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
Ok(process)
}

View File

@@ -14,6 +14,11 @@ mod error;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(feature = "updater")]
mod updater_impl;
#[cfg(not(feature = "updater"))]
mod updater_impl_noop;
// Should be called in launcher initialization
#[tracing::instrument(skip_all)]
#[tauri::command]
@@ -21,75 +26,9 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
tracing::info!("Initializing app event state...");
theseus::EventState::init(app.clone()).await?;
#[cfg(feature = "updater")]
'updater: {
if env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_ok() {
State::init().await?;
break 'updater;
}
tracing::info!("Initializing app state...");
State::init().await?;
use tauri_plugin_updater::UpdaterExt;
let updater = app.updater_builder().build()?;
let update_fut = updater.check();
tracing::info!("Initializing app state...");
State::init().await?;
let check_bar = theseus::init_loading(
theseus::LoadingBarType::CheckingForUpdates,
1.0,
"Checking for updates...",
)
.await?;
tracing::info!("Checking for updates...");
let update = update_fut.await;
drop(check_bar);
if let Some(update) = update.ok().flatten() {
tracing::info!("Update found: {:?}", update.download_url);
let loader_bar_id = theseus::init_loading(
theseus::LoadingBarType::LauncherUpdate {
version: update.version.clone(),
current_version: update.current_version.clone(),
},
1.0,
"Updating Modrinth App...",
)
.await?;
// 100 MiB
const DEFAULT_CONTENT_LENGTH: u64 = 1024 * 1024 * 100;
update
.download_and_install(
|chunk_length, content_length| {
let _ = theseus::emit_loading(
&loader_bar_id,
(chunk_length as f64)
/ (content_length
.unwrap_or(DEFAULT_CONTENT_LENGTH)
as f64),
None,
);
},
|| {},
)
.await?;
app.restart();
}
}
#[cfg(not(feature = "updater"))]
{
State::init().await?;
}
tracing::info!("Finished checking for updates!");
let state = State::get().await?;
app.asset_protocol_scope()
.allow_directory(state.directories.caches_dir(), true)?;
@@ -125,6 +64,17 @@ fn is_dev() -> bool {
cfg!(debug_assertions)
}
#[tauri::command]
fn are_updates_enabled() -> bool {
cfg!(feature = "updater")
}
#[cfg(feature = "updater")]
pub use updater_impl::*;
#[cfg(not(feature = "updater"))]
pub use updater_impl_noop::*;
// Toggles decorations
#[tauri::command]
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
@@ -166,7 +116,17 @@ fn main() {
#[cfg(feature = "updater")]
{
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
use tauri_plugin_http::reqwest::header::{HeaderValue, USER_AGENT};
use theseus::LAUNCHER_USER_AGENT;
builder = builder.plugin(
tauri_plugin_updater::Builder::new()
.header(
USER_AGENT,
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
)
.unwrap()
.build(),
);
}
builder = builder
@@ -261,9 +221,14 @@ fn main() {
.plugin(api::ads::init())
.plugin(api::friends::init())
.plugin(api::worlds::init())
.manage(PendingUpdateData::default())
.invoke_handler(tauri::generate_handler![
initialize_state,
is_dev,
are_updates_enabled,
get_update_size,
enqueue_update_for_installation,
remove_enqueued_update,
toggle_decorations,
show_window,
restart_app,
@@ -275,8 +240,41 @@ fn main() {
match app {
Ok(app) => {
app.run(|app, event| {
#[cfg(not(target_os = "macos"))]
#[cfg(not(any(feature = "updater", target_os = "macos")))]
drop((app, event));
#[cfg(feature = "updater")]
if matches!(event, tauri::RunEvent::Exit) {
let update_data = app.state::<PendingUpdateData>().inner();
if let Some((update, data)) = &*update_data.0.lock().unwrap() {
fn set_changelog_toast(version: Option<String>) {
let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move {
let mut settings = settings::get().await?;
settings.pending_update_toast_for_version = version;
settings::set(settings).await?;
Ok(())
});
if let Err(e) = toast_result {
tracing::warn!("Failed to set pending_update_toast: {e}")
}
}
set_changelog_toast(Some(update.version.clone()));
if let Err(e) = update.install(data) {
tracing::error!("Error while updating: {e}");
set_changelog_toast(None);
DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_title("Update error")
.set_text(format!("Failed to install update due to an error:\n{e}"))
.alert()
.show()
.unwrap();
}
}
}
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = event {
tracing::info!("Handling webview open {urls:?}");
@@ -304,6 +302,8 @@ fn main() {
});
}
Err(e) => {
tracing::error!("Error while running tauri application: {:?}", e);
#[cfg(target_os = "windows")]
{
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
@@ -332,7 +332,6 @@ fn main() {
.show()
.unwrap();
tracing::error!("Error while running tauri application: {:?}", e);
panic!("{1}: {:?}", e, "error while running tauri application")
}
}

View File

@@ -0,0 +1,121 @@
use crate::api::Result;
use std::sync::{Arc, Mutex};
use tauri::http::HeaderValue;
use tauri::http::header::ACCEPT;
use tauri::{Manager, ResourceId, Runtime, Webview};
use tauri_plugin_http::reqwest;
use tauri_plugin_http::reqwest::ClientBuilder;
use tauri_plugin_updater::Error;
use tauri_plugin_updater::Update;
use theseus::{
LAUNCHER_USER_AGENT, LoadingBarType, emit_loading, init_loading,
};
use tokio::time::Instant;
#[derive(Default)]
pub struct PendingUpdateData(pub Mutex<Option<(Arc<Update>, Vec<u8>)>>);
// Reimplementation of Update::download mostly, minus the actual download part
#[tauri::command]
pub async fn get_update_size<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<Option<u64>> {
let update = webview.resources_table().get::<Update>(rid)?;
let mut headers = update.headers.clone();
if !headers.contains_key(ACCEPT) {
headers.insert(
ACCEPT,
HeaderValue::from_static("application/octet-stream"),
);
}
let mut request = ClientBuilder::new().user_agent(LAUNCHER_USER_AGENT);
if let Some(timeout) = update.timeout {
request = request.timeout(timeout);
}
if let Some(ref proxy) = update.proxy {
let proxy = reqwest::Proxy::all(proxy.as_str())?;
request = request.proxy(proxy);
}
let response = request
.build()?
.head(update.download_url.clone())
.headers(headers)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::Network(format!(
"Download request failed with status: {}",
response.status()
))
.into());
}
let content_length = response
.headers()
.get("Content-Length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok());
Ok(content_length)
}
#[tauri::command]
pub async fn enqueue_update_for_installation<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<()> {
let pending_data = webview.state::<PendingUpdateData>().inner();
let update = webview.resources_table().get::<Update>(rid)?;
let progress = init_loading(
LoadingBarType::LauncherUpdate {
version: update.version.clone(),
current_version: update.current_version.clone(),
},
1.0,
"Downloading update...",
)
.await?;
let download_start = Instant::now();
let update_data = update
.download(
|chunk_size, total_size| {
let Some(total_size) = total_size else {
return;
};
if let Err(e) = emit_loading(
&progress,
chunk_size as f64 / total_size as f64,
None,
) {
tracing::error!(
"Failed to update download progress bar: {e}"
);
}
},
|| {},
)
.await?;
let download_duration = download_start.elapsed();
tracing::info!("Downloaded update in {download_duration:?}");
pending_data
.0
.lock()
.unwrap()
.replace((update, update_data));
Ok(())
}
#[tauri::command]
pub fn remove_enqueued_update<R: Runtime>(webview: Webview<R>) {
let pending_data = webview.state::<PendingUpdateData>().inner();
pending_data.0.lock().unwrap().take();
}

View File

@@ -0,0 +1,26 @@
use crate::api::Result;
use theseus::ErrorKind;
#[derive(Default)]
pub struct PendingUpdateData(());
#[tauri::command]
pub fn get_update_size() -> Result<()> {
updates_are_disabled()
}
#[tauri::command]
pub fn enqueue_update_for_installation() -> Result<()> {
updates_are_disabled()
}
fn updates_are_disabled() -> Result<()> {
let error: theseus::Error = ErrorKind::OtherError(
"Updates are disabled in this build.".to_string(),
)
.into();
Err(error.into())
}
#[tauri::command]
pub fn remove_enqueued_update() {}

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
"postinstall": "nuxi prepare",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "nuxi build"
},
"devDependencies": {
@@ -59,12 +59,10 @@
"markdown-it": "14.1.0",
"pathe": "^1.1.2",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^4.4.1",
"prettier": "^3.6.2",
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"three": "^0.172.0",
"vue-confetti-explosion": "^1.0.2",
"vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",

View File

@@ -1,14 +1,11 @@
<template>
<NuxtLayout>
<ModrinthLoadingIndicator />
<NotificationPanel />
<Notifications />
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
import { NotificationPanel, provideNotificationManager } from "@modrinth/ui";
import { FrontendNotificationManager } from "./providers/frontend-notifications.ts";
import ModrinthLoadingIndicator from "~/components/ui/modrinth-loading-indicator.ts";
provideNotificationManager(new FrontendNotificationManager());
import Notifications from "~/components/ui/Notifications.vue";
</script>

View File

@@ -51,9 +51,7 @@
<script setup>
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { injectNotificationManager } from "@modrinth/ui";
const { addNotification } = injectNotificationManager();
const router = useNativeRouter();
const name = ref("");
@@ -89,6 +87,7 @@ async function create() {
await router.push(`/collection/${result.id}`);
} catch (err) {
addNotification({
group: "main",
title: "An error occurred",
text: err?.data?.description || err?.message || err,
type: "error",

View File

@@ -84,11 +84,8 @@
</template>
<script setup>
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, DropdownSelect, NewModal } from "@modrinth/ui";
import { injectNotificationManager } from "@modrinth/ui";
const { addNotification } = injectNotificationManager();
import { NewModal, ButtonStyled, DropdownSelect } from "@modrinth/ui";
import { XIcon, PlusIcon } from "@modrinth/assets";
const router = useRouter();
const app = useNuxtApp();
@@ -183,7 +180,8 @@ async function createProject() {
},
});
} catch (err) {
addNotification({
app.$notify({
group: "main",
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -319,21 +319,30 @@
</template>
<script setup>
import { renderString } from "@modrinth/utils";
import {
BellIcon,
CalendarIcon,
CheckCircleIcon,
CheckIcon,
ExternalIcon,
ScaleIcon,
UserPlusIcon,
ScaleIcon,
BellIcon,
CheckCircleIcon,
CalendarIcon,
VersionIcon,
CheckIcon,
XIcon,
ExternalIcon,
} from "@modrinth/assets";
import { injectNotificationManager } from "@modrinth/ui";
import { Avatar, ProjectStatusBadge, CopyCode, useRelativeTime } from "@modrinth/ui";
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
import { getProjectLink, getVersionLink } from "~/helpers/projects.js";
import { getUserLink } from "~/helpers/users.js";
import { acceptTeamInvite, removeSelfFromTeam } from "~/helpers/teams.js";
import { markAsRead } from "~/helpers/notifications.ts";
import DoubleIcon from "~/components/ui/DoubleIcon.vue";
import Categories from "~/components/ui/search/Categories.vue";
const { addNotification } = injectNotificationManager();
const app = useNuxtApp();
const emit = defineEmits(["update:notifications"]);
const formatRelativeTime = useRelativeTime();
const props = defineProps({
@@ -398,7 +407,8 @@ async function read() {
const newNotifs = updateNotifs(props.notifications);
emit("update:notifications", newNotifs);
} catch (err) {
addNotification({
app.$notify({
group: "main",
title: "Error marking notification as read",
text: err.data ? err.data.description : err,
type: "error",
@@ -417,7 +427,8 @@ async function performAction(notification, actionIndex) {
});
}
} catch (err) {
addNotification({
app.$notify({
group: "main",
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",

View File

@@ -3,9 +3,7 @@
class="vue-notification-group experimental-styles-within"
:class="{
'intercom-present': isIntercomPresent,
'location-left': notificationLocation === 'left',
'location-right': notificationLocation === 'right',
'has-sidebar': hasSidebar,
rightwards: moveNotificationsRight,
}"
>
<transition-group name="notifs">
@@ -55,7 +53,7 @@
</button>
</ButtonStyled>
<ButtonStyled circular size="small">
<button v-tooltip="`Dismiss`" @click="dismissNotification(index)">
<button v-tooltip="`Dismiss`" @click="notifications.splice(index, 1)">
<XIcon />
</button>
</ButtonStyled>
@@ -75,117 +73,106 @@
</transition-group>
</div>
</template>
<script setup lang="ts">
<script setup>
import { ButtonStyled } from "@modrinth/ui";
import {
XCircleIcon,
CheckCircleIcon,
CheckIcon,
CopyIcon,
InfoIcon,
IssuesIcon,
XCircleIcon,
XIcon,
} from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { injectNotificationManager, type WebNotification } from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
CopyIcon,
} from "@modrinth/assets";
const notifications = useNotifications();
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
const notificationManager = injectNotificationManager()
const notifications = computed<WebNotification[]>(() => notificationManager.getNotifications())
const notificationLocation = computed(() => notificationManager.getNotificationLocation())
const isIntercomPresent = ref(false);
const isIntercomPresent = ref<boolean>(false)
const copied = ref<Record<string, boolean>>({})
const stopTimer = (n: WebNotification) => notificationManager.stopNotificationTimer(n)
const setNotificationTimer = (n: WebNotification) => notificationManager.setNotificationTimer(n)
const dismissNotification = (n: number) => notificationManager.removeNotificationByIndex(n)
function createNotifText(notif: WebNotification): string {
return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n')
function stopTimer(notif) {
clearTimeout(notif.timer);
}
function checkIntercomPresence(): void {
isIntercomPresent.value = !!document.querySelector('.intercom-lightweight-app')
}
const copied = ref({});
function copyToClipboard(notif: WebNotification): void {
const text = createNotifText(notif)
const createNotifText = (notif) => {
let text = "";
if (notif.title) {
text += notif.title;
}
if (notif.text) {
if (text.length > 0) {
text += "\n";
}
text += notif.text;
}
if (notif.errorCode) {
if (text.length > 0) {
text += "\n";
}
text += notif.errorCode;
}
return text;
};
copied.value[text] = true
navigator.clipboard.writeText(text)
setTimeout(() => {
const { [text]: _, ...rest } = copied.value
copied.value = rest
}, 2000)
function checkIntercomPresence() {
isIntercomPresent.value = !!document.querySelector(".intercom-lightweight-app");
}
onMounted(() => {
checkIntercomPresence()
checkIntercomPresence();
const observer = new MutationObserver(() => {
checkIntercomPresence()
})
checkIntercomPresence();
});
observer.observe(document.body, {
childList: true,
subtree: true,
})
});
onBeforeUnmount(() => {
observer.disconnect()
})
})
observer.disconnect();
});
});
withDefaults(
defineProps<{
hasSidebar?: boolean
}>(),
{
hasSidebar: false,
},
)
function copyToClipboard(notif) {
const text = createNotifText(notif);
copied.value[text] = true;
navigator.clipboard.writeText(text);
setTimeout(() => {
delete copied.value[text];
}, 2000);
}
</script>
<style lang="scss" scoped>
.vue-notification-group {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
z-index: 200;
width: 450px;
&.location-right {
right: 1.5rem;
&.has-sidebar {
right: 325px;
}
}
&.location-left {
left: 1.5rem;
}
@media screen and (max-width: 500px) {
width: calc(100% - 0.75rem * 2);
right: 0.75rem;
bottom: 0.75rem;
&.location-right {
right: 0.75rem;
left: auto;
}
&.location-left {
left: 0.75rem;
right: auto;
}
}
&.intercom-present {
bottom: 5rem;
}
&.rightwards {
right: unset !important;
left: 1.5rem;
@media screen and (max-width: 500px) {
left: 0.75rem;
}
}
.vue-notification-wrapper {
width: 100%;
overflow: hidden;
@@ -221,12 +208,6 @@ withDefaults(
}
.notifs-leave-to {
.location-right & {
transform: translateX(100%) scale(0.8);
}
.location-left & {
transform: translateX(-100%) scale(0.8);
}
transform: translateX(100%) scale(0.8);
}
</style>

View File

@@ -15,7 +15,7 @@
maxlength="64"
:placeholder="`Enter organization name...`"
autocomplete="off"
@input="updateSlug"
@input="updateSlug()"
/>
</div>
<div class="flex flex-col gap-2">
@@ -33,7 +33,7 @@
type="text"
maxlength="64"
autocomplete="off"
@input="setManualSlug"
@input="manualSlug = true"
/>
</div>
</div>
@@ -61,7 +61,7 @@
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<button @click="modal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
@@ -70,22 +70,20 @@
</div>
</NewModal>
</template>
<script setup lang="ts">
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal, injectNotificationManager } from "@modrinth/ui";
import { ref } from "vue";
<script setup>
import { XIcon, PlusIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
const router = useNativeRouter();
const { addNotification } = injectNotificationManager();
const name = ref<string>("");
const slug = ref<string>("");
const description = ref<string>("");
const manualSlug = ref<boolean>(false);
const modal = ref<InstanceType<typeof NewModal>>();
const name = ref("");
const slug = ref("");
const description = ref("");
const manualSlug = ref(false);
async function createOrganization(): Promise<void> {
const modal = ref();
async function createOrganization() {
startLoading();
try {
const value = {
@@ -94,18 +92,19 @@ async function createOrganization(): Promise<void> {
slug: slug.value.trim().replace(/ +/g, ""),
};
const result: any = await useBaseFetch("organization", {
const result = await useBaseFetch("organization", {
method: "POST",
body: JSON.stringify(value),
apiVersion: 3,
});
modal.value?.hide();
modal.value.hide();
await router.push(`/organization/${result.slug}`);
} catch (err: any) {
} catch (err) {
console.error(err);
addNotification({
group: "main",
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
@@ -113,18 +112,13 @@ async function createOrganization(): Promise<void> {
}
stopLoading();
}
function show(event?: MouseEvent): void {
function show(event) {
name.value = "";
description.value = "";
modal.value?.show(event);
modal.value.show(event);
}
function hide(): void {
modal.value?.hide();
}
function updateSlug(): void {
function updateSlug() {
if (!manualSlug.value) {
slug.value = name.value
.trim()
@@ -135,10 +129,6 @@ function updateSlug(): void {
}
}
function setManualSlug(): void {
manualSlug.value = true;
}
defineExpose({
show,
});

View File

@@ -109,16 +109,15 @@
<script setup>
import {
AsteriskIcon,
CheckIcon,
ChevronRightIcon,
DropdownIcon,
LightBulbIcon,
ScaleIcon,
SendIcon,
CheckIcon,
XIcon,
AsteriskIcon,
LightBulbIcon,
SendIcon,
ScaleIcon,
DropdownIcon,
} from "@modrinth/assets";
import { injectNotificationManager } from "@modrinth/ui";
import { formatProjectType } from "@modrinth/utils";
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
@@ -165,8 +164,8 @@ const props = defineProps({
type: Function,
default() {
return () => {
const { addNotification } = injectNotificationManager();
addNotification({
group: "main",
title: "An error occurred",
text: "setProcessing function not found",
type: "error",
@@ -178,8 +177,8 @@ const props = defineProps({
type: Function,
default() {
return () => {
const { addNotification } = injectNotificationManager();
addNotification({
group: "main",
title: "An error occurred",
text: "toggleCollapsed function not found",
type: "error",
@@ -191,8 +190,8 @@ const props = defineProps({
type: Function,
default() {
return () => {
const { addNotification } = injectNotificationManager();
addNotification({
group: "main",
title: "An error occurred",
text: "updateMembers function not found",
type: "error",

View File

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

View File

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

View File

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

View File

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

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