Merge branch 'main' into tunneling
This commit is contained in:
@@ -1,22 +1,22 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
LogInIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
HomeIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
LogInIcon,
|
||||
LogOutIcon,
|
||||
MaximizeIcon,
|
||||
MinimizeIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
RightArrowIcon,
|
||||
SettingsIcon,
|
||||
XIcon,
|
||||
DownloadIcon,
|
||||
CompassIcon,
|
||||
MinimizeIcon,
|
||||
MaximizeIcon,
|
||||
RestoreIcon,
|
||||
LogOutIcon,
|
||||
RightArrowIcon,
|
||||
LeftArrowIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
|
||||
import { useLoading, useTheming } from '@/store/state'
|
||||
@@ -32,12 +32,12 @@ 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 { isDev, getOS, restartApp } from '@/helpers/utils.js'
|
||||
import { initAnalytics, debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { getOS, isDev, restartApp } 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 { install_from_file } from './helpers/pack'
|
||||
import { create_profile_and_install_from_file } from './helpers/pack'
|
||||
import { useError } from '@/store/error.js'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
|
||||
@@ -51,7 +51,7 @@ import { renderString } from '@modrinth/utils'
|
||||
import { useFetch } from '@/helpers/fetch.js'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { get as getCreds, logout, login } from '@/helpers/mr_auth.js'
|
||||
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 dayjs from 'dayjs'
|
||||
@@ -296,7 +296,7 @@ async function handleCommand(e) {
|
||||
if (e.event === 'RunMRPack') {
|
||||
// RunMRPack should directly install a local mrpack given a path
|
||||
if (e.path.endsWith('.mrpack')) {
|
||||
await install_from_file(e.path).catch(handleError)
|
||||
await create_profile_and_install_from_file(e.path).catch(handleError)
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModalFileDrop',
|
||||
})
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<script setup>
|
||||
import { onUnmounted, ref, computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { SpinnerIcon, GameIcon, TimerIcon, StopCircleIcon, PlayIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Avatar } from '@modrinth/ui'
|
||||
import {
|
||||
DownloadIcon,
|
||||
GameIcon,
|
||||
PlayIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { kill, run } from '@/helpers/profile'
|
||||
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'
|
||||
@@ -42,7 +49,8 @@ const modLoading = computed(
|
||||
currentEvent.value === 'installing' ||
|
||||
(currentEvent.value === 'launched' && !playing.value),
|
||||
)
|
||||
const installing = computed(() => props.instance.install_stage !== 'installed')
|
||||
const installing = computed(() => props.instance.install_stage.includes('installing'))
|
||||
const installed = computed(() => props.instance.install_stage === 'installed')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -84,6 +92,12 @@ const stop = async (e, context) => {
|
||||
})
|
||||
}
|
||||
|
||||
const repair = async (e) => {
|
||||
e?.stopPropagation()
|
||||
|
||||
await finish_install(props.instance)
|
||||
}
|
||||
|
||||
const openFolder = async () => {
|
||||
await showProfileInFolder(props.instance.path)
|
||||
}
|
||||
@@ -195,6 +209,15 @@ onUnmounted(() => unlisten())
|
||||
class="animate-spin w-8 h-8"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="'Repair'"
|
||||
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
|
||||
@click="(e) => repair(e)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="'Play'"
|
||||
|
||||
@@ -199,16 +199,16 @@
|
||||
<script setup>
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import {
|
||||
PlusIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
CodeIcon,
|
||||
FolderOpenIcon,
|
||||
InfoIcon,
|
||||
FolderSearchIcon,
|
||||
InfoIcon,
|
||||
PlusIcon,
|
||||
UpdatedIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Button, Chips, Checkbox } from '@modrinth/ui'
|
||||
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'
|
||||
@@ -218,7 +218,7 @@ import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import Multiselect from 'vue-multiselect'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_from_file } from '@/helpers/pack.js'
|
||||
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
|
||||
import {
|
||||
get_default_launcher_path,
|
||||
get_importable_instances,
|
||||
@@ -263,7 +263,7 @@ defineExpose({
|
||||
hide()
|
||||
const { paths } = event.payload
|
||||
if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) {
|
||||
await install_from_file(paths[0]).catch(handleError)
|
||||
await create_profile_and_install_from_file(paths[0]).catch(handleError)
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModalFileDrop',
|
||||
})
|
||||
@@ -419,7 +419,7 @@ const openFile = async () => {
|
||||
const newProject = await open({ multiple: false })
|
||||
if (!newProject) return
|
||||
hide()
|
||||
await install_from_file(newProject.path ?? newProject).catch(handleError)
|
||||
await create_profile_and_install_from_file(newProject.path ?? newProject).catch(handleError)
|
||||
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModalFileOpen',
|
||||
|
||||
@@ -20,7 +20,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
credentials: unknown | null
|
||||
signIn: () => void2
|
||||
signIn: () => void
|
||||
}>()
|
||||
|
||||
const userCredentials = computed(() => props.credentials)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { XIcon, DownloadIcon } from '@modrinth/assets'
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button } from '@modrinth/ui'
|
||||
import { install as pack_install } from '@/helpers/pack'
|
||||
import { create_profile_and_install as pack_install } from '@/helpers/pack'
|
||||
import { ref } from 'vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { handleError } from '@/store/state.js'
|
||||
|
||||
@@ -7,7 +7,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { create } from './profile'
|
||||
|
||||
// Installs pack from a version ID
|
||||
export async function install(projectId, versionId, packTitle, iconUrl) {
|
||||
export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
|
||||
const location = {
|
||||
type: 'fromVersionId',
|
||||
project_id: projectId,
|
||||
@@ -28,8 +28,18 @@ export async function install(projectId, versionId, packTitle, iconUrl) {
|
||||
return await invoke('plugin:pack|pack_install', { location, profile })
|
||||
}
|
||||
|
||||
export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
|
||||
const location = {
|
||||
type: 'fromVersionId',
|
||||
project_id: projectId,
|
||||
version_id: versionId,
|
||||
title,
|
||||
}
|
||||
return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
|
||||
}
|
||||
|
||||
// Installs pack from a path
|
||||
export async function install_from_file(path) {
|
||||
export async function create_profile_and_install_from_file(path) {
|
||||
const location = {
|
||||
type: 'fromFile',
|
||||
path: path,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { install_to_existing_profile } from '@/helpers/pack.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
|
||||
/// Add instance
|
||||
/*
|
||||
@@ -186,3 +188,17 @@ export async function edit(path, editProfile) {
|
||||
export async function edit_icon(path, iconPath) {
|
||||
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
|
||||
}
|
||||
|
||||
export async function finish_install(instance) {
|
||||
if (instance.install_stage !== 'pack_installed') {
|
||||
let linkedData = instance.linked_data
|
||||
await install_to_existing_profile(
|
||||
linkedData.project_id,
|
||||
linkedData.version_id,
|
||||
instance.name,
|
||||
instance.path,
|
||||
).catch(handleError)
|
||||
} else {
|
||||
await install(instance.path, false).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
7
apps/app-frontend/src/helpers/types.d.ts
vendored
7
apps/app-frontend/src/helpers/types.d.ts
vendored
@@ -32,7 +32,12 @@ type GameInstance = {
|
||||
hooks: Hooks
|
||||
}
|
||||
|
||||
type InstallStage = 'installed' | 'installing' | 'pack_installing' | 'not_installed'
|
||||
type InstallStage =
|
||||
| 'installed'
|
||||
| 'minecraft_installing'
|
||||
| 'pack_installed'
|
||||
| 'pack_installing'
|
||||
| 'not_installed'
|
||||
|
||||
type LinkedData = {
|
||||
project_id: ModrinthId
|
||||
|
||||
@@ -30,9 +30,23 @@
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled v-if="instance.install_stage !== 'installed'" color="brand" size="large">
|
||||
<ButtonStyled
|
||||
v-if="instance.install_stage.includes('installing')"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Installing...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="instance.install_stage !== 'installed'"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button @click="repairInstance()">
|
||||
<DownloadIcon />
|
||||
Repair
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
||||
<button @click="stopInstance('InstancePage')">
|
||||
<StopCircleIcon />
|
||||
@@ -137,38 +151,39 @@
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
ContentPageHeader,
|
||||
ButtonStyled,
|
||||
OverflowMenu,
|
||||
ContentPageHeader,
|
||||
LoadingIndicator,
|
||||
OverflowMenu,
|
||||
} from '@modrinth/ui'
|
||||
import {
|
||||
UserPlusIcon,
|
||||
ServerIcon,
|
||||
PackageIcon,
|
||||
SettingsIcon,
|
||||
PlayIcon,
|
||||
StopCircleIcon,
|
||||
EditIcon,
|
||||
FolderOpenIcon,
|
||||
ClipboardCopyIcon,
|
||||
PlusIcon,
|
||||
ExternalIcon,
|
||||
HashIcon,
|
||||
GlobeIcon,
|
||||
EyeIcon,
|
||||
XIcon,
|
||||
CheckCircleIcon,
|
||||
UpdatedIcon,
|
||||
MoreVerticalIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
GameIcon,
|
||||
GlobeIcon,
|
||||
HashIcon,
|
||||
MoreVerticalIcon,
|
||||
PackageIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
UpdatedIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { get, get_full_path, kill, run } from '@/helpers/profile'
|
||||
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 { ref, onUnmounted, computed, watch } from 'vue'
|
||||
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'
|
||||
@@ -294,6 +309,10 @@ const stopInstance = async (context) => {
|
||||
})
|
||||
}
|
||||
|
||||
const repairInstance = async () => {
|
||||
await finish_install(instance.value)
|
||||
}
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
const baseOptions = [
|
||||
{ name: 'add_content' },
|
||||
|
||||
@@ -2,14 +2,14 @@ import { defineStore } from 'pinia'
|
||||
import {
|
||||
add_project_from_version,
|
||||
check_installed,
|
||||
list,
|
||||
get,
|
||||
get_projects,
|
||||
list,
|
||||
remove_project,
|
||||
} from '@/helpers/profile.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { get_project, get_version_many } from '@/helpers/cache.js'
|
||||
import { install as packInstall } from '@/helpers/pack.js'
|
||||
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
|
||||
import { trackEvent } from '@/helpers/analytics.js'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
)]
|
||||
|
||||
use native_dialog::{MessageDialog, MessageType};
|
||||
use std::env;
|
||||
use tauri::{Listener, Manager};
|
||||
use theseus::prelude::*;
|
||||
|
||||
@@ -29,7 +30,12 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
||||
theseus::EventState::init(app.clone()).await?;
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
{
|
||||
'updater: {
|
||||
if env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_ok() {
|
||||
State::init().await?;
|
||||
break 'updater;
|
||||
}
|
||||
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
let updater = app.updater_builder().build()?;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"admin.billing.error.not-found": {
|
||||
"message": "User not found"
|
||||
},
|
||||
"auth.authorize.action.authorize": {
|
||||
"message": "Authorize"
|
||||
},
|
||||
@@ -338,6 +341,12 @@
|
||||
"layout.nav.search": {
|
||||
"message": "Search"
|
||||
},
|
||||
"profile.button.billing": {
|
||||
"message": "Manage user billing"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"message": "View user details"
|
||||
},
|
||||
"profile.button.manage-projects": {
|
||||
"message": "Manage projects"
|
||||
},
|
||||
|
||||
@@ -184,7 +184,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewModal ref="downloadModal">
|
||||
<NewModal
|
||||
ref="downloadModal"
|
||||
:on-show="
|
||||
() => {
|
||||
navigateTo({ query: route.query, hash: '#download' });
|
||||
}
|
||||
"
|
||||
:on-hide="
|
||||
() => {
|
||||
navigateTo({ query: route.query, hash: '' });
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
||||
<div class="truncate text-lg font-extrabold text-contrast">
|
||||
@@ -275,7 +287,7 @@
|
||||
</div>
|
||||
<ScrollablePanel :class="project.game_versions.length > 4 ? 'h-[15rem]' : ''">
|
||||
<ButtonStyled
|
||||
v-for="version in project.game_versions
|
||||
v-for="gameVersion in project.game_versions
|
||||
.filter(
|
||||
(x) =>
|
||||
(versionFilter && x.includes(versionFilter)) ||
|
||||
@@ -284,30 +296,39 @@
|
||||
)
|
||||
.slice()
|
||||
.reverse()"
|
||||
:key="version"
|
||||
:color="currentGameVersion === version ? 'brand' : 'standard'"
|
||||
:key="gameVersion"
|
||||
:color="currentGameVersion === gameVersion ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!possibleGameVersions.includes(version)
|
||||
? `${project.title} does not support ${version} for ${formatCategory(currentPlatform)}`
|
||||
!possibleGameVersions.includes(gameVersion)
|
||||
? `${project.title} does not support ${gameVersion} for ${formatCategory(currentPlatform)}`
|
||||
: null
|
||||
"
|
||||
:class="{
|
||||
'looks-disabled !text-brand-red': !possibleGameVersions.includes(version),
|
||||
'looks-disabled !text-brand-red': !possibleGameVersions.includes(gameVersion),
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
userSelectedGameVersion = version;
|
||||
userSelectedGameVersion = gameVersion;
|
||||
gameVersionAccordion.close();
|
||||
if (!currentPlatform && platformAccordion) {
|
||||
platformAccordion.open();
|
||||
}
|
||||
|
||||
navigateTo({
|
||||
query: {
|
||||
...route.query,
|
||||
...(userSelectedGameVersion && { version: userSelectedGameVersion }),
|
||||
...(userSelectedPlatform && { loader: userSelectedPlatform }),
|
||||
},
|
||||
hash: route.hash,
|
||||
});
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ version }}
|
||||
<CheckIcon v-if="userSelectedGameVersion === version" />
|
||||
{{ gameVersion }}
|
||||
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ScrollablePanel>
|
||||
@@ -379,6 +400,15 @@
|
||||
if (!currentGameVersion && gameVersionAccordion) {
|
||||
gameVersionAccordion.open();
|
||||
}
|
||||
|
||||
navigateTo({
|
||||
query: {
|
||||
...route.query,
|
||||
...(userSelectedGameVersion && { version: userSelectedGameVersion }),
|
||||
...(userSelectedPlatform && { loader: userSelectedPlatform }),
|
||||
},
|
||||
hash: route.hash,
|
||||
});
|
||||
}
|
||||
"
|
||||
>
|
||||
@@ -506,7 +536,7 @@
|
||||
placeholder="Search collections..."
|
||||
class="search-input menu-search"
|
||||
/>
|
||||
<div v-if="collections.length > 0" class="collections-list">
|
||||
<div v-if="collections.length > 0" class="collections-list text-primary">
|
||||
<Checkbox
|
||||
v-for="option in collections
|
||||
.slice()
|
||||
@@ -785,31 +815,31 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
ScaleIcon,
|
||||
AlignLeftIcon as DescriptionIcon,
|
||||
BookmarkIcon,
|
||||
BookTextIcon,
|
||||
CalendarIcon,
|
||||
ChartIcon,
|
||||
CheckIcon,
|
||||
ClipboardCopyIcon,
|
||||
CopyrightIcon,
|
||||
AlignLeftIcon as DescriptionIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
ImageIcon as GalleryIcon,
|
||||
GameIcon,
|
||||
HeartIcon,
|
||||
ImageIcon as GalleryIcon,
|
||||
InfoIcon,
|
||||
LinkIcon as LinksIcon,
|
||||
MoreVerticalIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
BookTextIcon,
|
||||
CalendarIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -818,32 +848,33 @@ import {
|
||||
NewModal,
|
||||
OverflowMenu,
|
||||
PopoutMenu,
|
||||
ScrollablePanel,
|
||||
ProjectBackgroundGradient,
|
||||
ProjectHeader,
|
||||
ProjectSidebarCompatibility,
|
||||
ProjectSidebarCreators,
|
||||
ProjectSidebarLinks,
|
||||
ProjectSidebarDetails,
|
||||
ProjectBackgroundGradient,
|
||||
ProjectSidebarLinks,
|
||||
ScrollablePanel,
|
||||
} from "@modrinth/ui";
|
||||
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
||||
import dayjs from "dayjs";
|
||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
||||
import { navigateTo } from "#app";
|
||||
import dayjs from "dayjs";
|
||||
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import { userCollectProject } from "~/composables/user.js";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
|
||||
const data = useNuxtApp();
|
||||
const route = useNativeRoute();
|
||||
@@ -1247,6 +1278,23 @@ if (!route.name.startsWith("type-id-settings")) {
|
||||
|
||||
const onUserCollectProject = useClientTry(userCollectProject);
|
||||
|
||||
const { version, loader } = route.query;
|
||||
if (version !== undefined && project.value.game_versions.includes(version)) {
|
||||
userSelectedGameVersion.value = version;
|
||||
}
|
||||
if (loader !== undefined && project.value.loaders.includes(loader)) {
|
||||
userSelectedPlatform.value = loader;
|
||||
}
|
||||
|
||||
watch(downloadModal, (modal) => {
|
||||
if (!modal) return;
|
||||
|
||||
// route.hash returns everything in the hash string, including the # itself
|
||||
if (route.hash === "#download") {
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
|
||||
async function setProcessing() {
|
||||
startLoading();
|
||||
|
||||
|
||||
@@ -821,6 +821,13 @@ export default defineNuxtComponent({
|
||||
if (route.query.version) {
|
||||
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version));
|
||||
}
|
||||
if (versionList.length === 0) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: "No version matches the filters",
|
||||
});
|
||||
}
|
||||
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b));
|
||||
} else {
|
||||
version = props.versions.find((x) => x.id === route.params.version);
|
||||
|
||||
220
apps/frontend/src/pages/admin/billing/[id].vue
Normal file
220
apps/frontend/src/pages/admin/billing/[id].vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<NewModal ref="refundModal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">Refund charge</span>
|
||||
</template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="visibility" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Refund type
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span> The type of refund to issue. </span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="refund-type"
|
||||
v-model="refundType"
|
||||
:options="refundTypes"
|
||||
name="Refund type"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="refundType === 'partial'" class="flex flex-col gap-2">
|
||||
<label for="amount" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Amount
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>
|
||||
Enter the amount in cents of USD. For example for $2, enter 200. (net
|
||||
{{ selectedCharge.net }})
|
||||
</span>
|
||||
</label>
|
||||
<input id="amount" v-model="refundAmount" type="number" autocomplete="off" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="unprovision" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Unprovision
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span> Whether or not the subscription should be unprovisioned on refund. </span>
|
||||
</label>
|
||||
<Toggle
|
||||
id="unprovision"
|
||||
:model-value="unprovision"
|
||||
:checked="unprovision"
|
||||
@update:model-value="() => (unprovision = !unprovision)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="refunding" @click="refundCharge">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Refund charge
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="refundModal.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="normal-page no-sidebar">
|
||||
<h1>{{ user.username }}'s subscriptions</h1>
|
||||
<div class="normal-page__content">
|
||||
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
|
||||
<span class="font-extrabold text-contrast">
|
||||
<template v-if="subscription.product.metadata.type === 'midas'"> Modrinth Plus </template>
|
||||
<template v-else-if="subscription.product.metadata.type === 'pyro'">
|
||||
Modrinth Servers
|
||||
</template>
|
||||
<template v-else> Unknown product </template>
|
||||
<template v-if="subscription.interval">
|
||||
{{ subscription.interval }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="mb-4 mt-2 flex items-center gap-1">
|
||||
{{ subscription.status }} ⋅ {{ $dayjs(subscription.created).format("YYYY-MM-DD") }}
|
||||
<template v-if="subscription.metadata?.id"> ⋅ {{ subscription.metadata.id }}</template>
|
||||
</div>
|
||||
<div
|
||||
v-for="charge in subscription.charges"
|
||||
:key="charge.id"
|
||||
class="universal-card recessed flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge
|
||||
:color="charge.status === 'succeeded' ? 'green' : 'red'"
|
||||
:type="charge.status"
|
||||
/>
|
||||
⋅
|
||||
{{ charge.type }}
|
||||
⋅
|
||||
{{ $dayjs(charge.due).format("YYYY-MM-DD") }}
|
||||
⋅
|
||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||
<template v-if="subscription.interval"> ⋅ {{ subscription.interval }} </template>
|
||||
</div>
|
||||
<button
|
||||
v-if="charge.status === 'succeeded' && charge.type !== 'refund'"
|
||||
class="btn"
|
||||
@click="showRefundModal(charge)"
|
||||
>
|
||||
Refund charge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge, NewModal, ButtonStyled, DropdownSelect, Toggle } from "@modrinth/ui";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { CheckIcon, XIcon } from "@modrinth/assets";
|
||||
import { products } from "~/generated/state.json";
|
||||
|
||||
const route = useRoute();
|
||||
const data = useNuxtApp();
|
||||
const vintl = useVIntl();
|
||||
const { formatMessage } = vintl;
|
||||
|
||||
const messages = defineMessages({
|
||||
userNotFoundError: {
|
||||
id: "admin.billing.error.not-found",
|
||||
defaultMessage: "User not found",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: user } = await useAsyncData(`user/${route.params.id}`, () =>
|
||||
useBaseFetch(`user/${route.params.id}`),
|
||||
);
|
||||
|
||||
if (!user.value) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.userNotFoundError),
|
||||
});
|
||||
}
|
||||
|
||||
let subscriptions, charges, refreshCharges;
|
||||
try {
|
||||
[{ data: subscriptions }, { data: charges, refresh: refreshCharges }] = await Promise.all([
|
||||
useAsyncData(`billing/subscriptions?user_id=${route.params.id}`, () =>
|
||||
useBaseFetch(`billing/subscriptions?user_id=${user.value.id}`, {
|
||||
internal: true,
|
||||
}),
|
||||
),
|
||||
useAsyncData(`billing/payments?user_id=${route.params.id}`, () =>
|
||||
useBaseFetch(`billing/payments?user_id=${user.value.id}`, {
|
||||
internal: true,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.userNotFoundError),
|
||||
});
|
||||
}
|
||||
|
||||
const subscriptionCharges = computed(() => {
|
||||
return subscriptions.value.map((subscription) => {
|
||||
return {
|
||||
...subscription,
|
||||
charges: charges.value.filter((charge) => charge.subscription_id === subscription.id),
|
||||
product: products.find((product) =>
|
||||
product.prices.some((price) => price.id === subscription.price_id),
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const refunding = ref(false);
|
||||
const refundModal = ref();
|
||||
const selectedCharge = ref(null);
|
||||
const refundType = ref("full");
|
||||
const refundTypes = ref(["full", "partial"]);
|
||||
const refundAmount = ref(0);
|
||||
const unprovision = ref(false);
|
||||
|
||||
function showRefundModal(charge) {
|
||||
selectedCharge.value = charge;
|
||||
refundType.value = "full";
|
||||
refundAmount.value = 0;
|
||||
unprovision.value = false;
|
||||
refundModal.value.show();
|
||||
}
|
||||
|
||||
async function refundCharge() {
|
||||
refunding.value = true;
|
||||
try {
|
||||
await useBaseFetch(`billing/charge/${selectedCharge.value.id}/refund`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: refundType.value,
|
||||
amount: refundAmount.value,
|
||||
unprovision: unprovision.value,
|
||||
}),
|
||||
internal: true,
|
||||
});
|
||||
await refreshCharges();
|
||||
refundModal.value.hide();
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "Error refunding",
|
||||
text: err.data?.description ?? err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
refunding.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -38,9 +38,13 @@
|
||||
<div class="withdraw-options-scroll">
|
||||
<div class="withdraw-options">
|
||||
<button
|
||||
v-for="method in payoutMethods.filter((x) =>
|
||||
x.name.toLowerCase().includes(search.toLowerCase()),
|
||||
)"
|
||||
v-for="method in payoutMethods
|
||||
.filter((x) => x.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) =>
|
||||
a.type !== 'tremendous'
|
||||
? -1
|
||||
: a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||
)"
|
||||
:key="method.id"
|
||||
class="withdraw-option button-base"
|
||||
:class="{ selected: selectedMethodId === method.id }"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</template>
|
||||
</span>
|
||||
⋅
|
||||
<span>{{ formatPrice(charge.amount, charge.currency_code) }}</span>
|
||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge :color="charge.status === 'succeeded' ? 'green' : 'red'" :type="charge.status" />
|
||||
@@ -39,6 +39,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, Badge } from "@modrinth/ui";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { products } from "~/generated/state.json";
|
||||
|
||||
definePageMeta({
|
||||
@@ -66,19 +67,4 @@ const { data: charges } = await useAsyncData(
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// TODO move to omorphia utils , duplicated from index
|
||||
function formatPrice(price, currency) {
|
||||
const formatter = new Intl.NumberFormat(vintl.locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
});
|
||||
|
||||
const maxDigits = formatter.resolvedOptions().maximumFractionDigits;
|
||||
|
||||
const convertedPrice = price / Math.pow(10, maxDigits);
|
||||
|
||||
return formatter.format(convertedPrice);
|
||||
}
|
||||
console.log(charges);
|
||||
</script>
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
v-else-if="getPyroCharge(subscription).status === 'processing'"
|
||||
class="text-sm text-orange"
|
||||
>
|
||||
Your payment is being processed. Perks will activate once payment is
|
||||
Your payment is being processed. Your server will activate once payment is
|
||||
complete.
|
||||
</span>
|
||||
<span
|
||||
@@ -270,7 +270,8 @@
|
||||
v-else-if="getPyroCharge(subscription).status === 'failed'"
|
||||
class="text-sm text-red"
|
||||
>
|
||||
Your subscription payment failed. Please update your payment method.
|
||||
Your subscription payment failed. Please update your payment method, then
|
||||
resubscribe.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,7 +279,8 @@
|
||||
<ButtonStyled
|
||||
v-if="
|
||||
getPyroCharge(subscription) &&
|
||||
getPyroCharge(subscription).status !== 'cancelled'
|
||||
getPyroCharge(subscription).status !== 'cancelled' &&
|
||||
getPyroCharge(subscription).status !== 'failed'
|
||||
"
|
||||
type="standard"
|
||||
@click="showPyroCancelModal(subscription.id)"
|
||||
@@ -291,7 +293,8 @@
|
||||
<ButtonStyled
|
||||
v-else-if="
|
||||
getPyroCharge(subscription) &&
|
||||
getPyroCharge(subscription).status === 'cancelled'
|
||||
(getPyroCharge(subscription).status === 'cancelled' ||
|
||||
getPyroCharge(subscription).status === 'failed')
|
||||
"
|
||||
type="standard"
|
||||
color="green"
|
||||
|
||||
@@ -2,6 +2,57 @@
|
||||
<div v-if="user" class="experimental-styles-within">
|
||||
<ModalCreation ref="modal_creation" />
|
||||
<CollectionCreateModal ref="modal_collection_creation" />
|
||||
<NewModal v-if="auth.user && isStaff(auth.user)" ref="userDetailsModal" header="User details">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary">Email</span>
|
||||
<div>
|
||||
<span
|
||||
v-tooltip="user.email_verified ? 'Email verified' : 'Email not verified'"
|
||||
class="flex w-fit items-center gap-1"
|
||||
>
|
||||
<span>{{ user.email }}</span>
|
||||
<CheckIcon v-if="user.email_verified" class="h-4 w-4 text-brand" />
|
||||
<XIcon v-else class="h-4 w-4 text-red" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Auth providers </span>
|
||||
<span>{{ user.auth_providers.join(", ") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Payment methods</span>
|
||||
<span>
|
||||
<template v-if="user.payout_data?.paypal_address">
|
||||
Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }})
|
||||
</template>
|
||||
<template v-if="user.payout_data?.paypal_address && user.payout_data?.venmo_address">
|
||||
,
|
||||
</template>
|
||||
<template v-if="user.payout_data?.venmo_address">
|
||||
Venmo ({{ user.payout_data.venmo_address }})
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Has password </span>
|
||||
<span>
|
||||
{{ user.has_password ? "Yes" : "No" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Has TOTP </span>
|
||||
<span>
|
||||
{{ user.has_totp ? "Yes" : "No" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
|
||||
<div class="normal-page__header py-4">
|
||||
<ContentPageHeader>
|
||||
@@ -74,6 +125,16 @@
|
||||
shown: auth.user?.id !== user.id,
|
||||
},
|
||||
{ id: 'copy-id', action: () => copyId() },
|
||||
{
|
||||
id: 'open-billing',
|
||||
action: () => navigateTo(`/admin/billing/${user.id}`),
|
||||
shown: auth.user && isStaff(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'open-info',
|
||||
action: () => $refs.userDetailsModal.show(),
|
||||
shown: auth.user && isStaff(auth.user),
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
@@ -90,6 +151,14 @@
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.copyIdButton) }}
|
||||
</template>
|
||||
<template #open-billing>
|
||||
<CurrencyIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.billingButton) }}
|
||||
</template>
|
||||
<template #open-info>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.infoButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -264,8 +333,18 @@ import {
|
||||
DownloadIcon,
|
||||
ClipboardCopyIcon,
|
||||
MoreVerticalIcon,
|
||||
CurrencyIcon,
|
||||
InfoIcon,
|
||||
CheckIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { OverflowMenu, ButtonStyled, ContentPageHeader, commonMessages } from "@modrinth/ui";
|
||||
import {
|
||||
OverflowMenu,
|
||||
ButtonStyled,
|
||||
ContentPageHeader,
|
||||
commonMessages,
|
||||
NewModal,
|
||||
} from "@modrinth/ui";
|
||||
import { isStaff } from "~/helpers/users.js";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import ProjectCard from "~/components/ui/ProjectCard.vue";
|
||||
import { reportUser } from "~/utils/report-helpers.ts";
|
||||
@@ -367,6 +446,14 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
"You don't have any collections.\nWould you like to <create-link>create one</create-link>?",
|
||||
},
|
||||
billingButton: {
|
||||
id: "profile.button.billing",
|
||||
defaultMessage: "Manage user billing",
|
||||
},
|
||||
infoButton: {
|
||||
id: "profile.button.info",
|
||||
defaultMessage: "View user details",
|
||||
},
|
||||
userNotFoundError: {
|
||||
id: "profile.error.not-found",
|
||||
defaultMessage: "User not found",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.81.0 as build
|
||||
FROM rust:1.84.0 as build
|
||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||
|
||||
WORKDIR /usr/src/labrinth
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
ALTER TABLE version_fields
|
||||
DROP CONSTRAINT version_fields_enum_value_fkey;
|
||||
|
||||
ALTER TABLE version_fields
|
||||
ALTER COLUMN enum_value SET DEFAULT -1;
|
||||
|
||||
UPDATE version_fields SET enum_value = -1 WHERE enum_value IS NULL;
|
||||
|
||||
ALTER TABLE version_fields
|
||||
ALTER COLUMN enum_value SET NOT NULL;
|
||||
|
||||
WITH CTE AS (
|
||||
SELECT ctid,
|
||||
ROW_NUMBER() OVER (PARTITION BY version_id, field_id, enum_value ORDER BY ctid) AS row_num
|
||||
FROM version_fields
|
||||
)
|
||||
DELETE FROM version_fields
|
||||
WHERE ctid IN (
|
||||
SELECT ctid
|
||||
FROM CTE
|
||||
WHERE row_num > 1
|
||||
);
|
||||
|
||||
ALTER TABLE version_fields
|
||||
ADD PRIMARY KEY (version_id, field_id, enum_value);
|
||||
|
||||
ALTER TABLE loader_fields_loaders
|
||||
ADD PRIMARY KEY (loader_id, loader_field_id);
|
||||
@@ -757,7 +757,7 @@ impl VersionField {
|
||||
l.field_id.0,
|
||||
l.version_id.0,
|
||||
l.int_value,
|
||||
l.enum_value.as_ref().map(|e| e.0),
|
||||
l.enum_value.as_ref().map(|e| e.0).unwrap_or(-1),
|
||||
l.string_value.clone(),
|
||||
)
|
||||
})
|
||||
@@ -772,7 +772,7 @@ impl VersionField {
|
||||
&version_ids[..],
|
||||
&int_values[..] as &[Option<i32>],
|
||||
&string_values[..] as &[Option<String>],
|
||||
&enum_values[..] as &[Option<i32>]
|
||||
&enum_values[..] as &[i32]
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
@@ -595,12 +595,12 @@ impl Project {
|
||||
version_id: VersionId(m.version_id),
|
||||
field_id: LoaderFieldId(m.field_id),
|
||||
int_value: m.int_value,
|
||||
enum_value: m.enum_value.map(LoaderFieldEnumValueId),
|
||||
enum_value: if m.enum_value == -1 { None } else { Some(LoaderFieldEnumValueId(m.enum_value)) },
|
||||
string_value: m.string_value,
|
||||
};
|
||||
|
||||
if let Some(enum_value) = m.enum_value {
|
||||
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(enum_value));
|
||||
if m.enum_value != -1 {
|
||||
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(m.enum_value));
|
||||
}
|
||||
|
||||
acc.entry(ProjectId(m.mod_id)).or_default().push(qvf);
|
||||
|
||||
@@ -405,7 +405,7 @@ impl TeamMember {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete<'a, 'b>(
|
||||
pub async fn delete(
|
||||
id: TeamId,
|
||||
user_id: UserId,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
|
||||
@@ -499,12 +499,12 @@ impl Version {
|
||||
version_id: VersionId(m.version_id),
|
||||
field_id: LoaderFieldId(m.field_id),
|
||||
int_value: m.int_value,
|
||||
enum_value: m.enum_value.map(LoaderFieldEnumValueId),
|
||||
enum_value: if m.enum_value == -1 { None } else { Some(LoaderFieldEnumValueId(m.enum_value)) },
|
||||
string_value: m.string_value,
|
||||
};
|
||||
|
||||
if let Some(enum_value) = m.enum_value {
|
||||
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(enum_value));
|
||||
if m.enum_value != -1 {
|
||||
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(m.enum_value));
|
||||
}
|
||||
|
||||
acc.entry(VersionId(m.version_id)).or_default().push(qvf);
|
||||
|
||||
@@ -161,7 +161,7 @@ pub struct Charge {
|
||||
pub id: ChargeId,
|
||||
pub user_id: UserId,
|
||||
pub price_id: ProductPriceId,
|
||||
pub amount: u64,
|
||||
pub amount: i64,
|
||||
pub currency_code: String,
|
||||
pub status: ChargeStatus,
|
||||
pub due: DateTime<Utc>,
|
||||
@@ -171,6 +171,9 @@ pub struct Charge {
|
||||
pub subscription_id: Option<UserSubscriptionId>,
|
||||
pub subscription_interval: Option<PriceDuration>,
|
||||
pub platform: PaymentPlatform,
|
||||
|
||||
pub parent_charge_id: Option<ChargeId>,
|
||||
pub net: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
|
||||
@@ -189,7 +189,7 @@ pub struct DelphiIngest {
|
||||
pub url: String,
|
||||
pub project_id: crate::models::ids::ProjectId,
|
||||
pub version_id: crate::models::ids::VersionId,
|
||||
pub issues: Vec<String>,
|
||||
pub issues: HashMap<String, HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[post("/_delphi", guard = "admin_key_guard")]
|
||||
@@ -218,29 +218,41 @@ pub async fn delphi_result_ingest(
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut header = format!("Suspicious traces found at {}", body.url);
|
||||
|
||||
for (issue, trace) in &body.issues {
|
||||
for (path, code) in trace {
|
||||
header.push_str(&format!(
|
||||
"\n issue {issue} found at file {}: \n ```\n{}\n```",
|
||||
path, code
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
crate::util::webhook::send_slack_webhook(
|
||||
body.project_id,
|
||||
&pool,
|
||||
&redis,
|
||||
webhook_url,
|
||||
Some(format!(
|
||||
"Suspicious traces found at {}. Traces: {}",
|
||||
body.url,
|
||||
body.issues.join(", ")
|
||||
)),
|
||||
Some(header),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let mut thread_header = format!("Suspicious traces found at [version {}](https://modrinth.com/project/{}/version/{})", body.version_id, body.project_id, body.version_id);
|
||||
|
||||
for (issue, trace) in &body.issues {
|
||||
for path in trace.keys() {
|
||||
thread_header
|
||||
.push_str(&format!("\n issue {issue} found at file {}", path));
|
||||
}
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
ThreadMessageBuilder {
|
||||
author_id: Some(crate::database::models::UserId(AUTOMOD_ID)),
|
||||
body: MessageBody::Text {
|
||||
body: format!(
|
||||
"WSR; Suspicious traces found for version_id {}. Traces: {}",
|
||||
body.version_id,
|
||||
body.issues.join(", ")
|
||||
),
|
||||
body: thread_header,
|
||||
private: true,
|
||||
replying_to: None,
|
||||
associated_images: vec![],
|
||||
|
||||
@@ -83,12 +83,18 @@ pub async fn products(
|
||||
Ok(HttpResponse::Ok().json(products))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SubscriptionsQuery {
|
||||
pub user_id: Option<crate::models::ids::UserId>,
|
||||
}
|
||||
|
||||
#[get("subscriptions")]
|
||||
pub async fn subscriptions(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
query: web::Query<SubscriptionsQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
@@ -102,7 +108,18 @@ pub async fn subscriptions(
|
||||
|
||||
let subscriptions =
|
||||
user_subscription_item::UserSubscriptionItem::get_all_user(
|
||||
user.id.into(),
|
||||
if let Some(user_id) = query.user_id {
|
||||
if user.role.is_admin() {
|
||||
user_id.into()
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You cannot see the subscriptions of other users!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
user.id.into()
|
||||
},
|
||||
&**pool,
|
||||
)
|
||||
.await?
|
||||
@@ -573,12 +590,18 @@ pub async fn user_customer(
|
||||
Ok(HttpResponse::Ok().json(customer))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChargesQuery {
|
||||
pub user_id: Option<crate::models::ids::UserId>,
|
||||
}
|
||||
|
||||
#[get("payments")]
|
||||
pub async fn charges(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
query: web::Query<ChargesQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
@@ -592,7 +615,18 @@ pub async fn charges(
|
||||
|
||||
let charges =
|
||||
crate::database::models::charge_item::ChargeItem::get_from_user(
|
||||
user.id.into(),
|
||||
if let Some(user_id) = query.user_id {
|
||||
if user.role.is_admin() {
|
||||
user_id.into()
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You cannot see the subscriptions of other users!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
user.id.into()
|
||||
},
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -604,7 +638,7 @@ pub async fn charges(
|
||||
id: x.id.into(),
|
||||
user_id: x.user_id.into(),
|
||||
price_id: x.price_id.into(),
|
||||
amount: x.amount as u64,
|
||||
amount: x.amount,
|
||||
currency_code: x.currency_code,
|
||||
status: x.status,
|
||||
due: x.due,
|
||||
@@ -613,6 +647,8 @@ pub async fn charges(
|
||||
subscription_id: x.subscription_id.map(|x| x.into()),
|
||||
subscription_interval: x.subscription_interval,
|
||||
platform: x.payment_platform,
|
||||
parent_charge_id: x.parent_charge_id.map(|x| x.into()),
|
||||
net: if user.role.is_admin() { x.net } else { None },
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
@@ -880,11 +916,11 @@ pub async fn active_servers(
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let master_key = dotenvy::var("PYRO_API_KEY")?;
|
||||
|
||||
if !req
|
||||
if req
|
||||
.head()
|
||||
.headers()
|
||||
.get("X-Master-Key")
|
||||
.map_or(false, |it| it.as_bytes() == master_key.as_bytes())
|
||||
.is_none_or(|it| it.as_bytes() != master_key.as_bytes())
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"Invalid master key".to_string(),
|
||||
|
||||
@@ -85,11 +85,13 @@ pub async fn users_get(
|
||||
|
||||
#[get("{id}")]
|
||||
pub async fn user_get(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let response = v3::users::user_get(info, pool, redis)
|
||||
let response = v3::users::user_get(req, info, pool, redis, session_queue)
|
||||
.await
|
||||
.or_else(v2_reroute::flatten_404_error)?;
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ pub struct NewOAuthApp {
|
||||
}
|
||||
|
||||
#[post("app")]
|
||||
pub async fn oauth_client_create<'a>(
|
||||
pub async fn oauth_client_create(
|
||||
req: HttpRequest,
|
||||
new_oauth_app: web::Json<NewOAuthApp>,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -217,7 +217,7 @@ pub async fn oauth_client_create<'a>(
|
||||
}
|
||||
|
||||
#[delete("app/{id}")]
|
||||
pub async fn oauth_client_delete<'a>(
|
||||
pub async fn oauth_client_delete(
|
||||
req: HttpRequest,
|
||||
client_id: web::Path<ApiOAuthClientId>,
|
||||
pool: web::Data<PgPool>,
|
||||
|
||||
@@ -86,8 +86,6 @@ pub enum CreateError {
|
||||
CustomAuthenticationError(String),
|
||||
#[error("Image Parsing Error: {0}")]
|
||||
ImageError(#[from] ImageError),
|
||||
#[error("Reroute Error: {0}")]
|
||||
RerouteError(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for CreateError {
|
||||
@@ -119,7 +117,6 @@ impl actix_web::ResponseError for CreateError {
|
||||
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::RerouteError(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +143,6 @@ impl actix_web::ResponseError for CreateError {
|
||||
CreateError::ValidationError(..) => "invalid_input",
|
||||
CreateError::FileValidationError(..) => "invalid_input",
|
||||
CreateError::ImageError(..) => "invalid_image",
|
||||
CreateError::RerouteError(..) => "reroute_error",
|
||||
},
|
||||
description: self.to_string(),
|
||||
})
|
||||
|
||||
@@ -128,14 +128,33 @@ pub async fn users_get(
|
||||
}
|
||||
|
||||
pub async fn user_get(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user_data = User::get(&info.into_inner().0, &**pool, &redis).await?;
|
||||
|
||||
if let Some(data) = user_data {
|
||||
let response: crate::models::users::User = data.into();
|
||||
let auth_user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::SESSION_ACCESS]),
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.ok();
|
||||
|
||||
let response: crate::models::users::User =
|
||||
if auth_user.map(|x| x.role.is_admin()).unwrap_or(false) {
|
||||
crate::models::users::User::from_full(data)
|
||||
} else {
|
||||
data.into()
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
|
||||
@@ -985,7 +985,7 @@ pub async fn upload_file(
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let delphi_url = dotenvy::var("DELPHI_URL")?;
|
||||
let res = client
|
||||
match client
|
||||
.post(delphi_url)
|
||||
.json(&serde_json::json!({
|
||||
"url": url,
|
||||
@@ -993,10 +993,16 @@ pub async fn upload_file(
|
||||
"version_id": version_id,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
error!("Failed to upload file to Delphi: {url}");
|
||||
.await
|
||||
{
|
||||
Ok(res) => {
|
||||
if !res.status().is_success() {
|
||||
error!("Failed to upload file to Delphi: {url}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to upload file to Delphi: {url}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
version_files.push(VersionFileBuilder {
|
||||
|
||||
@@ -505,7 +505,11 @@ async fn index_versions(
|
||||
version_id: VersionId(m.version_id),
|
||||
field_id: LoaderFieldId(m.field_id),
|
||||
int_value: m.int_value,
|
||||
enum_value: m.enum_value.map(LoaderFieldEnumValueId),
|
||||
enum_value: if m.enum_value == -1 {
|
||||
None
|
||||
} else {
|
||||
Some(LoaderFieldEnumValueId(m.enum_value))
|
||||
},
|
||||
string_value: m.string_value,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@ pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
||||
ctx.head()
|
||||
.headers()
|
||||
.get(ADMIN_KEY_HEADER)
|
||||
.map_or(false, |it| it.as_bytes() == admin_key.as_bytes())
|
||||
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
|
||||
}
|
||||
|
||||
@@ -180,9 +180,8 @@ pub async fn import_mmc(
|
||||
instance_folder: String, // instance folder in mmc_base_path
|
||||
profile_path: &str, // path to profile
|
||||
) -> crate::Result<()> {
|
||||
let mmc_instance_path = mmc_base_path
|
||||
.join("instances")
|
||||
.join(instance_folder.clone());
|
||||
let mmc_instance_path =
|
||||
mmc_base_path.join("instances").join(instance_folder);
|
||||
|
||||
let mmc_pack =
|
||||
io::read_to_string(&mmc_instance_path.join("mmc-pack.json")).await?;
|
||||
@@ -209,9 +208,18 @@ pub async fn import_mmc(
|
||||
profile_path: profile_path.to_string(),
|
||||
};
|
||||
|
||||
// Managed pack
|
||||
let backup_name = "Imported Modpack".to_string();
|
||||
let mut minecraft_folder = mmc_instance_path.join("minecraft");
|
||||
if !minecraft_folder.is_dir() {
|
||||
minecraft_folder = mmc_instance_path.join(".minecraft");
|
||||
if !minecraft_folder.is_dir() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance is missing Minecraft directory".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Managed pack
|
||||
if instance_cfg.managed_pack.unwrap_or(false) {
|
||||
match instance_cfg.managed_pack_type {
|
||||
Some(MMCManagedPackType::Modrinth) => {
|
||||
@@ -220,38 +228,26 @@ pub async fn import_mmc(
|
||||
|
||||
// Modrinth Managed Pack
|
||||
// Kept separate as we may in the future want to add special handling for modrinth managed packs
|
||||
let backup_name = "Imported Modrinth Modpack".to_string();
|
||||
let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join(".minecraft");
|
||||
import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?;
|
||||
import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modrinth Modpack".to_string(), description, mmc_pack).await?;
|
||||
}
|
||||
Some(MMCManagedPackType::Flame) | Some(MMCManagedPackType::ATLauncher) => {
|
||||
// For flame/atlauncher managed packs
|
||||
// Treat as unmanaged, but with 'minecraft' folder instead of '.minecraft'
|
||||
let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join("minecraft");
|
||||
import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?;
|
||||
import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modpack".to_string(), description, mmc_pack).await?;
|
||||
},
|
||||
Some(_) => {
|
||||
// For managed packs that aren't modrinth, flame, atlauncher
|
||||
// Treat as unmanaged
|
||||
let backup_name = "ImportedModpack".to_string();
|
||||
let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join(".minecraft");
|
||||
import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?;
|
||||
import_mmc_unmanaged(profile_path, minecraft_folder, "ImportedModpack".to_string(), description, mmc_pack).await?;
|
||||
},
|
||||
_ => return Err(crate::ErrorKind::InputError({
|
||||
"Instance is managed, but managed pack type not specified in instance.cfg".to_string()
|
||||
}).into())
|
||||
_ => return Err(crate::ErrorKind::InputError("Instance is managed, but managed pack type not specified in instance.cfg".to_string()).into())
|
||||
}
|
||||
} else {
|
||||
// Direclty import unmanaged pack
|
||||
let backup_name = "Imported Modpack".to_string();
|
||||
let minecraft_folder = mmc_base_path
|
||||
.join("instances")
|
||||
.join(instance_folder)
|
||||
.join(".minecraft");
|
||||
import_mmc_unmanaged(
|
||||
profile_path,
|
||||
minecraft_folder,
|
||||
backup_name,
|
||||
"Imported Modpack".to_string(),
|
||||
description,
|
||||
mmc_pack,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::pack::install_from::{
|
||||
};
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, Credentials, JavaVersion, ProcessMetadata,
|
||||
ProfileFile, ProjectType, SideType,
|
||||
ProfileFile, ProfileInstallStage, ProjectType, SideType,
|
||||
};
|
||||
|
||||
use crate::event::{emit::emit_profile, ProfilePayloadType};
|
||||
@@ -225,7 +225,18 @@ pub async fn list() -> crate::Result<Vec<Profile>> {
|
||||
#[tracing::instrument]
|
||||
pub async fn install(path: &str, force: bool) -> crate::Result<()> {
|
||||
if let Some(profile) = get(path).await? {
|
||||
crate::launcher::install_minecraft(&profile, None, force).await?;
|
||||
let result =
|
||||
crate::launcher::install_minecraft(&profile, None, force).await;
|
||||
if result.is_err()
|
||||
&& profile.install_stage != ProfileInstallStage::Installed
|
||||
{
|
||||
edit(path, |prof| {
|
||||
prof.install_stage = ProfileInstallStage::NotInstalled;
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
result?;
|
||||
} else {
|
||||
return Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
|
||||
.as_error());
|
||||
|
||||
@@ -111,7 +111,7 @@ async fn replace_managed_modrinth(
|
||||
ignore_lock: bool,
|
||||
) -> crate::Result<()> {
|
||||
crate::profile::edit(profile_path, |profile| {
|
||||
profile.install_stage = ProfileInstallStage::Installing;
|
||||
profile.install_stage = ProfileInstallStage::MinecraftInstalling;
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -7,11 +7,7 @@ use crate::state::{
|
||||
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
||||
};
|
||||
use crate::util::io;
|
||||
use crate::{
|
||||
process,
|
||||
state::{self as st},
|
||||
State,
|
||||
};
|
||||
use crate::{process, state as st, State};
|
||||
use chrono::Utc;
|
||||
use daedalus as d;
|
||||
use daedalus::minecraft::{RuleAction, VersionInfo};
|
||||
@@ -199,7 +195,7 @@ pub async fn install_minecraft(
|
||||
.await?;
|
||||
|
||||
crate::api::profile::edit(&profile.path, |prof| {
|
||||
prof.install_stage = ProfileInstallStage::Installing;
|
||||
prof.install_stage = ProfileInstallStage::MinecraftInstalling;
|
||||
|
||||
async { Ok(()) }
|
||||
})
|
||||
@@ -431,7 +427,7 @@ pub async fn launch_minecraft(
|
||||
profile: &Profile,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
if profile.install_stage == ProfileInstallStage::PackInstalling
|
||||
|| profile.install_stage == ProfileInstallStage::Installing
|
||||
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
|
||||
{
|
||||
return Err(crate::ErrorKind::LauncherError(
|
||||
"Profile is still installing".to_string(),
|
||||
|
||||
@@ -308,7 +308,7 @@ where
|
||||
ProfileInstallStage::Installed
|
||||
}
|
||||
LegacyProfileInstallStage::Installing => {
|
||||
ProfileInstallStage::Installing
|
||||
ProfileInstallStage::MinecraftInstalling
|
||||
}
|
||||
LegacyProfileInstallStage::PackInstalling => {
|
||||
ProfileInstallStage::PackInstalling
|
||||
|
||||
@@ -178,19 +178,16 @@ pub async fn login_finish(
|
||||
|
||||
minecraft_entitlements(&minecraft_token.access_token).await?;
|
||||
|
||||
let profile = minecraft_profile(&minecraft_token.access_token).await?;
|
||||
|
||||
let profile_id = profile.id.unwrap_or_default();
|
||||
|
||||
let credentials = Credentials {
|
||||
id: profile_id,
|
||||
username: profile.name,
|
||||
let mut credentials = Credentials {
|
||||
id: Uuid::default(),
|
||||
username: String::default(),
|
||||
access_token: minecraft_token.access_token,
|
||||
refresh_token: oauth_token.value.refresh_token,
|
||||
expires: oauth_token.date
|
||||
+ Duration::seconds(oauth_token.value.expires_in as i64),
|
||||
active: true,
|
||||
};
|
||||
credentials.get_profile().await?;
|
||||
|
||||
credentials.upsert(exec).await?;
|
||||
|
||||
@@ -245,11 +242,22 @@ impl Credentials {
|
||||
self.expires = oauth_token.date
|
||||
+ Duration::seconds(oauth_token.value.expires_in as i64);
|
||||
|
||||
self.get_profile().await?;
|
||||
|
||||
self.upsert(exec).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_profile(&mut self) -> crate::Result<()> {
|
||||
let profile = minecraft_profile(&self.access_token).await?;
|
||||
|
||||
self.id = profile.id.unwrap_or_default();
|
||||
self.username = profile.name;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_default_credential(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
|
||||
@@ -53,7 +53,9 @@ pub enum ProfileInstallStage {
|
||||
/// Profile is installed
|
||||
Installed,
|
||||
/// Profile's minecraft game is still installing
|
||||
Installing,
|
||||
MinecraftInstalling,
|
||||
/// Pack is installed, but Minecraft installation has not begun
|
||||
PackInstalled,
|
||||
/// Profile created for pack, but the pack hasn't been fully installed yet
|
||||
PackInstalling,
|
||||
/// Profile is not installed
|
||||
@@ -64,7 +66,8 @@ impl ProfileInstallStage {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match *self {
|
||||
Self::Installed => "installed",
|
||||
Self::Installing => "installing",
|
||||
Self::MinecraftInstalling => "minecraft_installing",
|
||||
Self::PackInstalled => "pack_installed",
|
||||
Self::PackInstalling => "pack_installing",
|
||||
Self::NotInstalled => "not_installed",
|
||||
}
|
||||
@@ -73,7 +76,9 @@ impl ProfileInstallStage {
|
||||
pub fn from_str(val: &str) -> Self {
|
||||
match val {
|
||||
"installed" => Self::Installed,
|
||||
"installing" => Self::Installing,
|
||||
"minecraft_installing" => Self::MinecraftInstalling,
|
||||
"installing" => Self::MinecraftInstalling, // Backwards compatibility
|
||||
"pack_installed" => Self::PackInstalled,
|
||||
"pack_installing" => Self::PackInstalling,
|
||||
"not_installed" => Self::NotInstalled,
|
||||
_ => Self::NotInstalled,
|
||||
@@ -549,11 +554,11 @@ impl Profile {
|
||||
|
||||
pub(crate) async fn refresh_all() -> crate::Result<()> {
|
||||
let state = crate::State::get().await?;
|
||||
let all = Self::get_all(&state.pool).await?;
|
||||
let mut all = Self::get_all(&state.pool).await?;
|
||||
|
||||
let mut keys = vec![];
|
||||
|
||||
for profile in &all {
|
||||
for profile in &mut all {
|
||||
let path =
|
||||
crate::api::profile::get_full_path(&profile.path).await?;
|
||||
|
||||
@@ -586,6 +591,17 @@ impl Profile {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if profile.install_stage == ProfileInstallStage::MinecraftInstalling
|
||||
{
|
||||
profile.install_stage = ProfileInstallStage::PackInstalled;
|
||||
profile.upsert(&state.pool).await?;
|
||||
} else if profile.install_stage
|
||||
== ProfileInstallStage::PackInstalling
|
||||
{
|
||||
profile.install_stage = ProfileInstallStage::NotInstalled;
|
||||
profile.upsert(&state.pool).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let file_hashes = CachedEntry::get_file_hash_many(
|
||||
|
||||
@@ -208,23 +208,6 @@ html {
|
||||
}
|
||||
|
||||
.experimental-styles-within {
|
||||
// Reset deprecated properties
|
||||
--color-icon: initial !important;
|
||||
--color-text: initial !important;
|
||||
--color-text-inactive: initial !important;
|
||||
--color-text-dark: initial !important;
|
||||
--color-heading: initial !important;
|
||||
--color-text-inverted: initial !important;
|
||||
--color-bg-inverted: initial !important;
|
||||
|
||||
--color-brand: var(--color-green) !important;
|
||||
--color-brand-inverted: initial !important;
|
||||
|
||||
--tab-underline-hovered: initial !important;
|
||||
|
||||
--color-grey-link: inherit !important;
|
||||
--color-grey-link-hover: inherit !important; // DEPRECATED, use filters in future
|
||||
--color-grey-link-active: inherit !important; // DEPRECATED, use filters in future
|
||||
--color-link: var(--color-blue) !important;
|
||||
--color-link-hover: var(--color-blue) !important; // DEPRECATED, use filters in future
|
||||
--color-link-active: var(--color-blue) !important; // DEPRECATED, use filters in future
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<template #menu>
|
||||
<template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)">
|
||||
<div
|
||||
v-if="option.divider"
|
||||
v-if="isDivider(option)"
|
||||
:key="`divider-${index}`"
|
||||
class="h-px mx-3 my-2 bg-button-bg"
|
||||
></div>
|
||||
@@ -25,15 +25,15 @@
|
||||
:v-close-popper="!option.remainOnClick"
|
||||
:action="
|
||||
option.action
|
||||
? (event) => {
|
||||
option.action(event)
|
||||
? (event: MouseEvent) => {
|
||||
option.action?.(event)
|
||||
if (!option.remainOnClick) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
: null
|
||||
: undefined
|
||||
"
|
||||
:link="option.link ? option.link : null"
|
||||
:link="option.link ? option.link : undefined"
|
||||
:external="option.external ? option.external : false"
|
||||
:disabled="option.disabled"
|
||||
@click="
|
||||
@@ -67,7 +67,7 @@ interface Divider extends BaseOption {
|
||||
|
||||
interface Item extends BaseOption {
|
||||
id: string
|
||||
action?: () => void
|
||||
action?: (event?: MouseEvent) => void
|
||||
link?: string
|
||||
external?: boolean
|
||||
color?:
|
||||
@@ -99,8 +99,8 @@ withDefaults(
|
||||
{
|
||||
options: () => [],
|
||||
disabled: false,
|
||||
dropdownId: null,
|
||||
tooltip: null,
|
||||
dropdownId: undefined,
|
||||
tooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -118,6 +118,10 @@ const open = () => {
|
||||
dropdown.value?.show()
|
||||
}
|
||||
|
||||
function isDivider(option: BaseOption): option is Divider {
|
||||
return 'divider' in option
|
||||
}
|
||||
|
||||
defineExpose({ open, close })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -41,9 +41,12 @@
|
||||
<input
|
||||
ref="value"
|
||||
:value="currentValue"
|
||||
type="text"
|
||||
type="number"
|
||||
class="slider-input"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@change="onInput(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ const props = withDefaults(
|
||||
closeOnClickOutside: true,
|
||||
closeOnEsc: true,
|
||||
warnOnClose: false,
|
||||
header: null,
|
||||
header: undefined,
|
||||
onHide: () => {},
|
||||
onShow: () => {},
|
||||
},
|
||||
|
||||
@@ -37,7 +37,11 @@
|
||||
<div v-if="project.categories.length > 0" class="hidden items-center gap-2 md:flex">
|
||||
<TagsIcon class="h-6 w-6 text-secondary" />
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<TagItem v-for="(category, index) in project.categories" :key="index">
|
||||
<TagItem
|
||||
v-for="(category, index) in project.categories"
|
||||
:key="index"
|
||||
:action="() => router.push(`/${project.project_type}s?f=categories:${category}`)"
|
||||
>
|
||||
{{ formatCategory(category) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
@@ -53,9 +57,12 @@ import { DownloadIcon, HeartIcon, TagsIcon } from '@modrinth/assets'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import ContentPageHeader from '../base/ContentPageHeader.vue'
|
||||
import { formatCategory, formatNumber, type Project } from '@modrinth/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import ProjectStatusBadge from './ProjectStatusBadge.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
project: Project
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<TagItem
|
||||
v-for="platform in project.loaders"
|
||||
:key="`platform-tag-${platform}`"
|
||||
:action="() => router.push(`/${project.project_type}s?g=categories:${platform}`)"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
>
|
||||
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
|
||||
@@ -78,9 +79,11 @@ import { ClientIcon, MonitorSmartphoneIcon, ServerIcon, UserIcon } from '@modrin
|
||||
import { formatCategory, getVersionsToDisplay } from '@modrinth/utils'
|
||||
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { useRouter } from 'vue-router'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
|
||||
type EnvironmentValue = 'optional' | 'required' | 'unsupported' | 'unknown'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user