+
-
@@ -63,7 +73,7 @@
+
+
+
+ {{ instance.name }}
+
+
diff --git a/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue b/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
index 772473856..81f386f62 100644
--- a/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
+++ b/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
@@ -1,8 +1,8 @@
diff --git a/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
new file mode 100644
index 000000000..3bb559d60
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ None
+
+
+
+
+
+
+
+
+
+
+ Select
+
+
+
+
+
+ Cancel
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
new file mode 100644
index 000000000..818922eff
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue
new file mode 100644
index 000000000..12fc67468
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue
@@ -0,0 +1,230 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+
+
+ {{
+ formatMessage(commonMessages.playedLabel, {
+ time: formatRelativeTime(last_played.toISOString?.()),
+ })
+ }}
+
+ Not played yet
+
+ •
+
+
+
+ {{ modpack.title }}
+
+ ({{ loader }} {{ instance.game_version }})
+
+
+
+ Loading modpack...
+
+
+ {{ loader }}
+ {{ instance.game_version }}
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.stopButton) }}
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.playButton) }}
+
+
+
+
+
+
+
+ View instance
+
+
+
+ {{ formatMessage(commonMessages.openFolderButton) }}
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
new file mode 100644
index 000000000..a960f805f
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
@@ -0,0 +1,304 @@
+
+
+
+
+
+ Jump back in
+
+
+ Jump back in
+
+
+
+
+ item.world.type === 'server'
+ ? refreshServer(item.world.address, item.instance.path)
+ : {}
+ "
+ @update="() => populateJumpBackIn()"
+ @play="
+ () => {
+ currentProfile = item.instance.path
+ currentWorld = getWorldIdentifier(item.world)
+ joinWorld(item.world)
+ }
+ "
+ @play-instance="
+ () => {
+ currentProfile = item.instance.path
+ playInstance(item.instance)
+ }
+ "
+ @stop="() => stopInstance(item.instance.path)"
+ />
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue
new file mode 100644
index 000000000..f30aca810
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue
@@ -0,0 +1,506 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ world.name }}
+
+
+
+ {{ formatMessage(commonMessages.singleplayerLabel) }}
+
+
+
+
+ Loading...
+
+
+
+
+
+ Incompatible version {{ serverStatus.version?.name }}
+
+
+
+
+
+
+ {{ formatNumber(serverStatus.players?.online, false) }} online
+
+
+
+
+ {{ player.name }}
+
+
+
+
+
+
+
+ Offline
+
+
+
+
+
+
+ {{
+ formatMessage(commonMessages.playedLabel, {
+ time: formatRelativeTime(dayjs(world.last_played).toISOString()),
+ })
+ }}
+
+ Not played yet
+
+
+ •
+
+
+ {{ instanceName }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.loadingLabel) }}
+
+
+
+ {{ formatMessage(messages.cantConnect) }}
+
+
+ {{ formatMessage(messages.aMinecraftServer) }}
+
+
+
+
+
+ {{ formatMessage(messages.hardcore) }}
+
+
+
+ {{ formatMessage(gameMode.message) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.stopButton) }}
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.playButton) }}
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.playButton) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.playInstance) }}
+
+
+
+ {{ formatMessage(messages.playAnyway) }}
+
+
+
+ {{ formatMessage(messages.viewInstance) }}
+
+
+ {{ formatMessage(commonMessages.editButton) }}
+
+
+
+ {{ formatMessage(commonMessages.openFolderButton) }}
+
+
+ {{ formatMessage(messages.copyAddress) }}
+
+
+ {{ formatMessage(commonMessages.refreshButton) }}
+
+
+
+ {{ formatMessage(messages.dontShowOnHome) }}
+
+
+
+ {{
+ formatMessage(
+ world.type === 'server'
+ ? commonMessages.removeButton
+ : commonMessages.deleteLabel,
+ )
+ }}
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue b/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue
new file mode 100644
index 000000000..00fab96ec
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.addAndPlay) }}
+
+
+
+
+
+ {{ formatMessage(messages.addServer) }}
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue b/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue
new file mode 100644
index 000000000..5e03bbb89
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.saveChangesButton) }}
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue b/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue
new file mode 100644
index 000000000..5a01d93ec
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.saveChangesButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.resetIcon) }}
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/HideFromHomeOption.vue b/apps/app-frontend/src/components/ui/world/modal/HideFromHomeOption.vue
new file mode 100644
index 000000000..024072b57
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/HideFromHomeOption.vue
@@ -0,0 +1,18 @@
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue b/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue
new file mode 100644
index 000000000..64c82b27d
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue
@@ -0,0 +1,86 @@
+
+
+
+
diff --git a/apps/app-frontend/src/helpers/analytics.js b/apps/app-frontend/src/helpers/analytics.js
index 90d2c8aef..6fa5ea3ab 100644
--- a/apps/app-frontend/src/helpers/analytics.js
+++ b/apps/app-frontend/src/helpers/analytics.js
@@ -1,8 +1,9 @@
import { posthog } from 'posthog-js'
export const initAnalytics = () => {
- posthog.init('phc_hm2ihMpTAoE86xIm7XzsCB8RPiTRKivViK5biiHedm', {
+ posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
persistence: 'localStorage',
+ api_host: 'https://posthog.modrinth.com',
})
}
diff --git a/apps/app-frontend/src/helpers/events.js b/apps/app-frontend/src/helpers/events.js
index 0ed288365..81a849b9e 100644
--- a/apps/app-frontend/src/helpers/events.js
+++ b/apps/app-frontend/src/helpers/events.js
@@ -62,7 +62,7 @@ export async function process_listener(callback) {
ProfilePayload {
uuid: unique identification of the process in the state (currently identified by path, but that will change)
name: name of the profile
- profile_path: relative path to profile (used for path identification)
+ profile_path: relative path toprofile_listener profile (used for path identification)
path: path to profile (used for opening the profile in the OS file explorer)
event: event type ("Created", "Added", "Edited", "Removed")
}
diff --git a/apps/app-frontend/src/helpers/fetch.js b/apps/app-frontend/src/helpers/fetch.js
index ff3e8b62e..5c5cf39cf 100644
--- a/apps/app-frontend/src/helpers/fetch.js
+++ b/apps/app-frontend/src/helpers/fetch.js
@@ -1,12 +1,12 @@
-import { ofetch } from 'ofetch'
+import { fetch } from '@tauri-apps/plugin-http'
import { handleError } from '@/store/state.js'
import { getVersion } from '@tauri-apps/api/app'
export const useFetch = async (url, item, isSilent) => {
try {
const version = await getVersion()
-
- return await ofetch(url, {
+ return await fetch(url, {
+ method: 'GET',
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
})
} catch (err) {
diff --git a/apps/app-frontend/src/helpers/jre.js b/apps/app-frontend/src/helpers/jre.js
index 0814e9b0a..207c02583 100644
--- a/apps/app-frontend/src/helpers/jre.js
+++ b/apps/app-frontend/src/helpers/jre.js
@@ -36,8 +36,8 @@ export async function get_jre(path) {
// Tests JRE version by running 'java -version' on it.
// Returns true if the version is valid, and matches given (after extraction)
-export async function test_jre(path, majorVersion, minorVersion) {
- return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
+export async function test_jre(path, majorVersion) {
+ return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
}
// Automatically installs specified java version
diff --git a/apps/app-frontend/src/helpers/pack.js b/apps/app-frontend/src/helpers/pack.js
index c175f9030..2026ec120 100644
--- a/apps/app-frontend/src/helpers/pack.js
+++ b/apps/app-frontend/src/helpers/pack.js
@@ -7,7 +7,13 @@ import { invoke } from '@tauri-apps/api/core'
import { create } from './profile'
// Installs pack from a version ID
-export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
+export async function create_profile_and_install(
+ projectId,
+ versionId,
+ packTitle,
+ iconUrl,
+ createInstanceCallback = () => {},
+) {
const location = {
type: 'fromVersionId',
project_id: projectId,
@@ -24,6 +30,7 @@ export async function create_profile_and_install(projectId, versionId, packTitle
null,
true,
)
+ createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile })
}
diff --git a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
new file mode 100644
index 000000000..6495837d1
--- /dev/null
+++ b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
@@ -0,0 +1,354 @@
+import * as THREE from 'three'
+import type { Skin, Cape } from '../skins'
+import { get_normalized_skin_texture, determineModelType } from '../skins'
+import { reactive } from 'vue'
+import { setupSkinModel, disposeCaches } from '@modrinth/utils'
+import { skinPreviewStorage } from '../storage/skin-preview-storage'
+import { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
+
+export interface RenderResult {
+ forwards: string
+ backwards: string
+}
+
+class BatchSkinRenderer {
+ private renderer: THREE.WebGLRenderer
+ private readonly scene: THREE.Scene
+ private readonly camera: THREE.PerspectiveCamera
+ private currentModel: THREE.Group | null = null
+
+ constructor(width: number = 360, height: number = 504) {
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+
+ this.renderer = new THREE.WebGLRenderer({
+ canvas: canvas,
+ antialias: true,
+ alpha: true,
+ preserveDrawingBuffer: true,
+ })
+
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace
+ this.renderer.toneMapping = THREE.NoToneMapping
+ this.renderer.toneMappingExposure = 10.0
+ this.renderer.setClearColor(0x000000, 0)
+ this.renderer.setSize(width, height)
+
+ this.scene = new THREE.Scene()
+ this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000)
+
+ const ambientLight = new THREE.AmbientLight(0xffffff, 2)
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
+ directionalLight.castShadow = true
+ directionalLight.position.set(2, 4, 3)
+ this.scene.add(ambientLight)
+ this.scene.add(directionalLight)
+ }
+
+ public async renderSkin(
+ textureUrl: string,
+ modelUrl: string,
+ capeUrl?: string,
+ capeModelUrl?: string,
+ ): Promise
{
+ await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
+
+ const headPart = this.currentModel!.getObjectByName('Head')
+ let lookAtTarget: [number, number, number]
+
+ if (headPart) {
+ const headPosition = new THREE.Vector3()
+ headPart.getWorldPosition(headPosition)
+ lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z]
+ } else {
+ throw new Error("Failed to find 'Head' object in model.")
+ }
+
+ const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
+ const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
+
+ const forwards = await this.renderView(frontCameraPos, lookAtTarget)
+ const backwards = await this.renderView(backCameraPos, lookAtTarget)
+
+ return { forwards, backwards }
+ }
+
+ private async renderView(
+ cameraPosition: [number, number, number],
+ lookAtPosition: [number, number, number],
+ ): Promise {
+ this.camera.position.set(...cameraPosition)
+ this.camera.lookAt(...lookAtPosition)
+
+ this.renderer.render(this.scene, this.camera)
+
+ return new Promise((resolve, reject) => {
+ this.renderer.domElement.toBlob((blob) => {
+ if (blob) {
+ const url = URL.createObjectURL(blob)
+ resolve(url)
+ } else {
+ reject(new Error('Failed to create blob from canvas'))
+ }
+ }, 'image/png')
+ })
+ }
+
+ private async setupModel(
+ modelUrl: string,
+ textureUrl: string,
+ capeModelUrl?: string,
+ capeUrl?: string,
+ ): Promise {
+ if (this.currentModel) {
+ this.scene.remove(this.currentModel)
+ }
+
+ const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
+
+ const group = new THREE.Group()
+ group.add(model)
+ group.position.set(0, 0.3, 1.95)
+ group.scale.set(0.8, 0.8, 0.8)
+
+ this.scene.add(group)
+ this.currentModel = group
+ }
+
+ public dispose(): void {
+ this.renderer.dispose()
+ disposeCaches()
+ }
+}
+
+function getModelUrlForVariant(variant: string): string {
+ switch (variant) {
+ case 'SLIM':
+ return SlimPlayerModel
+ case 'CLASSIC':
+ case 'UNKNOWN':
+ default:
+ return ClassicPlayerModel
+ }
+}
+
+export const map = reactive(new Map())
+export const headMap = reactive(new Map())
+const DEBUG_MODE = false
+
+export async function cleanupUnusedPreviews(skins: Skin[]): Promise {
+ const validKeys = new Set()
+ const validHeadKeys = new Set()
+
+ for (const skin of skins) {
+ const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
+ const headKey = `${skin.texture_key}-head`
+ validKeys.add(key)
+ validHeadKeys.add(headKey)
+ }
+
+ try {
+ await skinPreviewStorage.cleanupInvalidKeys(validKeys)
+ await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys)
+ } catch (error) {
+ console.warn('Failed to cleanup unused skin previews:', error)
+ }
+}
+
+export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image()
+ img.crossOrigin = 'anonymous'
+
+ img.onload = () => {
+ try {
+ const sourceCanvas = document.createElement('canvas')
+ const sourceCtx = sourceCanvas.getContext('2d')
+
+ if (!sourceCtx) {
+ throw new Error('Could not get 2D context from source canvas')
+ }
+
+ sourceCanvas.width = img.width
+ sourceCanvas.height = img.height
+
+ sourceCtx.drawImage(img, 0, 0)
+
+ const outputCanvas = document.createElement('canvas')
+ const outputCtx = outputCanvas.getContext('2d')
+
+ if (!outputCtx) {
+ throw new Error('Could not get 2D context from output canvas')
+ }
+
+ outputCanvas.width = size
+ outputCanvas.height = size
+
+ outputCtx.imageSmoothingEnabled = false
+
+ const headImageData = sourceCtx.getImageData(8, 8, 8, 8)
+
+ const headCanvas = document.createElement('canvas')
+ const headCtx = headCanvas.getContext('2d')
+
+ if (!headCtx) {
+ throw new Error('Could not get 2D context from head canvas')
+ }
+
+ headCanvas.width = 8
+ headCanvas.height = 8
+ headCtx.putImageData(headImageData, 0, 0)
+
+ outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size)
+
+ const hatImageData = sourceCtx.getImageData(40, 8, 8, 8)
+
+ const hatCanvas = document.createElement('canvas')
+ const hatCtx = hatCanvas.getContext('2d')
+
+ if (!hatCtx) {
+ throw new Error('Could not get 2D context from hat canvas')
+ }
+
+ hatCanvas.width = 8
+ hatCanvas.height = 8
+ hatCtx.putImageData(hatImageData, 0, 0)
+
+ const hatPixels = hatImageData.data
+ let hasHat = false
+
+ for (let i = 3; i < hatPixels.length; i += 4) {
+ if (hatPixels[i] > 0) {
+ hasHat = true
+ break
+ }
+ }
+
+ if (hasHat) {
+ outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
+ }
+
+ outputCanvas.toBlob((blob) => {
+ if (blob) {
+ resolve(blob)
+ } else {
+ reject(new Error('Failed to create blob from canvas'))
+ }
+ }, 'image/png')
+ } catch (error) {
+ reject(error)
+ }
+ }
+
+ img.onerror = () => {
+ reject(new Error('Failed to load skin texture image'))
+ }
+
+ img.src = skinUrl
+ })
+}
+
+async function generateHeadRender(skin: Skin): Promise {
+ const headKey = `${skin.texture_key}-head`
+
+ if (headMap.has(headKey)) {
+ if (DEBUG_MODE) {
+ const url = headMap.get(headKey)!
+ URL.revokeObjectURL(url)
+ headMap.delete(headKey)
+ } else {
+ return headMap.get(headKey)!
+ }
+ }
+
+ try {
+ const cached = await skinPreviewStorage.retrieve(headKey)
+ if (cached && typeof cached === 'string') {
+ headMap.set(headKey, cached)
+ return cached
+ }
+ } catch (error) {
+ console.warn('Failed to retrieve cached head render:', error)
+ }
+
+ const skinUrl = await get_normalized_skin_texture(skin)
+ const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
+ const headUrl = URL.createObjectURL(headBlob)
+
+ headMap.set(headKey, headUrl)
+
+ try {
+ // @ts-expect-error - skinPreviewStorage.store expects a RenderResult, but we are storing a string url.
+ await skinPreviewStorage.store(headKey, headUrl)
+ } catch (error) {
+ console.warn('Failed to store head render in persistent storage:', error)
+ }
+
+ return headUrl
+}
+
+export async function getPlayerHeadUrl(skin: Skin): Promise {
+ return await generateHeadRender(skin)
+}
+
+export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise {
+ const renderer = new BatchSkinRenderer()
+
+ try {
+ for (const skin of skins) {
+ const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
+
+ if (map.has(key)) {
+ if (DEBUG_MODE) {
+ const result = map.get(key)!
+ URL.revokeObjectURL(result.forwards)
+ URL.revokeObjectURL(result.backwards)
+ map.delete(key)
+ } else continue
+ }
+
+ try {
+ const cached = await skinPreviewStorage.retrieve(key)
+ if (cached) {
+ map.set(key, cached)
+ continue
+ }
+ } catch (error) {
+ console.warn('Failed to retrieve cached skin preview:', error)
+ }
+
+ let variant = skin.variant
+ if (variant === 'UNKNOWN') {
+ try {
+ variant = await determineModelType(skin.texture)
+ } catch (error) {
+ console.error(`Failed to determine model type for skin ${key}:`, error)
+ variant = 'CLASSIC'
+ }
+ }
+
+ const modelUrl = getModelUrlForVariant(variant)
+ const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
+ const renderResult = await renderer.renderSkin(
+ await get_normalized_skin_texture(skin),
+ modelUrl,
+ cape?.texture,
+ CapeModel,
+ )
+
+ map.set(key, renderResult)
+
+ try {
+ await skinPreviewStorage.store(key, renderResult)
+ } catch (error) {
+ console.warn('Failed to store skin preview in persistent storage:', error)
+ }
+
+ await generateHeadRender(skin)
+ }
+ } finally {
+ renderer.dispose()
+ await cleanupUnusedPreviews(skins)
+ }
+}
diff --git a/apps/app-frontend/src/helpers/settings.js b/apps/app-frontend/src/helpers/settings.js
deleted file mode 100644
index b27bfe90b..000000000
--- a/apps/app-frontend/src/helpers/settings.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * All theseus API calls return serialized values (both return values and errors);
- * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
- * and deserialized into a usable JS object.
- */
-import { invoke } from '@tauri-apps/api/core'
-
-// Settings object
-/*
-
-Settings {
- "memory": MemorySettings,
- "game_resolution": [int int],
- "custom_java_args": [String ...],
- "custom_env_args" : [(string, string) ... ]>,
- "java_globals": Hash of (string, Path),
- "default_user": Uuid string (can be null),
- "hooks": Hooks,
- "max_concurrent_downloads": uint,
- "version": u32,
- "collapsed_navigation": bool,
-}
-
-Memorysettings {
- "min": u32, can be null,
- "max": u32,
-}
-
-*/
-
-// Get full settings object
-export async function get() {
- return await invoke('plugin:settings|settings_get')
-}
-
-// Set full settings object
-export async function set(settings) {
- return await invoke('plugin:settings|settings_set', { settings })
-}
-
-export async function cancel_directory_change() {
- return await invoke('plugin:settings|cancel_directory_change')
-}
diff --git a/apps/app-frontend/src/helpers/settings.ts b/apps/app-frontend/src/helpers/settings.ts
new file mode 100644
index 000000000..c256575a4
--- /dev/null
+++ b/apps/app-frontend/src/helpers/settings.ts
@@ -0,0 +1,79 @@
+/**
+ * All theseus API calls return serialized values (both return values and errors);
+ * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
+ * and deserialized into a usable JS object.
+ */
+import { invoke } from '@tauri-apps/api/core'
+import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
+import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
+
+// Settings object
+/*
+
+Settings {
+ "memory": MemorySettings,
+ "game_resolution": [int int],
+ "custom_java_args": [String ...],
+ "custom_env_args" : [(string, string) ... ]>,
+ "java_globals": Hash of (string, Path),
+ "default_user": Uuid string (can be null),
+ "hooks": Hooks,
+ "max_concurrent_downloads": uint,
+ "version": u32,
+ "collapsed_navigation": bool,
+}
+
+Memorysettings {
+ "min": u32, can be null,
+ "max": u32,
+}
+
+*/
+
+export type AppSettings = {
+ max_concurrent_downloads: number
+ max_concurrent_writes: number
+
+ theme: ColorTheme
+ default_page: 'home' | 'library'
+ collapsed_navigation: boolean
+ hide_nametag_skins_page: boolean
+ advanced_rendering: boolean
+ native_decorations: boolean
+ toggle_sidebar: boolean
+
+ telemetry: boolean
+ discord_rpc: boolean
+ personalized_ads: boolean
+
+ onboarded: boolean
+
+ extra_launch_args: string[]
+ custom_env_vars: [string, string][]
+ memory: MemorySettings
+ force_fullscreen: boolean
+ game_resolution: WindowSize
+ hide_on_process_start: boolean
+ hooks: Hooks
+
+ custom_dir?: string | null
+ prev_custom_dir?: string | null
+ migrated: boolean
+
+ developer_mode: boolean
+ feature_flags: Record
+}
+
+// Get full settings object
+export async function get() {
+ return (await invoke('plugin:settings|settings_get')) as AppSettings
+}
+
+// Set full settings object
+export async function set(settings: AppSettings) {
+ return await invoke('plugin:settings|settings_set', { settings })
+}
+
+export async function cancel_directory_change(): Promise {
+ return await invoke('plugin:settings|cancel_directory_change')
+}
diff --git a/apps/app-frontend/src/helpers/skins.ts b/apps/app-frontend/src/helpers/skins.ts
new file mode 100644
index 000000000..28a29ba1a
--- /dev/null
+++ b/apps/app-frontend/src/helpers/skins.ts
@@ -0,0 +1,167 @@
+import { invoke } from '@tauri-apps/api/core'
+import { handleError } from '@/store/notifications'
+import { arrayBufferToBase64 } from '@modrinth/utils'
+
+export interface Cape {
+ id: string
+ name: string
+ texture: string
+ is_default: boolean
+ is_equipped: boolean
+}
+
+export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN'
+export type SkinSource = 'default' | 'custom_external' | 'custom'
+
+export interface Skin {
+ texture_key: string
+ name?: string
+ variant: SkinModel
+ cape_id?: string
+ texture: string
+ source: SkinSource
+ is_equipped: boolean
+}
+
+export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
+
+export const DEFAULT_MODELS: Record = {
+ Steve: 'CLASSIC',
+ Alex: 'SLIM',
+ Zuri: 'CLASSIC',
+ Sunny: 'CLASSIC',
+ Noor: 'SLIM',
+ Makena: 'SLIM',
+ Kai: 'CLASSIC',
+ Efe: 'SLIM',
+ Ari: 'CLASSIC',
+}
+
+export function filterSavedSkins(list: Skin[]) {
+ const customSkins = list.filter((s) => s.source !== 'default')
+ fixUnknownSkins(customSkins).catch(handleError)
+ return customSkins
+}
+
+export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> {
+ return new Promise((resolve, reject) => {
+ const canvas = document.createElement('canvas')
+ const context = canvas.getContext('2d')
+
+ if (!context) {
+ return reject(new Error('Failed to create canvas rendering context.'))
+ }
+
+ const image = new Image()
+ image.crossOrigin = 'anonymous'
+ image.src = texture
+
+ image.onload = () => {
+ canvas.width = image.width
+ canvas.height = image.height
+
+ context.drawImage(image, 0, 0)
+
+ const armX = 44
+ const armY = 16
+ const armWidth = 4
+ const armHeight = 12
+
+ const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
+
+ for (let y = 0; y < armHeight; y++) {
+ const alphaIndex = (3 + y * armWidth) * 4 + 3
+ if (imageData[alphaIndex] !== 0) {
+ resolve('CLASSIC')
+ return
+ }
+ }
+
+ canvas.remove()
+ resolve('SLIM')
+ }
+
+ image.onerror = () => {
+ canvas.remove()
+ reject(new Error('Failed to load the image.'))
+ }
+ })
+}
+
+export async function fixUnknownSkins(list: Skin[]) {
+ const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN')
+ for (const unknownSkin of unknownSkins) {
+ unknownSkin.variant = await determineModelType(unknownSkin.texture)
+ }
+}
+
+export function filterDefaultSkins(list: Skin[]) {
+ return list
+ .filter(
+ (s) =>
+ s.source === 'default' &&
+ (!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]),
+ )
+ .sort((a, b) => {
+ const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
+ const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1
+ return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
+ })
+}
+
+export async function get_available_capes(): Promise {
+ return invoke('plugin:minecraft-skins|get_available_capes', {})
+}
+
+export async function get_available_skins(): Promise {
+ return invoke('plugin:minecraft-skins|get_available_skins', {})
+}
+
+export async function add_and_equip_custom_skin(
+ textureBlob: Uint8Array,
+ variant: SkinModel,
+ capeOverride?: Cape,
+): Promise {
+ await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
+ textureBlob,
+ variant,
+ capeOverride,
+ })
+}
+
+export async function set_default_cape(cape?: Cape): Promise {
+ await invoke('plugin:minecraft-skins|set_default_cape', {
+ cape,
+ })
+}
+
+export async function equip_skin(skin: Skin): Promise {
+ await invoke('plugin:minecraft-skins|equip_skin', {
+ skin,
+ })
+}
+
+export async function remove_custom_skin(skin: Skin): Promise {
+ await invoke('plugin:minecraft-skins|remove_custom_skin', {
+ skin,
+ })
+}
+
+export async function get_normalized_skin_texture(skin: Skin): Promise {
+ const data = await normalize_skin_texture(skin.texture)
+ const base64 = arrayBufferToBase64(data)
+ return `data:image/png;base64,${base64}`
+}
+
+export async function normalize_skin_texture(texture: Uint8Array | string): Promise {
+ return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
+}
+
+export async function unequip_skin(): Promise {
+ await invoke('plugin:minecraft-skins|unequip_skin')
+}
+
+export async function get_dragged_skin_data(path: string): Promise {
+ const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
+ return new Uint8Array(data)
+}
diff --git a/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
new file mode 100644
index 000000000..2e4990850
--- /dev/null
+++ b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
@@ -0,0 +1,118 @@
+import type { RenderResult } from '../rendering/batch-skin-renderer'
+
+interface StoredPreview {
+ forwards: Blob
+ backwards: Blob
+ timestamp: number
+}
+
+export class SkinPreviewStorage {
+ private dbName = 'skin-previews'
+ private version = 1
+ private db: IDBDatabase | null = null
+
+ async init(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(this.dbName, this.version)
+
+ request.onerror = () => reject(request.error)
+ request.onsuccess = () => {
+ this.db = request.result
+ resolve()
+ }
+
+ request.onupgradeneeded = () => {
+ const db = request.result
+ if (!db.objectStoreNames.contains('previews')) {
+ db.createObjectStore('previews')
+ }
+ }
+ })
+ }
+
+ async store(key: string, result: RenderResult): Promise {
+ if (!this.db) await this.init()
+
+ const forwardsBlob = await fetch(result.forwards).then((r) => r.blob())
+ const backwardsBlob = await fetch(result.backwards).then((r) => r.blob())
+
+ const transaction = this.db!.transaction(['previews'], 'readwrite')
+ const store = transaction.objectStore('previews')
+
+ const storedPreview: StoredPreview = {
+ forwards: forwardsBlob,
+ backwards: backwardsBlob,
+ timestamp: Date.now(),
+ }
+
+ return new Promise((resolve, reject) => {
+ const request = store.put(storedPreview, key)
+
+ request.onsuccess = () => resolve()
+ request.onerror = () => reject(request.error)
+ })
+ }
+
+ async retrieve(key: string): Promise {
+ if (!this.db) await this.init()
+
+ const transaction = this.db!.transaction(['previews'], 'readonly')
+ const store = transaction.objectStore('previews')
+
+ return new Promise((resolve, reject) => {
+ const request = store.get(key)
+
+ request.onsuccess = () => {
+ const result = request.result as StoredPreview | undefined
+
+ if (!result) {
+ resolve(null)
+ return
+ }
+
+ const forwards = URL.createObjectURL(result.forwards)
+ const backwards = URL.createObjectURL(result.backwards)
+ resolve({ forwards, backwards })
+ }
+ request.onerror = () => reject(request.error)
+ })
+ }
+
+ async cleanupInvalidKeys(validKeys: Set): Promise {
+ if (!this.db) await this.init()
+
+ const transaction = this.db!.transaction(['previews'], 'readwrite')
+ const store = transaction.objectStore('previews')
+ let deletedCount = 0
+
+ return new Promise((resolve, reject) => {
+ const request = store.openCursor()
+
+ request.onsuccess = (event) => {
+ const cursor = (event.target as IDBRequest).result
+
+ if (cursor) {
+ const key = cursor.primaryKey as string
+
+ if (!validKeys.has(key)) {
+ const deleteRequest = cursor.delete()
+ deleteRequest.onsuccess = () => {
+ deletedCount++
+ }
+ deleteRequest.onerror = () => {
+ console.warn('Failed to delete invalid entry:', key)
+ }
+ }
+
+ cursor.continue()
+ } else {
+ resolve(deletedCount)
+ }
+ }
+
+ request.onerror = () => reject(request.error)
+ })
+ }
+}
+
+export const skinPreviewStorage = new SkinPreviewStorage()
diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts
index 1007744d0..aa60ec2f7 100644
--- a/apps/app-frontend/src/helpers/types.d.ts
+++ b/apps/app-frontend/src/helpers/types.d.ts
@@ -48,6 +48,32 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
+type ContentFile = {
+ hash: string
+ file_name: string
+ size: number
+ metadata?: FileMetadata
+ update_version_id?: string
+ project_type: ContentFileProjectType
+}
+
+type FileMetadata = {
+ project_id: string
+ version_id: string
+}
+
+type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
+
+type CacheBehaviour =
+ // Serve expired data. If fetch fails / launcher is offline, errors are ignored
+ | 'stale_while_revalidate_skip_offline'
+ // Serve expired data, revalidate in background
+ | 'stale_while_revalidate'
+ // Must revalidate if data is expired
+ | 'must_revalidate'
+ // Ignore cache- always fetch updated data from origin
+ | 'bypass'
+
type MemorySettings = {
maximum: number
}
@@ -88,6 +114,7 @@ type AppSettings = {
collapsed_navigation: boolean
advanced_rendering: boolean
native_decorations: boolean
+ worlds_in_home: boolean
telemetry: boolean
discord_rpc: boolean
diff --git a/apps/app-frontend/src/helpers/utils.js b/apps/app-frontend/src/helpers/utils.js
index 569420101..89ebd52ba 100644
--- a/apps/app-frontend/src/helpers/utils.js
+++ b/apps/app-frontend/src/helpers/utils.js
@@ -37,6 +37,13 @@ export async function restartApp() {
return await invoke('restart_app')
}
+/**
+ * @deprecated This method is no longer needed, and just returns its parameter
+ */
+export function sanitizePotentialFileUrl(url) {
+ return url
+}
+
export const releaseColor = (releaseType) => {
switch (releaseType) {
case 'release':
@@ -49,3 +56,7 @@ export const releaseColor = (releaseType) => {
return ''
}
}
+
+export async function copyToClipboard(text) {
+ await navigator.clipboard.writeText(text)
+}
diff --git a/apps/app-frontend/src/helpers/worlds.ts b/apps/app-frontend/src/helpers/worlds.ts
new file mode 100644
index 000000000..89f98d7d7
--- /dev/null
+++ b/apps/app-frontend/src/helpers/worlds.ts
@@ -0,0 +1,327 @@
+import { invoke } from '@tauri-apps/api/core'
+import { get_full_path } from '@/helpers/profile'
+import { openPath } from '@/helpers/utils'
+import { autoToHTML } from '@geometrically/minecraft-motd-parser'
+import dayjs from 'dayjs'
+import type { GameVersion } from '@modrinth/ui'
+
+type BaseWorld = {
+ name: string
+ last_played?: string
+ icon?: string
+ display_status: DisplayStatus
+ type: WorldType
+}
+
+export type WorldType = 'singleplayer' | 'server'
+export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
+
+export type SingleplayerWorld = BaseWorld & {
+ type: 'singleplayer'
+ path: string
+ game_mode: SingleplayerGameMode
+ hardcore: boolean
+ locked: boolean
+}
+
+export type ServerWorld = BaseWorld & {
+ type: 'server'
+ index: number
+ address: string
+ pack_status: ServerPackStatus
+}
+
+export type World = SingleplayerWorld | ServerWorld
+
+export type WorldWithProfile = {
+ profile: string
+} & World
+
+export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
+export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt'
+
+export type ServerStatus = {
+ // https://minecraft.wiki/w/Text_component_format
+ description?: string | Chat
+ players?: {
+ max: number
+ online: number
+ sample: { name: string; id: string }[]
+ }
+ version?: {
+ name: string
+ protocol: number
+ }
+ favicon?: string
+ enforces_secure_chat: boolean
+ ping?: number
+}
+
+export interface Chat {
+ text: string
+ bold: boolean
+ italic: boolean
+ underlined: boolean
+ strikethrough: boolean
+ obfuscated: boolean
+ color?: string
+ extra: Chat[]
+}
+
+export type ServerData = {
+ refreshing: boolean
+ status?: ServerStatus
+ rawMotd?: string | Chat
+ renderedMotd?: string
+}
+
+export async function get_recent_worlds(
+ limit: number,
+ displayStatuses?: DisplayStatus[],
+): Promise {
+ return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
+}
+
+export async function get_profile_worlds(path: string): Promise {
+ return await invoke('plugin:worlds|get_profile_worlds', { path })
+}
+
+export async function get_singleplayer_world(
+ instance: string,
+ world: string,
+): Promise {
+ return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
+}
+
+export async function set_world_display_status(
+ instance: string,
+ worldType: WorldType,
+ worldId: string,
+ displayStatus: DisplayStatus,
+): Promise {
+ return await invoke('plugin:worlds|set_world_display_status', {
+ instance,
+ worldType,
+ worldId,
+ displayStatus,
+ })
+}
+
+export async function rename_world(
+ instance: string,
+ world: string,
+ newName: string,
+): Promise {
+ return await invoke('plugin:worlds|rename_world', { instance, world, newName })
+}
+
+export async function reset_world_icon(instance: string, world: string): Promise {
+ return await invoke('plugin:worlds|reset_world_icon', { instance, world })
+}
+
+export async function backup_world(instance: string, world: string): Promise {
+ return await invoke('plugin:worlds|backup_world', { instance, world })
+}
+
+export async function delete_world(instance: string, world: string): Promise {
+ return await invoke('plugin:worlds|delete_world', { instance, world })
+}
+
+export async function add_server_to_profile(
+ path: string,
+ name: string,
+ address: string,
+ packStatus: ServerPackStatus,
+): Promise {
+ return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
+}
+
+export async function edit_server_in_profile(
+ path: string,
+ index: number,
+ name: string,
+ address: string,
+ packStatus: ServerPackStatus,
+): Promise {
+ return await invoke('plugin:worlds|edit_server_in_profile', {
+ path,
+ index,
+ name,
+ address,
+ packStatus,
+ })
+}
+
+export async function remove_server_from_profile(path: string, index: number): Promise {
+ return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
+}
+
+export async function get_profile_protocol_version(path: string): Promise {
+ return await invoke('plugin:worlds|get_profile_protocol_version', { path })
+}
+
+export async function get_server_status(
+ address: string,
+ protocolVersion: number | null = null,
+): Promise {
+ return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
+}
+
+export async function start_join_singleplayer_world(path: string, world: string): Promise {
+ return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
+}
+
+export async function start_join_server(path: string, address: string): Promise {
+ return await invoke('plugin:worlds|start_join_server', { path, address })
+}
+
+export async function showWorldInFolder(instancePath: string, worldPath: string) {
+ const fullPath = await get_full_path(instancePath)
+ return await openPath(fullPath + '/saves/' + worldPath)
+}
+
+export function getWorldIdentifier(world: World) {
+ return world.type === 'singleplayer' ? world.path : world.address
+}
+
+export function sortWorlds(worlds: World[]) {
+ worlds.sort((a, b) => {
+ if (!a.last_played) {
+ return 1
+ }
+ if (!b.last_played) {
+ return -1
+ }
+ return dayjs(b.last_played).diff(dayjs(a.last_played))
+ })
+}
+
+export function isSingleplayerWorld(world: World): world is SingleplayerWorld {
+ return world.type === 'singleplayer'
+}
+
+export function isServerWorld(world: World): world is ServerWorld {
+ return world.type === 'server'
+}
+
+export async function refreshServerData(
+ serverData: ServerData,
+ protocolVersion: number | null,
+ address: string,
+): Promise {
+ serverData.refreshing = true
+ await get_server_status(address, protocolVersion)
+ .then((status) => {
+ serverData.status = status
+ if (status.description) {
+ serverData.rawMotd = status.description
+ serverData.renderedMotd = autoToHTML(status.description)
+ }
+ })
+ .catch((err) => {
+ console.error(`Refreshing addr: ${address}`, err)
+ })
+ .finally(() => {
+ serverData.refreshing = false
+ })
+}
+
+export async function refreshServers(
+ worlds: World[],
+ serverData: Record,
+ protocolVersion: number | null,
+) {
+ const servers = worlds.filter(isServerWorld)
+ servers.forEach((server) => {
+ if (!serverData[server.address]) {
+ serverData[server.address] = {
+ refreshing: true,
+ }
+ } else {
+ serverData[server.address].refreshing = true
+ }
+ })
+
+ // noinspection ES6MissingAwait - handled with .then by refreshServerData already
+ Promise.all(
+ Object.keys(serverData).map((address) =>
+ refreshServerData(serverData[address], protocolVersion, address),
+ ),
+ )
+}
+
+export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
+ const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
+ const newWorld = await get_singleplayer_world(instancePath, worldPath)
+ if (index !== -1) {
+ worlds[index] = newWorld
+ } else {
+ console.info(`Adding new world at path: ${worldPath}.`)
+ worlds.push(newWorld)
+ }
+ sortWorlds(worlds)
+}
+
+export async function handleDefaultProfileUpdateEvent(
+ worlds: World[],
+ instancePath: string,
+ e: ProfileEvent,
+) {
+ if (e.event === 'world_updated') {
+ await refreshWorld(worlds, instancePath, e.world)
+ }
+
+ if (e.event === 'server_joined') {
+ const world = worlds.find(
+ (w) =>
+ w.type === 'server' &&
+ (w.address === `${e.host}:${e.port}` || (e.port == 25565 && w.address == e.host)),
+ )
+ if (world) {
+ world.last_played = e.timestamp
+ sortWorlds(worlds)
+ } else {
+ console.error(`Could not find world for server join event: ${e.host}:${e.port}`)
+ }
+ }
+}
+
+export async function refreshWorlds(instancePath: string): Promise {
+ const worlds = await get_profile_worlds(instancePath).catch((err) => {
+ console.error(`Error refreshing worlds for instance: ${instancePath}`, err)
+ })
+ if (worlds) {
+ sortWorlds(worlds)
+ }
+
+ return worlds ?? []
+}
+
+const FIRST_QUICK_PLAY_VERSION = '23w14a'
+
+export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
+ if (!gameVersions.length) {
+ return false
+ }
+
+ const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
+ const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
+
+ return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
+}
+
+export type ProfileEvent = { profile_path_id: string } & (
+ | {
+ event: 'servers_updated'
+ }
+ | {
+ event: 'world_updated'
+ world: string
+ }
+ | {
+ event: 'server_joined'
+ host: string
+ port: number
+ timestamp: string
+ }
+)
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 515e4e71a..fa2563da9 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -20,12 +20,60 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
+ "instance.add-server.add-and-play": {
+ "message": "Add and play"
+ },
+ "instance.add-server.add-server": {
+ "message": "Add server"
+ },
+ "instance.add-server.resource-pack.disabled": {
+ "message": "Disabled"
+ },
+ "instance.add-server.resource-pack.enabled": {
+ "message": "Enabled"
+ },
+ "instance.add-server.resource-pack.prompt": {
+ "message": "Prompt"
+ },
+ "instance.add-server.title": {
+ "message": "Add a server"
+ },
+ "instance.edit-server.title": {
+ "message": "Edit server"
+ },
+ "instance.edit-world.hide-from-home": {
+ "message": "Hide from the Home page"
+ },
+ "instance.edit-world.name": {
+ "message": "Name"
+ },
+ "instance.edit-world.placeholder-name": {
+ "message": "Minecraft World"
+ },
+ "instance.edit-world.reset-icon": {
+ "message": "Reset icon"
+ },
+ "instance.edit-world.title": {
+ "message": "Edit world"
+ },
"instance.filter.disabled": {
"message": "Disabled projects"
},
"instance.filter.updates-available": {
"message": "Updates available"
},
+ "instance.server-modal.address": {
+ "message": "Address"
+ },
+ "instance.server-modal.name": {
+ "message": "Name"
+ },
+ "instance.server-modal.placeholder-name": {
+ "message": "Minecraft Server"
+ },
+ "instance.server-modal.resource-pack": {
+ "message": "Resource pack"
+ },
"instance.settings.tabs.general": {
"message": "General"
},
@@ -308,6 +356,48 @@
"instance.settings.title": {
"message": "Settings"
},
+ "instance.worlds.a_minecraft_server": {
+ "message": "A Minecraft Server"
+ },
+ "instance.worlds.cant_connect": {
+ "message": "Can't connect to server"
+ },
+ "instance.worlds.copy_address": {
+ "message": "Copy address"
+ },
+ "instance.worlds.dont_show_on_home": {
+ "message": "Don't show on Home"
+ },
+ "instance.worlds.filter.available": {
+ "message": "Available"
+ },
+ "instance.worlds.game_already_open": {
+ "message": "Instance is already open"
+ },
+ "instance.worlds.hardcore": {
+ "message": "Hardcore mode"
+ },
+ "instance.worlds.no_quick_play": {
+ "message": "You can only jump straight into worlds on Minecraft 1.20+"
+ },
+ "instance.worlds.play_anyway": {
+ "message": "Play anyway"
+ },
+ "instance.worlds.play_instance": {
+ "message": "Play instance"
+ },
+ "instance.worlds.type.server": {
+ "message": "Server"
+ },
+ "instance.worlds.type.singleplayer": {
+ "message": "Singleplayer"
+ },
+ "instance.worlds.view_instance": {
+ "message": "View instance"
+ },
+ "instance.worlds.world_in_use": {
+ "message": "World is in use"
+ },
"search.filter.locked.instance": {
"message": "Provided by the instance"
},
diff --git a/apps/app-frontend/src/main.js b/apps/app-frontend/src/main.js
index ba6d3f49b..a37a7018f 100644
--- a/apps/app-frontend/src/main.js
+++ b/apps/app-frontend/src/main.js
@@ -6,6 +6,7 @@ import FloatingVue from 'floating-vue'
import 'floating-vue/dist/style.css'
import { createPlugin } from '@vintl/vintl/plugin'
import * as Sentry from '@sentry/vue'
+import { VueScanPlugin } from '@taijased/vue-render-tracker'
const VIntlPlugin = createPlugin({
controllerOpts: {
@@ -24,6 +25,13 @@ const VIntlPlugin = createPlugin({
injectInto: [],
})
+const vueScan = new VueScanPlugin({
+ enabled: false, // Enable or disable the tracker
+ showOverlay: true, // Show overlay to visualize renders
+ log: false, // Log render events to the console
+ playSound: false, // Play sound on each render
+})
+
const pinia = createPinia()
let app = createApp(App)
@@ -35,6 +43,7 @@ Sentry.init({
tracesSampleRate: 0.1,
})
+app.use(vueScan)
app.use(router)
app.use(pinia)
app.use(FloatingVue, {
diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue
index a57b2fddd..f11e2c044 100644
--- a/apps/app-frontend/src/pages/Browse.vue
+++ b/apps/app-frontend/src/pages/Browse.vue
@@ -220,6 +220,7 @@ async function refreshSearch() {
}
}
results.value = rawResults.result
+ currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
const persistentParams: LocationQuery = {}
diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue
index 9d9064eb5..3eba1ba68 100644
--- a/apps/app-frontend/src/pages/Index.vue
+++ b/apps/app-frontend/src/pages/Index.vue
@@ -1,4 +1,4 @@
-
+
+
+ loadSkins()"
+ @open-upload-modal="openUploadSkinModal"
+ />
+
+
+
+
+
+
+
+ Skins
+ Beta
+
+
+
+
+
+
+ selectCapeModal?.show(
+ e,
+ selectedSkin?.texture_key,
+ currentCape,
+ skinTexture,
+ skinVariant,
+ )
+ "
+ >
+
+ Change cape
+
+
+
+
+
+
+
+
+
+ Saved skins
+
+
+
+
+
+ Add a skin
+
+
+
+
+ editSkinModal?.show(e, skin)"
+ >
+ Edit
+
+ confirmDeleteSkin(skin)"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Excited Modrinth Bot]()
+
+
+
+
+
+ Please sign into your Minecraft account to use the skin management features of the
+ Modrinth app.
+
+
+
+
+
+ Sign In
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/Worlds.vue b/apps/app-frontend/src/pages/Worlds.vue
new file mode 100644
index 000000000..8c1f57bf3
--- /dev/null
+++ b/apps/app-frontend/src/pages/Worlds.vue
@@ -0,0 +1,4 @@
+
+
+ Worlds
+
diff --git a/apps/app-frontend/src/pages/index.js b/apps/app-frontend/src/pages/index.js
index 6c4866b46..2e0361cd5 100644
--- a/apps/app-frontend/src/pages/index.js
+++ b/apps/app-frontend/src/pages/index.js
@@ -1,4 +1,6 @@
import Index from './Index.vue'
import Browse from './Browse.vue'
+import Worlds from './Worlds.vue'
+import Skins from './Skins.vue'
-export { Index, Browse }
+export { Index, Browse, Worlds, Skins }
diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue
index d2f3d52e0..8b6d63bfa 100644
--- a/apps/app-frontend/src/pages/instance/Index.vue
+++ b/apps/app-frontend/src/pages/instance/Index.vue
@@ -1,152 +1,160 @@
- handleRightClick(event, instance.path)"
- >
-
-
-
-
-
-
-
- {{ instance.name }}
-
-
-
-
-
- {{ instance.loader }} {{ instance.game_version }}
-
-
-
-
- {{ timePlayedHumanized }}
-
- Never played
-
-
-
-
-
- Installing...
-
-
-
-
- Repair
-
-
-
-
-
- Stop
-
-
-
-
-
- Play
-
-
-
- Loading...
-
-
-
-
-
-
-
-
-
- Share instance
- Create a server
- Open folder
- Export modpack
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Play
- Stop
- Add content
- Edit
- Copy path
- Open folder
- Copy link
- Open in Modrinth
- Copy names
- Copy slugs
- Copy links
- Toggle selected
- Disable selected
- Enable selected
- Show/Hide unselected
- Update {{ selected.length > 0 ? 'selected' : 'all' }}
+ handleRightClick(event, instance.path)"
>
-
Select Updatable
-
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+
+
+ {{ instance.loader }} {{ instance.game_version }}
+
+
+
+
+ {{ timePlayedHumanized }}
+
+ Never played
+
+
+
+
+
+ Installing...
+
+
+
+
+ Repair
+
+
+
+
+
+ Stop
+
+
+
+
+
+ Play
+
+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+ Share instance
+ Create a server
+ Open folder
+ Export modpack
+
+
+
+
+
+
+
+
+
+
+
+
+
+ stopInstance('InstanceSubpage')"
+ >
+
+
+
+
+
+
+
+
+ Play
+ Stop
+ Add content
+ Edit
+ Copy path
+ Open folder
+ Copy link
+ Open in Modrinth
+ Copy names
+ Copy slugs
+ Copy links
+ Toggle selected
+ Disable selected
+ Enable selected
+ Show/Hide unselected
+ Update {{ selected.length > 0 ? 'selected' : 'all' }}
+ Select Updatable
+
+
diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue
new file mode 100644
index 000000000..8490269e3
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/Worlds.vue
@@ -0,0 +1,465 @@
+
+
diff --git a/apps/app-frontend/src/pages/instance/index.js b/apps/app-frontend/src/pages/instance/index.js
index e433570eb..fa77df524 100644
--- a/apps/app-frontend/src/pages/instance/index.js
+++ b/apps/app-frontend/src/pages/instance/index.js
@@ -1,5 +1,7 @@
import Index from './Index.vue'
+import Overview from './Overview.vue'
+import Worlds from './Worlds.vue'
import Mods from './Mods.vue'
import Logs from './Logs.vue'
-export { Index, Mods, Logs }
+export { Index, Overview, Worlds, Mods, Logs }
diff --git a/apps/app-frontend/src/pages/project/Gallery.vue b/apps/app-frontend/src/pages/project/Gallery.vue
index 833048612..dee119970 100644
--- a/apps/app-frontend/src/pages/project/Gallery.vue
+++ b/apps/app-frontend/src/pages/project/Gallery.vue
@@ -31,10 +31,10 @@
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
- @click.stop=""
+ @click.stop="() => {}"
/>
-
+
{}">
{{ expandedGalleryItem.title }}
@@ -99,7 +99,7 @@ import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
const props = defineProps({
project: {
type: Object,
- default: () => {},
+ default: () => ({}),
},
})
diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue
index 1f5082b13..941ab0d08 100644
--- a/apps/app-frontend/src/pages/project/Index.vue
+++ b/apps/app-frontend/src/pages/project/Index.vue
@@ -155,7 +155,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
-import { useRoute } from 'vue-router'
+import { useRoute, useRouter } from 'vue-router'
import { ref, shallowRef, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
@@ -170,6 +170,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'
dayjs.extend(relativeTime)
const route = useRoute()
+const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
@@ -192,6 +193,11 @@ const [allLoaders, allGameVersions] = await Promise.all([
async function fetchProjectData() {
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
+ if (!project) {
+ handleError('Error loading project')
+ return
+ }
+
data.value = project
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
await Promise.all([
@@ -242,6 +248,9 @@ async function install(version) {
installedVersion.value = version
}
},
+ (profile) => {
+ router.push(`/instance/${profile}`)
+ },
)
}
diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js
index 49eae8461..67172e68d 100644
--- a/apps/app-frontend/src/routes.js
+++ b/apps/app-frontend/src/routes.js
@@ -18,6 +18,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Home' }],
},
},
+ {
+ path: '/worlds',
+ name: 'Worlds',
+ component: Pages.Worlds,
+ meta: {
+ breadcrumb: [{ name: 'Worlds' }],
+ },
+ },
{
path: '/browse/:projectType',
name: 'Discover content',
@@ -26,6 +34,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Discover content' }],
},
},
+ {
+ path: '/skins',
+ name: 'Skins',
+ component: Pages.Skins,
+ meta: {
+ breadcrumb: [{ name: 'Skins' }],
+ },
+ },
{
path: '/library',
name: 'Library',
@@ -106,13 +122,31 @@ export default new createRouter({
component: Instance.Index,
props: true,
children: [
+ // {
+ // path: '',
+ // name: 'Overview',
+ // component: Instance.Overview,
+ // meta: {
+ // useRootContext: true,
+ // breadcrumb: [{ name: '?Instance' }],
+ // },
+ // },
+ {
+ path: 'worlds',
+ name: 'InstanceWorlds',
+ component: Instance.Worlds,
+ meta: {
+ useRootContext: true,
+ breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
+ },
+ },
{
path: '',
name: 'Mods',
component: Instance.Mods,
meta: {
useRootContext: true,
- breadcrumb: [{ name: '?Instance' }],
+ breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{
@@ -121,7 +155,7 @@ export default new createRouter({
component: Instance.Mods,
meta: {
useRootContext: true,
- breadcrumb: [{ name: '?Instance' }],
+ breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{
diff --git a/apps/app-frontend/src/store/install.js b/apps/app-frontend/src/store/install.js
index 90ca66cd0..4746b9070 100644
--- a/apps/app-frontend/src/store/install.js
+++ b/apps/app-frontend/src/store/install.js
@@ -23,8 +23,8 @@ export const useInstall = defineStore('installStore', {
setInstallConfirmModal(ref) {
this.installConfirmModal = ref
},
- showInstallConfirmModal(project, version_id, onInstall) {
- this.installConfirmModal.show(project, version_id, onInstall)
+ showInstallConfirmModal(project, version_id, onInstall, createInstanceCallback) {
+ this.installConfirmModal.show(project, version_id, onInstall, createInstanceCallback)
},
setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref
@@ -41,7 +41,14 @@ export const useInstall = defineStore('installStore', {
},
})
-export const install = async (projectId, versionId, instancePath, source, callback = () => {}) => {
+export const install = async (
+ projectId,
+ versionId,
+ instancePath,
+ source,
+ callback = () => {},
+ createInstanceCallback = () => {},
+) => {
const project = await get_project(projectId, 'must_revalidate').catch(handleError)
if (project.project_type === 'modpack') {
@@ -49,7 +56,13 @@ export const install = async (projectId, versionId, instancePath, source, callba
const packs = await list().catch(handleError)
if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) {
- await packInstall(project.id, version, project.title, project.icon_url).catch(handleError)
+ await packInstall(
+ project.id,
+ version,
+ project.title,
+ project.icon_url,
+ createInstanceCallback,
+ ).catch(handleError)
trackEvent('PackInstall', {
id: project.id,
@@ -61,7 +74,7 @@ export const install = async (projectId, versionId, instancePath, source, callba
callback(version)
} else {
const install = useInstall()
- install.showInstallConfirmModal(project, version, callback)
+ install.showInstallConfirmModal(project, version, callback, createInstanceCallback)
}
} else {
if (instancePath) {
diff --git a/apps/app-frontend/src/store/state.js b/apps/app-frontend/src/store/state.js
index dd68a6eec..e9811e623 100644
--- a/apps/app-frontend/src/store/state.js
+++ b/apps/app-frontend/src/store/state.js
@@ -1,4 +1,4 @@
-import { useTheming } from './theme'
+import { useTheming } from './theme.ts'
import { useBreadcrumbs } from './breadcrumbs'
import { useLoading } from './loading'
import { useNotifications, handleError } from './notifications'
diff --git a/apps/app-frontend/src/store/theme.js b/apps/app-frontend/src/store/theme.js
deleted file mode 100644
index d9111f50c..000000000
--- a/apps/app-frontend/src/store/theme.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { defineStore } from 'pinia'
-
-export const useTheming = defineStore('themeStore', {
- state: () => ({
- themeOptions: ['dark', 'light', 'oled', 'system'],
- advancedRendering: true,
- selectedTheme: 'dark',
- toggleSidebar: false,
-
- devMode: false,
- featureFlags: {},
- }),
- actions: {
- setThemeState(newTheme) {
- if (this.themeOptions.includes(newTheme)) this.selectedTheme = newTheme
- else console.warn('Selected theme is not present. Check themeOptions.')
-
- this.setThemeClass()
- },
- setThemeClass() {
- for (const theme of this.themeOptions) {
- document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
- }
-
- let theme = this.selectedTheme
- if (this.selectedTheme === 'system') {
- const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
- if (darkThemeMq.matches) {
- theme = 'dark'
- } else {
- theme = 'light'
- }
- }
-
- document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
- },
- },
-})
diff --git a/apps/app-frontend/src/store/theme.ts b/apps/app-frontend/src/store/theme.ts
new file mode 100644
index 000000000..a79094167
--- /dev/null
+++ b/apps/app-frontend/src/store/theme.ts
@@ -0,0 +1,70 @@
+import { defineStore } from 'pinia'
+
+export const DEFAULT_FEATURE_FLAGS = {
+ project_background: false,
+ page_path: false,
+ worlds_tab: false,
+ worlds_in_home: true,
+}
+
+export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
+
+export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
+export type FeatureFlags = Record
+export type ColorTheme = (typeof THEME_OPTIONS)[number]
+
+export type ThemeStore = {
+ selectedTheme: ColorTheme
+ advancedRendering: boolean
+ toggleSidebar: boolean
+
+ devMode: boolean
+ featureFlags: FeatureFlags
+}
+
+export const DEFAULT_THEME_STORE: ThemeStore = {
+ selectedTheme: 'dark',
+ advancedRendering: true,
+ toggleSidebar: false,
+
+ devMode: false,
+ featureFlags: DEFAULT_FEATURE_FLAGS,
+}
+
+export const useTheming = defineStore('themeStore', {
+ state: () => DEFAULT_THEME_STORE,
+ actions: {
+ setThemeState(newTheme: ColorTheme) {
+ if (THEME_OPTIONS.includes(newTheme)) {
+ this.selectedTheme = newTheme
+ } else {
+ console.warn('Selected theme is not present. Check themeOptions.')
+ }
+
+ this.setThemeClass()
+ },
+ setThemeClass() {
+ for (const theme of THEME_OPTIONS) {
+ document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
+ }
+
+ let theme = this.selectedTheme
+ if (this.selectedTheme === 'system') {
+ const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
+ if (darkThemeMq.matches) {
+ theme = 'dark'
+ } else {
+ theme = 'light'
+ }
+ }
+
+ document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
+ },
+ getFeatureFlag(key: FeatureFlag) {
+ return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
+ },
+ getThemeOptions() {
+ return THEME_OPTIONS
+ },
+ },
+})
diff --git a/apps/app-frontend/tailwind.config.js b/apps/app-frontend/tailwind.config.js
index 0d0fab4bf..b5196b368 100644
--- a/apps/app-frontend/tailwind.config.js
+++ b/apps/app-frontend/tailwind.config.js
@@ -41,6 +41,7 @@ export default {
green: 'var(--color-green-highlight)',
blue: 'var(--color-blue-highlight)',
purple: 'var(--color-purple-highlight)',
+ gray: 'var(--color-gray-highlight)',
},
divider: {
DEFAULT: 'var(--color-divider)',
diff --git a/apps/app-frontend/tsconfig.node.json b/apps/app-frontend/tsconfig.node.json
index e5a932a9e..ac300be84 100644
--- a/apps/app-frontend/tsconfig.node.json
+++ b/apps/app-frontend/tsconfig.node.json
@@ -10,6 +10,7 @@
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
+ "resolveJsonModule": true,
"strict": true
},
diff --git a/apps/app-frontend/vite.config.ts b/apps/app-frontend/vite.config.ts
index 3f88715a9..8adf5fb2d 100644
--- a/apps/app-frontend/vite.config.ts
+++ b/apps/app-frontend/vite.config.ts
@@ -4,6 +4,8 @@ import svgLoader from 'vite-svg-loader'
import vue from '@vitejs/plugin-vue'
+import tauriConf from '../app/tauri.conf.json'
+
const projectRootDir = resolve(__dirname)
// https://vitejs.dev/config/
@@ -41,17 +43,32 @@ export default defineConfig({
server: {
port: 1420,
strictPort: true,
+ headers: {
+ 'content-security-policy': Object.entries(tauriConf.app.security.csp)
+ .map(([directive, sources]) => {
+ // An additional websocket connect-src is required for Vite dev tools to work
+ if (directive === 'connect-src') {
+ sources = Array.isArray(sources) ? sources : [sources]
+ sources.push('ws://localhost:1420')
+ }
+
+ return Array.isArray(sources)
+ ? `${directive} ${sources.join(' ')}`
+ : `${directive} ${sources}`
+ })
+ .join('; '),
+ },
},
// to make use of `TAURI_ENV_DEBUG` and other env variables
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
envPrefix: ['VITE_', 'TAURI_'],
build: {
// Tauri supports es2021
- target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
+ target: process.env.TAURI_ENV_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
// don't minify for debug builds
- minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
+ minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
// produce sourcemaps for debug builds
- sourcemap: !!process.env.TAURI_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
+ sourcemap: !!process.env.TAURI_ENV_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
commonjsOptions: {
esmExternals: true,
},
diff --git a/apps/app-playground/Cargo.toml b/apps/app-playground/Cargo.toml
index a37251612..691c9d3b7 100644
--- a/apps/app-playground/Cargo.toml
+++ b/apps/app-playground/Cargo.toml
@@ -1,24 +1,14 @@
[package]
name = "theseus_playground"
version = "0.0.0"
-edition = "2021"
+edition.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
-theseus = { path = "../../packages/app-lib", features = ["cli"] }
+theseus = { workspace = true, features = ["cli"] }
+tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+enumset.workspace = true
-serde_json = "1.0"
-serde = { version = "1.0", features = ["derive"] }
-tokio = { version = "1", features = ["full"] }
-thiserror = "1.0"
-url = "2.2"
-webbrowser = "0.8.13"
-dunce = "1.0.3"
-
-futures = "0.3"
-uuid = { version = "1.1", features = ["serde", "v4"] }
-
-tracing = "0.1.37"
-tracing-subscriber = "0.3.18"
-tracing-error = "0.2.0"
+[lints]
+workspace = true
diff --git a/apps/app-playground/package.json b/apps/app-playground/package.json
index 0d76eaed8..342b3cecb 100644
--- a/apps/app-playground/package.json
+++ b/apps/app-playground/package.json
@@ -2,9 +2,9 @@
"name": "@modrinth/app-playground",
"scripts": {
"build": "cargo build --release",
- "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
- "fix": "cargo fmt && cargo clippy --fix",
+ "lint": "cargo fmt --check && cargo clippy --all-targets",
+ "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
"dev": "cargo run",
- "test": "cargo test"
+ "test": "cargo nextest run --all-targets --no-fail-fast"
}
}
diff --git a/apps/app-playground/src/main.rs b/apps/app-playground/src/main.rs
index f8d943938..a2c2b8922 100644
--- a/apps/app-playground/src/main.rs
+++ b/apps/app-playground/src/main.rs
@@ -3,9 +3,9 @@
windows_subsystem = "windows"
)]
-use std::time::Duration;
+use enumset::EnumSet;
use theseus::prelude::*;
-use tokio::signal::ctrl_c;
+use theseus::worlds::get_recent_worlds;
// A simple Rust implementation of the authentication run
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
@@ -15,8 +15,7 @@ pub async fn authenticate_run() -> theseus::Result {
println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?;
- println!("URL {}", login.redirect_uri.as_str());
- webbrowser::open(login.redirect_uri.as_str())?;
+ println!("Open URL {} in a browser", login.redirect_uri.as_str());
println!("Please enter URL code: ");
let mut input = String::new();
@@ -28,7 +27,10 @@ pub async fn authenticate_run() -> theseus::Result {
let credentials = minecraft_auth::finish_login(&input, login).await?;
- println!("Logged in user {}.", credentials.username);
+ println!(
+ "Logged in user {}.",
+ credentials.maybe_online_profile().await.name
+ );
Ok(credentials)
}
@@ -41,21 +43,16 @@ async fn main() -> theseus::Result<()> {
// Initialize state
State::init().await?;
- loop {
- if State::get().await?.friends_socket.is_connected().await {
- break;
- }
- tokio::time::sleep(Duration::from_millis(500)).await;
+ let worlds = get_recent_worlds(4, EnumSet::all()).await?;
+ for world in worlds {
+ println!(
+ "World: {:?}/{:?} played at {:?}: {:#?}",
+ world.profile,
+ world.world.name,
+ world.world.last_played,
+ world.world.details
+ );
}
- tracing::info!("Starting host");
-
- let socket = State::get().await?.friends_socket.open_port(25565).await?;
- tracing::info!("Running host on socket {}", socket.socket_id());
-
- ctrl_c().await?;
- tracing::info!("Stopping host");
- socket.shutdown().await?;
-
Ok(())
}
diff --git a/apps/app/.gitignore b/apps/app/.gitignore
index d887d6c0b..f73fca36c 100644
--- a/apps/app/.gitignore
+++ b/apps/app/.gitignore
@@ -1,6 +1,2 @@
-# Generated by Cargo
-# will have compiled files and executables
-/target/
-
# Generated by tauri, metadata generated at compile time
/gen/
diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml
index 0e3bc20b3..d1c67affc 100644
--- a/apps/app/Cargo.toml
+++ b/apps/app/Cargo.toml
@@ -1,66 +1,52 @@
[package]
name = "theseus_gui"
-version = "0.9.3"
+version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
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/"
-edition = "2021"
-build = "build.rs"
+edition.workspace = true
[build-dependencies]
-tauri-build = { version = "2.0.3", features = ["codegen"] }
+tauri-build = { workspace = true, features = ["codegen"] }
[dependencies]
-theseus = { path = "../../packages/app-lib", features = ["tauri"] }
+theseus = { workspace = true, features = ["tauri"] }
-serde_json = "1.0"
-serde = { version = "1.0", features = ["derive"] }
-serde_with = "3.0.0"
+serde_json.workspace = true
+serde = { workspace = true, features = ["derive"] }
+serde_with.workspace = true
-tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
-tauri-plugin-window-state = "2.2.0"
-tauri-plugin-deep-link = "2.2.0"
-tauri-plugin-os = "2.2.0"
-tauri-plugin-opener = "2.2.1"
-tauri-plugin-dialog = "2.2.0"
-tauri-plugin-updater = { version = "2.3.0" }
-tauri-plugin-single-instance = { version = "2.2.0" }
+tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
+tauri-plugin-deep-link.workspace = true
+tauri-plugin-dialog.workspace = true
+tauri-plugin-http.workspace = true
+tauri-plugin-opener.workspace = true
+tauri-plugin-os.workspace = true
+tauri-plugin-single-instance.workspace = true
+tauri-plugin-updater.workspace = true
+tauri-plugin-window-state.workspace = true
-tokio = { version = "1", features = ["full"] }
-thiserror = "1.0"
-futures = "0.3"
-daedalus = { path = "../../packages/daedalus" }
-chrono = "0.4.26"
+tokio = { workspace = true, features = ["time"] }
+thiserror.workspace = true
+daedalus.workspace = true
+chrono.workspace = true
+either.workspace = true
-dirs = "5.0.1"
+url.workspace = true
+urlencoding.workspace = true
+uuid = { workspace = true, features = ["serde", "v4"] }
-url = "2.2"
-uuid = { version = "1.1", features = ["serde", "v4"] }
-os_info = "3.7.0"
+tracing.workspace = true
+tracing-error.workspace = true
-tracing = "0.1.37"
-tracing-error = "0.2.0"
+dashmap.workspace = true
+paste.workspace = true
+enumset = { workspace = true, features = ["serde"] }
-lazy_static = "1"
-once_cell = "1"
-
-dashmap = "6.0.1"
-paste = "1.0.15"
-
-opener = { version = "0.7.2", features = ["reveal", "dbus-vendored"] }
-
-native-dialog = "0.7.0"
-
-[target.'cfg(not(target_os = "linux"))'.dependencies]
-window-shadows = "0.2.1"
-
-[target.'cfg(target_os = "macos")'.dependencies]
-cocoa = "0.25.0"
-objc = "0.2.7"
-rand = "0.8.5"
+native-dialog.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
-tauri-plugin-updater = { version = "2.3.0", optional = true, features = ["native-tls-vendored", "zip"], default-features = false }
+tauri-plugin-updater = { workspace = true, optional = true }
[features]
# by default Tauri runs in production mode
@@ -70,3 +56,6 @@ default = ["custom-protocol"]
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
updater = []
+
+[lints]
+workspace = true
diff --git a/apps/app/Info.plist b/apps/app/Info.plist
index 2e875fe1f..844d3a25f 100644
--- a/apps/app/Info.plist
+++ b/apps/app/Info.plist
@@ -18,5 +18,25 @@
A Minecraft mod wants to access your camera.
NSMicrophoneUsageDescription
A Minecraft mod wants to access your microphone.
+ NSAppTransportSecurity
+
+ NSExceptionDomains
+
+ asset.localhost
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+ NSIncludesSubdomains
+
+
+ textures.minecraft.net
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+ NSIncludesSubdomains
+
+
+
+
diff --git a/apps/app/build.rs b/apps/app/build.rs
index ae314cd9b..7a4da8872 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -99,6 +99,24 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
+ .plugin(
+ "minecraft-skins",
+ InlinedPlugin::new()
+ .commands(&[
+ "get_available_capes",
+ "get_available_skins",
+ "add_and_equip_custom_skin",
+ "set_default_cape",
+ "equip_skin",
+ "remove_custom_skin",
+ "unequip_skin",
+ "normalize_skin_texture",
+ "get_dragged_skin_data",
+ ])
+ .default_permission(
+ DefaultPermissionRule::AllowAllCommands,
+ ),
+ )
.plugin(
"mr-auth",
InlinedPlugin::new()
@@ -151,7 +169,6 @@ fn main() {
"profile_update_managed_modrinth_version",
"profile_repair_managed_modrinth",
"profile_run",
- "profile_run_credentials",
"profile_kill",
"profile_edit",
"profile_edit_icon",
@@ -240,6 +257,30 @@ fn main() {
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
+ )
+ .plugin(
+ "worlds",
+ InlinedPlugin::new()
+ .commands(&[
+ "get_recent_worlds",
+ "get_profile_worlds",
+ "get_singleplayer_world",
+ "set_world_display_status",
+ "rename_world",
+ "reset_world_icon",
+ "backup_world",
+ "delete_world",
+ "add_server_to_profile",
+ "edit_server_in_profile",
+ "remove_server_from_profile",
+ "get_profile_protocol_version",
+ "get_server_status",
+ "start_join_singleplayer_world",
+ "start_join_server",
+ ])
+ .default_permission(
+ DefaultPermissionRule::AllowAllCommands,
+ ),
),
)
.expect("Failed to run tauri-build");
diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json
index 1a93620cd..b3947857b 100644
--- a/apps/app/capabilities/plugins.json
+++ b/apps/app/capabilities/plugins.json
@@ -19,12 +19,21 @@
"window-state:default",
"window-state:allow-restore-state",
"window-state:allow-save-window-state",
+
+ {
+ "identifier": "http:default",
+ "allow": [
+ { "url": "https://modrinth.com/*" },
+ { "url": "https://*.modrinth.com/*" }
+ ]
+ },
"auth:default",
"import:default",
"jre:default",
"logs:default",
"metadata:default",
+ "minecraft-skins:default",
"mr-auth:default",
"profile-create:default",
"pack:default",
@@ -35,6 +44,7 @@
"tags:default",
"utils:default",
"ads:default",
- "friends:default"
+ "friends:default",
+ "worlds:default"
]
}
diff --git a/apps/app/icons/128x128.png b/apps/app/icons/128x128.png
index ed62fb0cc..3d402563c 100644
Binary files a/apps/app/icons/128x128.png and b/apps/app/icons/128x128.png differ
diff --git a/apps/app/icons/128x128@2x.png b/apps/app/icons/128x128@2x.png
index c8fe8fc3d..4b7afa6e2 100644
Binary files a/apps/app/icons/128x128@2x.png and b/apps/app/icons/128x128@2x.png differ
diff --git a/apps/app/icons/Square107x107Logo.png b/apps/app/icons/Square107x107Logo.png
index f51ee7240..739bd7003 100644
Binary files a/apps/app/icons/Square107x107Logo.png and b/apps/app/icons/Square107x107Logo.png differ
diff --git a/apps/app/icons/Square142x142Logo.png b/apps/app/icons/Square142x142Logo.png
index e553ffdb8..5db44b079 100644
Binary files a/apps/app/icons/Square142x142Logo.png and b/apps/app/icons/Square142x142Logo.png differ
diff --git a/apps/app/icons/Square150x150Logo.png b/apps/app/icons/Square150x150Logo.png
index 82bbb433c..10037096e 100644
Binary files a/apps/app/icons/Square150x150Logo.png and b/apps/app/icons/Square150x150Logo.png differ
diff --git a/apps/app/icons/Square284x284Logo.png b/apps/app/icons/Square284x284Logo.png
index eb36c3e2f..0eb0bb8b9 100644
Binary files a/apps/app/icons/Square284x284Logo.png and b/apps/app/icons/Square284x284Logo.png differ
diff --git a/apps/app/icons/Square30x30Logo.png b/apps/app/icons/Square30x30Logo.png
index c65427855..5a5535ade 100644
Binary files a/apps/app/icons/Square30x30Logo.png and b/apps/app/icons/Square30x30Logo.png differ
diff --git a/apps/app/icons/Square310x310Logo.png b/apps/app/icons/Square310x310Logo.png
index 23c18a862..a4f460d99 100644
Binary files a/apps/app/icons/Square310x310Logo.png and b/apps/app/icons/Square310x310Logo.png differ
diff --git a/apps/app/icons/Square44x44Logo.png b/apps/app/icons/Square44x44Logo.png
index 2a1af8d1e..bd91178ab 100644
Binary files a/apps/app/icons/Square44x44Logo.png and b/apps/app/icons/Square44x44Logo.png differ
diff --git a/apps/app/icons/Square71x71Logo.png b/apps/app/icons/Square71x71Logo.png
index 7da0aaf65..9d233188a 100644
Binary files a/apps/app/icons/Square71x71Logo.png and b/apps/app/icons/Square71x71Logo.png differ
diff --git a/apps/app/icons/Square89x89Logo.png b/apps/app/icons/Square89x89Logo.png
index ac9440470..f7f8f47b7 100644
Binary files a/apps/app/icons/Square89x89Logo.png and b/apps/app/icons/Square89x89Logo.png differ
diff --git a/apps/app/icons/StoreLogo.png b/apps/app/icons/StoreLogo.png
index 7007585b0..c70dee80d 100644
Binary files a/apps/app/icons/StoreLogo.png and b/apps/app/icons/StoreLogo.png differ
diff --git a/apps/app/icons/icon.png b/apps/app/icons/icon.png
index 4431edb50..d64d8fbec 100644
Binary files a/apps/app/icons/icon.png and b/apps/app/icons/icon.png differ
diff --git a/apps/app/package.json b/apps/app/package.json
index 168ac3454..43f017203 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -1,15 +1,15 @@
{
"name": "@modrinth/app",
"scripts": {
- "build": "tauri build",
"tauri": "tauri",
+ "build": "tauri build",
"dev": "tauri dev",
- "test": "cargo test",
- "lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings",
- "fix": "cargo fmt && cargo clippy --fix"
+ "test": "cargo nextest run --all-targets --no-fail-fast",
+ "lint": "cargo fmt --check && cargo clippy --all-targets",
+ "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
},
"devDependencies": {
- "@tauri-apps/cli": "2.1.0"
+ "@tauri-apps/cli": "2.5.0"
},
"dependencies": {
"@modrinth/app-frontend": "workspace:*",
diff --git a/apps/app/src/api/jre.rs b/apps/app/src/api/jre.rs
index 036d5889b..71c72257c 100644
--- a/apps/app/src/api/jre.rs
+++ b/apps/app/src/api/jre.rs
@@ -41,8 +41,8 @@ pub async fn jre_find_filtered_jres(
// Validates JRE at a given path
// Returns None if the path is not a valid JRE
#[tauri::command]
-pub async fn jre_get_jre(path: PathBuf) -> Result
diff --git a/apps/frontend/src/components/ui/charts/Chart.client.vue b/apps/frontend/src/components/ui/charts/Chart.client.vue
index 9f0c636d5..9769c64b2 100644
--- a/apps/frontend/src/components/ui/charts/Chart.client.vue
+++ b/apps/frontend/src/components/ui/charts/Chart.client.vue
@@ -133,7 +133,7 @@ function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
props,
);
} else {
- const returnTopN = 5;
+ const returnTopN = 15;
const listEntries = series
.map((value, index) => [
diff --git a/apps/frontend/src/components/ui/charts/ChartDisplay.vue b/apps/frontend/src/components/ui/charts/ChartDisplay.vue
index a6ca889c1..39c93e98b 100644
--- a/apps/frontend/src/components/ui/charts/ChartDisplay.vue
+++ b/apps/frontend/src/components/ui/charts/ChartDisplay.vue
@@ -86,8 +86,8 @@
-
+
Hidden
+ >Other
{{ countryCodeToName(name) }}
{{ formatNumber(count) }}
@@ -256,11 +256,11 @@
>
-
+
- Hidden
+ Other
{{ countryCodeToName(name) }}
{{ formatNumber(count) }}
diff --git a/apps/frontend/src/components/ui/news/LatestNewsRow.vue b/apps/frontend/src/components/ui/news/LatestNewsRow.vue
new file mode 100644
index 000000000..198c5564e
--- /dev/null
+++ b/apps/frontend/src/components/ui/news/LatestNewsRow.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+ View all news
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/report/ReportInfo.vue b/apps/frontend/src/components/ui/report/ReportInfo.vue
index 313b1c12c..5e2b924c1 100644
--- a/apps/frontend/src/components/ui/report/ReportInfo.vue
+++ b/apps/frontend/src/components/ui/report/ReportInfo.vue
@@ -11,8 +11,8 @@
{{ report.project.title }}
{{
- $formatProjectType(
- $getProjectTypeForUrl(report.project.project_type, report.project.loaders),
+ formatProjectType(
+ getProjectTypeForUrl(report.project.project_type, report.project.loaders),
)
}}
@@ -31,7 +31,9 @@
-
+
+
+
Reported user not found:
@@ -40,7 +42,9 @@
:to="`/project/${report.project.slug}/version/${report.version.id}`"
class="iconified-link"
>
-
+
+
+
{{ report.version.name }}
of
@@ -49,15 +53,17 @@
{{ report.project.title }}
{{
- $formatProjectType(
- $getProjectTypeForUrl(report.project.project_type, report.project.loaders),
+ formatProjectType(
+ getProjectTypeForUrl(report.project.project_type, report.project.loaders),
)
}}
-
+
+
+
Unknown report type: {{ report.item_type }}
@@ -74,7 +80,8 @@
:auth="auth"
/>
-
Reported by
+
+ Reported by
you
{{
- fromNow(report.created)
+ formatRelativeTime(report.created)
}}
@@ -96,14 +103,14 @@
diff --git a/apps/frontend/src/components/ui/servers/BackupCreateModal.vue b/apps/frontend/src/components/ui/servers/BackupCreateModal.vue
index 31c3a6578..427142a58 100644
--- a/apps/frontend/src/components/ui/servers/BackupCreateModal.vue
+++ b/apps/frontend/src/components/ui/servers/BackupCreateModal.vue
@@ -1,29 +1,32 @@
-
Name
+
-
-
-
- If left empty, the backup name will default to
- Backup #{{ newBackupAmount }}
+
+
+
+ You already have a backup named '{{ trimmedName }}'
You're creating backups too fast. Please wait a moment before trying again.
-
+
-
+
Create backup
@@ -41,24 +44,32 @@
diff --git a/apps/frontend/src/components/ui/servers/BackupDeleteModal.vue b/apps/frontend/src/components/ui/servers/BackupDeleteModal.vue
index 44d0ac11e..35b5bfc78 100644
--- a/apps/frontend/src/components/ui/servers/BackupDeleteModal.vue
+++ b/apps/frontend/src/components/ui/servers/BackupDeleteModal.vue
@@ -1,86 +1,41 @@
-
-
-
-
-
-
- {{ formattedDate }}
-
-
-
-
-
-
-
- Delete backup
-
-
-
- Cancel
-
-
-
+
+
+
diff --git a/apps/frontend/src/components/ui/servers/BackupItem.vue b/apps/frontend/src/components/ui/servers/BackupItem.vue
new file mode 100644
index 000000000..de3af15da
--- /dev/null
+++ b/apps/frontend/src/components/ui/servers/BackupItem.vue
@@ -0,0 +1,345 @@
+
+
+
+
+
+
+
+
+
+ {{ backup.name }}
+
+
+
+ {{ formatMessage(messages.locked) }}
+
+ •
+
+ {{ formatMessage(messages.automated) }}
+
+ •
+
+
+ {{
+ formatMessage(
+ failedToCreate
+ ? messages.failedToCreateBackup
+ : failedToRestore
+ ? messages.failedToRestoreBackup
+ : messages.failedToPrepareFile,
+ )
+ }}
+
+
+
+
+
+ {{ formatMessage(messages.queuedForBackup) }}
+
+
{{ formatMessage(messages.creatingBackup) }}
+
+
+
+ {{ formatMessage(messages.restoringBackup) }}
+
+
+
+
+ {{ dayjs(backup.created_at).format("MMMM D, YYYY [at] h:mm A") }}
+
+ {{ 245 }} MiB
+
+
+
+
+ emit('retry')">
+
+ {{ formatMessage(messages.retry) }}
+
+
+
+ emit('delete', true)">
+
+ Remove
+
+
+
+
+ emit('delete')">
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+ emit('download')"
+ >
+
+ {{ formatMessage(commonMessages.downloadButton) }}
+
+ {
+ initiatedPrepare = true;
+ emit('prepare');
+ }
+ "
+ >
+
+
+ {{
+ formatMessage(
+ preparingFile
+ ? messages.preparingDownload
+ : failedToPrepareFile
+ ? messages.prepareDownloadAgain
+ : messages.prepareDownload,
+ )
+ }}
+
+
+
+
+
+ {{ formatMessage(messages.rename) }}
+ {{ formatMessage(messages.restore) }}
+
+ {{ formatMessage(messages.unlock) }}
+
+ {{ formatMessage(messages.lock) }}
+
+ {{ formatMessage(commonMessages.deleteLabel) }}
+
+
+
+
+
+
{{ backup }}
+
+
diff --git a/apps/frontend/src/components/ui/servers/BackupRenameModal.vue b/apps/frontend/src/components/ui/servers/BackupRenameModal.vue
index f1ab41e2f..d5db4ba32 100644
--- a/apps/frontend/src/components/ui/servers/BackupRenameModal.vue
+++ b/apps/frontend/src/components/ui/servers/BackupRenameModal.vue
@@ -1,24 +1,41 @@
-
Name
+
+
+
+
+ You already have a backup named '{{ trimmedName }}'
+
+
-
+
-
-
- Rename backup
+
+
+
+ Renaming...
+
+
+
+ Save changes
+
-
+
Cancel
@@ -28,23 +45,39 @@
diff --git a/apps/frontend/src/components/ui/servers/BackupRestoreModal.vue b/apps/frontend/src/components/ui/servers/BackupRestoreModal.vue
index 9439ecb2e..b77c5f4c7 100644
--- a/apps/frontend/src/components/ui/servers/BackupRestoreModal.vue
+++ b/apps/frontend/src/components/ui/servers/BackupRestoreModal.vue
@@ -1,82 +1,59 @@
-
-
-
-
-
-
- {{ formattedDate }}
-
-
-
-
-
- Restore backup
-
-
- Cancel
-
-
-
+
+
+
diff --git a/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue b/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue
index a92c8b618..fe4c32bbc 100644
--- a/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue
+++ b/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue
@@ -59,10 +59,10 @@
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { XIcon, SaveIcon } from "@modrinth/assets";
import { ref, computed } from "vue";
-import type { Server } from "~/composables/pyroServers";
+import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<{
- server: Server<["backups"]>;
+ server: ModrinthServer;
}>();
const modal = ref>();
diff --git a/apps/frontend/src/components/ui/servers/ContentVersionEditModal.vue b/apps/frontend/src/components/ui/servers/ContentVersionEditModal.vue
index 5ea0a9a0f..b142bb814 100644
--- a/apps/frontend/src/components/ui/servers/ContentVersionEditModal.vue
+++ b/apps/frontend/src/components/ui/servers/ContentVersionEditModal.vue
@@ -2,7 +2,7 @@
@@ -155,9 +155,11 @@
class="w-full"
:disabled="gameVersions.length < 2 && platforms.length < 2"
@click="
- versionFilter = !versionFilter;
- setInitialFilters();
- updateFiltersToUi();
+ () => {
+ versionFilter = !versionFilter;
+ setInitialFilters();
+ updateFiltersToUi();
+ }
"
>
@@ -183,7 +185,7 @@
Something went wrong trying to load versions for this {{ type.toLocaleLowerCase() }}.
Please try again later or contact support if the issue persists.
-
+
@@ -234,10 +236,10 @@ import {
GameIcon,
ExternalIcon,
} from "@modrinth/assets";
-import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
+import { Admonition, Avatar, ButtonStyled, CopyCode, 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 { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils";
import Accordion from "~/components/ui/Accordion.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import ContentVersionFilter, {
diff --git a/apps/frontend/src/components/ui/servers/FileItem.vue b/apps/frontend/src/components/ui/servers/FileItem.vue
index 6d269d8f7..5889503b6 100644
--- a/apps/frontend/src/components/ui/servers/FileItem.vue
+++ b/apps/frontend/src/components/ui/servers/FileItem.vue
@@ -53,6 +53,7 @@
+ Extract
Rename
Move
Download
@@ -73,6 +74,8 @@ import {
FolderOpenIcon,
FileIcon,
RightArrowIcon,
+ PackageOpenIcon,
+ FileArchiveIcon,
} from "@modrinth/assets";
import { computed, shallowRef, ref } from "vue";
import { renderToString } from "vue/server-renderer";
@@ -99,15 +102,14 @@ interface FileItemProps {
const props = defineProps();
const emit = defineEmits<{
- (e: "rename", item: { name: string; type: string; path: string }): void;
- (e: "move", item: { name: string; type: string; path: string }): void;
+ (
+ e: "rename" | "move" | "download" | "delete" | "edit" | "extract",
+ item: { name: string; type: string; path: string },
+ ): void;
(
e: "moveDirectTo",
item: { name: string; type: string; path: string; destination: string },
): void;
- (e: "download", item: { name: string; type: string; path: string }): void;
- (e: "delete", item: { name: string; type: string; path: string }): void;
- (e: "edit", item: { name: string; type: string; path: string }): void;
(e: "contextmenu", x: number, y: number): void;
}>();
@@ -143,6 +145,7 @@ const codeExtensions = Object.freeze([
const textExtensions = Object.freeze(["txt", "md", "log", "cfg", "conf", "properties", "ini"]);
const imageExtensions = Object.freeze(["png", "jpg", "jpeg", "gif", "svg", "webp"]);
+const supportedArchiveExtensions = Object.freeze(["zip"]);
const units = Object.freeze(["B", "KB", "MB", "GB", "TB", "PB", "EB"]);
const route = shallowRef(useRoute());
@@ -156,7 +159,18 @@ const containerClasses = computed(() => [
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
+const isZip = computed(() => fileExtension.value === "zip");
+
const menuOptions = computed(() => [
+ {
+ id: "extract",
+ shown: isZip.value,
+ action: () => emit("extract", { name: props.name, type: props.type, path: props.path }),
+ },
+ {
+ divider: true,
+ shown: isZip.value,
+ },
{
id: "rename",
action: () => emit("rename", { name: props.name, type: props.type, path: props.path }),
@@ -189,6 +203,7 @@ const iconComponent = computed(() => {
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon;
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
+ if (supportedArchiveExtensions.includes(ext)) return FileArchiveIcon;
return FileIcon;
});
diff --git a/apps/frontend/src/components/ui/servers/FileVirtualList.vue b/apps/frontend/src/components/ui/servers/FileVirtualList.vue
index 56125d76b..ecc38ce75 100644
--- a/apps/frontend/src/components/ui/servers/FileVirtualList.vue
+++ b/apps/frontend/src/components/ui/servers/FileVirtualList.vue
@@ -30,6 +30,7 @@
:size="item.size"
@delete="$emit('delete', item)"
@rename="$emit('rename', item)"
+ @extract="$emit('extract', item)"
@download="$emit('download', item)"
@move="$emit('move', item)"
@move-direct-to="$emit('moveDirectTo', $event)"
@@ -49,14 +50,12 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
- (e: "delete", item: any): void;
- (e: "rename", item: any): void;
- (e: "download", item: any): void;
- (e: "move", item: any): void;
- (e: "edit", item: any): void;
+ (
+ e: "delete" | "rename" | "download" | "move" | "edit" | "moveDirectTo" | "extract",
+ item: any,
+ ): void;
(e: "contextmenu", item: any, x: number, y: number): void;
(e: "loadMore"): void;
- (e: "moveDirectTo", item: any): void;
}>();
const ITEM_HEIGHT = 61;
diff --git a/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue b/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue
index c2e5e70a4..5f9f1afea 100644
--- a/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue
+++ b/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue
@@ -117,7 +117,8 @@
- $emit('create', 'file') },
{ id: 'directory', action: () => $emit('create', 'directory') },
{ id: 'upload', action: () => $emit('upload') },
+ { divider: true },
+ { id: 'upload-zip', shown: false, action: () => $emit('upload-zip') },
+ { id: 'install-from-url', action: () => $emit('unzip-from-url', false) },
+ { id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
]"
>
@@ -132,7 +137,16 @@
New file
New folder
Upload file
-
+
+ Upload from .zip file
+
+
+ Upload from .zip URL
+
+
+ Install CurseForge pack
+
+
@@ -140,6 +154,9 @@
diff --git a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue b/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
index 4841ea53c..001b6624a 100644
--- a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
+++ b/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
@@ -1,108 +1,125 @@
-
-
-
-
-
-
-
-
- {{ props.fileType ? props.fileType : "File" }} Uploads
+
+
+
+
+
+
+
+
+
+ {{ props.fileType ? props.fileType : "File" }} uploads
+
+ {{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}
- {{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}
-
-
-
-
-
-
-
-
-
-
-
-
- {{ item.file.name }}
- {{ item.size }}
-
-
- Done
-
-
- Failed - File already exists
-
-
- Failed - Incorrect file type
-
-
-
- {{ item.progress }}%
-
-
- Cancel
-
+
+
+
+
+
+
+
+
+
+
+ {{ item.file.name }}
+ {{ item.size }}
+
+
+
+ Done
-
- Cancelled
+
+ Failed - File already exists
+
+
+ Failed - {{ item.error?.message || "An unexpected error occured." }}
+
+
+ Failed - Incorrect file type
- {{ item.progress }}%
-
+
+ {{ item.progress }}%
+
+
+ Cancel
+
+
+
+ Cancelled
+
+
+ {{ item.progress }}%
+
+
-
+
-
-
+
+
diff --git a/apps/frontend/src/components/ui/servers/LoaderSelector.vue b/apps/frontend/src/components/ui/servers/LoaderSelector.vue
index ec37299a3..b5f526555 100644
--- a/apps/frontend/src/components/ui/servers/LoaderSelector.vue
+++ b/apps/frontend/src/components/ui/servers/LoaderSelector.vue
@@ -63,6 +63,7 @@ const props = defineProps<{
loader: string | null;
loader_version: string | null;
};
+ ignoreCurrentInstallation?: boolean;
isInstalling?: boolean;
}>();
diff --git a/apps/frontend/src/components/ui/servers/MOTDEditor.vue b/apps/frontend/src/components/ui/servers/MOTDEditor.vue
deleted file mode 100644
index aa4c5d0e8..000000000
--- a/apps/frontend/src/components/ui/servers/MOTDEditor.vue
+++ /dev/null
@@ -1,660 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/OverviewLoading.vue b/apps/frontend/src/components/ui/servers/OverviewLoading.vue
deleted file mode 100644
index a3d5135f1..000000000
--- a/apps/frontend/src/components/ui/servers/OverviewLoading.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-
-
-
-
-
-
-
-
-
/ 100%
-
-
CPU usage
-
-
-
-
-
-
-
-
/ 100%
-
-
Memory usage
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue b/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue
index 073909fd3..8b1632cd7 100644
--- a/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue
+++ b/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue
@@ -42,6 +42,9 @@
:column="true"
class="mb-6 flex flex-col gap-2"
/>
+
Close
@@ -56,7 +59,7 @@
-
+
{{ isStoppingState ? "Stopping..." : "Stop" }}
@@ -89,6 +92,10 @@
Details
+
+
+ Copy ID
+
@@ -108,16 +115,17 @@ import {
ServerIcon,
InfoIcon,
MoreVerticalIcon,
+ ClipboardCopyIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { useRouter } from "vue-router";
import { useStorage } from "@vueuse/core";
+import type { PowerAction as ServerPowerAction, ServerState } from "@modrinth/utils";
-type ServerAction = "start" | "stop" | "restart" | "kill";
-type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
+const flags = useFeatureFlags();
interface PowerAction {
- action: ServerAction;
+ action: ServerPowerAction;
nextState: ServerState;
}
@@ -132,7 +140,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
- (e: "action", action: ServerAction): void;
+ (e: "action", action: ServerPowerAction): void;
}>();
const router = useRouter();
@@ -160,7 +168,7 @@ const isStoppingState = computed(() => serverState.value === "stopping");
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
const primaryActionText = computed(() => {
- const states: Record
= {
+ const states: Partial> = {
starting: "Starting...",
restarting: "Restarting...",
running: "Restart",
@@ -183,7 +191,7 @@ const menuOptions = computed(() => [
id: "kill",
label: "Kill server",
icon: SlashIcon,
- action: () => initiateAction("kill"),
+ action: () => initiateAction("Kill"),
},
]),
{
@@ -198,19 +206,30 @@ const menuOptions = computed(() => [
icon: InfoIcon,
action: () => detailsModal.value?.show(),
},
+ {
+ id: "copy-id",
+ label: "Copy ID",
+ icon: ClipboardCopyIcon,
+ action: () => copyId(),
+ shown: flags.value.developerMode,
+ },
]);
-function initiateAction(action: ServerAction) {
+async function copyId() {
+ await navigator.clipboard.writeText(serverId as string);
+}
+
+function initiateAction(action: ServerPowerAction) {
if (!canTakeAction.value) return;
- const stateMap: Record = {
- start: "starting",
- stop: "stopping",
- restart: "restarting",
- kill: "stopping",
+ const stateMap: Record = {
+ Start: "starting",
+ Stop: "stopping",
+ Restart: "restarting",
+ Kill: "stopping",
};
- if (action === "start") {
+ if (action === "Start") {
emit("action", action);
serverState.value = stateMap[action];
startingDelay.value = true;
@@ -228,7 +247,7 @@ function initiateAction(action: ServerAction) {
}
function handlePrimaryAction() {
- initiateAction(isRunning.value ? "restart" : "start");
+ initiateAction(isRunning.value ? "Restart" : "Start");
}
function executePowerAction() {
@@ -242,7 +261,7 @@ function executePowerAction() {
userPreferences.value.powerDontAskAgain = true;
}
- if (action === "start") {
+ if (action === "Start") {
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
}
diff --git a/apps/frontend/src/components/ui/servers/PanelServerStatus.vue b/apps/frontend/src/components/ui/servers/PanelServerStatus.vue
index 9f6674b0b..fb7a1fcf7 100644
--- a/apps/frontend/src/components/ui/servers/PanelServerStatus.vue
+++ b/apps/frontend/src/components/ui/servers/PanelServerStatus.vue
@@ -40,7 +40,7 @@
diff --git a/apps/frontend/src/components/ui/servers/SaveBanner.vue b/apps/frontend/src/components/ui/servers/SaveBanner.vue
index 65508267e..13835f39b 100644
--- a/apps/frontend/src/components/ui/servers/SaveBanner.vue
+++ b/apps/frontend/src/components/ui/servers/SaveBanner.vue
@@ -31,7 +31,7 @@
+
+
diff --git a/apps/frontend/src/components/ui/servers/ServerListing.vue b/apps/frontend/src/components/ui/servers/ServerListing.vue
index a8bfeece4..b82227512 100644
--- a/apps/frontend/src/components/ui/servers/ServerListing.vue
+++ b/apps/frontend/src/components/ui/servers/ServerListing.vue
@@ -33,7 +33,7 @@
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
>
-
+
+ New server
+
-
- You recently requested support for your server and we are actively working on it. It will be
- back online shortly.
-
-
Your server has been suspended. Please
update your billing information or contact Modrinth Support for more information.
-
+
diff --git a/apps/frontend/src/components/ui/servers/ServerSidebar.vue b/apps/frontend/src/components/ui/servers/ServerSidebar.vue
index 8c52c6c02..c3edaab68 100644
--- a/apps/frontend/src/components/ui/servers/ServerSidebar.vue
+++ b/apps/frontend/src/components/ui/servers/ServerSidebar.vue
@@ -21,7 +21,12 @@
-
+
@@ -29,14 +34,16 @@
diff --git a/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue b/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue
index 284f432bf..da6ac622d 100644
--- a/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue
+++ b/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue
@@ -32,68 +32,68 @@
@mousedown.stop
@mouseleave="handleMouseLeave"
>
-
-
- {{ option.id }}
-
-
- {{ option.id }}
-
-
- {{ option.id }}
-
-
- {{ option.id }}
-
-
+
+
+
+ {{ option.id }}
+
+
+ {{ option.id }}
+
+
+ {{ option.id }}
+
+
+ {{ option.id }}
+
+
+
@@ -112,9 +112,20 @@ interface Option {
color?: "standard" | "brand" | "red" | "orange" | "green" | "blue" | "purple";
}
+type Divider = {
+ divider: true;
+ shown?: boolean;
+};
+
+type Item = Option | Divider;
+
+function isDivider(item: Item): item is Divider {
+ return (item as Divider).divider;
+}
+
const props = withDefaults(
defineProps<{
- options: Option[];
+ options: Item[];
hoverable?: boolean;
}>(),
{
@@ -338,7 +349,9 @@ const handleKeydown = (event: KeyboardEvent) => {
case " ":
event.preventDefault();
if (selectedIndex.value >= 0) {
- selectOption(filteredOptions.value[selectedIndex.value]);
+ const option = filteredOptions.value[selectedIndex.value];
+ if (isDivider(option)) break;
+ selectOption(option);
}
break;
case "Escape":
@@ -361,8 +374,9 @@ const handleKeydown = (event: KeyboardEvent) => {
default:
if (event.key.length === 1) {
typeAheadBuffer.value += event.key.toLowerCase();
- const matchIndex = filteredOptions.value.findIndex((option) =>
- option.id.toLowerCase().startsWith(typeAheadBuffer.value),
+ const matchIndex = filteredOptions.value.findIndex(
+ (option) =>
+ !isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
);
if (matchIndex !== -1) {
selectedIndex.value = matchIndex;
diff --git a/apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue b/apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue
index 0ae4a3ab7..5e53075a7 100644
--- a/apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue
+++ b/apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue
@@ -224,7 +224,7 @@
+
+
+
+
+
+
+
{{ formatMessage(plans[plan].name) }}
+
+ Most popular
+
+
+
+ {{ formatPrice(locale, price / billingMonths, currency, true) }}
+ {{ isUsa ? "" : currency }}
+
+ / month, billed {{ interval }}
+
+
+
{{ formatMessage(plans[plan].description) }}
+
+
+ Out of Stock
+ emit('select')">Select plan
+
+
emit('scroll-to-faq')"
+ />
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/servers/notice/AssignNoticeModal.vue b/apps/frontend/src/components/ui/servers/notice/AssignNoticeModal.vue
new file mode 100644
index 000000000..4b10b7a43
--- /dev/null
+++ b/apps/frontend/src/components/ui/servers/notice/AssignNoticeModal.vue
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ assignedServers.length }} servers
+
+
+
+ {{ server.id }}
+
+
+
+
No servers assigned yet
+
+
+
+
+ {{ assignedNodes.length }} nodes
+
+
+
+ {{ node.id }}
+
+
+
+
No nodes assigned yet
+
+
+
+
+ assign(true)">
+
+ Add server
+
+
+
+ assign(false)">
+
+ Add node
+
+
+
+ unassignDetect()">
+
+ Remove
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/servers/notice/NoticeDashboardItem.vue b/apps/frontend/src/components/ui/servers/notice/NoticeDashboardItem.vue
new file mode 100644
index 000000000..e76cb7233
--- /dev/null
+++ b/apps/frontend/src/components/ui/servers/notice/NoticeDashboardItem.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+ {{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
+ formatRelativeTime(notice.announce_at)
+ }})
+
+ Never begins
+
+
+
+ {{ formatRelativeTime(notice.expires) }}
+
+ Never expires
+
+
+
+ {{
+ NOTICE_LEVELS[notice.level]
+ ? formatMessage(NOTICE_LEVELS[notice.level].name)
+ : notice.level
+ }}
+
+
+
+
+ {{ formatMessage(getDismissableMetadata(notice.dismissable).name) }}
+
+
+
+
+ startEditing(notice)">
+ {{ formatMessage(commonMessages.editButton) }}
+
+
+
+ deleteNotice(notice)">
+ {{ formatMessage(commonMessages.deleteLabel) }}
+
+
+
+
+
+
+
+ Not assigned to any servers
+
+ Assigned to
+ {{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
+
+
+ Assigned to
+ {{ notice.assigned.filter((n) => n.kind === "server").length }} servers
+
+
+ Assigned to
+ {{ notice.assigned.filter((n) => n.kind === "server").length }} servers and
+ {{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
+
+ •
+ startEditing(notice, true)"
+ >
+
+ Edit assignments
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/thread/ConversationThread.vue b/apps/frontend/src/components/ui/thread/ConversationThread.vue
index 450b9ac67..9109822e0 100644
--- a/apps/frontend/src/components/ui/thread/ConversationThread.vue
+++ b/apps/frontend/src/components/ui/thread/ConversationThread.vue
@@ -28,13 +28,15 @@
:disabled="!submissionConfirmation"
@click="resubmit()"
>
-
Resubmit for review
+
+ Resubmit for review
- Thread ID:
+ Thread ID:
+
This thread is closed and new messages cannot be sent to it.
- Reopen thread
+
+ Reopen thread
@@ -70,10 +73,12 @@
:disabled="!replyBody"
@click="sendReply()"
>
- Reply
+
+ Reply
- Send
+
+ Send
- Add private note
+
+ Add private note
@@ -90,14 +96,16 @@
class="iconified-button moderation-button"
@click="openResubmitModal(true)"
>
- Resubmit for review with reply
+
+ Resubmit for review with reply
- Resubmit for review
+
+ Resubmit for review
@@ -110,10 +118,12 @@
class="iconified-button danger-button"
@click="closeReport(true)"
>
- Close with reply
+
+ Close with reply
- Close thread
+
+ Close thread
@@ -125,7 +135,8 @@
:disabled="isApproved(project)"
@click="sendReply(requestedStatus)"
>
- Approve with reply
+
+ Approve with reply
- Approve
+
+ Approve
- Reject with reply
+
+ Reject with reply
- Reject
+
+ Reject
- Withhold with reply
+
+ Withhold with reply
+
+
+
+ Withhold
- Withhold
@@ -196,17 +214,18 @@
diff --git a/apps/frontend/src/helpers/fileUtils.js b/apps/frontend/src/helpers/fileUtils.js
index 5d21ee0b2..3f97618a8 100644
--- a/apps/frontend/src/helpers/fileUtils.js
+++ b/apps/frontend/src/helpers/fileUtils.js
@@ -1,4 +1,4 @@
-import { formatBytes } from "~/plugins/shorthands.js";
+import { formatBytes } from "@modrinth/utils";
export const fileIsValid = (file, validationOptions) => {
const { maxSize, alertOnInvalid } = validationOptions;
diff --git a/apps/frontend/src/helpers/notifications.js b/apps/frontend/src/helpers/notifications.js
deleted file mode 100644
index e499e9e2e..000000000
--- a/apps/frontend/src/helpers/notifications.js
+++ /dev/null
@@ -1,170 +0,0 @@
-import { useNuxtApp } from "#imports";
-
-async function getBulk(type, ids, apiVersion = 2) {
- if (ids.length === 0) {
- return [];
- }
-
- const url = `${type}?ids=${encodeURIComponent(JSON.stringify([...new Set(ids)]))}`;
- return await useBaseFetch(url, { apiVersion });
-}
-
-export async function fetchExtraNotificationData(notifications) {
- const bulk = {
- projects: [],
- reports: [],
- threads: [],
- users: [],
- versions: [],
- organizations: [],
- };
-
- for (const notification of notifications) {
- if (notification.body) {
- if (notification.body.project_id) {
- bulk.projects.push(notification.body.project_id);
- }
- if (notification.body.version_id) {
- bulk.versions.push(notification.body.version_id);
- }
- if (notification.body.report_id) {
- bulk.reports.push(notification.body.report_id);
- }
- if (notification.body.thread_id) {
- bulk.threads.push(notification.body.thread_id);
- }
- if (notification.body.invited_by) {
- bulk.users.push(notification.body.invited_by);
- }
- if (notification.body.organization_id) {
- bulk.organizations.push(notification.body.organization_id);
- }
- }
- }
-
- const reports = await getBulk("reports", bulk.reports);
- for (const report of reports) {
- if (report.item_type === "project") {
- bulk.projects.push(report.item_id);
- } else if (report.item_type === "user") {
- bulk.users.push(report.item_id);
- } else if (report.item_type === "version") {
- bulk.versions.push(report.item_id);
- }
- }
- const versions = await getBulk("versions", bulk.versions);
- for (const version of versions) {
- bulk.projects.push(version.project_id);
- }
- const [projects, threads, users, organizations] = await Promise.all([
- getBulk("projects", bulk.projects),
- getBulk("threads", bulk.threads),
- getBulk("users", bulk.users),
- getBulk("organizations", bulk.organizations, 3),
- ]);
- for (const notification of notifications) {
- notification.extra_data = {};
- if (notification.body) {
- if (notification.body.project_id) {
- notification.extra_data.project = projects.find(
- (x) => x.id === notification.body.project_id,
- );
- }
- if (notification.body.organization_id) {
- notification.extra_data.organization = organizations.find(
- (x) => x.id === notification.body.organization_id,
- );
- }
- if (notification.body.report_id) {
- notification.extra_data.report = reports.find((x) => x.id === notification.body.report_id);
-
- const type = notification.extra_data.report.item_type;
- if (type === "project") {
- notification.extra_data.project = projects.find(
- (x) => x.id === notification.extra_data.report.item_id,
- );
- } else if (type === "user") {
- notification.extra_data.user = users.find(
- (x) => x.id === notification.extra_data.report.item_id,
- );
- } else if (type === "version") {
- notification.extra_data.version = versions.find(
- (x) => x.id === notification.extra_data.report.item_id,
- );
- notification.extra_data.project = projects.find(
- (x) => x.id === notification.extra_data.version.project_id,
- );
- }
- }
- if (notification.body.thread_id) {
- notification.extra_data.thread = threads.find((x) => x.id === notification.body.thread_id);
- }
- if (notification.body.invited_by) {
- notification.extra_data.invited_by = users.find(
- (x) => x.id === notification.body.invited_by,
- );
- }
- if (notification.body.version_id) {
- notification.extra_data.version = versions.find(
- (x) => x.id === notification.body.version_id,
- );
- }
- }
- }
- return notifications;
-}
-
-export function groupNotifications(notifications) {
- const grouped = [];
-
- for (let i = 0; i < notifications.length; i++) {
- const current = notifications[i];
- const next = notifications[i + 1];
- if (current.body && i < notifications.length - 1 && isSimilar(current, next)) {
- current.grouped_notifs = [next];
-
- let j = i + 2;
- while (j < notifications.length && isSimilar(current, notifications[j])) {
- current.grouped_notifs.push(notifications[j]);
- j++;
- }
-
- grouped.push(current);
- i = j - 1; // skip i to the last ungrouped
- } else {
- grouped.push(current);
- }
- }
-
- return grouped;
-}
-
-function isSimilar(notifA, notifB) {
- return !!notifA.body.project_id && notifA.body.project_id === notifB.body.project_id;
-}
-
-export async function markAsRead(ids) {
- try {
- await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {
- method: "PATCH",
- });
- return (notifications) => {
- const newNotifs = notifications;
- newNotifs.forEach((notif) => {
- if (ids.includes(notif.id)) {
- notif.read = true;
- }
- });
- return newNotifs;
- };
- } catch (err) {
- const app = useNuxtApp();
- app.$notify({
- group: "main",
- title: "Error marking notification as read",
- text: err.data ? err.data.description : err,
- type: "error",
- });
- return () => {};
- }
-}
diff --git a/apps/frontend/src/helpers/notifications.ts b/apps/frontend/src/helpers/notifications.ts
new file mode 100644
index 000000000..789a07d30
--- /dev/null
+++ b/apps/frontend/src/helpers/notifications.ts
@@ -0,0 +1,185 @@
+import { useNuxtApp } from "#imports";
+
+// TODO: There needs to be a standardized way to get these types, eg; @modrinth/types generated from api schema. Later problem.
+type Project = { id: string };
+type Version = { id: string; project_id: string };
+type Report = { id: string; item_type: "project" | "user" | "version"; item_id: string };
+type Thread = { id: string };
+type User = { id: string };
+type Organization = { id: string };
+
+export type NotificationAction = {
+ title: string;
+ action_route: [string, string];
+};
+
+export type NotificationBody = {
+ project_id?: string;
+ version_id?: string;
+ report_id?: string;
+ thread_id?: string;
+ invited_by?: string;
+ organization_id?: string;
+};
+
+export type Notification = {
+ id: string;
+ user_id: string;
+ type: "project_update" | "team_invite" | "status_change" | "moderator_message";
+ title: string;
+ text: string;
+ link: string;
+ read: boolean;
+ created: string;
+ actions: NotificationAction[];
+ body?: NotificationBody;
+ extra_data?: Record;
+ grouped_notifs?: Notification[];
+};
+
+async function getBulk(
+ type: string,
+ ids: string[],
+ apiVersion = 2,
+): Promise {
+ if (!ids || ids.length === 0) {
+ return [];
+ }
+ const url = `${type}?ids=${encodeURIComponent(JSON.stringify([...new Set(ids)]))}`;
+ try {
+ const res = await useBaseFetch(url, { apiVersion });
+ return Array.isArray(res) ? res : [];
+ } catch {
+ return [];
+ }
+}
+
+export async function fetchExtraNotificationData(
+ notifications: Notification[],
+): Promise {
+ const bulk = {
+ projects: [] as string[],
+ reports: [] as string[],
+ threads: [] as string[],
+ users: [] as string[],
+ versions: [] as string[],
+ organizations: [] as string[],
+ };
+
+ for (const notification of notifications) {
+ if (notification.body) {
+ if (notification.body.project_id) bulk.projects.push(notification.body.project_id);
+ if (notification.body.version_id) bulk.versions.push(notification.body.version_id);
+ if (notification.body.report_id) bulk.reports.push(notification.body.report_id);
+ if (notification.body.thread_id) bulk.threads.push(notification.body.thread_id);
+ if (notification.body.invited_by) bulk.users.push(notification.body.invited_by);
+ if (notification.body.organization_id)
+ bulk.organizations.push(notification.body.organization_id);
+ }
+ }
+
+ const reports = (await getBulk("reports", bulk.reports)).filter(Boolean);
+ for (const r of reports) {
+ if (!r?.item_type) continue;
+ if (r.item_type === "project") bulk.projects.push(r.item_id);
+ else if (r.item_type === "user") bulk.users.push(r.item_id);
+ else if (r.item_type === "version") bulk.versions.push(r.item_id);
+ }
+
+ const versions = (await getBulk("versions", bulk.versions)).filter(Boolean);
+ for (const v of versions) bulk.projects.push(v.project_id);
+
+ const [projects, threads, users, organizations] = await Promise.all([
+ getBulk("projects", bulk.projects),
+ getBulk("threads", bulk.threads),
+ getBulk("users", bulk.users),
+ getBulk("organizations", bulk.organizations, 3),
+ ]);
+
+ for (const n of notifications) {
+ n.extra_data = {};
+ if (n.body) {
+ if (n.body.project_id)
+ n.extra_data.project = projects.find((x) => x.id === n.body!.project_id);
+ if (n.body.organization_id)
+ n.extra_data.organization = organizations.find((x) => x.id === n.body!.organization_id);
+ if (n.body.report_id) {
+ n.extra_data.report = reports.find((x) => x.id === n.body!.report_id);
+ const t = (n.extra_data.report as Report | undefined)?.item_type;
+ if (t === "project")
+ n.extra_data.project = projects.find(
+ (x) => x.id === (n.extra_data?.report as Report | undefined)?.item_id,
+ );
+ else if (t === "user")
+ n.extra_data.user = users.find(
+ (x) => x.id === (n.extra_data?.report as Report | undefined)?.item_id,
+ );
+ else if (t === "version") {
+ n.extra_data.version = versions.find(
+ (x) => x.id === (n.extra_data?.report as Report | undefined)?.item_id,
+ );
+ n.extra_data.project = projects.find(
+ (x) => x.id === (n.extra_data?.version as Version | undefined)?.project_id,
+ );
+ }
+ }
+ if (n.body.thread_id) n.extra_data.thread = threads.find((x) => x.id === n.body!.thread_id);
+ if (n.body.invited_by)
+ n.extra_data.invited_by = users.find((x) => x.id === n.body!.invited_by);
+ if (n.body.version_id)
+ n.extra_data.version = versions.find((x) => x.id === n.body!.version_id);
+ }
+ }
+ return notifications;
+}
+
+export function groupNotifications(notifications: Notification[]): Notification[] {
+ const grouped: Notification[] = [];
+ for (let i = 0; i < notifications.length; i++) {
+ const current = notifications[i];
+ const next = notifications[i + 1];
+ if (current.body && i < notifications.length - 1 && isSimilar(current, next)) {
+ current.grouped_notifs = [next];
+ let j = i + 2;
+ while (j < notifications.length && isSimilar(current, notifications[j])) {
+ current.grouped_notifs.push(notifications[j]);
+ j++;
+ }
+ grouped.push(current);
+ i = j - 1;
+ } else {
+ grouped.push(current);
+ }
+ }
+ return grouped;
+}
+
+function isSimilar(a: Notification, b: Notification | undefined): boolean {
+ return !!a?.body?.project_id && a.body!.project_id === b?.body?.project_id;
+}
+
+export async function markAsRead(
+ ids: string[],
+): Promise<(notifications: Notification[]) => Notification[]> {
+ try {
+ await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {
+ method: "PATCH",
+ });
+ return (notifications: Notification[]) => {
+ const newNotifs = notifications ?? [];
+ newNotifs.forEach((n) => {
+ if (ids.includes(n.id)) n.read = true;
+ });
+ return newNotifs;
+ };
+ } catch (err: any) {
+ const app: any = useNuxtApp();
+ app.$notify({
+ group: "main",
+ title: "Error marking notification as read",
+ text: err?.data?.description ?? err,
+ type: "error",
+ });
+ return () => [];
+ }
+}
diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue
index 1e2297b69..643765456 100644
--- a/apps/frontend/src/layouts/default.vue
+++ b/apps/frontend/src/layouts/default.vue
@@ -27,59 +27,90 @@
-
-
- {{ formatMessage(verifyEmailBannerMessages.title) }}
-
+
+
+ {{
+ auth?.user?.email
+ ? formatMessage(verifyEmailBannerMessages.title)
+ : formatMessage(addEmailBannerMessages.title)
+ }}
+
+
+
+
+ {{
+ auth?.user?.email
+ ? formatMessage(verifyEmailBannerMessages.description)
+ : formatMessage(addEmailBannerMessages.description)
+ }}
+
+
+
+
{{ formatMessage(verifyEmailBannerMessages.action) }}
-
-
- {{ formatMessage(addEmailBannerMessages.title) }}
-
+
{{ formatMessage(addEmailBannerMessages.action) }}
-
-
+
- {{ formatMessage(subscriptionPaymentFailedBannerMessages.title) }}
-
-
- {{ formatMessage(subscriptionPaymentFailedBannerMessages.action) }}
-
-
-
+
{{ formatMessage(subscriptionPaymentFailedBannerMessages.title) }}
+
+
+ {{ formatMessage(subscriptionPaymentFailedBannerMessages.description) }}
+
+
+
+
+ {{ formatMessage(subscriptionPaymentFailedBannerMessages.action) }}
+
+
+
+
-
-
+
{{ formatMessage(stagingBannerMessages.title) }}
-
-
+
+
{{ formatMessage(stagingBannerMessages.description) }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(failedToBuildBannerMessages.title) }}
+
+
+ {{
+ formatMessage(failedToBuildBannerMessages.description, {
+ errors: generatedStateErrors,
+ url: config.public.apiBaseUrl,
+ })
+ }}
+
+
@@ -111,7 +142,7 @@
"
>
- Resource Packs
+ Resource Packs
- Mods
- Resource Packs
+ Resource Packs
Data Packs
Plugins
@@ -281,6 +312,12 @@
link: '/admin/user_email',
shown: isAdmin(auth.user),
},
+ {
+ id: 'servers-notices',
+ color: 'primary',
+ link: '/admin/servers/notices',
+ shown: isAdmin(auth.user),
+ },
]"
>
@@ -288,6 +325,9 @@
Review projects
Reports
Lookup by email
+
+ Manage server notices
+
@@ -538,7 +578,7 @@
@@ -495,6 +505,64 @@
+
+
+ {
+ flags.showProjectPageCreateServersTooltip = false;
+ saveFeatureFlags();
+ }
+ "
+ >
+
+
+
+
+
+
+
+ Create a server
+ New
+
+
+ {
+ flags.showProjectPageCreateServersTooltip = false;
+ saveFeatureFlags();
+ }
+ "
+ >
+
+
+
+
+
+ Modrinth Servers is the easiest way to play with your friends without hassle!
+
+
+ Starting at $5 / month
+
+
+
+
copyId() },
+ { id: 'copy-permalink', action: () => copyPermalink() },
]"
aria-label="More options"
:dropdown-id="`${baseId}-more-options`"
@@ -659,6 +728,10 @@
Copy ID
+
+
+ Copy permanent link
+
@@ -689,12 +762,7 @@
:tags="tags"
class="card flex-card experimental-styles-within"
/>
-
+
useBaseFetch(`project/${route.params.id}`), {
@@ -1159,14 +1247,30 @@ try {
versions = shallowRef(toRaw(versions));
featuredVersions = shallowRef(toRaw(featuredVersions));
-} catch {
+} catch (err) {
throw createError({
fatal: true,
- statusCode: 404,
- message: "Project not found",
+ statusCode: err.statusCode ?? 500,
+ message: "Error loading project data" + (err.message ? `: ${err.message}` : ""),
});
}
+function handleError(err, project = false) {
+ if (err.value && err.value.statusCode) {
+ throw createError({
+ fatal: true,
+ statusCode: err.value.statusCode,
+ message: err.value.statusCode === 404 && project ? "Project not found" : err.value.message,
+ });
+ }
+}
+
+handleError(projectError, true);
+handleError(membersError);
+handleError(dependenciesError);
+handleError(featuredVersionsError);
+handleError(versionsError);
+
if (!project.value) {
throw createError({
fatal: true,
@@ -1258,7 +1362,7 @@ featuredVersions.value.sort((a, b) => {
});
const projectTypeDisplay = computed(() =>
- data.$formatProjectType(
+ formatProjectType(
data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders),
),
);
@@ -1276,6 +1380,10 @@ const description = computed(
} by ${members.value.find((x) => x.is_owner)?.user?.username || "a Creator"} on Modrinth`,
);
+const canCreateServerFrom = computed(() => {
+ return project.value.project_type === "modpack" && project.value.server_side !== "unsupported";
+});
+
if (!route.name.startsWith("type-id-settings")) {
useSeoMeta({
title: () => title.value,
@@ -1325,7 +1433,7 @@ async function setProcessing() {
data.$notify({
group: "main",
title: "An error occurred",
- text: err.data.description,
+ text: err.data ? err.data.description : err,
type: "error",
});
}
@@ -1368,7 +1476,7 @@ async function patchProject(resData, quiet = false) {
data.$notify({
group: "main",
title: "An error occurred",
- text: err.data.description,
+ text: err.data ? err.data.description : err,
type: "error",
});
window.scrollTo({ top: 0, behavior: "smooth" });
@@ -1405,7 +1513,7 @@ async function patchIcon(icon) {
data.$notify({
group: "main",
title: "An error occurred",
- text: err.data.description,
+ text: err.data ? err.data.description : err,
type: "error",
});
@@ -1437,6 +1545,10 @@ async function copyId() {
await navigator.clipboard.writeText(project.value.id);
}
+async function copyPermalink() {
+ await navigator.clipboard.writeText(`${config.public.siteUrl}/project/${project.value.id}`);
+}
+
const collapsedChecklist = ref(false);
const showModerationChecklist = ref(false);
@@ -1605,10 +1717,12 @@ const navLinks = computed(() => {
width: 25rem;
height: 25rem;
}
+
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
+
.animation-ring-3 {
width: 100rem;
height: 100rem;
@@ -1638,4 +1752,33 @@ const navLinks = computed(() => {
display: none;
}
}
+
+.servers-popup {
+ box-shadow:
+ 0 0 12px 1px rgba(0, 175, 92, 0.6),
+ var(--shadow-floating);
+
+ &::before {
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-bottom: 6px solid var(--color-button-bg);
+ content: " ";
+ position: absolute;
+ top: -7px;
+ left: 17px;
+ }
+ &::after {
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-bottom: 5px solid var(--color-raised-bg);
+ content: " ";
+ position: absolute;
+ top: -5px;
+ left: 18px;
+ }
+}
diff --git a/apps/frontend/src/pages/[type]/[id]/moderation.vue b/apps/frontend/src/pages/[type]/[id]/moderation.vue
index 86275b3a3..c7b9c3a22 100644
--- a/apps/frontend/src/pages/[type]/[id]/moderation.vue
+++ b/apps/frontend/src/pages/[type]/[id]/moderation.vue
@@ -50,7 +50,7 @@
Listed in search results
-
+
Not listed in search results
@@ -58,11 +58,11 @@
Listed on the profiles of members
-
+
Not listed on the profiles of members
-
+
Not accessible with a direct link
@@ -77,7 +77,7 @@
This is a private conversation thread with the Modrinth moderators. They may message you
with issues concerning this project. This thread is only checked when you submit your
project for review. For additional inquiries, contact
- Modrinth support.
+ Modrinth Support.
+
diff --git a/apps/frontend/src/pages/app.vue b/apps/frontend/src/pages/app.vue
index 01f07ba85..5fca6ca08 100644
--- a/apps/frontend/src/pages/app.vue
+++ b/apps/frontend/src/pages/app.vue
@@ -1,4 +1,4 @@
-
+
+
diff --git a/apps/frontend/src/pages/collection/[id].vue b/apps/frontend/src/pages/collection/[id].vue
index 6db09cb9c..6f522ee34 100644
--- a/apps/frontend/src/pages/collection/[id].vue
+++ b/apps/frontend/src/pages/collection/[id].vue
@@ -25,7 +25,8 @@
- {{ formatMessage(messages.editIconButton) }}
+
+ {{ formatMessage(messages.editIconButton) }}
-
+
-
+
+
@@ -379,6 +379,7 @@ import {
UpdatedIcon,
UploadIcon,
XIcon,
+ GlobeIcon,
} from "@modrinth/assets";
import {
Avatar,
@@ -388,10 +389,10 @@ import {
DropdownSelect,
FileInput,
PopoutMenu,
+ useRelativeTime,
} 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 NavRow from "~/components/ui/NavRow.vue";
@@ -489,7 +490,6 @@ const route = useNativeRoute();
const auth = await useAuth();
const cosmetics = useCosmetics();
const tags = useTags();
-const flags = useFeatureFlags();
const isEditing = ref(false);
@@ -695,7 +695,7 @@ async function deleteCollection() {
addNotification({
group: "main",
title: formatMessage(commonMessages.errorNotificationTitle),
- text: err.data.description,
+ text: err.data ? err.data.description : err,
type: "error",
});
}
diff --git a/apps/frontend/src/pages/dashboard/collections.vue b/apps/frontend/src/pages/dashboard/collections.vue
index 11c86c38e..a5201a331 100644
--- a/apps/frontend/src/pages/dashboard/collections.vue
+++ b/apps/frontend/src/pages/dashboard/collections.vue
@@ -17,7 +17,8 @@
$refs.modal_creation.show(event)">
- {{ formatMessage(messages.createNewButton) }}
+
+ {{ formatMessage(messages.createNewButton) }}
diff --git a/apps/frontend/src/pages/news/changelog.vue b/apps/frontend/src/pages/news/changelog.vue
index 9cfb0802b..ee4019663 100644
--- a/apps/frontend/src/pages/news/changelog.vue
+++ b/apps/frontend/src/pages/news/changelog.vue
@@ -6,6 +6,21 @@