Merge commit '037cc86c1f520d8e89e721a631c9163d01c61070' into feature-clean

This commit is contained in:
2025-02-10 22:15:18 +03:00
118 changed files with 4847 additions and 2135 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

1793
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,4 +21,4 @@ strip = true # Remove debug symbols
opt-level = 3
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev ="cdbf938" }
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }

View File

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

View File

@@ -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 } 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'
@@ -49,9 +49,9 @@ 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 { check } from '@tauri-apps/plugin-updater'
// 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'
@@ -311,7 +311,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',
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,9 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"instance.filter.disabled": {
"message": "Disabled projects"
},
"instance.filter.updates-available": {
"message": "Updates available"
},

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_gui"
version = "0.9.2"
version = "0.9.3"
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/"

View File

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

View File

@@ -44,7 +44,7 @@
]
},
"productName": "AstralRinth App",
"version": "0.9.204",
"version": "0.9.301",
"mainBinaryName": "AstralRinth App",
"identifier": "AstralRinthApp",
"plugins": {

View File

@@ -22,10 +22,10 @@ import { ChevronRightIcon } from "@modrinth/assets";
useHead({
script: [
{
// Clean.io
src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
},
// {
// // Clean.io
// src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
// },
{
// Aditude
src: "https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js",

View File

@@ -411,18 +411,21 @@ Per section 5.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
name: "Insufficient",
resultingMessage: `## Insufficient Summary
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) Your project summary should provide a brief overview of your project that informs and entices users.
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
},
{
name: "Repeat of title",
resultingMessage: `## Insufficient Summary
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not be the same as your project's Title. Your project summary should provide a brief overview of your project that informs and entices users.
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
},
{
name: "Formatting",
resultingMessage: `## Insufficient Summary
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not include any extra formatting such as lists, or links. Your project summary should provide a brief overview of your project that informs and entices users.
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
},
],
@@ -559,7 +562,9 @@ Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
name: "Inaccurate (modpack)",
resultingMessage: `## Incorrect Environment Information
Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side.
For a brief rundown of how this works:
Some modpacks can be client-side, usually aimed at providing utility and optimization while allowing the player to join an unmodded server, for instance, [Fabulously Optimized](https://modrinth.com/modpack/fabulously-optimized).
Most other modpacks that change how the game is played are going to be required on both the client and server, like the modpack [Dying Light](https://modrinth.com/modpack/dying-light).
When in doubt, test for yourself or check the requirements of the mods in your pack.`,
@@ -568,10 +573,11 @@ When in doubt, test for yourself or check the requirements of the mods in your p
name: "Inaccurate (mod)",
resultingMessage: `## Environment Information
Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side.
For a brief rundown of how this works:
**Client side** refers to a mod that is only required by the client, like [Sodium](https://modrinth.com/mod/sodium).
**Server side** mods change the behavior of the server without the client needing the mod, like Datapacks, recipes, or server-side behaviors, like [Falling Tree](https://modrinth.com/mod/fallingtree).
A mod that adds features, entities, or new blocks and items, generally will be required on **both** the server and the client, for example [Cobblemon](https://modrinth.com/mod/cobblemon).`,
- **Client side** refers to a mod that is only required by the client, like [Sodium](https://modrinth.com/mod/sodium).
- **Server side** mods change the behavior of the server without the client needing the mod, like Datapacks, recipes, or server-side behaviors, like [Falling Tree](https://modrinth.com/mod/fallingtree).
- A mod that adds features, entities, or new blocks and items, generally will be required on **both** the server and the client, for example [Cobblemon](https://modrinth.com/mod/cobblemon).`,
},
],
},
@@ -602,6 +608,7 @@ Per section 5.5 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
name: "Incorrect additional files",
resultingMessage: `## Incorrect Use of Additional Files
It looks like you've uploaded multiple \`mod.jar\` files to one Version as Additional Files. Per section 5.7 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) each Version of your project must include only one \`mod.jar\` that corresponds to its respective Minecraft and loader versions. This allows users to easily find and download the file they need for the version they're on with ease. The Additional Files feature can be used for things like a \`Sources.jar\`.
Please upload each version of your mod separately, thank you.`,
},
{
@@ -629,7 +636,9 @@ It looks like you've selected loaders for your Resource Pack that are causing it
name: "Re-upload",
resultingMessage: `## Reuploads are forbidden
This project appears to contain content from %ORIGINAL_PROJECT% by %ORIGINAL_AUTHOR%.
Per section 4 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) this is strictly forbidden.
If you believe this is an error, or you can verify you are the creator and rightful owner of this content please let us know. Otherwise, we ask that you **do not resubmit this project**.`,
fillers: [
{
@@ -847,6 +856,7 @@ async function generateMessage() {
for (const mod of mods) {
message.value += `- ${mod}\n`;
}
message.value += "\n";
}
if (modPackData.value && modPackData.value.length > 0) {
@@ -913,7 +923,7 @@ async function generateMessage() {
permanentNoMods.length > 0 ||
unidentifiedMods.length > 0
) {
message.value += "## Copyrighted Content \n";
message.value += "## Copyrighted content \n";
printMods(
attributeMods,

View File

@@ -14,7 +14,7 @@
<CompactChart
v-if="analytics.formattedData.value.downloads"
ref="tinyDownloadChart"
:title="`Downloads since ${dayjs(startDate).format('MMM D, YYYY')}`"
:title="`Downloads`"
color="var(--color-brand)"
:value="formatNumber(analytics.formattedData.value.downloads.sum, false)"
:data="analytics.formattedData.value.downloads.chart.sumData"
@@ -33,7 +33,7 @@
<CompactChart
v-if="analytics.formattedData.value.views"
ref="tinyViewChart"
:title="`Page views since ${dayjs(startDate).format('MMM D, YYYY')}`"
:title="`Views`"
color="var(--color-blue)"
:value="formatNumber(analytics.formattedData.value.views.sum, false)"
:data="analytics.formattedData.value.views.chart.sumData"
@@ -50,7 +50,7 @@
<CompactChart
v-if="analytics.formattedData.value.revenue"
ref="tinyRevenueChart"
:title="`Revenue since ${dayjs(startDate).format('MMM D, YYYY')}`"
:title="`Revenue`"
color="var(--color-purple)"
:value="formatMoney(analytics.formattedData.value.revenue.sum, false)"
:data="analytics.formattedData.value.revenue.chart.sumData"
@@ -71,6 +71,9 @@
<span class="label__title">
{{ formatCategoryHeader(selectedChart) }}
</span>
<span class="label__subtitle">
{{ formattedCategorySubtitle }}
</span>
</h2>
<div class="chart-controls__buttons">
<Button v-tooltip="'Toggle project colors'" icon-only @click="onToggleColors">
@@ -83,11 +86,12 @@
<UpdatedIcon />
</Button>
<DropdownSelect
class="range-dropdown"
v-model="selectedRange"
:options="selectableRanges"
:options="ranges"
name="Time range"
:display-name="
(o: (typeof selectableRanges)[number] | undefined) => o?.label || 'Custom'
(o: RangeObject) => o?.getLabel([startDate, endDate]) ?? 'Loading...'
"
/>
</div>
@@ -322,7 +326,7 @@ const props = withDefaults(
* @deprecated Use `ranges` instead
*/
resoloutions?: Record<string, number>;
ranges?: Record<number, [string, number] | string>;
ranges?: RangeObject[];
personal?: boolean;
}>(),
{
@@ -335,12 +339,6 @@ const props = withDefaults(
const projects = ref(props.projects || []);
const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) => ({
label: typeof extra === "string" ? extra : extra[0],
value: Number(duration),
res: typeof extra === "string" ? Number(duration) : extra[1],
}));
// const selectedChart = ref('downloads')
const selectedChart = computed({
get: () => {
@@ -413,33 +411,78 @@ const isUsingProjectColors = computed({
},
});
const startDate = ref(dayjs().startOf("day"));
const endDate = ref(dayjs().endOf("day"));
const timeResolution = ref(30);
onBeforeMount(() => {
// Load cached data and range from localStorage - cache.
if (import.meta.client) {
const rangeLabel = localStorage.getItem("analyticsSelectedRange");
if (rangeLabel) {
const range = props.ranges.find((r) => r.getLabel([dayjs(), dayjs()]) === rangeLabel)!;
if (range !== undefined) {
internalRange.value = range;
const ranges = range.getDates(dayjs());
timeResolution.value = range.timeResolution;
startDate.value = ranges.startDate;
endDate.value = ranges.endDate;
}
}
}
});
onMounted(() => {
if (internalRange.value === null) {
internalRange.value = props.ranges.find(
(r) => r.getLabel([dayjs(), dayjs()]) === "Previous 30 days",
)!;
}
const ranges = selectedRange.value.getDates(dayjs());
startDate.value = ranges.startDate;
endDate.value = ranges.endDate;
timeResolution.value = selectedRange.value.timeResolution;
});
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject);
const selectedRange = computed({
get: () => {
return internalRange.value;
},
set: (newRange) => {
const ranges = newRange.getDates(dayjs());
startDate.value = ranges.startDate;
endDate.value = ranges.endDate;
timeResolution.value = newRange.timeResolution;
internalRange.value = newRange;
if (import.meta.client) {
localStorage.setItem(
"analyticsSelectedRange",
internalRange.value?.getLabel([dayjs(), dayjs()]) ?? "Previous 30 days",
);
}
},
});
const analytics = useFetchAllAnalytics(
resetCharts,
projects,
selectedDisplayProjects,
props.personal,
startDate,
endDate,
timeResolution,
);
const { startDate, endDate, timeRange, timeResolution } = analytics;
const selectedRange = computed({
get: () => {
return (
selectableRanges.find((option) => option.value === timeRange.value) || {
label: "Custom",
value: timeRange.value,
}
);
},
set: (newRange: { label: string; value: number; res?: number }) => {
timeRange.value = newRange.value;
startDate.value = Date.now() - timeRange.value * 60 * 1000;
endDate.value = Date.now();
if (newRange?.res) {
timeResolution.value = newRange.res;
}
},
const formattedCategorySubtitle = computed(() => {
return (
selectedRange.value?.getLabel([dayjs(startDate.value), dayjs(endDate.value)]) ?? "Loading..."
);
});
const selectedDataSet = computed(() => {
@@ -484,6 +527,9 @@ const onToggleColors = () => {
</script>
<script lang="ts">
/**
* @deprecated Use `ranges` instead
*/
const defaultResoloutions: Record<string, number> = {
"5 minutes": 5,
"30 minutes": 30,
@@ -493,17 +539,169 @@ const defaultResoloutions: Record<string, number> = {
"A week": 10080,
};
const defaultRanges: Record<number, [string, number] | string> = {
30: ["Last 30 minutes", 1],
60: ["Last hour", 5],
720: ["Last 12 hours", 15],
1440: ["Last day", 60],
10080: ["Last week", 720],
43200: ["Last month", 1440],
129600: ["Last quarter", 10080],
525600: ["Last year", 20160],
1051200: ["Last two years", 40320],
type DateRange = { startDate: dayjs.Dayjs; endDate: dayjs.Dayjs };
type RangeObject = {
getLabel: (dateRange: [dayjs.Dayjs, dayjs.Dayjs]) => string;
getDates: (currentDate: dayjs.Dayjs) => DateRange;
// A time resolution in minutes.
timeResolution: number;
};
const defaultRanges: RangeObject[] = [
{
getLabel: () => "Previous 30 minutes",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(30, "minute"),
endDate: currentDate,
}),
timeResolution: 1,
},
{
getLabel: () => "Previous hour",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "hour"),
endDate: currentDate,
}),
timeResolution: 5,
},
{
getLabel: () => "Previous 12 hours",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(12, "hour"),
endDate: currentDate,
}),
timeResolution: 12,
},
{
getLabel: () => "Previous 24 hours",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "day"),
endDate: currentDate,
}),
timeResolution: 30,
},
{
getLabel: () => "Today",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("day"),
endDate: currentDate,
}),
timeResolution: 30,
},
{
getLabel: () => "Yesterday",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "day").startOf("day"),
endDate: dayjs(currentDate).startOf("day").subtract(1, "second"),
}),
timeResolution: 30,
},
{
getLabel: () => "This week",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("week").add(1, "hour"),
endDate: currentDate,
}),
timeResolution: 360,
},
{
getLabel: () => "Last week",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "week").startOf("week").add(1, "hour"),
endDate: dayjs(currentDate).startOf("week").subtract(1, "second"),
}),
timeResolution: 1440,
},
{
getLabel: () => "Previous 7 days",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("day").subtract(7, "day").add(1, "hour"),
endDate: currentDate.startOf("day"),
}),
timeResolution: 720,
},
{
getLabel: () => "This month",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("month").add(1, "hour"),
endDate: currentDate,
}),
timeResolution: 1440,
},
{
getLabel: () => "Last month",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "month").startOf("month").add(1, "hour"),
endDate: dayjs(currentDate).startOf("month").subtract(1, "second"),
}),
timeResolution: 1440,
},
{
getLabel: () => "Previous 30 days",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("day").subtract(30, "day").add(1, "hour"),
endDate: currentDate.startOf("day"),
}),
timeResolution: 1440,
},
{
getLabel: () => "This quarter",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("quarter").add(1, "hour"),
endDate: currentDate,
}),
timeResolution: 1440,
},
{
getLabel: () => "Last quarter",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "quarter").startOf("quarter").add(1, "hour"),
endDate: dayjs(currentDate).startOf("quarter").subtract(1, "second"),
}),
timeResolution: 1440,
},
{
getLabel: () => "This year",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("year"),
endDate: currentDate,
}),
timeResolution: 20160,
},
{
getLabel: () => "Last year",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "year").startOf("year"),
endDate: dayjs(currentDate).startOf("year").subtract(1, "second"),
}),
timeResolution: 20160,
},
{
getLabel: () => "Previous year",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "year"),
endDate: dayjs(currentDate),
}),
timeResolution: 40320,
},
{
getLabel: () => "Previous two years",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(2, "year"),
endDate: currentDate,
}),
timeResolution: 40320,
},
{
getLabel: () => "All Time",
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(0),
endDate: currentDate,
}),
timeResolution: 40320,
},
];
</script>
<style scoped lang="scss">
@@ -524,6 +722,20 @@ const defaultRanges: Record<number, [string, number] | string> = {
min-height: auto;
}
}
h2 {
display: flex;
flex-direction: column;
.label__subtitle {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
}
}
.range-dropdown {
font-size: var(--font-size-sm);
}
.chart-area {
@@ -688,6 +900,7 @@ const defaultRanges: Record<number, [string, number] | string> = {
flex-direction: column;
gap: var(--gap-xs);
}
.percentage-bar {
grid-area: bar;
width: 100%;
@@ -696,6 +909,7 @@ const defaultRanges: Record<number, [string, number] | string> = {
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-sm);
overflow: hidden;
span {
display: block;
height: 100%;

View File

@@ -35,7 +35,7 @@ defineProps({
const viewMode = ref("open");
const reports = ref([]);
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report"));
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, "");

View File

@@ -4,8 +4,8 @@
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Auto backup</div>
<p class="m-0">
Automatically create a backup of your server every
<strong>{{ autoBackupInterval == 1 ? "hour" : `${autoBackupInterval} hours` }}</strong>
Automatically create a backup of your server
<strong>{{ backupIntervalsLabel.toLowerCase() }}</strong>
</p>
</div>
@@ -22,54 +22,19 @@
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Interval</div>
<p class="m-0">
The amount of hours between each backup. This will only backup your server if it has
been modified since the last backup.
The amount of time between each backup. This will only backup your server if it has been
modified since the last backup.
</p>
</div>
<div class="flex items-center gap-2 text-contrast">
<div
class="flex w-fit items-center rounded-xl border border-solid border-button-border bg-table-alternateRow"
>
<button
class="rounded-l-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
:disabled="!autoBackupEnabled || isSaving"
@click="autoBackupInterval = Math.max(autoBackupInterval - 1, 1)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="-2 0 18 2">
<path
d="M18,12H6"
transform="translate(-5 -11)"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<input
id="auto-backup-interval"
v-model="autoBackupInterval"
class="w-16 !appearance-none text-center [&&]:bg-transparent [&&]:focus:shadow-none"
type="number"
style="-moz-appearance: textfield; appearance: none"
min="1"
max="24"
step="1"
:disabled="!autoBackupEnabled || isSaving"
/>
<button
class="rounded-r-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
:disabled="!autoBackupEnabled || isSaving"
@click="autoBackupInterval = Math.min(autoBackupInterval + 1, 24)"
>
<PlusIcon />
</button>
</div>
{{ autoBackupInterval == 1 ? "hour" : "hours" }}
</div>
<UiServersTeleportDropdownMenu
:id="'interval-field'"
v-model="backupIntervalsLabel"
:disabled="!autoBackupEnabled || isSaving"
name="interval"
:options="Object.keys(backupIntervals)"
placeholder="Backup interval"
/>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
@@ -92,7 +57,7 @@
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { PlusIcon, XIcon, SaveIcon } from "@modrinth/assets";
import { XIcon, SaveIcon } from "@modrinth/assets";
import { ref, computed } from "vue";
import type { Server } from "~/composables/pyroServers";
@@ -104,19 +69,25 @@ const modal = ref<InstanceType<typeof NewModal>>();
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
const autoBackupEnabled = ref(false);
const autoBackupInterval = ref(6);
const isLoadingSettings = ref(true);
const isSaving = ref(false);
const validatedBackupInterval = computed(() => {
const roundedValue = Math.round(autoBackupInterval.value);
const backupIntervals = {
"Every 3 hours": 3,
"Every 6 hours": 6,
"Every 12 hours": 12,
Daily: 24,
};
if (roundedValue < 1) {
return 1;
} else if (roundedValue > 24) {
return 24;
}
return roundedValue;
const backupIntervalsLabel = ref<keyof typeof backupIntervals>("Every 6 hours");
const autoBackupInterval = computed({
get: () => backupIntervals[backupIntervalsLabel.value],
set: (value) => {
const [label] =
Object.entries(backupIntervals).find(([_, interval]) => interval === value) || [];
if (label) backupIntervalsLabel.value = label as keyof typeof backupIntervals;
},
});
const hasChanges = computed(() => {
@@ -124,7 +95,7 @@ const hasChanges = computed(() => {
return (
autoBackupEnabled.value !== initialSettings.value.enabled ||
autoBackupInterval.value !== initialSettings.value.interval
(initialSettings.value.enabled && autoBackupInterval.value !== initialSettings.value.interval)
);
});
@@ -182,10 +153,6 @@ const saveSettings = async () => {
}
};
watch(autoBackupInterval, () => {
autoBackupInterval.value = validatedBackupInterval.value;
});
defineExpose({
show: async () => {
await fetchSettings();

View File

@@ -0,0 +1,530 @@
<template>
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
<template #title>
<div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]">
<UiAvatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
</div>
</template>
<div class="flex flex-col gap-2 md:w-[420px]">
<div class="flex flex-col gap-2">
<template v-if="versionsLoading">
<div class="flex items-center gap-2">
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg font-semibold">
<span class="opacity-0" aria-hidden="true">{{ type }} version</span>
</div>
<div class="min-h-[22px] min-w-[140px] animate-pulse rounded-full bg-button-bg" />
</div>
<div class="min-h-9 w-full animate-pulse rounded-xl bg-button-bg" />
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg">
<span class="ml-6 opacity-0" aria-hidden="true">
Show any beta and alpha releases
</span>
</div>
</template>
<template v-else>
<div class="flex justify-between">
<div class="flex items-center gap-2">
<div class="font-semibold text-contrast">{{ type }} version</div>
<NuxtLink
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
@click="
versionFilter &&
(unlockFilterAccordion.isOpen
? unlockFilterAccordion.close()
: unlockFilterAccordion.open())
"
>
<TagItem
v-if="formattedVersions.game_versions.length > 0"
v-tooltip="formattedVersions.game_versions.join(', ')"
:style="`--_color: var(--color-green)`"
>
{{ formattedVersions.game_versions[0] }}
</TagItem>
<TagItem
v-if="formattedVersions.loaders.length > 0"
v-tooltip="formattedVersions.loaders.join(', ')"
:style="`--_color: var(--color-platform-${formattedVersions.loaders[0].toLowerCase()})`"
>
{{ formattedVersions.loaders[0] }}
</TagItem>
<DropdownIcon
:class="[
'transition-all duration-200 ease-in-out',
{ 'rotate-180': unlockFilterAccordion.isOpen },
{ 'opacity-0': !versionFilter },
]"
/>
</NuxtLink>
</div>
</div>
<UiServersTeleportDropdownMenu
v-model="selectedVersion"
name="Project"
:options="filteredVersions"
placeholder="No valid versions found"
class="!min-w-full"
:disabled="filteredVersions.length === 0"
:display-name="
(version) => (typeof version === 'object' ? version?.version_number : version)
"
/>
<Checkbox v-model="showBetaAlphaReleases"> Show any beta and alpha releases </Checkbox>
</template>
</div>
<Accordion
ref="unlockFilterAccordion"
:open-by-default="!versionFilter"
:class="[
versionFilter ? '' : '!border-solid border-orange bg-bg-orange !text-contrast',
'flex flex-col gap-2 rounded-2xl border-2 border-dashed border-divider p-3 transition-all',
]"
>
<p class="m-0 items-center font-bold">
<span>
{{
noCompatibleVersions
? `No compatible versions of this ${type.toLowerCase()} were found`
: versionFilter
? "Game version and platform is provided by the server"
: "Incompatible game version and platform versions are unlocked"
}}
</span>
</p>
<p class="m-0 text-sm">
{{
noCompatibleVersions
? `No versions compatible with your server were found. You can still select any available version.`
: versionFilter
? `Unlocking this filter may allow you to change this ${type.toLowerCase()}
to an incompatible version.`
: "You might see versions listed that aren't compatible with your server configuration."
}}
</p>
<ContentVersionFilter
v-if="currentVersions"
ref="filtersRef"
:versions="currentVersions"
:game-versions="tags.gameVersions"
:select-classes="'w-full'"
:type="type"
:disabled="versionFilter"
:platform-tags="tags.loaders"
:listed-game-versions="gameVersions"
:listed-platforms="platforms"
@update:query="updateFiltersFromUi($event)"
@vue:mounted="updateFiltersToUi"
>
<template #platform>
<LoaderIcon
v-if="filtersRef?.selectedPlatforms.length === 0"
:loader="'Vanilla'"
class="size-5 flex-none"
/>
<svg
v-else
class="size-5 flex-none"
v-html="tags.loaders.find((x) => x.name === filtersRef?.selectedPlatforms[0])?.icon"
></svg>
<div class="w-full truncate text-left">
{{
filtersRef?.selectedPlatforms.length === 0
? "All platforms"
: filtersRef?.selectedPlatforms.map((x) => formatCategory(x)).join(", ")
}}
</div>
</template>
<template #game-versions>
<GameIcon class="size-5 flex-none" />
<div class="w-full truncate text-left">
{{
filtersRef?.selectedGameVersions.length === 0
? "All game versions"
: filtersRef?.selectedGameVersions.join(", ")
}}
</div>
</template>
</ContentVersionFilter>
<ButtonStyled v-if="!noCompatibleVersions" color-fill="text">
<button
class="w-full"
:disabled="gameVersions.length < 2 && platforms.length < 2"
@click="
versionFilter = !versionFilter;
setInitialFilters();
updateFiltersToUi();
"
>
<LockOpenIcon />
{{
gameVersions.length < 2 && platforms.length < 2
? "No other platforms or versions available"
: versionFilter
? "Unlock"
: "Return to compatibility"
}}
</button>
</ButtonStyled>
</Accordion>
<Admonition
v-if="versionsError"
type="critical"
header="Failed to load versions"
class="mb-2"
>
<div>
<span>
Something went wrong trying to load versions for this {{ type.toLocaleLowerCase() }}.
Please try again later or contact support if the issue persists.
</span>
<LazyUiCopyCode class="!mt-2 !break-all" :text="versionsError" />
</div>
</Admonition>
<Admonition
v-else-if="props.modPack"
type="warning"
header="Changing version may cause issues"
class="mb-2"
>
Your server was created using a modpack. It's recommended to use the modpack's version of
the mod.
<NuxtLink
class="mt-2 flex items-center gap-1"
:to="`/servers/manage/${props.serverId}/options/loader`"
target="_blank"
>
<ExternalIcon class="size-5 flex-none"></ExternalIcon> Modify modpack version
</NuxtLink>
</Admonition>
<div class="flex flex-row items-center gap-4">
<ButtonStyled color="brand">
<button
:disabled="versionsLoading || selectedVersion.id === modDetails?.version_id"
@click="emitChangeModVersion"
>
<CheckIcon />
Install
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modModal.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import {
DropdownIcon,
XIcon,
CheckIcon,
LockOpenIcon,
GameIcon,
ExternalIcon,
} from "@modrinth/assets";
import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
import { ref, computed } from "vue";
import { formatCategory, formatVersionsForDisplay, type Version } from "@modrinth/utils";
import Accordion from "~/components/ui/Accordion.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import ContentVersionFilter, {
type ListedGameVersion,
type ListedPlatform,
} from "~/components/ui/servers/ContentVersionFilter.vue";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
const props = defineProps<{
type: "Mod" | "Plugin";
loader: string;
gameVersion: string;
modPack: boolean;
serverId: string;
}>();
interface ContentItem extends Mod {
changing?: boolean;
}
interface EditVersion extends Version {
installed: boolean;
upgrade?: boolean;
}
const modModal = ref();
const modDetails = ref<ContentItem>();
const currentVersions = ref<EditVersion[] | null>(null);
const versionsLoading = ref(false);
const versionsError = ref("");
const showBetaAlphaReleases = ref(false);
const unlockFilterAccordion = ref();
const versionFilter = ref(true);
const tags = useTags();
const noCompatibleVersions = ref(false);
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(
(acc, tag) => {
if (tag.supported_project_types.includes("plugin")) {
acc.pluginLoaders.push(tag.name);
}
if (tag.supported_project_types.includes("mod")) {
acc.modLoaders.push(tag.name);
}
return acc;
},
{ pluginLoaders: [] as string[], modLoaders: [] as string[] },
);
const selectedVersion = ref();
const filtersRef: Ref<InstanceType<typeof ContentVersionFilter> | null> = ref(null);
interface SelectedContentFilters {
selectedGameVersions: string[];
selectedPlatforms: string[];
}
const selectedFilters = ref<SelectedContentFilters>({
selectedGameVersions: [],
selectedPlatforms: [],
});
const backwardCompatPlatformMap = {
purpur: ["purpur", "paper", "spigot", "bukkit"],
paper: ["paper", "spigot", "bukkit"],
spigot: ["spigot", "bukkit"],
};
const platforms = ref<ListedPlatform[]>([]);
const gameVersions = ref<ListedGameVersion[]>([]);
const initPlatform = ref<string>("");
const setInitialFilters = () => {
selectedFilters.value = {
selectedGameVersions: [
gameVersions.value.find((version) => version.name === props.gameVersion)?.name ??
gameVersions.value.find((version) => version.release)?.name ??
gameVersions.value[0]?.name,
],
selectedPlatforms: [initPlatform.value],
};
};
const updateFiltersToUi = () => {
if (!filtersRef.value) return;
filtersRef.value.selectedGameVersions = selectedFilters.value.selectedGameVersions;
filtersRef.value.selectedPlatforms = selectedFilters.value.selectedPlatforms;
selectedVersion.value = filteredVersions.value[0];
};
const updateFiltersFromUi = (event: { g: string[]; l: string[] }) => {
selectedFilters.value = {
selectedGameVersions: event.g,
selectedPlatforms: event.l,
};
};
const filteredVersions = computed(() => {
if (!currentVersions.value) return [];
const versionsWithoutReleaseFilter = currentVersions.value.filter((version: EditVersion) => {
if (version.installed) return true;
return (
filtersRef.value?.selectedPlatforms.every((platform) =>
(
backwardCompatPlatformMap[platform as keyof typeof backwardCompatPlatformMap] || [
platform,
]
).some((loader) => version.loaders.includes(loader)),
) &&
filtersRef.value?.selectedGameVersions.every((gameVersion) =>
version.game_versions.includes(gameVersion),
)
);
});
const versionTypes = new Set(
versionsWithoutReleaseFilter.map((v: EditVersion) => v.version_type),
);
const releaseVersions = versionTypes.has("release");
const betaVersions = versionTypes.has("beta");
const alphaVersions = versionTypes.has("alpha");
const versions = versionsWithoutReleaseFilter.filter((version: EditVersion) => {
if (showBetaAlphaReleases.value || version.installed) return true;
return releaseVersions
? version.version_type === "release"
: betaVersions
? version.version_type === "beta"
: alphaVersions
? version.version_type === "alpha"
: false;
});
return versions.map((version: EditVersion) => {
let suffix = "";
if (version.version_type === "alpha" && releaseVersions && betaVersions) {
suffix += " (alpha)";
} else if (version.version_type === "beta" && releaseVersions) {
suffix += " (beta)";
}
return {
...version,
version_number: version.version_number + suffix,
};
});
});
const formattedVersions = computed(() => {
return {
game_versions: formatVersionsForDisplay(
selectedVersion.value?.game_versions || [],
tags.value.gameVersions,
),
loaders: (selectedVersion.value?.loaders || [])
.sort((firstLoader: string, secondLoader: string) => {
const loaderList = backwardCompatPlatformMap[
props.loader as keyof typeof backwardCompatPlatformMap
] || [props.loader];
const firstLoaderPosition = loaderList.indexOf(firstLoader.toLowerCase());
const secondLoaderPosition = loaderList.indexOf(secondLoader.toLowerCase());
if (firstLoaderPosition === -1 && secondLoaderPosition === -1) return 0;
if (firstLoaderPosition === -1) return 1;
if (secondLoaderPosition === -1) return -1;
return firstLoaderPosition - secondLoaderPosition;
})
.map((loader: string) => formatCategory(loader)),
};
});
async function show(mod: ContentItem) {
versionFilter.value = true;
modModal.value.show();
versionsLoading.value = true;
modDetails.value = mod;
versionsError.value = "";
currentVersions.value = null;
try {
const result = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
if (
Array.isArray(result) &&
result.every(
(item) =>
"id" in item &&
"version_number" in item &&
"version_type" in item &&
"loaders" in item &&
"game_versions" in item,
)
) {
currentVersions.value = result as EditVersion[];
} else {
throw new Error("Invalid version data received.");
}
// find the installed version and move it to the top of the list
const currentModIndex = currentVersions.value.findIndex(
(item: { id: string }) => item.id === mod.version_id,
);
if (currentModIndex === -1) {
currentVersions.value[currentModIndex] = {
...currentVersions.value[currentModIndex],
installed: true,
version_number: `${mod.version_number} (current) (external)`,
};
} else {
currentVersions.value[currentModIndex].version_number = `${mod.version_number} (current)`;
currentVersions.value[currentModIndex].installed = true;
}
// initially filter the platform and game versions for the server config
const platformSet = new Set<string>();
const gameVersionSet = new Set<string>();
for (const version of currentVersions.value) {
for (const loader of version.loaders) {
platformSet.add(loader);
}
for (const gameVersion of version.game_versions) {
gameVersionSet.add(gameVersion);
}
}
if (gameVersionSet.size > 0) {
const filteredGameVersions = tags.value.gameVersions.filter((x) =>
gameVersionSet.has(x.version),
);
gameVersions.value = filteredGameVersions.map((x) => ({
name: x.version,
release: x.version_type === "release",
}));
}
if (platformSet.size > 0) {
const tempPlatforms = Array.from(platformSet).map((platform) => ({
name: platform,
isType:
props.type === "Plugin"
? pluginLoaders.includes(platform)
: props.type === "Mod"
? modLoaders.includes(platform)
: false,
}));
platforms.value = tempPlatforms;
}
// set default platform
const defaultPlatform = Array.from(platformSet)[0];
initPlatform.value = platformSet.has(props.loader)
? props.loader
: props.loader in backwardCompatPlatformMap
? backwardCompatPlatformMap[props.loader as keyof typeof backwardCompatPlatformMap].find(
(p) => platformSet.has(p),
) || defaultPlatform
: defaultPlatform;
// check if there's nothing compatible with the server config
noCompatibleVersions.value =
!platforms.value.some((p) => p.isType) ||
!gameVersions.value.some((v) => v.name === props.gameVersion);
if (noCompatibleVersions.value) {
unlockFilterAccordion.value.open();
versionFilter.value = false;
}
setInitialFilters();
versionsLoading.value = false;
} catch (error) {
console.error("Error loading versions:", error);
versionsError.value = error instanceof Error ? error.message : "Unknown";
}
}
const emit = defineEmits<{
changeVersion: [string];
}>();
function emitChangeModVersion() {
if (!selectedVersion.value) return;
emit("changeVersion", selectedVersion.value.id.toString());
}
defineExpose({
show,
hide: () => modModal.value.hide(),
});
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="experimental-styles-within flex w-full flex-col items-center gap-2">
<ManySelect
v-model="selectedPlatforms"
:tooltip="
filterOptions.platform.length < 2 && !disabled ? 'No other platforms available' : undefined
"
:options="filterOptions.platform"
:dropdown-id="`${baseId}-platform`"
search
show-always
class="w-full"
:disabled="disabled || filterOptions.platform.length < 2"
:dropdown-class="'w-full'"
@change="updateFilters"
>
<slot name="platform">
<FilterIcon class="h-5 w-5 text-secondary" />
Platform
</slot>
<template #option="{ option }">
{{ formatCategory(option) }}
</template>
<template v-if="hasAnyUnsupportedPlatforms" #footer>
<Checkbox
v-model="showSupportedPlatformsOnly"
class="mx-1"
:label="`Show ${type?.toLowerCase()} platforms only`"
/>
</template>
</ManySelect>
<ManySelect
v-model="selectedGameVersions"
:tooltip="
filterOptions.gameVersion.length < 2 && !disabled
? 'No other game versions available'
: undefined
"
:options="filterOptions.gameVersion"
:dropdown-id="`${baseId}-game-version`"
search
show-always
class="w-full"
:disabled="disabled || filterOptions.gameVersion.length < 2"
:dropdown-class="'w-full'"
@change="updateFilters"
>
<slot name="game-versions">
<FilterIcon class="h-5 w-5 text-secondary" />
Game versions
</slot>
<template v-if="hasAnySnapshots" #footer>
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
</template>
</ManySelect>
</div>
</template>
<script setup lang="ts">
import { FilterIcon } from "@modrinth/assets";
import { type Version, formatCategory, type GameVersionTag } from "@modrinth/utils";
import { ref, computed } from "vue";
import { useRoute } from "vue-router";
import ManySelect from "@modrinth/ui/src/components/base/ManySelect.vue";
import Checkbox from "@modrinth/ui/src/components/base/Checkbox.vue";
export type ListedGameVersion = {
name: string;
release: boolean;
};
export type ListedPlatform = {
name: string;
isType: boolean;
};
const props = defineProps<{
versions: Version[];
gameVersions: GameVersionTag[];
listedGameVersions: ListedGameVersion[];
listedPlatforms: ListedPlatform[];
baseId?: string;
type: "Mod" | "Plugin";
platformTags: {
name: string;
supported_project_types: string[];
}[];
disabled?: boolean;
}>();
const emit = defineEmits(["update:query"]);
const route = useRoute();
const showSnapshots = ref(false);
const hasAnySnapshots = computed(() => {
return props.versions.some((x) =>
props.gameVersions.some(
(y) => y.version_type !== "release" && x.game_versions.includes(y.version),
),
);
});
const hasOnlySnapshots = computed(() => {
return props.versions.every((version) => {
return version.game_versions.every((gv) => {
const matched = props.gameVersions.find((tag) => tag.version === gv);
return matched && matched.version_type !== "release";
});
});
});
const hasAnyUnsupportedPlatforms = computed(() => {
return props.listedPlatforms.some((x) => !x.isType);
});
const hasOnlyUnsupportedPlatforms = computed(() => {
return props.listedPlatforms.every((x) => !x.isType);
});
const showSupportedPlatformsOnly = ref(true);
const filterOptions = computed(() => {
const filters: Record<"gameVersion" | "platform", string[]> = {
gameVersion: [],
platform: [],
};
filters.gameVersion = props.listedGameVersions
.filter((x) => {
return showSnapshots.value || hasOnlySnapshots.value ? true : x.release;
})
.map((x) => x.name);
filters.platform = props.listedPlatforms
.filter((x) => {
return !showSupportedPlatformsOnly.value || hasOnlyUnsupportedPlatforms.value
? true
: x.isType;
})
.map((x) => x.name);
return filters;
});
const selectedGameVersions = ref<string[]>([]);
const selectedPlatforms = ref<string[]>([]);
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
function updateFilters() {
emit("update:query", {
g: selectedGameVersions.value,
l: selectedPlatforms.value,
});
}
defineExpose({
selectedGameVersions,
selectedPlatforms,
});
function getArrayOrString(x: string | (string | null)[]): string[] {
if (typeof x === "string") {
return [x];
} else {
return x.filter((item): item is string => item !== null);
}
}
</script>
<style></style>

View File

@@ -0,0 +1,75 @@
<template>
<div
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<slot />
<div
v-if="isDragging"
:class="[
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white',
overlayClass,
]"
>
<div class="text-center">
<UploadIcon class="mx-auto h-16 w-16" />
<p class="mt-2 text-xl">
Drop {{ type ? type.toLocaleLowerCase() : "file" }}s here to upload
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { UploadIcon } from "@modrinth/assets";
import { ref } from "vue";
const emit = defineEmits<{
(event: "filesDropped", files: File[]): void;
}>();
defineProps<{
overlayClass?: string;
type?: string;
}>();
const isDragging = ref(false);
const dragCounter = ref(0);
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
dragCounter.value++;
isDragging.value = true;
}
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
dragCounter.value--;
if (dragCounter.value === 0) {
isDragging.value = false;
}
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
isDragging.value = false;
dragCounter.value = 0;
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
if (isInternalMove) return;
const files = event.dataTransfer?.files;
if (files) {
emit("filesDropped", Array.from(files));
}
};
</script>

View File

@@ -0,0 +1,306 @@
<template>
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
<div
ref="statusContentRef"
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-bold">
<FolderOpenIcon class="size-4" />
<span>
<span class="capitalize">
{{ props.fileType ? props.fileType : "File" }} Uploads
</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
</span>
</div>
</div>
<div class="mt-2 space-y-2">
<div
v-for="item in uploadQueue"
:key="item.file.name"
class="flex h-6 items-center justify-between gap-2 text-xs"
>
<div class="flex flex-1 items-center gap-2 truncate">
<transition-group name="status-icon" mode="out-in">
<UiServersPanelSpinner
v-show="item.status === 'uploading'"
key="spinner"
class="absolute !size-4"
/>
<CheckCircleIcon
v-show="item.status === 'completed'"
key="check"
class="absolute size-4 text-green"
/>
<XCircleIcon
v-show="
item.status === 'error' ||
item.status === 'cancelled' ||
item.status === 'incorrect-type'
"
key="error"
class="absolute size-4 text-red"
/>
</transition-group>
<span class="ml-6 truncate">{{ item.file.name }}</span>
<span class="text-secondary">{{ item.size }}</span>
</div>
<div class="flex min-w-[80px] items-center justify-end gap-2">
<template v-if="item.status === 'completed'">
<span>Done</span>
</template>
<template v-else-if="item.status === 'error'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else-if="item.status === 'incorrect-type'">
<span class="text-red">Failed - Incorrect file type</span>
</template>
<template v-else>
<template v-if="item.status === 'uploading'">
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
<button>Cancel</button>
</ButtonStyled>
</template>
<template v-else-if="item.status === 'cancelled'">
<span class="text-red">Cancelled</span>
</template>
<template v-else>
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
</template>
</template>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, nextTick } from "vue";
interface UploadItem {
file: File;
progress: number;
status: "pending" | "uploading" | "completed" | "error" | "cancelled" | "incorrect-type";
size: string;
uploader?: any;
}
interface Props {
currentPath: string;
fileType?: string;
marginBottom?: number;
acceptedTypes?: Array<string>;
fs: FSModule;
}
defineOptions({
inheritAttrs: false,
});
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "uploadComplete"): void;
}>();
const uploadStatusRef = ref<HTMLElement | null>(null);
const statusContentRef = ref<HTMLElement | null>(null);
const uploadQueue = ref<UploadItem[]>([]);
const isUploading = computed(() => uploadQueue.value.length > 0);
const activeUploads = computed(() =>
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
);
const onUploadStatusEnter = (el: Element) => {
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
(el as HTMLElement).style.height = "0";
// eslint-disable-next-line no-void
void (el as HTMLElement).offsetHeight;
(el as HTMLElement).style.height = `${height}px`;
};
const onUploadStatusLeave = (el: Element) => {
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
(el as HTMLElement).style.height = `${height}px`;
// eslint-disable-next-line no-void
void (el as HTMLElement).offsetHeight;
(el as HTMLElement).style.height = "0";
};
watch(
uploadQueue,
() => {
if (!uploadStatusRef.value) return;
const el = uploadStatusRef.value;
const itemsHeight = uploadQueue.value.length * 32;
const headerHeight = 12;
const gap = 8;
const padding = 32;
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0);
el.style.height = `${totalHeight}px`;
},
{ deep: true },
);
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + " KB";
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + " MB";
return (bytes / 1024 ** 3).toFixed(1) + " GB";
};
const cancelUpload = (item: UploadItem) => {
if (item.uploader && item.status === "uploading") {
item.uploader.cancel();
item.status = "cancelled";
setTimeout(async () => {
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
if (index !== -1) {
uploadQueue.value.splice(index, 1);
await nextTick();
}
}, 5000);
}
};
const badFileTypeMsg = "Upload had incorrect file type";
const uploadFile = async (file: File) => {
const uploadItem: UploadItem = {
file,
progress: 0,
status: "pending",
size: formatFileSize(file.size),
};
uploadQueue.value.push(uploadItem);
try {
if (
props.acceptedTypes &&
!props.acceptedTypes.includes(file.type) &&
!props.acceptedTypes.some((type) => file.name.endsWith(type))
) {
throw new Error(badFileTypeMsg);
}
uploadItem.status = "uploading";
const filePath = `${props.currentPath}/${file.name}`.replace("//", "/");
const uploader = await props.fs.uploadFile(filePath, file);
uploadItem.uploader = uploader;
if (uploader?.onProgress) {
uploader.onProgress(({ progress }: { progress: number }) => {
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (index !== -1) {
uploadQueue.value[index].progress = Math.round(progress);
}
});
}
await uploader?.promise;
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
uploadQueue.value[index].status = "completed";
uploadQueue.value[index].progress = 100;
}
await nextTick();
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1);
await nextTick();
}
}, 5000);
emit("uploadComplete");
} catch (error) {
console.error("Error uploading file:", error);
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
uploadQueue.value[index].status =
error instanceof Error && error.message === badFileTypeMsg ? "incorrect-type" : "error";
}
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1);
await nextTick();
}
}, 5000);
if (error instanceof Error && error.message !== "Upload cancelled") {
addNotification({
group: "files",
title: "Upload failed",
text: `Failed to upload ${file.name}`,
type: "error",
});
}
}
};
defineExpose({
uploadFile,
cancelUpload,
});
</script>
<style scoped>
.upload-status {
overflow: hidden;
transition: height 0.2s ease;
}
.upload-status-enter-active,
.upload-status-leave-active {
transition: height 0.2s ease;
overflow: hidden;
}
.upload-status-enter-from,
.upload-status-leave-to {
height: 0 !important;
}
.status-icon-enter-active,
.status-icon-leave-active {
transition: all 0.25s ease;
}
.status-icon-enter-from,
.status-icon-leave-to {
transform: scale(0);
opacity: 0;
}
.status-icon-enter-to,
.status-icon-leave-from {
transform: scale(1);
opacity: 1;
}
</style>

View File

@@ -61,7 +61,15 @@
Your server's hardware is currently being upgraded and will be back online shortly.
</div>
<div
v-else-if="status === 'suspended'"
v-if="status === 'suspended' && suspension_reason === 'support'"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-blue p-4 text-sm font-bold text-contrast"
>
<HammerIcon />
You recently requested support for your server and we are actively working on it. It will be
back online shortly.
</div>
<div
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<UiServersIconsPanelErrorIcon class="!size-5" />
@@ -72,7 +80,7 @@
</template>
<script setup lang="ts">
import { ChevronRightIcon, LockIcon } from "@modrinth/assets";
import { ChevronRightIcon, HammerIcon, LockIcon } from "@modrinth/assets";
import type { Project, Server } from "~/types/servers";
const props = defineProps<Partial<Server>>();

View File

@@ -72,6 +72,8 @@
:class="{
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
'bg-bg-raised': focusedOptionIndex === item.index,
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
'rounded-t-xl': item.index === 0 && isRenderingUp,
}"
:aria-selected="selectedValue === item.option"
@click="selectOption(item.option, item.index)"
@@ -225,7 +227,7 @@ const radioValue = computed<OptionValue>({
});
const triggerClasses = computed(() => ({
"cursor-not-allowed opacity-50 grayscale": props.disabled,
"!cursor-not-allowed opacity-50 grayscale": props.disabled,
"rounded-b-none": dropdownVisible.value && !isRenderingUp.value && !props.disabled,
"rounded-t-none": dropdownVisible.value && isRenderingUp.value && !props.disabled,
}));

View File

@@ -104,22 +104,15 @@ export const initAuth = async (oldToken = null) => {
return auth;
};
export const getAuthUrl = (provider, redirect = "") => {
export const getAuthUrl = (provider, redirect = "/dashboard") => {
const config = useRuntimeConfig();
const route = useNativeRoute();
if (redirect === "") {
redirect = route.path;
}
const fullURL = route.query.launcher
? "https://launcher-files.modrinth.com"
: `${config.public.siteUrl}/auth/sign-in?redirect=${redirect}`;
let fullURL;
if (route.query.launcher) {
fullURL = `https://launcher-files.modrinth.com`;
} else {
fullURL = `${config.public.siteUrl}${redirect}`;
}
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${fullURL}`;
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`;
};
export const removeAuthProvider = async (provider) => {

View File

@@ -67,10 +67,10 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
});
return response;
} catch (error) {
console.error("[PYROSERVERS]:", error);
console.error("[PyroServers/PyroFetch]:", error);
if (error instanceof FetchError) {
const statusCode = error.response?.status;
const statusText = error.response?.statusText || "Unknown error";
const statusText = error.response?.statusText || "[no status text available]";
const errorMessages: { [key: number]: string } = {
400: "Bad Request",
401: "Unauthorized",
@@ -80,15 +80,16 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
};
const message =
statusCode && statusCode in errorMessages
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
throw new PyroFetchError(`[PYROSERVERS][PYRO] ${message}`, statusCode, error);
: `HTTP Error: ${statusCode || "[unhandled status code]"} ${statusText}`;
throw new PyroFetchError(`[PyroServers/PyroFetch] ${message}`, statusCode, error);
}
throw new PyroFetchError(
"[PYROSERVERS][PYRO] An unexpected error occurred during the fetch operation.",
"[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.",
undefined,
error as Error,
);
@@ -168,7 +169,15 @@ interface General {
backup_quota: number;
used_backup_quota: number;
status: string;
suspension_reason: string;
suspension_reason:
| "moderated"
| "paymentfailed"
| "cancelled"
| "other"
| "transferring"
| "upgrading"
| "support"
| (string & {});
loader: string;
loader_version: string;
mc_version: string;
@@ -198,14 +207,16 @@ interface Startup {
jdk_build: "corretto" | "temurin" | "graal";
}
interface Mod {
export interface Mod {
filename: string;
project_id: string;
version_id: string;
name: string;
version_number: string;
icon_url: string;
project_id: string | undefined;
version_id: string | undefined;
name: string | undefined;
version_number: string | undefined;
icon_url: string | undefined;
owner: string | undefined;
disabled: boolean;
installing: boolean;
}
interface Backup {
@@ -241,7 +252,7 @@ export interface DirectoryResponse {
current?: number;
}
type ContentType = "Mod" | "Plugin";
type ContentType = "mod" | "plugin";
const constructServerProperties = (properties: any): string => {
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
@@ -508,8 +519,8 @@ const installContent = async (contentType: ContentType, projectId: string, versi
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods`, {
method: "POST",
body: {
install_as: contentType,
rinth_ids: { project_id: projectId, version_id: versionId },
install_as: contentType,
},
});
} catch (error) {
@@ -518,13 +529,12 @@ const installContent = async (contentType: ContentType, projectId: string, versi
}
};
const removeContent = async (contentType: ContentType, contentId: string) => {
const removeContent = async (path: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/deleteMod`, {
method: "POST",
body: {
install_as: contentType,
path: contentId,
path,
},
});
} catch (error) {
@@ -533,15 +543,11 @@ const removeContent = async (contentType: ContentType, contentId: string) => {
}
};
const reinstallContent = async (
contentType: ContentType,
contentId: string,
newContentId: string,
) => {
const reinstallContent = async (replace: string, projectId: string, versionId: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/${contentId}`, {
method: "PUT",
body: { install_as: contentType, version_id: newContentId },
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/update`, {
method: "POST",
body: { replace, project_id: projectId, version_id: versionId },
});
} catch (error) {
console.error("Error reinstalling mod:", error);
@@ -1149,18 +1155,17 @@ type ContentFunctions = {
/**
* Removes a mod from a server.
* @param contentType - The type of content to remove.
* @param contentId - The ID of the content.
* @param path - The path of the mod file.
*/
remove: (contentType: ContentType, contentId: string) => Promise<void>;
remove: (path: string) => Promise<void>;
/**
* Reinstalls a mod to a server.
* @param contentType - The type of content to reinstall.
* @param contentId - The ID of the content.
* @param newContentId - The ID of the new version.
* @param replace - The path of the mod to replace.
* @param projectId - The ID of the content.
* @param versionId - The ID of the new version.
*/
reinstall: (contentType: ContentType, contentId: string, newContentId: string) => Promise<void>;
reinstall: (replace: string, projectId: string, versionId: string) => Promise<void>;
};
type BackupFunctions = {
@@ -1364,7 +1369,7 @@ type ContentModule = { data: Mod[] } & ContentFunctions;
type BackupsModule = { data: Backup[] } & BackupFunctions;
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
type StartupModule = Startup & StartupFunctions;
type FSModule = { auth: JWTAuth } & FSFunctions;
export type FSModule = { auth: JWTAuth } & FSFunctions;
type ModulesMap = {
general: GeneralModule;

View File

@@ -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"
},
@@ -476,6 +485,81 @@
"project.versions.title": {
"message": "Versions"
},
"report.already-reported": {
"message": "You've already reported {title}"
},
"report.already-reported-description": {
"message": "You have an open report for this {item} already. You can add more details to your report if you have more information to add."
},
"report.back-to-item": {
"message": "Back to {item}"
},
"report.body.description": {
"message": "Include links and images if possible and relevant. Empty or insufficient reports will be closed and ignored."
},
"report.body.title": {
"message": "Please provide additional context about your report"
},
"report.checking": {
"message": "Checking {item}..."
},
"report.could-not-find": {
"message": "Could not find {item}"
},
"report.for.violation": {
"message": "Violation of Modrinth <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>"
},
"report.for.violation.description": {
"message": "Examples include malicious, spam, offensive, deceptive, misleading, and illegal content."
},
"report.form-not-for": {
"message": "This form is not for:"
},
"report.go-to-report": {
"message": "Go to report"
},
"report.not-for.bug-reports": {
"message": "Bug reports"
},
"report.not-for.dmca": {
"message": "DMCA takedowns"
},
"report.not-for.dmca.description": {
"message": "See our <policy-link>Copyright Policy</policy-link>."
},
"report.note.copyright.1": {
"message": "Please note that you are *not* submitting a DMCA takedown request, but rather a report of reuploaded content."
},
"report.note.copyright.2": {
"message": "If you meant to file a DMCA takedown request (which is a legal action) instead, please see our <copyright-policy-link>Copyright Policy</copyright-policy-link>."
},
"report.note.malicious.1": {
"message": "Reports for malicious or deceptive content must include substantial evidence of the behavior, such as code samples."
},
"report.note.malicious.2": {
"message": "Summaries from Microsoft Defender, VirusTotal, or AI malware detection are not sufficient forms of evidence and will not be accepted."
},
"report.please-report": {
"message": "Please report:"
},
"report.question.content-id": {
"message": "What is the ID of the {item}?"
},
"report.question.content-type": {
"message": "What type of content are you reporting?"
},
"report.question.report-reason": {
"message": "Which of Modrinth's rules is this {item} violating?"
},
"report.report-content": {
"message": "Report content to moderators"
},
"report.report-item": {
"message": "Report {title} to moderators"
},
"report.submit": {
"message": "Submit report"
},
"revenue.transfers.total": {
"message": "You have withdrawn {amount} in total."
},

View File

@@ -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()
@@ -601,7 +631,7 @@
auth.user ? reportProject(project.id) : navigateTo('/auth/sign-in'),
color: 'red',
hoverOnly: true,
shown: !currentMember,
shown: !isMember,
},
{ id: 'copy-id', action: () => copyId() },
]"
@@ -772,6 +802,7 @@
:reset-members="resetMembers"
:route="route"
@on-download="triggerDownloadAnimation"
@delete-version="deleteVersion"
/>
</div>
</div>
@@ -785,31 +816,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 +849,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();
@@ -1172,6 +1204,10 @@ const members = computed(() => {
return owner ? [owner, ...rest] : rest;
});
const isMember = computed(
() => auth.value.user && allMembers.value.some((x) => x.user.id === auth.value.user.id),
);
const currentMember = computed(() => {
let val = auth.value.user ? allMembers.value.find((x) => x.user.id === auth.value.user.id) : null;
@@ -1247,6 +1283,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();
@@ -1403,6 +1456,20 @@ function onDownload(event) {
}, 400);
}
async function deleteVersion(id) {
if (!id) return;
startLoading();
await useBaseFetch(`version/${id}`, {
method: "DELETE",
});
versions.value = versions.value.filter((x) => x.id !== id);
stopLoading();
}
const navLinks = computed(() => {
const projectUrl = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`;

View File

@@ -381,6 +381,7 @@
/>
<ButtonStyled v-if="isEditing">
<button
class="raised-button"
:disabled="primaryFile.hashes.sha1 === file.hashes.sha1"
@click="
() => {
@@ -821,6 +822,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);

View File

@@ -1,4 +1,13 @@
<template>
<ConfirmModal
v-if="currentMember"
ref="deleteVersionModal"
title="Are you sure you want to delete this version?"
description="This will remove this version forever (like really forever)."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteVersion()"
/>
<section class="experimental-styles-within overflow-visible">
<div
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
@@ -41,7 +50,7 @@
:href="getPrimaryFile(version).url"
class="group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted"
aria-label="Download"
@click="emits('onDownload')"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
</a>
@@ -57,7 +66,7 @@
hoverFilled: true,
link: getPrimaryFile(version).url,
action: () => {
emits('onDownload');
emit('onDownload');
},
},
{
@@ -101,8 +110,11 @@
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => {},
shown: currentMember && false,
action: () => {
selectedVersion = version.id;
deleteVersionModal.show();
},
shown: currentMember,
},
]"
aria-label="More options"
@@ -144,7 +156,13 @@
</template>
<script setup>
import { ButtonStyled, OverflowMenu, FileInput, ProjectPageVersions } from "@modrinth/ui";
import {
ButtonStyled,
OverflowMenu,
FileInput,
ProjectPageVersions,
ConfirmModal,
} from "@modrinth/ui";
import {
DownloadIcon,
MoreVerticalIcon,
@@ -185,7 +203,10 @@ const tags = useTags();
const flags = useFeatureFlags();
const auth = await useAuth();
const emits = defineEmits(["onDownload"]);
const deleteVersionModal = ref();
const selectedVersion = ref(null);
const emit = defineEmits(["onDownload", "deleteVersion"]);
const router = useNativeRouter();
@@ -212,4 +233,9 @@ async function handleFiles(files) {
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text);
}
function deleteVersion() {
emit("deleteVersion", selectedVersion.value);
selectedVersion.value = null;
}
</script>

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

View File

@@ -1,7 +1,7 @@
<template>
<div>
<ModalConfirm
v-if="auth.user && auth.user.id === creator.id"
<ConfirmModal
v-if="canEdit"
ref="deleteModal"
:title="formatMessage(messages.deleteModalTitle)"
:description="formatMessage(messages.deleteModalDescription)"
@@ -387,12 +387,13 @@ import {
Avatar,
Button,
commonMessages,
ConfirmModal,
} from "@modrinth/ui";
import { isAdmin } from "@modrinth/utils";
import WorldIcon from "assets/images/utils/world.svg";
import UpToDate from "assets/images/illustrations/up_to_date.svg";
import { addNotification } from "~/composables/notifs.js";
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
import NavRow from "~/components/ui/NavRow.vue";
import ProjectCard from "~/components/ui/ProjectCard.vue";
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
@@ -596,7 +597,7 @@ useSeoMeta({
const canEdit = computed(
() =>
auth.value.user &&
auth.value.user.id === collection.value.user &&
(auth.value.user.id === collection.value.user || isAdmin(auth.value.user)) &&
collection.value.id !== "following",
);
@@ -685,7 +686,11 @@ async function deleteCollection() {
method: "DELETE",
apiVersion: 3,
});
await navigateTo("/dashboard/collections");
if (auth.value.user.id === collection.value.user) {
await navigateTo("/dashboard/collections");
} else {
await navigateTo(`/user/${collection.value.user}/collections`);
}
} catch (err) {
addNotification({
group: "main",

View File

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

View File

@@ -65,7 +65,7 @@
<div class="users-section">
<div class="section-header">
<div class="section-label green">For Players</div>
<h2 class="section-tagline">Discover over 10,000 creations</h2>
<h2 class="section-tagline">Discover over 50,000 creations</h2>
<p class="section-description">
From magical biomes to cursed dungeons, you can be sure to find content to bring your
gameplay to the next level.

View File

@@ -124,7 +124,7 @@
We aim to be as transparent as possible with creator revenue. All of our code is open source,
including our
<a href="https://github.com/modrinth/code/blob/main/apps/labrinth/src/queue/payouts.rs#L598">
revenue distribution system </a
revenue distribution system</a
>. We also have an
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
to query exact daily revenue for the site.

View File

@@ -1,99 +1,256 @@
<template>
<div class="page">
<Card>
<div class="content">
<div>
<h1 class="card-title-adjustments">Submit a Report</h1>
<div>
<p>
Modding should be safe for everyone, so we take abuse and malicious intent seriously
at Modrinth. If you encounter content that violates our
<nuxt-link class="text-link" to="/legal/terms">Terms of Service</nuxt-link> or our
<nuxt-link class="text-link" to="/legal/rules">Rules</nuxt-link>, please report it to
us here.
</p>
<p>
This form is intended exclusively for reporting abuse or harmful content to Modrinth
staff. For bugs related to specific projects, please use the project's designated
Issues link or Discord channel.
</p>
<p>
Your privacy is important to us; rest assured that your identifying information will
be kept confidential.
</p>
</div>
</div>
<div class="report-info-section">
<div class="report-info-item">
<label for="report-item">Item type to report</label>
<DropdownSelect
id="report-item"
v-model="reportItem"
name="report-item"
:options="reportItems"
:display-name="capitalizeString"
:multiple="false"
:searchable="false"
:show-no-results="false"
:show-labels="false"
placeholder="Choose report item"
/>
</div>
<div class="report-info-item">
<label for="report-item-id">Item ID</label>
<input
id="report-item-id"
v-model="reportItemID"
type="text"
placeholder="ex. project ID"
autocomplete="off"
:disabled="reportItem === ''"
/>
</div>
<div class="report-info-item">
<label for="report-type">Reason for report</label>
<DropdownSelect
id="report-type"
v-model="reportType"
name="report-type"
:options="reportTypes"
:multiple="false"
:searchable="false"
:show-no-results="false"
:show-labels="false"
:display-name="capitalizeString"
placeholder="Choose report type"
/>
</div>
</div>
<div class="report-submission-section">
<div>
<p>
Please provide additional context about your report. Include links and images if
possible. <strong>Empty reports will be closed.</strong>
</p>
</div>
<MarkdownEditor v-model="reportBody" placeholder="" :on-image-upload="onImageUpload" />
</div>
<div class="submit-button">
<Button
id="submit-button"
color="primary"
:disabled="submitLoading || !canSubmit"
@click="submitReport"
>
<SaveIcon aria-hidden="true" />
Submit
</Button>
<div class="experimental-styles-within flex flex-col gap-2">
<RadialHeader class="top-box mb-2 text-center" color="orange">
<ScaleIcon class="h-12 w-12 text-brand-orange" />
<h1 class="m-3 gap-2 text-3xl font-extrabold">
{{
prefilled && itemName
? existingReport
? formatMessage(messages.alreadyReportedItem, { title: itemName })
: formatMessage(messages.reportItem, { title: itemName })
: formatMessage(messages.reportContent)
}}
</h1>
</RadialHeader>
<div
v-if="prefilled && itemName && existingReport"
class="mx-auto flex max-w-[35rem] flex-col items-center gap-4 text-center"
>
{{ formatMessage(messages.alreadyReportedDescription, { item: reportItem || "content" }) }}
<div class="flex gap-2">
<ButtonStyled v-if="itemLink">
<nuxt-link :to="itemLink">
<LeftArrowIcon />
{{ formatMessage(messages.backToItem, { item: reportItem || "content" }) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled color="brand">
<nuxt-link :to="`/dashboard/report/${existingReport.id}`">
{{ formatMessage(messages.goToReport) }} <RightArrowIcon />
</nuxt-link>
</ButtonStyled>
</div>
</div>
</Card>
<template v-else>
<div class="mb-3 grid grid-cols-1 gap-4 px-6 md:grid-cols-2">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-extrabold">{{ formatMessage(messages.pleaseReport) }}</h2>
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
<CheckCircleIcon class="h-8 w-8 shrink-0 text-brand-green" />
<div class="flex flex-col">
<span>
<IntlFormatted :message-id="messages.violation">
<template #rules-link="{ children }">
<nuxt-link class="text-link" :to="`/legal/rules`">
<component :is="() => children" />
</nuxt-link>
</template>
<template #terms-link="{ children }">
<nuxt-link class="text-link" :to="`/legal/terms`">
<component :is="() => children" />
</nuxt-link>
</template>
</IntlFormatted>
</span>
<span class="text-sm font-medium text-secondary">
{{ formatMessage(messages.violationDescription) }}
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-extrabold">{{ formatMessage(messages.formNotFor) }}</h2>
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
<span>{{ formatMessage(messages.bugReports) }}</span>
</div>
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
<div class="flex flex-col">
<span>{{ formatMessage(messages.dmcaTakedown) }}</span>
<span class="text-sm font-medium text-secondary">
<IntlFormatted :message-id="messages.dmcaTakedownDescription">
<template #policy-link="{ children }">
<nuxt-link class="text-link" :to="`/legal/copyright`">
<component :is="() => children" />
</nuxt-link>
</template>
</IntlFormatted>
</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-4 rounded-xl bg-bg-raised p-6">
<template v-if="!prefilled || !currentItemValid">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">
{{ formatMessage(messages.whatContentType) }}
</span>
<RadioButtons
v-slot="{ item }"
v-model="reportItem"
:items="reportItems"
@update:model-value="
() => {
prefilled = false;
fetchItem();
}
"
>
{{ capitalizeString(item) }}
</RadioButtons>
</div>
<div class="flex flex-col gap-2" :class="{ hidden: !reportItem }">
<span class="text-lg font-bold text-contrast">
{{ formatMessage(messages.whatContentId, { item: reportItem || "content" }) }}
</span>
<div class="flex gap-4">
<input
id="report-item-id"
v-model="reportItemID"
type="text"
placeholder="ex: Dc7EYhxG"
autocomplete="off"
:disabled="reportItem === ''"
class="w-40"
@blur="
() => {
prefilled = false;
reportItemID = reportItemID.trim();
fetchItem();
}
"
/>
<div v-if="checkingId || checkedId" class="flex items-center gap-1">
<template v-if="checkingId">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.checking, { item: reportItem }) }}...
</template>
<template v-else-if="checkedId && itemName">
<AutoLink
:to="itemLink"
target="_blank"
class="flex items-center gap-1 font-bold text-contrast hover:underline"
>
<Avatar
v-if="typeof itemIcon === 'string'"
:src="itemIcon"
:alt="itemName"
size="24px"
:circle="reportItem === 'user'"
/>
<component :is="itemIcon" v-else-if="itemIcon" />
<span>{{ itemName }}</span>
</AutoLink>
<CheckIcon class="text-brand-green" />
</template>
<span v-else-if="checkedId" class="contents text-brand-red">
<IssuesIcon />
{{ formatMessage(messages.couldNotFind, { item: reportItem }) }}
</span>
</div>
</div>
</div>
</template>
<template v-if="existingReport">
{{
formatMessage(messages.alreadyReportedDescription, { item: reportItem || "content" })
}}
<ButtonStyled color="brand">
<nuxt-link :to="`/dashboard/report/${existingReport.id}`" class="w-fit">
{{ formatMessage(messages.goToReport) }} <RightArrowIcon />
</nuxt-link>
</ButtonStyled>
</template>
<template v-else>
<div class="flex flex-col gap-2" :class="{ hidden: !reportItemID }">
<span class="text-lg font-bold text-contrast">
{{ formatMessage(messages.whatReportReason, { item: reportItem || "content" }) }}
</span>
<RadioButtons v-slot="{ item }" v-model="reportType" :items="reportTypes">
{{ item === "copyright" ? "Reuploaded work" : capitalizeString(item) }}
</RadioButtons>
</div>
<div
v-if="warnings[reportType]"
class="flex gap-2 rounded-xl border-2 border-solid border-brand-orange bg-highlight-orange p-4 text-contrast"
>
<IssuesIcon class="h-5 w-5 shrink-0 text-orange" />
<div class="flex flex-col gap-2">
<p
v-for="(warning, index) in warnings[reportType]"
:key="`warning-${reportType}-${index}`"
class="m-0 leading-tight"
>
<IntlFormatted :message-id="warning">
<template #copyright-policy-link="{ children }">
<nuxt-link class="text-link" :to="`/legal/copyright`">
<component :is="() => children" />
</nuxt-link>
</template>
</IntlFormatted>
</p>
</div>
</div>
<div :class="{ hidden: !reportType }">
<span class="text-lg font-bold text-contrast">
{{ formatMessage(messages.reportBodyTitle) }}
</span>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.reportBodyDescription) }}
</p>
</div>
<div :class="{ hidden: !reportType }">
<MarkdownEditor
v-model="reportBody"
placeholder=""
:on-image-upload="onImageUpload"
/>
</div>
<div :class="{ hidden: !reportType }">
<ButtonStyled color="brand">
<button
id="submit-button"
:disabled="submitLoading || !canSubmit"
@click="submitReport"
>
<SendIcon aria-hidden="true" />
{{ formatMessage(messages.submitReport) }}
</button>
</ButtonStyled>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { Card, Button, MarkdownEditor, DropdownSelect } from "@modrinth/ui";
import { SaveIcon } from "@modrinth/assets";
import {
MarkdownEditor,
RadialHeader,
RadioButtons,
ButtonStyled,
Avatar,
AutoLink,
} from "@modrinth/ui";
import {
LeftArrowIcon,
RightArrowIcon,
CheckIcon,
SpinnerIcon,
SendIcon,
IssuesIcon,
CheckCircleIcon,
XCircleIcon,
ScaleIcon,
VersionIcon,
} from "@modrinth/assets";
import type { User, Version, Report } from "@modrinth/utils";
import { useVIntl, defineMessages, type MessageDescriptor } from "@vintl/vintl";
import { useImageUpload } from "~/composables/image-upload.ts";
const tags = useTags();
@@ -101,6 +258,7 @@ const route = useNativeRoute();
const router = useRouter();
const auth = await useAuth();
const { formatMessage } = useVIntl();
if (!auth.value.user) {
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
@@ -119,6 +277,80 @@ const reportItem = ref<string>(accessQuery("item"));
const reportItemID = ref<string>(accessQuery("itemID"));
const reportType = ref<string>("");
const prefilled = ref<boolean>(!!reportItem.value && !!reportItemID.value);
const checkedId = ref<boolean>(false);
const checkingId = ref<boolean>(false);
const currentProject = ref<Project | null>(null);
const currentVersion = ref<Version | null>(null);
const currentUser = ref<User | null>(null);
const itemIcon = ref<string | Component | undefined>();
const itemName = ref<string | undefined>();
const itemLink = ref<string | undefined>();
const itemId = ref<string | undefined>();
const reports = ref<Report[]>([]);
const existingReport = computed(() =>
reports.value.find(
(x) =>
(x.item_id === reportItemID.value || x.item_id === itemId.value) &&
x.item_type === reportItem.value,
),
);
await fetchItem();
await fetchExistingReports();
const currentItemValid = computed(
() => !!currentProject.value || !!currentVersion.value || !!currentUser.value,
);
async function fetchExistingReports() {
reports.value = ((await useBaseFetch("report?count=1000")) as Report[]).filter(
(x) => x.reporter === auth.value.user?.id,
);
}
async function fetchItem() {
if (reportItem.value && reportItemID.value) {
checkingId.value = true;
itemIcon.value = undefined;
itemName.value = undefined;
itemLink.value = undefined;
itemId.value = undefined;
try {
if (reportItem.value === "project") {
const project = (await useBaseFetch(`project/${reportItemID.value}`)) as Project;
currentProject.value = project;
itemIcon.value = project.icon_url;
itemName.value = project.title;
itemLink.value = `/project/${project.id}`;
itemId.value = project.id;
} else if (reportItem.value === "version") {
const version = (await useBaseFetch(`version/${reportItemID.value}`)) as Version;
currentVersion.value = version;
itemIcon.value = VersionIcon;
itemName.value = version.version_number;
itemLink.value = `project/${version.project_id}/version/${version.id}`;
itemId.value = version.id;
} else if (reportItem.value === "user") {
const user = (await useBaseFetch(`user/${reportItemID.value}`)) as User;
currentUser.value = user;
itemIcon.value = user.avatar_url;
itemName.value = user.username;
itemLink.value = `/user/${user.username}`;
itemId.value = user.id;
}
} catch {}
checkedId.value = true;
checkingId.value = false;
}
}
const reportItems = ["project", "version", "user"];
const reportTypes = computed(() => tags.value.reportTypes);
@@ -232,70 +464,131 @@ const onImageUpload = async (file: File) => {
uploadedImageIDs.value.push(item.id);
return item.url;
};
const warnings: Record<string, MessageDescriptor[]> = {
copyright: [
defineMessage({
id: "report.note.copyright.1",
defaultMessage:
"Please note that you are *not* submitting a DMCA takedown request, but rather a report of reuploaded content.",
}),
defineMessage({
id: "report.note.copyright.2",
defaultMessage:
"If you meant to file a DMCA takedown request (which is a legal action) instead, please see our <copyright-policy-link>Copyright Policy</copyright-policy-link>.",
}),
],
malicious: [
defineMessage({
id: "report.note.malicious.1",
defaultMessage:
"Reports for malicious or deceptive content must include substantial evidence of the behavior, such as code samples.",
}),
defineMessage({
id: "report.note.malicious.2",
defaultMessage:
"Summaries from Microsoft Defender, VirusTotal, or AI malware detection are not sufficient forms of evidence and will not be accepted.",
}),
],
};
const messages = defineMessages({
reportContent: {
id: "report.report-content",
defaultMessage: "Report content to moderators",
},
reportItem: {
id: "report.report-item",
defaultMessage: "Report {title} to moderators",
},
alreadyReportedItem: {
id: "report.already-reported",
defaultMessage: "You've already reported {title}",
},
alreadyReportedDescription: {
id: "report.already-reported-description",
defaultMessage:
"You have an open report for this {item} already. You can add more details to your report if you have more information to add.",
},
backToItem: {
id: "report.back-to-item",
defaultMessage: "Back to {item}",
},
goToReport: {
id: "report.go-to-report",
defaultMessage: "Go to report",
},
pleaseReport: {
id: "report.please-report",
defaultMessage: "Please report:",
},
formNotFor: {
id: "report.form-not-for",
defaultMessage: "This form is not for:",
},
violation: {
id: "report.for.violation",
defaultMessage:
"Violation of Modrinth <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>",
},
violationDescription: {
id: "report.for.violation.description",
defaultMessage:
"Examples include malicious, spam, offensive, deceptive, misleading, and illegal content.",
},
bugReports: {
id: "report.not-for.bug-reports",
defaultMessage: "Bug reports",
},
dmcaTakedown: {
id: "report.not-for.dmca",
defaultMessage: "DMCA takedowns",
},
dmcaTakedownDescription: {
id: "report.not-for.dmca.description",
defaultMessage: "See our <policy-link>Copyright Policy</policy-link>.",
},
whatContentType: {
id: "report.question.content-type",
defaultMessage: "What type of content are you reporting?",
},
whatContentId: {
id: "report.question.content-id",
defaultMessage: "What is the ID of the {item}?",
},
whatReportReason: {
id: "report.question.report-reason",
defaultMessage: "Which of Modrinth's rules is this {item} violating?",
},
checking: {
id: "report.checking",
defaultMessage: "Checking {item}...",
},
couldNotFind: {
id: "report.could-not-find",
defaultMessage: "Could not find {item}",
},
reportBodyTitle: {
id: "report.body.title",
defaultMessage: "Please provide additional context about your report",
},
reportBodyDescription: {
id: "report.body.description",
defaultMessage:
"Include links and images if possible and relevant. Empty or insufficient reports will be closed and ignored.",
},
submitReport: {
id: "report.submit",
defaultMessage: "Submit report",
},
});
</script>
<style scoped lang="scss">
.submit-button {
display: flex;
justify-content: flex-end;
width: 100%;
margin-top: var(--spacing-card-md);
}
.card-title-adjustments {
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
}
.page {
padding: 0.5rem;
margin-left: auto;
margin-right: auto;
max-width: 56rem;
}
.content {
// TODO: Get rid of this hack when removing global styles from the website.
// Overflow decides the behavior of md editor but also clips the border.
// In the future, we should use ring instead of block-shadow for the
// green ring around the md editor
padding-inline: var(--gap-md);
padding-bottom: var(--gap-md);
margin-inline: calc(var(--gap-md) * -1);
display: grid;
// Disable horizontal stretch
grid-template-columns: minmax(0, 1fr);
overflow: hidden;
}
.report-info-section {
display: block;
width: 100%;
gap: var(--gap-md);
:global(.animated-dropdown) {
& > .selected {
height: 40px;
}
}
.report-info-item {
display: block;
width: 100%;
max-width: 100%;
label {
display: block;
margin-bottom: var(--gap-sm);
color: var(--color-text-dark);
font-size: var(--font-size-md);
font-weight: var(--font-weight-bold);
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
}
}
}
</style>

View File

@@ -168,7 +168,7 @@
name="Sort by"
:options="sortTypes"
:display-name="(option) => option?.display"
@change="updateSearchResults(1)"
@change="updateSearchResults()"
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
@@ -181,7 +181,7 @@
:default-value="maxResults"
:model-value="maxResults"
class="!w-auto flex-grow md:flex-grow-0"
@change="updateSearchResults(1)"
@change="updateSearchResults()"
>
<span class="font-semibold text-primary">View: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
@@ -206,7 +206,7 @@
:page="currentPage"
:count="pageCount"
class="mx-auto sm:ml-auto sm:mr-0"
@switch-page="setPage"
@switch-page="updateSearchResults"
/>
</div>
<SearchFilterControl
@@ -296,7 +296,7 @@
:page="currentPage"
:count="pageCount"
class="justify-end"
@switch-page="setPage"
@switch-page="updateSearchResults"
/>
</div>
</div>
@@ -545,19 +545,13 @@ const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
);
function setPage(newPageNumber) {
currentPage.value = newPageNumber;
window.scrollTo({ top: 0, behavior: "smooth" });
updateSearchResults();
}
function scrollToTop(behavior = "smooth") {
window.scrollTo({ top: 0, behavior });
}
function updateSearchResults() {
function updateSearchResults(pageNumber) {
currentPage.value = pageNumber || 1;
scrollToTop();
noLoad.value = true;
if (query.value === null) {
@@ -590,8 +584,8 @@ function updateSearchResults() {
}
}
watch([currentFilters, requestParams], () => {
updateSearchResults();
watch([currentFilters], () => {
updateSearchResults(1);
});
function cycleSearchDisplayMode() {

View File

@@ -456,9 +456,9 @@
Where are Modrinth Servers located? Can I choose a region?
</summary>
<p class="m-0 !leading-[190%]">
Currently, Modrinth Servers are located in New York, Los Angeles, and Miami. More
regions are coming soon! Your server's location is currently chosen algorithmically,
but you will be able to choose a region in the future.
Currently, Modrinth Servers are located in New York, Los Angeles, Seattle, and
Miami. More regions are coming soon! Your server's location is currently chosen
algorithmically, but you will be able to choose a region in the future.
</p>
</details>
@@ -512,9 +512,9 @@
: "There's a plan for everyone! Choose the one that fits your needs."
}}
<span class="font-bold">
Servers are currently US only, in New York, Los Angeles, and Miami. More regions coming
soon!</span
>
Servers are currently US only, in New York, Los Angeles, Seattle, and Miami. More
regions coming soon!
</span>
</h2>
<ul class="m-0 flex w-full flex-col gap-8 p-0 lg:flex-row">
@@ -533,9 +533,9 @@
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">4 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">32 GB Storage</p>
<p class="m-0">4 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">2 vCPUs</p>
<p class="m-0">32 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$12<span class="text-sm font-normal text-secondary">/month</span>
@@ -585,9 +585,9 @@
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">6 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">48 GB Storage</p>
<p class="m-0">6 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">3 vCPUs</p>
<p class="m-0">48 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$18<span class="text-sm font-normal text-secondary">/month</span>
@@ -626,9 +626,9 @@
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">8 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">64 GB Storage</p>
<p class="m-0">8 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">4 vCPUs</p>
<p class="m-0">64 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$24<span class="text-sm font-normal text-secondary">/month</span>
@@ -656,11 +656,11 @@
</ul>
<div
class="flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left md:flex-row md:gap-0"
class="flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left lg:flex-row lg:gap-0"
>
<div class="flex flex-col gap-4">
<h1 class="m-0">Build your own</h1>
<h2 class="m-0 text-base font-normal">
<h2 class="m-0 text-base font-normal text-primary">
If you're a more technical server administrator, you can pick your own RAM and storage
options.
</h2>

View File

@@ -19,7 +19,26 @@
</div>
</div>
<div
v-else-if="serverData?.status === 'suspended'"
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'support'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<TransferIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">We're working on your server</h1>
</div>
<p class="text-lg text-secondary">
You recently contacted Modrinth Support, and we're actively working on your server. It
will be back online shortly.
</p>
</div>
</div>
</div>
<div
v-else-if="serverData?.status === 'suspended' && serverData.suspension_reason !== 'upgrading'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -69,6 +88,58 @@
</ButtonStyled>
</div>
</div>
<div
v-else-if="server.error && server.error.message.includes('Service Unavailable')"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-red p-4">
<PanelErrorIcon class="size-12 text-red" />
</div>
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
</div>
<p class="m-0 mb-4 leading-[170%] text-secondary">
Your server's node, where your Modrinth Server is physically hosted, is experiencing
issues. We are working with our datacenter to resolve the issue as quickly as possible.
</p>
<p class="m-0 mb-4 leading-[170%] text-secondary">
Your data is safe and will not be lost, and your server will be back online as soon as
the issue is resolved.
</p>
<p class="m-0 mb-4 leading-[170%] text-secondary">
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
bubble in the bottom right corner and we'll be happy to help.
</p>
<div class="flex flex-col gap-2">
<UiCopyCode :text="'Server ID: ' + server.serverId" />
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
</div>
</div>
<ButtonStyled
size="large"
color="standard"
@click="
() =>
navigateTo('https://discord.modrinth.com', {
external: true,
})
"
>
<button class="mt-6 !w-full">Join Modrinth Discord</button>
</ButtonStyled>
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
color="standard"
@click="() => reloadNuxtApp()"
>
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="server.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
@@ -324,6 +395,7 @@ import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import { reloadNuxtApp } from "#app";
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
import { usePyroConsole } from "~/store/console.ts";
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
const socket = ref<WebSocket | null>(null);
const isReconnecting = ref(false);

View File

@@ -1,65 +1,21 @@
<template>
<NewModal ref="modModal" header="Editing mod version">
<div>
<div class="mb-4 flex flex-col gap-4">
<div class="inline-flex flex-wrap items-center">
You're changing the version of
<div class="inline-flex flex-wrap items-center gap-1 text-nowrap pl-2">
<UiAvatar
:src="currentMod?.icon_url"
size="24px"
class="inline-block"
alt="Server Icon"
/>
<strong>{{ currentMod?.name + "." }}</strong>
</div>
</div>
<div>
<div v-if="props.server.general?.upstream" class="flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
Your server was created from a modpack. Changing the mod version may cause unexpected
issues. You can update the modpack version in your server's Options > Platform
settings.
</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<UiServersTeleportDropdownMenu
v-model="currentVersion"
name="Project"
:options="currentVersions"
placeholder="Select project..."
class="!w-full"
:display-name="
(version) => (typeof version === 'object' ? version?.version_number : version)
"
/>
</div>
<div class="mt-4 flex flex-row items-center gap-4">
<ButtonStyled color="brand">
<button :disabled="currentMod.changing" @click="changeModVersion">
<PlusIcon />
Install
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modModal.value.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<UiServersContentVersionEditModal
v-if="!invalidModal"
ref="versionEditModal"
:type="type"
:mod-pack="Boolean(props.server.general?.upstream)"
:game-version="props.server.general?.mc_version ?? ''"
:loader="props.server.general?.loader?.toLowerCase() ?? ''"
:server-id="props.server.serverId"
@change-version="changeModVersion($event)"
/>
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
<div class="relative flex h-full w-full flex-col">
<div class="sticky top-0 z-20 -mt-4 flex items-center justify-between bg-bg py-4">
<div class="flex w-full flex-col items-center gap-2 sm:flex-row sm:gap-4">
<div class="flex w-full items-center gap-2 sm:gap-4">
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
<div class="flex w-full flex-col-reverse items-center gap-2 sm:flex-row">
<div class="flex w-full items-center gap-2">
<div class="relative flex-1 text-sm">
<label class="sr-only" for="search">Search</label>
<SearchIcon
@@ -73,7 +29,7 @@
type="search"
name="search"
autocomplete="off"
:placeholder="`Search ${type.toLocaleLowerCase()}s...`"
:placeholder="`Search ${localMods.length} ${type.toLocaleLowerCase()}s...`"
@input="debouncedSearch"
/>
</div>
@@ -88,7 +44,7 @@
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
]"
>
<span class="whitespace-pre text-sm font-medium">
<span class="hidden whitespace-pre sm:block">
{{ filterMethodLabel }}
</span>
<FilterIcon aria-hidden="true" />
@@ -99,179 +55,255 @@
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-if="hasMods" color="brand" type="outlined">
<nuxt-link
class="w-full text-nowrap sm:w-fit"
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
>
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</nuxt-link>
</ButtonStyled>
<div v-if="hasMods" class="flex w-full items-center gap-2 sm:w-fit">
<ButtonStyled>
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
<FileIcon />
Add file
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<nuxt-link
class="w-full text-nowrap sm:w-fit"
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
>
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<div v-if="hasMods" class="flex flex-col gap-2 transition-all">
<div ref="listContainer" class="relative w-full">
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
<div :style="{ position: 'absolute', top: `${visibleTop}px`, width: '100%' }">
<template v-for="mod in visibleItems.items" :key="mod.filename">
<div
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
style="height: 64px"
>
<NuxtLink
:to="
mod.project_id
? `/project/${mod.project_id}/version/${mod.version_id}`
: `files?path=mods`
"
class="group flex min-w-0 items-center rounded-xl p-2"
<FilesUploadDropdown
v-if="props.server.fs"
ref="uploadDropdownRef"
class="rounded-xl bg-bg-raised"
:margin-bottom="16"
:file-type="type"
:current-path="`/${type.toLocaleLowerCase()}s`"
:fs="props.server.fs"
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
@upload-complete="() => props.server.refresh(['content'])"
/>
<FilesUploadDragAndDrop
v-if="server.general && localMods"
class="relative min-h-[50vh]"
overlay-class="rounded-xl border-2 border-dashed border-secondary"
:type="type"
@files-dropped="handleDroppedFiles"
>
<div v-if="hasFilteredMods" class="flex flex-col gap-2 transition-all">
<div ref="listContainer" class="relative w-full">
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
<div :style="{ position: 'absolute', top: `${visibleTop}px`, width: '100%' }">
<template v-for="mod in visibleItems.items" :key="mod.filename">
<div
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
style="height: 64px"
>
<div class="flex min-w-0 items-center gap-2">
<NuxtLink
:to="
mod.project_id
? `/project/${mod.project_id}/version/${mod.version_id}`
: `files?path=${type.toLocaleLowerCase()}s`
"
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
draggable="false"
>
<UiAvatar
:src="mod.icon_url"
size="sm"
alt="Server Icon"
:class="mod.disabled ? 'grayscale' : ''"
:class="mod.disabled ? 'opacity-75 grayscale' : ''"
/>
<div class="flex min-w-0 flex-col">
<span class="flex min-w-0 items-center gap-2 text-lg font-bold">
<span class="truncate">{{
mod.name || mod.filename.replace(".disabled", "")
}}</span>
<div class="flex min-w-0 flex-col gap-1">
<span class="text-md flex min-w-0 items-center gap-2 font-bold">
<span class="truncate text-contrast">{{ friendlyModName(mod) }}</span>
<span
v-if="mod.disabled"
class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block"
>Disabled</span
>
</span>
<span class="min-w-0 text-xs text-secondary">{{
mod.version_number || "External mod"
<div class="min-w-0 text-xs text-secondary">
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
<span class="block font-semibold sm:hidden">
{{ mod.version_number || `External ${type.toLocaleLowerCase()}` }}
</span>
</div>
</div>
</NuxtLink>
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
<div class="truncate font-semibold text-contrast">
<span v-tooltip="`${type} version`">{{
mod.version_number || `External ${type.toLocaleLowerCase()}`
}}</span>
</div>
<div class="truncate">
<span v-tooltip="`${type} file name`">
{{ mod.filename }}
</span>
</div>
</div>
</NuxtLink>
<div class="flex items-center gap-2 pr-4 font-semibold text-contrast">
<ButtonStyled v-if="mod.project_id" type="transparent">
<button
v-tooltip="'Edit mod version'"
:disabled="mod.changing"
class="!hidden sm:!block"
@click="beginChangeModVersion(mod)"
>
<template v-if="mod.changing">
<UiServersIconsLoadingIcon />
</template>
<template v-else>
<EditIcon />
</template>
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
v-tooltip="'Delete mod'"
:disabled="mod.changing"
class="!hidden sm:!block"
@click="removeMod(mod)"
>
<TrashIcon />
</button>
</ButtonStyled>
<!-- Dropdown for mobile -->
<div class="mr-2 flex items-center sm:hidden">
<UiServersIconsLoadingIcon
v-if="mod.changing"
class="mr-2 h-5 w-5 animate-spin"
style="color: var(--color-base)"
/>
<ButtonStyled v-else circular type="transparent">
<UiServersTeleportOverflowMenu
:options="[
{
id: 'edit',
action: () => beginChangeModVersion(mod),
shown: !!(mod.project_id && !mod.changing),
},
{
id: 'delete',
action: () => removeMod(mod),
},
]"
<div
class="flex items-center justify-end gap-2 pr-4 font-semibold text-contrast sm:min-w-44"
>
<ButtonStyled color="red" type="transparent">
<button
v-tooltip="`Delete ${type.toLocaleLowerCase()}`"
:disabled="mod.changing"
class="!hidden sm:!block"
@click="removeMod(mod)"
>
<MoreVerticalIcon aria-hidden="true" />
<template #edit>
<EditIcon class="h-5 w-5" />
<span>Edit</span>
</template>
<template #delete>
<TrashIcon class="h-5 w-5" />
<span>Delete</span>
</template>
</UiServersTeleportOverflowMenu>
<TrashIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
v-tooltip="
mod.project_id
? `Edit ${type.toLocaleLowerCase()} version`
: `External ${type.toLocaleLowerCase()}s cannot be edited`
"
:disabled="mod.changing || !mod.project_id"
class="!hidden sm:!block"
@click="showVersionModal(mod)"
>
<template v-if="mod.changing">
<UiServersIconsLoadingIcon class="animate-spin" />
</template>
<template v-else>
<EditIcon />
</template>
</button>
</ButtonStyled>
</div>
<input
:id="`toggle-${mod.filename}`"
:checked="!mod.disabled"
:disabled="mod.changing"
class="switch stylized-toggle"
type="checkbox"
@change="toggleMod(mod)"
/>
<!-- Dropdown for mobile -->
<div class="mr-2 flex items-center sm:hidden">
<UiServersIconsLoadingIcon
v-if="mod.changing"
class="mr-2 h-5 w-5 animate-spin"
style="color: var(--color-base)"
/>
<ButtonStyled v-else circular type="transparent">
<UiServersTeleportOverflowMenu
:options="[
{
id: 'edit',
action: () => showVersionModal(mod),
shown: !!(mod.project_id && !mod.changing),
},
{
id: 'delete',
action: () => removeMod(mod),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #edit>
<EditIcon class="h-5 w-5" />
<span>Edit</span>
</template>
<template #delete>
<TrashIcon class="h-5 w-5" />
<span>Delete</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
<input
:id="`toggle-${mod.filename}`"
:checked="!mod.disabled"
:disabled="mod.changing"
class="switch stylized-toggle"
type="checkbox"
@change="toggleMod(mod)"
/>
</div>
</div>
</div>
</template>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- no mods has platform -->
<div
v-else-if="
!hasMods &&
props.server.general?.loader &&
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
"
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<PackageClosedIcon class="size-24" />
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
<p class="m-0">
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
</p>
<ButtonStyled color="brand">
<NuxtLink :to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`">
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</NuxtLink>
</ButtonStyled>
</div>
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
<UiServersIconsLoaderIcon loader="Vanilla" class="size-24" />
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
<p class="m-0">
Add content to your server by installing a modpack or choosing a different platform that
supports {{ type }}s.
</p>
<div class="flex flex-row items-center gap-4">
<ButtonStyled class="mt-8">
<NuxtLink :to="`/modpacks?sid=${props.server.serverId}`">
<CompassIcon />
Find a modpack
</NuxtLink>
</ButtonStyled>
<div>or</div>
<ButtonStyled class="mt-8">
<NuxtLink :to="`/servers/manage/${props.server.serverId}/options/loader`">
<WrenchIcon />
Change platform
</NuxtLink>
</ButtonStyled>
<!-- no mods has platform -->
<div
v-else-if="
props.server.general?.loader &&
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
"
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<div
v-if="!hasFilteredMods && hasMods"
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<SearchIcon class="size-24" />
<p class="m-0 font-bold text-contrast">
No {{ type.toLocaleLowerCase() }}s found for your query!
</p>
<p class="m-0">Try another query, or show everything.</p>
<ButtonStyled>
<button @click="showAll">
<ListIcon />
Show everything
</button>
</ButtonStyled>
</div>
<div
v-else
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<PackageClosedIcon class="size-24" />
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
<p class="m-0">
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
</p>
<div class="flex flex-row items-center gap-4">
<ButtonStyled type="outlined">
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
<FileIcon />
Add file
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<nuxt-link
class="w-full text-nowrap sm:w-fit"
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
>
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
<UiServersIconsLoaderIcon loader="Vanilla" class="size-24" />
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
<p class="m-0">
Add content to your server by installing a modpack or choosing a different platform that
supports {{ type }}s.
</p>
<div class="flex flex-row items-center gap-4">
<ButtonStyled class="mt-8">
<NuxtLink :to="`/modpacks?sid=${props.server.serverId}`">
<CompassIcon />
Find a modpack
</NuxtLink>
</ButtonStyled>
<div>or</div>
<ButtonStyled class="mt-8">
<NuxtLink :to="`/servers/manage/${props.server.serverId}/options/loader`">
<WrenchIcon />
Change platform
</NuxtLink>
</ButtonStyled>
</div>
</div>
</FilesUploadDragAndDrop>
</div>
</div>
</template>
@@ -284,16 +316,19 @@ import {
PackageClosedIcon,
FilterIcon,
DropdownIcon,
InfoIcon,
XIcon,
PlusIcon,
MoreVerticalIcon,
CompassIcon,
WrenchIcon,
ListIcon,
FileIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
import type { Server } from "~/composables/pyroServers";
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
@@ -304,14 +339,7 @@ const type = computed(() => {
return loader === "paper" || loader === "purpur" ? "Plugin" : "Mod";
});
interface Mod {
name?: string;
filename: string;
project_id?: string;
version_id?: string;
version_number?: string;
icon_url?: string;
disabled: boolean;
interface ContentItem extends Mod {
changing?: boolean;
}
@@ -322,12 +350,99 @@ const listContainer = ref<HTMLElement | null>(null);
const windowScrollY = ref(0);
const windowHeight = ref(0);
const localMods = ref<Mod[]>([]);
const localMods = ref<ContentItem[]>([]);
const searchInput = ref("");
const modSearchInput = ref("");
const filterMethod = ref("all");
const uploadDropdownRef = ref();
const versionEditModal = ref();
const currentEditMod = ref<ContentItem | null>(null);
const invalidModal = computed(
() => !props.server.general?.mc_version || !props.server.general?.loader,
);
async function changeModVersion(event: string) {
const mod = currentEditMod.value;
if (mod) mod.changing = true;
try {
versionEditModal.value.hide();
// This will be used instead once backend implementation is done
// await props.server.content?.reinstall(
// `/${type.value.toLowerCase()}s/${event.fileName}`,
// currentMod.value.project_id,
// currentVersion.value.id,
// );
await props.server.content?.install(
type.value.toLowerCase() as "mod" | "plugin",
mod?.project_id || "",
event,
);
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod?.filename}`);
await props.server.refresh(["general", "content"]);
} catch (error) {
const errmsg = `Error changing mod version: ${error}`;
console.error(errmsg);
addNotification({
text: errmsg,
type: "error",
});
return;
}
if (mod) mod.changing = false;
}
function showVersionModal(mod: ContentItem) {
if (invalidModal.value || !mod?.project_id || !mod?.filename) {
const errmsg = invalidModal.value
? "Data required for changing mod version was not found."
: `${!mod?.project_id ? "No mod project ID found" : "No mod filename found"} for ${friendlyModName(mod!)}`;
console.error(errmsg);
addNotification({
text: errmsg,
type: "error",
});
return;
}
currentEditMod.value = mod;
versionEditModal.value.show(mod);
}
const handleDroppedFiles = (files: File[]) => {
files.forEach((file) => {
uploadDropdownRef.value?.uploadFile(file);
});
};
const initiateFileUpload = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = acceptFileFromProjectType(type.value.toLowerCase());
input.multiple = true;
input.onchange = () => {
if (input.files) {
Array.from(input.files).forEach((file) => {
uploadDropdownRef.value?.uploadFile(file);
});
}
};
input.click();
};
const showAll = () => {
searchInput.value = "";
modSearchInput.value = "";
filterMethod.value = "all";
};
const filterMethodLabel = computed(() => {
switch (filterMethod.value) {
case "disabled":
@@ -419,24 +534,40 @@ const debouncedSearch = debounce(() => {
modSearchInput.value = searchInput.value;
if (pyroContentSentinel.value) {
pyroContentSentinel.value.scrollIntoView({
behavior: "smooth",
block: "start",
});
const sentinelRect = pyroContentSentinel.value.getBoundingClientRect();
if (sentinelRect.top < 0 || sentinelRect.bottom > window.innerHeight) {
pyroContentSentinel.value.scrollIntoView({
// behavior: "smooth",
block: "start",
});
}
}
}, 300);
async function toggleMod(mod: Mod) {
function friendlyModName(mod: ContentItem) {
if (mod.name) return mod.name;
// remove .disabled if at the end of the filename
let cleanName = mod.filename.endsWith(".disabled") ? mod.filename.slice(0, -9) : mod.filename;
// remove everything after the last dot
const lastDotIndex = cleanName.lastIndexOf(".");
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex);
return cleanName;
}
async function toggleMod(mod: ContentItem) {
mod.changing = true;
const originalFilename = mod.filename;
try {
const newFilename = mod.filename.endsWith(".disabled")
? mod.filename.replace(".disabled", "")
? mod.filename.slice(0, -9)
: `${mod.filename}.disabled`;
const sourcePath = `/mods/${mod.filename}`;
const destinationPath = `/mods/${newFilename}`;
const folder = `${type.value.toLocaleLowerCase()}s`;
const sourcePath = `/${folder}/${mod.filename}`;
const destinationPath = `/${folder}/${newFilename}`;
mod.disabled = newFilename.endsWith(".disabled");
mod.filename = newFilename;
@@ -450,7 +581,7 @@ async function toggleMod(mod: Mod) {
console.error("Error toggling mod:", error);
addNotification({
text: `Something went wrong toggling ${mod.name || mod.filename.replace(".disabled", "")}`,
text: `Something went wrong toggling ${friendlyModName(mod)}`,
type: "error",
});
}
@@ -458,14 +589,11 @@ async function toggleMod(mod: Mod) {
mod.changing = false;
}
async function removeMod(mod: Mod) {
async function removeMod(mod: ContentItem) {
mod.changing = true;
try {
await props.server.content?.remove(
type.value as "Mod" | "Plugin",
`/${type.value.toLowerCase()}s/${mod.filename}`,
);
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod.filename}`);
await props.server.refresh(["general", "content"]);
} catch (error) {
console.error("Error removing mod:", error);
@@ -479,42 +607,11 @@ async function removeMod(mod: Mod) {
mod.changing = false;
}
const modModal = ref();
const currentMod = ref();
const currentVersions = ref();
const currentVersion = ref();
async function beginChangeModVersion(mod: Mod) {
currentMod.value = mod;
currentVersions.value = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
currentVersions.value = currentVersions.value.filter((version: any) =>
version.loaders.includes(props.server.general?.loader?.toLowerCase()),
);
currentVersion.value = currentVersions.value.find(
(version: any) => version.id === mod.version_id,
);
modModal.value.show();
}
async function changeModVersion() {
currentMod.value.changing = true;
try {
modModal.value.hide();
await props.server.content?.reinstall(
type.value,
currentMod.value.version_id,
currentVersion.value.id,
);
await props.server.refresh(["general", "content"]);
} catch (error) {
console.error("Error changing mod version:", error);
}
currentMod.value.changing = false;
}
const hasMods = computed(() => {
return localMods.value?.length > 0;
});
const hasFilteredMods = computed(() => {
return filteredMods.value?.length > 0;
});
@@ -539,9 +636,7 @@ const filteredMods = computed(() => {
})();
return statusFilteredMods.sort((a, b) => {
const aName = a.name || a.filename.replace(".disabled", "");
const bName = b.name || b.filename.replace(".disabled", "");
return aName.localeCompare(bName);
return friendlyModName(a).localeCompare(friendlyModName(b));
});
});
</script>

View File

@@ -25,12 +25,9 @@
@delete="handleDeleteItem"
/>
<div
<FilesUploadDragAndDrop
class="relative flex w-full flex-col rounded-2xl border border-solid border-bg-raised"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@files-dropped="handleDroppedFiles"
>
<div ref="mainContent" class="relative isolate flex w-full flex-col">
<div v-if="!isEditing" class="contents">
@@ -44,94 +41,14 @@
@upload="initiateFileUpload"
@update:search-query="searchQuery = $event"
/>
<Transition
name="upload-status"
@enter="onUploadStatusEnter"
@leave="onUploadStatusLeave"
>
<div
v-if="isUploading"
ref="uploadStatusRef"
class="upload-status rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow text-contrast"
>
<div class="flex flex-col p-4 text-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-bold">
<FolderOpenIcon class="size-4" />
<span>
File Uploads{{
activeUploads.length > 0 ? ` - ${activeUploads.length} left` : ""
}}
</span>
</div>
</div>
<div class="mt-2 space-y-2">
<div
v-for="item in uploadQueue"
:key="item.file.name"
class="flex h-6 items-center justify-between gap-2 text-xs"
>
<div class="flex flex-1 items-center gap-2 truncate">
<transition-group name="status-icon" mode="out-in">
<UiServersPanelSpinner
v-show="item.status === 'uploading'"
key="spinner"
class="absolute !size-4"
/>
<CheckCircleIcon
v-show="item.status === 'completed'"
key="check"
class="absolute size-4 text-green"
/>
<XCircleIcon
v-show="item.status === 'error' || item.status === 'cancelled'"
key="error"
class="absolute size-4 text-red"
/>
</transition-group>
<span class="ml-6 truncate">{{ item.file.name }}</span>
<span class="text-secondary">{{ item.size }}</span>
</div>
<div class="flex min-w-[80px] items-center justify-end gap-2">
<template v-if="item.status === 'completed'">
<span>Done</span>
</template>
<template v-else-if="item.status === 'error'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else>
<template v-if="item.status === 'uploading'">
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
<button>Cancel</button>
</ButtonStyled>
</template>
<template v-else-if="item.status === 'cancelled'">
<span class="text-red">Cancelled</span>
</template>
<template v-else>
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
</template>
</template>
</div>
</div>
</div>
</div>
</div>
</Transition>
<FilesUploadDropdown
v-if="props.server.fs"
ref="uploadDropdownRef"
class="rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow"
:current-path="currentPath"
:fs="props.server.fs"
@upload-complete="refreshList()"
/>
</div>
<UiServersFilesEditingNavbar
@@ -220,7 +137,7 @@
<p class="mt-2 text-xl">Drop files here to upload</p>
</div>
</div>
</div>
</FilesUploadDragAndDrop>
<UiServersFilesContextMenu
ref="contextMenu"
@@ -238,9 +155,10 @@
<script setup lang="ts">
import { useInfiniteScroll } from "@vueuse/core";
import { UploadIcon, FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
interface BaseOperation {
type: "move" | "rename";
@@ -263,14 +181,6 @@ interface RenameOperation extends BaseOperation {
type Operation = MoveOperation | RenameOperation;
interface UploadItem {
file: File;
progress: number;
status: "pending" | "uploading" | "completed" | "error" | "cancelled";
size: string;
uploader?: any;
}
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
@@ -312,46 +222,8 @@ const isEditingImage = ref(false);
const imagePreview = ref();
const isDragging = ref(false);
const dragCounter = ref(0);
const uploadStatusRef = ref<HTMLElement | null>(null);
const isUploading = computed(() => uploadQueue.value.length > 0);
const uploadQueue = ref<UploadItem[]>([]);
const activeUploads = computed(() =>
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
);
const onUploadStatusEnter = (el: Element) => {
const height = (el as HTMLElement).scrollHeight;
(el as HTMLElement).style.height = "0";
// eslint-disable-next-line no-void
void (el as HTMLElement).offsetHeight;
(el as HTMLElement).style.height = `${height}px`;
};
const onUploadStatusLeave = (el: Element) => {
const height = (el as HTMLElement).scrollHeight;
(el as HTMLElement).style.height = `${height}px`;
// eslint-disable-next-line no-void
void (el as HTMLElement).offsetHeight;
(el as HTMLElement).style.height = "0";
};
watch(
uploadQueue,
() => {
if (!uploadStatusRef.value) return;
const el = uploadStatusRef.value;
const itemsHeight = uploadQueue.value.length * 32;
const headerHeight = 12;
const gap = 8;
const padding = 32;
const totalHeight = padding + headerHeight + gap + itemsHeight;
el.style.height = `${totalHeight}px`;
},
{ deep: true },
);
const uploadDropdownRef = ref();
const data = computed(() => props.server.general);
@@ -917,135 +789,12 @@ const requestShareLink = async () => {
}
};
const handleDragEnter = (event: DragEvent) => {
const handleDroppedFiles = (files: File[]) => {
if (isEditing.value) return;
event.preventDefault();
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
dragCounter.value++;
isDragging.value = true;
}
};
const handleDragOver = (event: DragEvent) => {
if (isEditing.value) return;
event.preventDefault();
};
const handleDragLeave = (event: DragEvent) => {
if (isEditing.value) return;
event.preventDefault();
dragCounter.value--;
if (dragCounter.value === 0) {
isDragging.value = false;
}
};
// eslint-disable-next-line require-await
const handleDrop = async (event: DragEvent) => {
if (isEditing.value) return;
event.preventDefault();
isDragging.value = false;
dragCounter.value = 0;
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
if (isInternalMove) return;
const files = event.dataTransfer?.files;
if (files) {
Array.from(files).forEach((file) => {
uploadFile(file);
});
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
};
const cancelUpload = (item: UploadItem) => {
if (item.uploader && item.status === "uploading") {
item.uploader.cancel();
item.status = "cancelled";
setTimeout(async () => {
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
if (index !== -1) {
uploadQueue.value.splice(index, 1);
await nextTick();
}
}, 5000);
}
};
const uploadFile = async (file: File) => {
const uploadItem: UploadItem = {
file,
progress: 0,
status: "pending",
size: formatFileSize(file.size),
};
uploadQueue.value.push(uploadItem);
try {
uploadItem.status = "uploading";
const filePath = `${currentPath.value}/${file.name}`.replace("//", "/");
const uploader = await props.server.fs?.uploadFile(filePath, file);
uploadItem.uploader = uploader;
if (uploader?.onProgress) {
uploader.onProgress(({ progress }: { progress: number }) => {
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (index !== -1) {
uploadQueue.value[index].progress = Math.round(progress);
}
});
}
await uploader?.promise;
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
uploadQueue.value[index].status = "completed";
uploadQueue.value[index].progress = 100;
}
await nextTick();
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1);
await nextTick();
}
}, 5000);
await refreshList();
} catch (error) {
console.error("Error uploading file:", error);
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
uploadQueue.value[index].status = "error";
}
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1);
await nextTick();
}
}, 5000);
if (error instanceof Error && error.message !== "Upload cancelled") {
addNotification({
group: "files",
title: "Upload failed",
text: `Failed to upload ${file.name}`,
type: "error",
});
}
}
files.forEach((file) => {
uploadDropdownRef.value?.uploadFile(file);
});
};
const initiateFileUpload = () => {
@@ -1055,7 +804,7 @@ const initiateFileUpload = () => {
input.onchange = () => {
if (input.files) {
Array.from(input.files).forEach((file) => {
uploadFile(file);
uploadDropdownRef.value?.uploadFile(file);
});
}
};

View File

@@ -237,24 +237,11 @@ interface ErrorData {
}
const inspectingError = ref<ErrorData | null>(null);
const mcError = ref<any>(null);
const inspectError = async () => {
const log = await props.server.fs?.downloadFile("logs/latest.log");
const response = (await $fetch("https://api.mclo.gs/1/log", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
content: log,
}),
})) as any;
mcError.value = response;
// @ts-ignore
const analysis = (await $fetch(`https://api.mclo.gs/1/insights/${response.id}`, {
const analysis = (await $fetch(`https://api.mclo.gs/1/analyse`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -269,7 +256,6 @@ const inspectError = async () => {
const clearError = () => {
inspectingError.value = null;
mcError.value = null;
};
watch(

View File

@@ -330,11 +330,11 @@
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
<DownloadIcon v-if="hasNewerVersion" color="brand">
<ButtonStyled v-if="hasNewerVersion" color="brand">
<button class="!w-full sm:!w-auto" @click="handleUpdateToLatest">
<UploadIcon class="size-4" /> Update modpack
</button>
</DownloadIcon>
</ButtonStyled>
</div>
</div>
<div v-if="data.upstream" class="contents">

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,7 @@
import dayjs from "dayjs";
import quarterOfYear from "dayjs/plugin/quarterOfYear";
dayjs.extend(quarterOfYear);
export default defineNuxtPlugin(() => {
return {

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Modrinth App Ad</title>
<script src="/inmobi.js"></script>
<script src="https://cadmus.script.ac/d14pdm1b7fi5kh/script.js"></script>
<script src="https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js"></script>
<link rel="preload" href="https://www.googletagservices.com/tag/js/gpt.js" as="script" />
<style>

View File

@@ -1,11 +1,11 @@
export interface Mod {
id: string;
filename: string;
modrinth_ids: {
project_id: string;
version_id: string;
};
}
// export interface Mod {
// id: string;
// filename: string;
// modrinth_ids: {
// project_id: string;
// version_id: string;
// };
// }
interface License {
id: string;

View File

@@ -304,13 +304,10 @@ export const useFetchAllAnalytics = (
projects,
selectedProjects,
personalRevenue = false,
startDate = ref(dayjs().subtract(30, "days")),
endDate = ref(dayjs()),
timeResolution = ref(1440),
) => {
const timeResolution = ref(1440); // 1 day
const timeRange = ref(43200); // 30 days
const startDate = ref(Date.now() - timeRange.value * 60 * 1000);
const endDate = ref(Date.now());
const downloadData = ref(null);
const viewData = ref(null);
const revenueData = ref(null);
@@ -394,8 +391,8 @@ export const useFetchAllAnalytics = (
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value],
async () => {
const q = {
start_date: dayjs(startDate.value).toISOString(),
end_date: dayjs(endDate.value).toISOString(),
start_date: startDate.value.toISOString(),
end_date: endDate.value.toISOString(),
resolution_minutes: timeResolution.value,
};
@@ -442,7 +439,6 @@ export const useFetchAllAnalytics = (
return {
// Configuration
timeResolution,
timeRange,
startDate,
endDate,

View File

@@ -68,6 +68,9 @@ PAYPAL_API_URL=https://api-m.sandbox.paypal.com/v1/
PAYPAL_WEBHOOK_ID=none
PAYPAL_CLIENT_ID=none
PAYPAL_CLIENT_SECRET=none
PAYPAL_NVP_USERNAME=none
PAYPAL_NVP_PASSWORD=none
PAYPAL_NVP_SIGNATURE=none
STEAM_API_KEY=none
@@ -106,4 +109,10 @@ STRIPE_WEBHOOK_SECRET=none
ADITUDE_API_KEY=none
PYRO_API_KEY=none
PYRO_API_KEY=none
BREX_API_URL=https://platform.brexapis.com/v2/
BREX_API_KEY=none
DELPHI_URL=none
DELPHI_SLACK_WEBHOOK=none

View File

@@ -99,7 +99,7 @@
false,
true,
true,
false
true
]
},
"hash": "109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b"

View File

@@ -38,7 +38,7 @@
false,
false,
true,
true,
false,
true
]
},

View File

@@ -38,7 +38,7 @@
false,
false,
true,
true,
false,
true
]
},

View File

@@ -99,7 +99,7 @@
false,
true,
true,
false
true
]
},
"hash": "6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634"

View File

@@ -44,7 +44,7 @@
false,
false,
true,
true,
false,
true
]
},

View File

@@ -99,7 +99,7 @@
false,
true,
true,
false
true
]
},
"hash": "7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc"

View File

@@ -99,7 +99,7 @@
false,
true,
true,
false
true
]
},
"hash": "8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1"

View File

@@ -99,7 +99,7 @@
false,
true,
true,
false
true
]
},
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"

View File

@@ -99,7 +99,7 @@
false,
true,
true,
false
true
]
},
"hash": "bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b"

View File

@@ -30,7 +30,7 @@ async-trait = "0.1.70"
dashmap = "5.4.0"
lazy_static = "1.4.0"
meilisearch-sdk = "0.24.3"
meilisearch-sdk = "0.27.1"
rust-s3 = "0.33.0"
reqwest = { version = "0.11.18", features = ["json", "multipart"] }
hyper = { version = "0.14", features = ["full"] }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -448,6 +448,9 @@ pub fn check_env_vars() -> bool {
failed |= check_var::<String>("PAYPAL_WEBHOOK_ID");
failed |= check_var::<String>("PAYPAL_CLIENT_ID");
failed |= check_var::<String>("PAYPAL_CLIENT_SECRET");
failed |= check_var::<String>("PAYPAL_NVP_USERNAME");
failed |= check_var::<String>("PAYPAL_NVP_PASSWORD");
failed |= check_var::<String>("PAYPAL_NVP_SIGNATURE");
failed |= check_var::<String>("HCAPTCHA_SECRET");
@@ -482,9 +485,14 @@ pub fn check_env_vars() -> bool {
failed |= check_var::<String>("STRIPE_API_KEY");
failed |= check_var::<String>("STRIPE_WEBHOOK_SECRET");
failed |= check_var::<u64>("ADITUDE_API_KEY");
failed |= check_var::<String>("ADITUDE_API_KEY");
failed |= check_var::<String>("PYRO_API_KEY");
failed |= check_var::<String>("BREX_API_URL");
failed |= check_var::<String>("BREX_API_KEY");
failed |= check_var::<String>("DELPHI_URL");
failed
}

View File

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

View File

@@ -18,7 +18,7 @@ use std::io::{Cursor, Read};
use std::time::Duration;
use zip::ZipArchive;
const AUTOMOD_ID: i64 = 0;
pub const AUTOMOD_ID: i64 = 0;
pub struct ModerationMessages {
pub messages: Vec<ModerationMessage>,

View File

@@ -23,7 +23,7 @@ pub struct PayoutsQueue {
payout_options: RwLock<Option<PayoutMethods>>,
}
#[derive(Clone)]
#[derive(Clone, Debug)]
struct PayPalCredentials {
access_token: String,
token_type: String,
@@ -36,6 +36,12 @@ struct PayoutMethods {
expires: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct AccountBalance {
pub available: Decimal,
pub pending: Decimal,
}
impl Default for PayoutsQueue {
fn default() -> Self {
Self::new()
@@ -545,6 +551,136 @@ impl PayoutsQueue {
Ok(options.options)
}
pub async fn get_brex_balance() -> Result<Option<AccountBalance>, ApiError>
{
#[derive(Deserialize)]
struct BrexBalance {
pub amount: i64,
// pub currency: String,
}
#[derive(Deserialize)]
struct BrexAccount {
pub current_balance: BrexBalance,
pub available_balance: BrexBalance,
}
#[derive(Deserialize)]
struct BrexResponse {
pub items: Vec<BrexAccount>,
}
let client = reqwest::Client::new();
let res = client
.get(format!("{}accounts/cash", dotenvy::var("BREX_API_URL")?))
.bearer_auth(&dotenvy::var("BREX_API_KEY")?)
.send()
.await?
.json::<BrexResponse>()
.await?;
Ok(Some(AccountBalance {
available: Decimal::from(
res.items
.iter()
.map(|x| x.available_balance.amount)
.sum::<i64>(),
) / Decimal::from(100),
pending: Decimal::from(
res.items
.iter()
.map(|x| {
x.current_balance.amount - x.available_balance.amount
})
.sum::<i64>(),
) / Decimal::from(100),
}))
}
pub async fn get_paypal_balance() -> Result<Option<AccountBalance>, ApiError>
{
let api_username = dotenvy::var("PAYPAL_NVP_USERNAME")?;
let api_password = dotenvy::var("PAYPAL_NVP_PASSWORD")?;
let api_signature = dotenvy::var("PAYPAL_NVP_SIGNATURE")?;
let mut params = HashMap::new();
params.insert("METHOD", "GetBalance");
params.insert("VERSION", "204");
params.insert("USER", &api_username);
params.insert("PWD", &api_password);
params.insert("SIGNATURE", &api_signature);
params.insert("RETURNALLCURRENCIES", "1");
let endpoint = "https://api-3t.paypal.com/nvp";
let client = reqwest::Client::new();
let response = client.post(endpoint).form(&params).send().await?;
let text = response.text().await?;
let body = urlencoding::decode(&text).unwrap_or_default();
let mut key_value_map = HashMap::new();
for pair in body.split('&') {
let mut iter = pair.splitn(2, '=');
if let (Some(key), Some(value)) = (iter.next(), iter.next()) {
key_value_map.insert(key.to_string(), value.to_string());
}
}
if let Some(amount) = key_value_map
.get("L_AMT0")
.and_then(|x| Decimal::from_str_exact(x).ok())
{
Ok(Some(AccountBalance {
available: amount,
pending: Decimal::ZERO,
}))
} else {
Ok(None)
}
}
pub async fn get_tremendous_balance(
&self,
) -> Result<Option<AccountBalance>, ApiError> {
#[derive(Deserialize)]
struct FundingSourceMeta {
available_cents: u64,
pending_cents: u64,
}
#[derive(Deserialize)]
struct FundingSource {
method: String,
meta: FundingSourceMeta,
}
#[derive(Deserialize)]
struct FundingSourceRequest {
pub funding_sources: Vec<FundingSource>,
}
let val = self
.make_tremendous_request::<(), FundingSourceRequest>(
Method::GET,
"funding_sources",
None,
)
.await?;
Ok(val
.funding_sources
.into_iter()
.find(|x| x.method == "balance")
.map(|x| AccountBalance {
available: Decimal::from(x.meta.available_cents)
/ Decimal::from(100),
pending: Decimal::from(x.meta.pending_cents)
/ Decimal::from(100),
}))
}
}
#[derive(Deserialize)]

View File

@@ -1,16 +1,21 @@
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
use crate::models::analytics::Download;
use crate::models::ids::ProjectId;
use crate::models::pats::Scopes;
use crate::models::threads::MessageBody;
use crate::queue::analytics::AnalyticsQueue;
use crate::queue::maxmind::MaxMindIndexer;
use crate::queue::moderation::AUTOMOD_ID;
use crate::queue::payouts::PayoutsQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::search::SearchConfig;
use crate::util::date::get_current_tenths_of_ms;
use crate::util::guards::admin_key_guard;
use actix_web::{patch, post, web, HttpRequest, HttpResponse};
use actix_web::{get, patch, post, web, HttpRequest, HttpResponse};
use log::info;
use serde::Deserialize;
use sqlx::PgPool;
use std::collections::HashMap;
@@ -21,7 +26,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("admin")
.service(count_download)
.service(force_reindex),
.service(force_reindex)
.service(get_balances)
.service(delphi_result_ingest),
);
}
@@ -158,3 +165,107 @@ pub async fn force_reindex(
index_projects(pool.as_ref().clone(), redis.clone(), &config).await?;
Ok(HttpResponse::NoContent().finish())
}
#[get("/_balances", guard = "admin_key_guard")]
pub async fn get_balances(
payouts: web::Data<PayoutsQueue>,
) -> Result<HttpResponse, ApiError> {
let (paypal, brex, tremendous) = futures::future::try_join3(
PayoutsQueue::get_paypal_balance(),
PayoutsQueue::get_brex_balance(),
payouts.get_tremendous_balance(),
)
.await?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"paypal": paypal,
"brex": brex,
"tremendous": tremendous,
})))
}
#[derive(Deserialize)]
pub struct DelphiIngest {
pub url: String,
pub project_id: crate::models::ids::ProjectId,
pub version_id: crate::models::ids::VersionId,
pub issues: HashMap<String, HashMap<String, String>>,
}
#[post("/_delphi", guard = "admin_key_guard")]
pub async fn delphi_result_ingest(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
body: web::Json<DelphiIngest>,
) -> Result<HttpResponse, ApiError> {
if body.issues.is_empty() {
info!("No issues found for file {}", body.url);
return Ok(HttpResponse::NoContent().finish());
}
let webhook_url = dotenvy::var("DELPHI_SLACK_WEBHOOK")?;
let project = crate::database::models::Project::get_id(
body.project_id.into(),
&**pool,
&redis,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Project {} does not exist",
body.project_id
))
})?;
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(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\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: thread_header,
private: true,
replying_to: None,
associated_images: vec![],
},
thread_id: project.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().finish())
}

View File

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

View File

@@ -61,11 +61,6 @@ pub async fn project_search(
let facets: Option<Vec<Vec<String>>> = if let Some(facets) = info.facets {
let facets = serde_json::from_str::<Vec<Vec<String>>>(&facets)?;
// These loaders specifically used to be combined with 'mod' to be a plugin, but now
// they are their own loader type. We will convert 'mod' to 'mod' OR 'plugin'
// as it essentially was before.
let facets = v2_reroute::convert_plugin_loader_facets_v3(facets);
Some(
facets
.into_iter()

View File

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

View File

@@ -190,28 +190,6 @@ pub fn convert_side_types_v3(
fields
}
// Converts plugin loaders from v2 to v3, for search facets
// Within every 1st and 2nd level (the ones allowed in v2), we convert every instance of:
// "project_type:mod" to "project_type:plugin" OR "project_type:mod"
pub fn convert_plugin_loader_facets_v3(
facets: Vec<Vec<String>>,
) -> Vec<Vec<String>> {
facets
.into_iter()
.map(|inner_facets| {
if inner_facets == ["project_type:mod"] {
vec![
"project_type:plugin".to_string(),
"project_type:datapack".to_string(),
"project_type:mod".to_string(),
]
} else {
inner_facets
}
})
.collect::<Vec<_>>()
}
// Convert search facets from V3 back to v2
// this is not lossless. (See tests)
pub fn convert_side_types_v2(

View File

@@ -160,7 +160,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>,
@@ -221,7 +221,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>,

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ use actix_web::{web, HttpRequest, HttpResponse};
use chrono::Utc;
use futures::stream::StreamExt;
use itertools::Itertools;
use log::error;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use std::collections::{HashMap, HashSet};
@@ -980,6 +981,30 @@ pub async fn upload_file(
}
}
let url = format!("{cdn_url}/{file_path_encode}");
let client = reqwest::Client::new();
let delphi_url = dotenvy::var("DELPHI_URL")?;
match client
.post(delphi_url)
.json(&serde_json::json!({
"url": url,
"project_id": project_id,
"version_id": version_id,
}))
.send()
.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 {
filename: file_name.to_string(),
url: format!("{cdn_url}/{file_path_encode}"),

View File

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

View File

@@ -6,10 +6,9 @@ use crate::models::ids::base62_impl::to_base62;
use crate::search::{SearchConfig, UploadSearchProject};
use local_import::index_local;
use log::info;
use meilisearch_sdk::client::Client;
use meilisearch_sdk::client::{Client, SwapIndexes};
use meilisearch_sdk::indexes::Index;
use meilisearch_sdk::settings::{PaginationSetting, Settings};
use meilisearch_sdk::SwapIndexes;
use sqlx::postgres::PgPool;
use thiserror::Error;
#[derive(Error, Debug)]
@@ -100,7 +99,7 @@ pub async fn swap_index(
config: &SearchConfig,
index_name: &str,
) -> Result<(), IndexingError> {
let client = config.make_client();
let client = config.make_client()?;
let index_name_next = config.get_index_name(index_name, true);
let index_name = config.get_index_name(index_name, false);
let swap_indices = SwapIndexes {
@@ -119,7 +118,7 @@ pub async fn get_indexes_for_indexing(
config: &SearchConfig,
next: bool, // Get the 'next' one
) -> Result<Vec<Index>, meilisearch_sdk::errors::Error> {
let client = config.make_client();
let client = config.make_client()?;
let project_name = config.get_index_name("projects", next);
let project_filtered_name =
config.get_index_name("projects_filtered", next);
@@ -285,7 +284,7 @@ pub async fn add_projects(
additional_fields: Vec<String>,
config: &SearchConfig,
) -> Result<(), IndexingError> {
let client = config.make_client();
let client = config.make_client()?;
for index in indices {
update_and_add_to_index(&client, index, &projects, &additional_fields)
.await?;
@@ -296,7 +295,7 @@ pub async fn add_projects(
fn default_settings() -> Settings {
Settings::new()
.with_distinct_attribute("project_id")
.with_distinct_attribute(Some("project_id"))
.with_displayed_attributes(DEFAULT_DISPLAYED_ATTRIBUTES)
.with_searchable_attributes(DEFAULT_SEARCHABLE_ATTRIBUTES)
.with_sortable_attributes(DEFAULT_SORTABLE_ATTRIBUTES)

View File

@@ -80,7 +80,9 @@ impl SearchConfig {
}
}
pub fn make_client(&self) -> Client {
pub fn make_client(
&self,
) -> Result<Client, meilisearch_sdk::errors::Error> {
Client::new(self.address.as_str(), Some(self.key.as_str()))
}
@@ -190,7 +192,7 @@ pub async fn search_for_project(
info: &SearchRequest,
config: &SearchConfig,
) -> Result<SearchResults, SearchError> {
let client = Client::new(&*config.address, Some(&*config.key));
let client = Client::new(&*config.address, Some(&*config.key))?;
let offset: usize = info.offset.as_deref().unwrap_or("0").parse()?;
let index = info.index.as_deref().unwrap_or("relevance");

View File

@@ -1,5 +1,6 @@
use actix_cors::Cors;
// Updating this? Remember to update the ratelimit CORS too!
pub fn default_cors() -> Cors {
Cors::default()
.allow_any_origin()

View File

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

View File

@@ -168,6 +168,15 @@ where
wait_time.as_secs().into(),
);
// TODO: Sentralize CORS in the CORS util.
headers.insert(
actix_web::http::header::HeaderName::from_str(
"Access-Control-Allow-Origin",
)
.unwrap(),
"*".parse().unwrap(),
);
Box::pin(async {
Ok(req.into_response(response.map_into_right_body()))
})

View File

@@ -12,7 +12,7 @@ services:
POSTGRES_PASSWORD: labrinth
POSTGRES_HOST_AUTH_METHOD: trust
meilisearch:
image: getmeili/meilisearch:v1.5.0
image: getmeili/meilisearch:v1.12.0
restart: on-failure
ports:
- '7700:7700'

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus"
version = "0.9.2"
version = "0.9.3"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2021"

View File

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

View File

@@ -13,13 +13,13 @@ use crate::util::io;
use crate::{profile, State};
use async_zip::base::read::seek::ZipFileReader;
use std::io::Cursor;
use std::path::{Component, PathBuf};
use super::install_from::{
generate_pack_from_file, generate_pack_from_version_id, CreatePack,
CreatePackLocation, PackFormat,
};
use crate::data::ProjectType;
use std::io::Cursor;
use std::path::{Component, PathBuf};
/// Install a pack
/// Wrapper around install_pack_files that generates a pack creation description, and
@@ -189,6 +189,7 @@ pub async fn install_zipped_mrpack_files(
.hashes
.get(&PackFileHash::Sha1)
.map(|x| &**x),
ProjectType::get_from_parent_folder(&path),
&state.pool,
)
.await?;
@@ -247,6 +248,7 @@ pub async fn install_zipped_mrpack_files(
&profile_path,
&new_path.to_string_lossy(),
None,
ProjectType::get_from_parent_folder(&new_path),
&state.pool,
)
.await?;

View File

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

View File

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

View File

@@ -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};
@@ -202,7 +198,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(()) }
})
@@ -434,7 +430,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(),

View File

@@ -1,4 +1,5 @@
use crate::config::{META_URL, MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::state::ProjectType;
use crate::util::fetch::{fetch_json, sha1_async, FetchSemaphore};
use chrono::{DateTime, Utc};
use dashmap::DashSet;
@@ -194,7 +195,7 @@ pub struct SearchEntry {
pub struct CachedFileUpdate {
pub hash: String,
pub game_version: String,
pub loader: String,
pub loaders: Vec<String>,
pub update_version_id: String,
}
@@ -203,6 +204,7 @@ pub struct CachedFileHash {
pub path: String,
pub size: u64,
pub hash: String,
pub project_type: Option<ProjectType>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -481,7 +483,12 @@ impl CacheValue {
)
}
CacheValue::FileUpdate(hash) => {
format!("{}-{}-{}", hash.hash, hash.loader, hash.game_version)
format!(
"{}-{}-{}",
hash.hash,
hash.loaders.join("+"),
hash.game_version
)
}
CacheValue::SearchResults(search) => search.search.clone(),
}
@@ -1240,6 +1247,9 @@ impl CachedEntry {
path: path.to_string(),
size,
hash,
project_type: ProjectType::get_from_parent_folder(
&full_path,
),
})
.get_entry(),
true,
@@ -1270,18 +1280,21 @@ impl CachedEntry {
if key.len() == 3 {
let hash = key[0];
let loader = key[1];
let loaders_key = key[1];
let game_version = key[2];
if let Some(values) =
filtered_keys.iter_mut().find(|x| {
x.0 .0 == loader && x.0 .1 == game_version
x.0 .0 == loaders_key && x.0 .1 == game_version
})
{
values.1.push(hash.to_string());
} else {
filtered_keys.push((
(loader.to_string(), game_version.to_string()),
(
loaders_key.to_string(),
game_version.to_string(),
),
vec![hash.to_string()],
))
}
@@ -1297,7 +1310,7 @@ impl CachedEntry {
format!("{}version_files/update", MODRINTH_API_URL);
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loader, game_version), hashes)| {
|((loaders_key, game_version), hashes)| {
fetch_json::<HashMap<String, Version>>(
Method::POST,
&version_update_url,
@@ -1305,7 +1318,7 @@ impl CachedEntry {
Some(serde_json::json!({
"algorithm": "sha1",
"hashes": hashes,
"loaders": [loader],
"loaders": loaders_key.split('+').collect::<Vec<_>>(),
"game_versions": [game_version]
})),
fetch_semaphore,
@@ -1317,7 +1330,7 @@ impl CachedEntry {
for (index, mut variation) in variations.into_iter().enumerate()
{
let ((loader, game_version), hashes) =
let ((loaders_key, game_version), hashes) =
&filtered_keys[index];
for hash in hashes {
@@ -1334,7 +1347,10 @@ impl CachedEntry {
CacheValue::FileUpdate(CachedFileUpdate {
hash: hash.clone(),
game_version: game_version.clone(),
loader: loader.clone(),
loaders: loaders_key
.split('+')
.map(|x| x.to_string())
.collect(),
update_version_id: version_id,
})
.get_entry(),
@@ -1343,7 +1359,9 @@ impl CachedEntry {
} else {
vals.push((
CacheValueType::FileUpdate.get_empty_entry(
format!("{hash}-{loader}-{game_version}"),
format!(
"{hash}-{loaders_key}-{game_version}"
),
),
true,
))
@@ -1450,6 +1468,7 @@ pub async fn cache_file_hash(
profile_path: &str,
path: &str,
known_hash: Option<&str>,
project_type: Option<ProjectType>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let size = bytes.len();
@@ -1465,6 +1484,7 @@ pub async fn cache_file_hash(
path: format!("{}/{}", profile_path, path),
size: size as u64,
hash,
project_type,
})
.get_entry()],
exec,

View File

@@ -1,4 +1,4 @@
use crate::data::{Dependency, User, Version};
use crate::data::{Dependency, ProjectType, User, Version};
use crate::jre::check_jre;
use crate::prelude::ModLoader;
use crate::state;
@@ -226,6 +226,7 @@ where
path: file_name,
size: metadata.len(),
hash: sha1.clone(),
project_type: ProjectType::get_from_parent_folder(&full_path),
},
));
}
@@ -249,9 +250,9 @@ where
.metadata
.game_version
.clone(),
loader: mod_loader
loaders: vec![mod_loader
.as_str()
.to_string(),
.to_string()],
update_version_id:
update_version.id.clone(),
},
@@ -307,7 +308,7 @@ where
ProfileInstallStage::Installed
}
LegacyProfileInstallStage::Installing => {
ProfileInstallStage::Installing
ProfileInstallStage::MinecraftInstalling
}
LegacyProfileInstallStage::PackInstalling => {
ProfileInstallStage::PackInstalling

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