You've already forked AstralRinth
Merge commit '8faea1663ae0c6d1190a5043054197b6a58019f3' into feature-clean
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||
[target.'cfg(windows)']
|
||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
|
||||
@@ -14,5 +14,5 @@ max_line_length = 100
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.rs]
|
||||
indent_size = 4
|
||||
[*.{rs,java,kts}]
|
||||
indent_size = 4
|
||||
|
||||
34
.gitattributes
vendored
34
.gitattributes
vendored
@@ -1 +1,35 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# SQLx calculates a checksum of migration scripts at build time to compare
|
||||
# it with the checksum of the applied migration for the same version at
|
||||
# runtime, to know if the migration script has been changed, and thus the
|
||||
# DB schema went out of sync with the code.
|
||||
#
|
||||
# However, such checksum treats the script as a raw byte stream, taking
|
||||
# into account inconsequential differences like different line endings
|
||||
# in different OSes. When combined with Git's EOL conversion and mixed
|
||||
# native and cross-compilation scenarios, this leads to existing
|
||||
# migrations that didn't change having potentially different checksums
|
||||
# according to the environment they were built in, which can break the
|
||||
# migration system when deploying the Modrinth App, rendering it
|
||||
# unusable.
|
||||
#
|
||||
# The gitattribute above ensures that all text files are checked out
|
||||
# with LF line endings, but widely deployed app versions were built
|
||||
# without this attribute set, which left such line endings variable to
|
||||
# the platform. Thus, there is no perfect solution to this problem:
|
||||
# forcing CRLF here would break Linux and macOS users, forcing LF
|
||||
# breaks Windows users, and leaving it unspecified may still lead to
|
||||
# line ending differences when cross-compiling from Linux to Windows
|
||||
# or vice versa, or having Git configured with different line
|
||||
# conversion settings. Moreover, there is no `eol=native` attribute,
|
||||
# and using CI-only scripts to convert line endings would make the
|
||||
# builds differ between CI and most local environments. So, let's pick
|
||||
# the least bad option: let Git handle line endings using its
|
||||
# configuration by leaving it unspecified, which works fine as long as
|
||||
# people don't mess with Git's line ending settings, which is the vast
|
||||
# majority of cases.
|
||||
/packages/app-lib/migrations/20240711194701_init.sql !eol
|
||||
/packages/app-lib/migrations/20240813205023_drop-active-unique.sql !eol
|
||||
/packages/app-lib/migrations/20240930001852_disable-personalized-ads.sql !eol
|
||||
/packages/app-lib/migrations/20241222013857_feature-flags.sql !eol
|
||||
|
||||
43
.github/workflows/theseus-release.yml
vendored
43
.github/workflows/theseus-release.yml
vendored
@@ -10,14 +10,18 @@ on:
|
||||
- .github/workflows/theseus-release.yml
|
||||
- 'apps/app/**'
|
||||
- 'apps/app-frontend/**'
|
||||
- 'apps/labrinth/src/common/**'
|
||||
- 'apps/labrinth/Cargo.toml'
|
||||
- 'packages/app-lib/**'
|
||||
- 'packages/app-macros/**'
|
||||
- 'packages/assets/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/utils/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sign-windows-binaries:
|
||||
description: Sign Windows binaries
|
||||
type: boolean
|
||||
default: true
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -104,26 +108,51 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev pkg-config libayatana-appindicator3-dev librsvg2-dev xdg-utils
|
||||
|
||||
- name: Install code signing client (Windows only)
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
run: choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Disable Windows code signing for non-final release builds
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && !startsWith(github.ref, 'refs/tags/v') && !inputs.sign-windows-binaries }}
|
||||
run: |
|
||||
jq 'del(.bundle.windows.signCommand)' apps/app/tauri-release.conf.json > apps/app/tauri-release.conf.json.new
|
||||
Move-Item -Path apps/app/tauri-release.conf.json.new -Destination apps/app/tauri-release.conf.json -Force
|
||||
|
||||
- name: build app (macos)
|
||||
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config "tauri-release.conf.json"
|
||||
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: build app
|
||||
run: pnpm --filter=@modrinth/app run tauri build --config "tauri-release.conf.json"
|
||||
id: build_os
|
||||
if: "!startsWith(matrix.platform, 'macos')"
|
||||
- name: build app (Linux)
|
||||
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: build app (Windows)
|
||||
run: |
|
||||
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
|
||||
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
||||
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
|
||||
Remove-Item -Path signer-client-cert.p12
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: upload ${{ matrix.platform }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
||||
2
.idea/code.iml
generated
2
.idea/code.iml
generated
@@ -17,4 +17,4 @@
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
</module>
|
||||
|
||||
2
.idea/modules.xml
generated
2
.idea/modules.xml
generated
@@ -5,4 +5,4 @@
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
789
Cargo.lock
generated
789
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
41
Cargo.toml
41
Cargo.toml
@@ -25,7 +25,7 @@ actix-ws = "0.3.0"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
ariadne = { path = "packages/ariadne" }
|
||||
async_zip = "0.0.17"
|
||||
async-compression = { version = "0.4.24", default-features = false }
|
||||
async-compression = { version = "0.4.25", default-features = false }
|
||||
async-recursion = "1.1.1"
|
||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
@@ -37,6 +37,7 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [
|
||||
async-walkdir = "2.1.0"
|
||||
base64 = "0.22.1"
|
||||
bitflags = "2.9.1"
|
||||
bytemuck = "1.23.0"
|
||||
bytes = "1.10.1"
|
||||
censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
@@ -47,6 +48,7 @@ color-thief = "0.2.2"
|
||||
console-subscriber = "0.4.1"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
dashmap = "6.1.0"
|
||||
data-url = "0.3.1"
|
||||
deadpool-redis = "0.21.1"
|
||||
dirs = "6.0.0"
|
||||
discord-rich-presence = "0.2.5"
|
||||
@@ -60,6 +62,8 @@ flate2 = "1.1.2"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
futures-util = "0.3.31"
|
||||
hashlink = "0.10.0"
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
@@ -89,6 +93,7 @@ notify = { version = "8.0.0", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||
p256 = "0.13.2"
|
||||
paste = "1.0.15"
|
||||
png = "0.17.16"
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
quick-xml = "0.37.5"
|
||||
@@ -96,8 +101,9 @@ rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
||||
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.19", default-features = false }
|
||||
rust_decimal = { version = "1.37.1", features = [
|
||||
reqwest = { version = "0.12.20", default-features = false }
|
||||
rgb = "0.8.50"
|
||||
rust_decimal = { version = "1.37.2", features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str",
|
||||
] }
|
||||
@@ -108,7 +114,7 @@ rust-s3 = { version = "0.35.1", default-features = false, features = [
|
||||
"tokio-rustls-tls",
|
||||
] }
|
||||
rusty-money = "0.4.1"
|
||||
sentry = { version = "0.38.1", default-features = false, features = [
|
||||
sentry = { version = "0.41.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
@@ -116,13 +122,13 @@ sentry = { version = "0.38.1", default-features = false, features = [
|
||||
"reqwest",
|
||||
"rustls",
|
||||
] }
|
||||
sentry-actix = "0.38.1"
|
||||
sentry-actix = "0.41.0"
|
||||
serde = "1.0.219"
|
||||
serde_bytes = "0.11.17"
|
||||
serde_cbor = "0.11.2"
|
||||
serde_ini = "0.2.0"
|
||||
serde_json = "1.0.140"
|
||||
serde_with = "3.12.0"
|
||||
serde_with = "3.13.0"
|
||||
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
||||
sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
@@ -131,18 +137,19 @@ spdx = "0.10.8"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
sysinfo = { version = "0.35.2", default-features = false }
|
||||
tar = "0.4.44"
|
||||
tauri = "2.5.1"
|
||||
tauri-build = "2.2.0"
|
||||
tauri-plugin-deep-link = "2.3.0"
|
||||
tauri-plugin-dialog = "2.2.2"
|
||||
tauri-plugin-opener = "2.2.7"
|
||||
tauri-plugin-os = "2.2.1"
|
||||
tauri-plugin-single-instance = "2.2.4"
|
||||
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
|
||||
tauri = "2.6.1"
|
||||
tauri-build = "2.3.0"
|
||||
tauri-plugin-deep-link = "2.4.0"
|
||||
tauri-plugin-dialog = "2.3.0"
|
||||
tauri-plugin-http = "2.5.0"
|
||||
tauri-plugin-opener = "2.4.0"
|
||||
tauri-plugin-os = "2.3.0"
|
||||
tauri-plugin-single-instance = "2.3.0"
|
||||
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"zip",
|
||||
] }
|
||||
tauri-plugin-window-state = "2.2.2"
|
||||
tauri-plugin-window-state = "2.3.0"
|
||||
tempfile = "3.20.0"
|
||||
theseus = { path = "packages/app-lib" }
|
||||
thiserror = "2.0.12"
|
||||
@@ -165,7 +172,7 @@ whoami = "1.6.0"
|
||||
winreg = "0.55.0"
|
||||
woothee = "0.13.0"
|
||||
yaserde = "0.12.0"
|
||||
zip = { version = "4.0.0", default-features = false, features = [
|
||||
zip = { version = "4.2.0", default-features = false, features = [
|
||||
"bzip2",
|
||||
"deflate",
|
||||
"deflate64",
|
||||
@@ -212,7 +219,7 @@ wildcard_dependencies = "warn"
|
||||
warnings = "deny"
|
||||
|
||||
[patch.crates-io]
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" }
|
||||
|
||||
# Optimize for speed and reduce size on release builds
|
||||
[profile.release]
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
**/dist
|
||||
*.gltf
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -20,16 +20,20 @@
|
||||
"@sentry/vue": "^8.27.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.6",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.7.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.2.2",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"floating-vue": "^5.2.2",
|
||||
"ofetch": "^1.3.4",
|
||||
"pinia": "^2.1.7",
|
||||
"posthog-js": "^1.158.2",
|
||||
"three": "^0.172.0",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-multiselect": "3.0.0",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ChangeSkinIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
HomeIcon,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
SettingsIcon,
|
||||
WorldIcon,
|
||||
XIcon,
|
||||
NewspaperIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
ButtonStyled,
|
||||
Notifications,
|
||||
OverflowMenu,
|
||||
useRelativeTime,
|
||||
NewsArticleCard,
|
||||
} from '@modrinth/ui'
|
||||
import { useLoading, useTheming } from '@/store/state'
|
||||
// import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||
@@ -62,14 +64,13 @@ import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { get_user } from '@/helpers/cache.js'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import dayjs from 'dayjs'
|
||||
// import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
// import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -187,31 +188,53 @@ async function setupApp() {
|
||||
}),
|
||||
)
|
||||
|
||||
//useFetch(
|
||||
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// 'criticalAnnouncements',
|
||||
// true,
|
||||
//)
|
||||
// .then((res) => {
|
||||
// if (res && res.header && res.body) {
|
||||
// criticalErrorMessage.value = res
|
||||
// }
|
||||
// })
|
||||
// .catch(() => {
|
||||
// console.log(
|
||||
// `No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// )
|
||||
// })
|
||||
// Patched by AstralRinth
|
||||
// useFetch(
|
||||
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// 'criticalAnnouncements',
|
||||
// true,
|
||||
// )
|
||||
// .then((response) => response.json())
|
||||
// .then((res) => {
|
||||
// if (res && res.header && res.body) {
|
||||
// criticalErrorMessage.value = res
|
||||
// }
|
||||
// })
|
||||
// .catch(() => {
|
||||
// console.log(
|
||||
// `No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// )
|
||||
// })
|
||||
|
||||
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
|
||||
if (res && res.articles) {
|
||||
news.value = res.articles
|
||||
}
|
||||
})
|
||||
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true)
|
||||
.then((response) => response.json())
|
||||
.then((res) => {
|
||||
if (res && res.articles) {
|
||||
// Format expected by NewsArticleCard component.
|
||||
news.value = res.articles
|
||||
.map((article) => ({
|
||||
...article,
|
||||
path: article.link,
|
||||
thumbnail: article.thumbnail,
|
||||
title: article.title,
|
||||
summary: article.summary,
|
||||
date: article.date,
|
||||
}))
|
||||
.slice(0, 4)
|
||||
}
|
||||
})
|
||||
|
||||
get_opening_command().then(handleCommand)
|
||||
// checkUpdates()
|
||||
fetchCredentials()
|
||||
|
||||
try {
|
||||
const skins = (await get_available_skins()) ?? []
|
||||
const capes = (await get_available_capes()) ?? []
|
||||
generateSkinPreviews(skins, capes)
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate skin previews in app setup.', error)
|
||||
}
|
||||
}
|
||||
|
||||
const stateFailed = ref(false)
|
||||
@@ -319,6 +342,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const accounts = ref(null)
|
||||
provide('accountsCard', accounts)
|
||||
|
||||
command_listener(handleCommand)
|
||||
async function handleCommand(e) {
|
||||
@@ -414,6 +438,9 @@ function handleAuxClick(e) {
|
||||
>
|
||||
<CompassIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Library'"
|
||||
to="/library"
|
||||
@@ -594,34 +621,20 @@ function handleAuxClick(e) {
|
||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||
</suspense>
|
||||
</div>
|
||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col">
|
||||
<h3 class="px-4 text-lg m-0">News</h3>
|
||||
<template v-for="(item, index) in news" :key="`news-${index}`">
|
||||
<a
|
||||
:class="`flex flex-col outline-offset-[-4px] hover:bg-[--brand-gradient-border] focus:bg-[--brand-gradient-border] px-4 transition-colors ${index === 0 ? 'pt-2 pb-4' : 'py-4'}`"
|
||||
:href="item.link"
|
||||
target="_blank"
|
||||
rel="external"
|
||||
>
|
||||
<img
|
||||
:src="item.thumbnail"
|
||||
alt="News thumbnail"
|
||||
aria-hidden="true"
|
||||
class="w-full aspect-[3/1] object-cover rounded-2xl border-[1px] border-solid border-[--brand-gradient-border]"
|
||||
/>
|
||||
<h4 class="mt-2 mb-0 text-sm leading-none text-contrast font-semibold">
|
||||
{{ item.title }}
|
||||
</h4>
|
||||
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
|
||||
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
|
||||
{{ formatRelativeTime(dayjs(item.date).toISOString()) }}
|
||||
</p>
|
||||
</a>
|
||||
<hr
|
||||
v-if="index !== news.length - 1"
|
||||
class="h-px my-[-2px] mx-4 border-0 m-0 bg-[--brand-gradient-border]"
|
||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
|
||||
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3>
|
||||
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full">
|
||||
<NewsArticleCard
|
||||
v-for="(item, index) in news"
|
||||
:key="`news-${index}`"
|
||||
:article="item"
|
||||
/>
|
||||
</template>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<a href="https://modrinth.com/news" target="_blank" class="my-4">
|
||||
<NewspaperIcon /> View all news
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1
apps/app-frontend/src/assets/models/cape.gltf
Normal file
1
apps/app-frontend/src/assets/models/cape.gltf
Normal file
@@ -0,0 +1 @@
|
||||
{"asset":{"version":"2.0","generator":"Blockbench 4.12.4 glTF exporter"},"scenes":[{"nodes":[1],"name":"blockbench_export"}],"scene":0,"nodes":[{"rotation":[0,0,0.19509032201612825,0.9807852804032304],"translation":[0.15625,1,0],"name":"Cape","mesh":0},{"children":[0]}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":288,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":576,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":768,"byteLength":72,"target":34963}],"buffers":[{"byteLength":840,"uri":"data:application/octet-stream;base64,AAAAPQAAAAAAAKA+AAAAPQAAAAAAAKC+AAAAPQAAgL8AAKA+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAvQAAgL8AAKC+AAAAvQAAgL8AAKA+AAAAvQAAAAAAAKC+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAvQAAgL8AAKC+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKC+AAAAPQAAgL8AAKC+AAAAvQAAgL8AAKC+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AACAPAAAgD0AADA+AACAPQAAgDwAAAg/AAAwPgAACD8AAEA+AACAPQAAsD4AAIA9AABAPgAACD8AALA+AAAIPwAAgDwAAAA9AACAPAAAgD0AADA+AAAAPQAAMD4AAIA9AAAwPgAAAD0AAKg+AAAAPQAAMD4AAAAAAACoPgAAAAAAAEA+AACAPQAAMD4AAIA9AABAPgAACD8AADA+AAAIPwAAAAAAAIA9AACAPAAAgD0AAAAAAAAIPwAAgDwAAAg/AAACAAEAAgADAAEABAAGAAUABgAHAAUACAAKAAkACgALAAkADAAOAA0ADgAPAA0AEAASABEAEgATABEAFAAWABUAFgAXABUA"}],"accessors":[{"bufferView":0,"componentType":5126,"count":24,"max":[0.03125,0,0.3125],"min":[-0.03125,-1,-0.3125],"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":24,"max":[1,1,1],"min":[-1,-1,-1],"type":"VEC3"},{"bufferView":2,"componentType":5126,"count":24,"max":[0.34375,0.53125],"min":[0,0],"type":"VEC2"},{"bufferView":3,"componentType":5123,"count":36,"max":[23],"min":[0],"type":"SCALAR"}],"materials":[{"pbrMetallicRoughness":{"metallicFactor":0,"roughnessFactor":1,"baseColorTexture":{"index":0}},"alphaMode":"MASK","alphaCutoff":0.05,"doubleSided":true}],"textures":[{"sampler":0,"source":0,"name":"cape.png"}],"samplers":[{"magFilter":9728,"minFilter":9728,"wrapS":33071,"wrapT":33071}],"images":[{"mimeType":"image/png","uri":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAAAAXNSR0IArs4c6QAABCRJREFUaEPtlktoE1EUhm/sIzFpYsBgHyn2oRZt0C5ESxWFYutKNyKIWBBR6kpBd3Ur2J0FRdCiuKmIIG4qLnzgQqTUXSlVqdoXTU0lljRpYtOHI/8dznBnOm1mphMwJWeTzJ0795zznf/cex2MMXam2S/ht38siJ8V1lgd5mOBARerc3rZnmK37rwvCyk2nE6wezMRh+4EncH9B1ql+fkU64rPsaBz5bqh4T7Daxn1Kc5zVNeEePJIci0Aq71bzenY6JChwAnA0OBHQ/OtJLnWNwqAy61R1tO3k891ueRKoDKwtqbv7MGbgCnfRgHQoqG9hyX4hc/yiho+/HNqVPFtdj2jwTpQgd/RKT7/0ZUku/o4qAJw50KYXbzr4e+3BioYzc3kwGzAUCLWFw2+UBiCb3bNTDHiPQeAP3CGNmg/6VcSBpDu3hhvDQpOCwABwrQKsRIsqYAChy/EQAXwlPiZ3a3igNPkXIyTjr8o5L7PPdzGf59c+sV/faeWeIIIAHPJ8M3kc7l1K09LKghWAIgqoPZ7djPFTlxb4D6yAgBOQfntrUVWVeRk44tplXJorOVGkVIJTBCTpw9ECFYBIEkyMfmsAXh3u1qi5MlxbbGX/x1ZSCjBAAwgfPr6h49R5UVavk0FXC2wju5p07s6iqEF0PtqSlEf+bKzDRwdgaDU7AkoySJ5Oo/D6ZRq/H0yyuJ/lxkSheG/FgCNm7kL0BoEAG32sqtY1YIHd2/mGzTMVgD3y2slqjgW9xY6ma9AThAARIMiBtOpjADwTWc0bEkB+FZsSTxTW0KBsGPXx0yvrUpEeHAAQIM7wBJLcu8DwHaP/H8i6VSND6SiSjBlRW4WWUypVIBbIgzjVgB0tpdKqLReS0KVPTMTfH0ra2cEgAmAAEd+l1z52LybqwBQYAQAyZPh6gtDW4jjuC4fHx8wXSm0JDbeI95SRYHiFZlUaWVtPQiKAugl5E8ASAUYiy8vc0C474uGasPE5PHc4g0wK/f4obom6UNimol7kTZwQLANwOuqBokqDEeQf4lfvvnNxZJcBTBAGZplWQcQ3tcgwY9oWlXinRW4ugoAgNAWWe6ocn1QvgyRTUb4RZFVljlY/3hSBYCqb6cCZo8ekuATVRZPI/gQW8FWAI1VHganVgDQUajdA6y2gAgAcZFBjTBSpG0AcAqc3VVmCMDTbxGWZvIRCaMNkJ7pFMCzVQD4liCQ8kRFUlvaBkCvL+wYw2ZmNUgCgLajFhRPJlv3ADuS1VtjPQCk823S574ffN/x1dQqy8dHR5SN2Spcbaymz2mjwNYDAD4IQn3TDpVLQIAqNjwAANRrAdoI/3sAOF7Xe1khCNQGVH1bL0JGJb1R52VtD8gVYHkAuVKpbMWZV0C2yObKunkF5EqlshVnXgHZIpsr6/4DlbxcPydnT74AAAAASUVORK5CYII="}],"meshes":[{"primitives":[{"mode":4,"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2},"indices":3,"material":0}]}]}
|
||||
1
apps/app-frontend/src/assets/models/classic_player.gltf
Normal file
1
apps/app-frontend/src/assets/models/classic_player.gltf
Normal file
File diff suppressed because one or more lines are too long
1
apps/app-frontend/src/assets/models/slim_player.gltf
Normal file
1
apps/app-frontend/src/assets/models/slim_player.gltf
Normal file
File diff suppressed because one or more lines are too long
BIN
apps/app-frontend/src/assets/skins/herobrine.png
Normal file
BIN
apps/app-frontend/src/assets/skins/herobrine.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/app-frontend/src/assets/skins/steve.png
Normal file
BIN
apps/app-frontend/src/assets/skins/steve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -9,15 +9,13 @@
|
||||
<Avatar
|
||||
size="36px"
|
||||
:src="
|
||||
selectedAccount
|
||||
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
|
||||
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
"
|
||||
/>
|
||||
<div class="flex flex-col w-full">
|
||||
<span>
|
||||
<component v-if="selectedAccount" :is="getAccountType(selectedAccount)" class="vector-icon" />
|
||||
{{ selectedAccount ? selectedAccount.username : 'Select account' }}
|
||||
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
|
||||
{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}
|
||||
</span>
|
||||
<span class="text-secondary text-xs">Minecraft account</span>
|
||||
</div>
|
||||
@@ -27,36 +25,49 @@
|
||||
<Card v-if="showCard || mode === 'isolated'" ref="card" class="account-card"
|
||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }">
|
||||
<div v-if="selectedAccount" class="selected account">
|
||||
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.username}/128`" />
|
||||
<Avatar size="xs" :src="avatarUrl" />
|
||||
<div>
|
||||
<h4>
|
||||
<component :is="getAccountType(selectedAccount)" class="vector-icon" /> {{ selectedAccount.username }}
|
||||
<component :is="getAccountType(selectedAccount)" class="vector-icon" /> {{ selectedAccount.profile.name }}
|
||||
</h4>
|
||||
<p>Selected</p>
|
||||
</div>
|
||||
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
|
||||
<Button
|
||||
v-tooltip="'Log out'"
|
||||
icon-only
|
||||
color="raised"
|
||||
@click="logout(selectedAccount.profile.id)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="login-section account">
|
||||
<h4>Not signed in</h4>
|
||||
<Button v-tooltip="'Log in'" icon-only @click="login()">
|
||||
<MicrosoftIcon />
|
||||
<Button
|
||||
v-tooltip="'Log in'"
|
||||
:disabled="loginDisabled"
|
||||
icon-only
|
||||
color="primary"
|
||||
@click="login()"
|
||||
>
|
||||
<LogInIcon v-if="!loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<MicrosoftIcon/>
|
||||
</Button>
|
||||
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
|
||||
<PirateIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
|
||||
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
||||
<Button class="option account" @click="setAccount(account)">
|
||||
<Avatar :src="`https://mc-heads.net/avatar/${account.username}/128`" class="icon" />
|
||||
<Avatar :src="getAccountAvatarUrl(account)" class="icon" />
|
||||
<p class="account-type">
|
||||
<component :is="getAccountType(account)" class="vector-icon" />
|
||||
{{ account.username }}
|
||||
{{ account.profile.name }}
|
||||
</p>
|
||||
</Button>
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -96,7 +107,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, PirateIcon as Offline, MicrosoftIcon as License, MicrosoftIcon, PirateIcon } from '@modrinth/assets'
|
||||
import {
|
||||
DropdownIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
LogInIcon,
|
||||
PirateIcon as Offline,
|
||||
MicrosoftIcon as License,
|
||||
MicrosoftIcon,
|
||||
PirateIcon,
|
||||
SpinnerIcon } from '@modrinth/assets'
|
||||
import { Avatar, Button, Card } from '@modrinth/ui'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import {
|
||||
@@ -111,7 +131,9 @@ import { handleError } from '@/store/state.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import ModalWrapper from './modal/ModalWrapper.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_available_skins } from '@/helpers/skins'
|
||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
|
||||
defineProps({
|
||||
mode: {
|
||||
@@ -124,18 +146,19 @@ defineProps({
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const accounts = ref({})
|
||||
const loginDisabled = ref(false)
|
||||
const defaultUser = ref()
|
||||
const loginOfflineModal = ref(null)
|
||||
const loginErrorModal = ref(null)
|
||||
const unexpectedErrorModal = ref(null)
|
||||
const playerName = ref('')
|
||||
|
||||
async function tryOfflineLogin() { // Patched
|
||||
async function tryOfflineLogin() { // Patched by AstralRinth
|
||||
loginOfflineModal.value.show()
|
||||
}
|
||||
|
||||
async function offlineLoginFinally() { // Patched
|
||||
let name = playerName.value
|
||||
async function offlineLoginFinally() { // Patched by AstralRinth
|
||||
const name = playerName.value
|
||||
if (name.length > 1 && name.length < 20 && name !== '') {
|
||||
const loggedIn = await offline_login(name).catch(handleError)
|
||||
loginOfflineModal.value.hide()
|
||||
@@ -153,43 +176,96 @@ async function offlineLoginFinally() { // Patched
|
||||
}
|
||||
}
|
||||
|
||||
function retryOfflineLogin() { // Patched
|
||||
function retryOfflineLogin() { // Patched by AstralRinth
|
||||
loginErrorModal.value.hide()
|
||||
tryOfflineLogin()
|
||||
}
|
||||
|
||||
function getAccountType(account) { // Patched
|
||||
function getAccountType(account) { // Patched by AstralRinth
|
||||
if (account.access_token != "null" && account.access_token != null && account.access_token != "") {
|
||||
return License
|
||||
} else {
|
||||
return Offline
|
||||
}
|
||||
}
|
||||
const equippedSkin = ref(null)
|
||||
const headUrlCache = ref(new Map())
|
||||
|
||||
async function refreshValues() {
|
||||
defaultUser.value = await get_default_user().catch(handleError)
|
||||
accounts.value = await users().catch(handleError)
|
||||
|
||||
try {
|
||||
const skins = await get_available_skins()
|
||||
equippedSkin.value = skins.find((skin) => skin.is_equipped)
|
||||
|
||||
if (equippedSkin.value) {
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
|
||||
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
equippedSkin.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function setLoginDisabled(value) {
|
||||
loginDisabled.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
setLoginDisabled,
|
||||
loginDisabled,
|
||||
})
|
||||
await refreshValues()
|
||||
|
||||
const displayAccounts = computed(() =>
|
||||
accounts.value.filter((account) => defaultUser.value !== account.id),
|
||||
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
|
||||
)
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (equippedSkin.value?.texture_key) {
|
||||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
||||
if (cachedUrl) {
|
||||
return cachedUrl
|
||||
}
|
||||
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
|
||||
}
|
||||
if (selectedAccount.value?.profile?.id) {
|
||||
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
|
||||
}
|
||||
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
})
|
||||
|
||||
function getAccountAvatarUrl(account) {
|
||||
if (
|
||||
account.profile.id === selectedAccount.value?.profile?.id &&
|
||||
equippedSkin.value?.texture_key
|
||||
) {
|
||||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
||||
if (cachedUrl) {
|
||||
return cachedUrl
|
||||
}
|
||||
}
|
||||
return `https://mc-heads.net/avatar/${account.profile.id}/128`
|
||||
}
|
||||
|
||||
const selectedAccount = computed(() =>
|
||||
accounts.value.find((account) => account.id === defaultUser.value),
|
||||
accounts.value.find((account) => account.profile.id === defaultUser.value),
|
||||
)
|
||||
|
||||
async function setAccount(account) {
|
||||
defaultUser.value = account.id
|
||||
await set_default_user(account.id).catch(handleError)
|
||||
defaultUser.value = account.profile.id
|
||||
await set_default_user(account.profile.id).catch(handleError)
|
||||
emit('change')
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loginDisabled.value = true
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn) {
|
||||
@@ -198,6 +274,7 @@ async function login() {
|
||||
}
|
||||
|
||||
trackEvent('AccountLogIn')
|
||||
loginDisabled.value = false
|
||||
}
|
||||
|
||||
const logout = async (id) => {
|
||||
|
||||
@@ -92,7 +92,7 @@ async function loginMinecraft() {
|
||||
const loggedIn = await login_flow()
|
||||
|
||||
if (loggedIn) {
|
||||
await set_default_user(loggedIn.id).catch(handleError)
|
||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||
}
|
||||
|
||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||
@@ -219,8 +219,8 @@ async function copyToClipboard(text) {
|
||||
<template v-else-if="metadata.notEnoughSpace">
|
||||
<h3>Not enough space</h3>
|
||||
<p>
|
||||
It looks like there is not enough space on the disk containing the dirctory you
|
||||
selected Please free up some space and try again or cancel the directory change.
|
||||
It looks like there is not enough space on the disk containing the directory you
|
||||
selected. Please free up some space and try again or cancel the directory change.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
@@ -19,7 +19,6 @@ import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
@@ -173,7 +172,10 @@ onUnmounted(() => unlisten())
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||
<TimerIcon />
|
||||
<span class="text-sm">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
<template v-if="instance.last_played">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,8 +239,8 @@ onUnmounted(() => unlisten())
|
||||
</p>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
|
||||
<GameIcon class="shrink-0" />
|
||||
<span class="text-sm">
|
||||
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
|
||||
<span class="text-sm capitalize">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ async function handleJavaFileInput() {
|
||||
const filePath = await open()
|
||||
|
||||
if (filePath) {
|
||||
let result = await get_jre(filePath.path ?? filePath)
|
||||
let result = await get_jre(filePath.path ?? filePath).catch(handleError)
|
||||
if (!result) {
|
||||
result = {
|
||||
path: filePath.path ?? filePath,
|
||||
|
||||
@@ -30,7 +30,7 @@ const getInstances = async () => {
|
||||
|
||||
return dateB - dateA
|
||||
})
|
||||
.slice(0, 4)
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
await getInstances()
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ShieldIcon,
|
||||
SettingsIcon,
|
||||
GaugeIcon,
|
||||
PaintBrushIcon,
|
||||
PaintbrushIcon,
|
||||
GameIcon,
|
||||
CoffeeIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -41,7 +41,7 @@ const tabs = [
|
||||
id: 'app.settings.tabs.appearance',
|
||||
defaultMessage: 'Appearance',
|
||||
}),
|
||||
icon: PaintBrushIcon,
|
||||
icon: PaintbrushIcon,
|
||||
content: AppearanceSettings,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { NewModal as Modal } from '@modrinth/ui'
|
||||
// import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
@@ -26,15 +26,16 @@ const props = defineProps({
|
||||
// default: true,
|
||||
// },
|
||||
})
|
||||
const modal = ref(null)
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
modal.value.show()
|
||||
show: (e: MouseEvent) => {
|
||||
// hide_ads_window()
|
||||
modal.value?.show(e)
|
||||
},
|
||||
hide: () => {
|
||||
onModalHide()
|
||||
modal.value.hide()
|
||||
modal.value?.hide()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -56,9 +56,17 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
|
||||
<p class="m-0 mt-1">Disables the nametag above your player on the skins page. page.</p>
|
||||
</div>
|
||||
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
|
||||
</div>
|
||||
|
||||
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
|
||||
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
||||
</div>
|
||||
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||
|
||||
420
apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
Normal file
420
apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
Normal file
@@ -0,0 +1,420 @@
|
||||
<script lang="ts">
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
</script>
|
||||
<template>
|
||||
<UploadSkinModal ref="uploadModal" />
|
||||
<ModalWrapper ref="modal" @on-hide="resetState">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
:slim-model-src="slimModelUrl"
|
||||
:wide-model-src="wideModelUrl"
|
||||
:cape-model-src="capeModelUrl"
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:cape-src="selectedCapeTexture"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Texture</h2>
|
||||
<Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Arm style</h2>
|
||||
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
|
||||
<template #default="{ item }">
|
||||
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Cape</h2>
|
||||
<div class="flex gap-2">
|
||||
<CapeButton
|
||||
v-if="defaultCape"
|
||||
:id="defaultCape.id"
|
||||
:texture="defaultCape.texture"
|
||||
:name="undefined"
|
||||
:selected="!selectedCape"
|
||||
faded
|
||||
@select="selectCape(undefined)"
|
||||
>
|
||||
<span>Use default cape</span>
|
||||
</CapeButton>
|
||||
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
|
||||
<span>Use default cape</span>
|
||||
</CapeLikeTextButton>
|
||||
|
||||
<CapeButton
|
||||
v-for="cape in visibleCapeList"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:texture="cape.texture"
|
||||
:name="cape.name || 'Cape'"
|
||||
:selected="selectedCape?.id === cape.id"
|
||||
@select="selectCape(cape)"
|
||||
/>
|
||||
|
||||
<CapeLikeTextButton
|
||||
v-if="(capes?.length ?? 0) > 2"
|
||||
tooltip="View more capes"
|
||||
@mouseup="openSelectCapeModal"
|
||||
>
|
||||
<template #icon><ChevronRightIcon /></template>
|
||||
<span>More</span>
|
||||
</CapeLikeTextButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-12">
|
||||
<ButtonStyled color="brand" :disabled="disableSave || isSaving">
|
||||
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
|
||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
||||
<CheckIcon v-else-if="mode === 'new'" />
|
||||
<SaveIcon v-else />
|
||||
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
|
||||
<SelectCapeModal
|
||||
ref="selectCapeModal"
|
||||
:capes="capes || []"
|
||||
@select="handleCapeSelected"
|
||||
@cancel="handleCapeCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, useTemplateRef } from 'vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import {
|
||||
SkinPreviewRenderer,
|
||||
Button,
|
||||
RadioButtons,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
ButtonStyled,
|
||||
} from '@modrinth/ui'
|
||||
import {
|
||||
add_and_equip_custom_skin,
|
||||
remove_custom_skin,
|
||||
unequip_skin,
|
||||
type Skin,
|
||||
type Cape,
|
||||
type SkinModel,
|
||||
get_normalized_skin_texture,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import {
|
||||
UploadIcon,
|
||||
CheckIcon,
|
||||
SaveIcon,
|
||||
XIcon,
|
||||
ChevronRightIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
const mode = ref<'new' | 'edit'>('new')
|
||||
const currentSkin = ref<Skin | null>(null)
|
||||
const shouldRestoreModal = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const uploadedTextureUrl = ref<string | null>(null)
|
||||
const previewSkin = ref<string>('')
|
||||
|
||||
const variant = ref<SkinModel>('CLASSIC')
|
||||
const selectedCape = ref<Cape | undefined>(undefined)
|
||||
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
|
||||
|
||||
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
|
||||
const visibleCapeList = ref<Cape[]>([])
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...(props.capes || [])].sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase()
|
||||
const nameB = (b.name || '').toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
function initVisibleCapeList() {
|
||||
if (!props.capes || props.capes.length === 0) {
|
||||
visibleCapeList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (visibleCapeList.value.length === 0) {
|
||||
if (selectedCape.value) {
|
||||
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
|
||||
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
|
||||
} else {
|
||||
visibleCapeList.value = getSortedCapes(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSortedCapes(count: number): Cape[] {
|
||||
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
|
||||
return sortedCapes.value.slice(0, count)
|
||||
}
|
||||
|
||||
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
|
||||
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
|
||||
return sortedCapes.value.find((cape) => cape.id !== excludeId)
|
||||
}
|
||||
|
||||
async function loadPreviewSkin() {
|
||||
if (uploadedTextureUrl.value) {
|
||||
previewSkin.value = uploadedTextureUrl.value
|
||||
} else if (currentSkin.value) {
|
||||
try {
|
||||
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to load skin texture:', error)
|
||||
previewSkin.value = '/src/assets/skins/steve.png'
|
||||
}
|
||||
} else {
|
||||
previewSkin.value = '/src/assets/skins/steve.png'
|
||||
}
|
||||
}
|
||||
|
||||
const hasEdits = computed(() => {
|
||||
if (mode.value !== 'edit') return true
|
||||
if (uploadedTextureUrl.value) return true
|
||||
if (!currentSkin.value) return false
|
||||
if (variant.value !== currentSkin.value.variant) return true
|
||||
if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const disableSave = computed(
|
||||
() =>
|
||||
(mode.value === 'new' && !uploadedTextureUrl.value) ||
|
||||
(mode.value === 'edit' && !hasEdits.value),
|
||||
)
|
||||
|
||||
const saveTooltip = computed(() => {
|
||||
if (isSaving.value) return 'Saving...'
|
||||
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
|
||||
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
|
||||
return undefined
|
||||
})
|
||||
|
||||
function resetState() {
|
||||
mode.value = 'new'
|
||||
currentSkin.value = null
|
||||
uploadedTextureUrl.value = null
|
||||
previewSkin.value = ''
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
shouldRestoreModal.value = false
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
async function show(e: MouseEvent, skin?: Skin) {
|
||||
mode.value = skin ? 'edit' : 'new'
|
||||
currentSkin.value = skin ?? null
|
||||
if (skin) {
|
||||
variant.value = skin.variant
|
||||
selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id)
|
||||
} else {
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
}
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
||||
mode.value = 'new'
|
||||
currentSkin.value = null
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function restoreWithNewTexture(skinTextureUrl: string) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
await loadPreviewSkin()
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
setTimeout(() => resetState(), 250)
|
||||
}
|
||||
|
||||
function selectCape(cape: Cape | undefined) {
|
||||
if (cape && selectedCape.value?.id !== cape.id) {
|
||||
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
|
||||
if (!isInVisibleList && visibleCapeList.value.length > 0) {
|
||||
visibleCapeList.value.splice(0, 1, cape)
|
||||
|
||||
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
|
||||
const otherCape = getSortedCapeExcluding(cape.id)
|
||||
if (otherCape) {
|
||||
visibleCapeList.value.splice(1, 1, otherCape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedCape.value = cape
|
||||
}
|
||||
|
||||
function handleCapeSelected(cape: Cape | undefined) {
|
||||
selectCape(cape)
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCapeCancel() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectCapeModal(e: MouseEvent) {
|
||||
if (!selectCapeModal.value) return
|
||||
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
|
||||
setTimeout(() => {
|
||||
selectCapeModal.value?.show(
|
||||
e,
|
||||
currentSkin.value?.texture_key,
|
||||
selectedCape.value,
|
||||
previewSkin.value,
|
||||
variant.value,
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
emit('open-upload-modal', e)
|
||||
}
|
||||
|
||||
function restoreModal() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
modal.value?.show(fakeEvent)
|
||||
shouldRestoreModal.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
let textureUrl: string
|
||||
|
||||
if (uploadedTextureUrl.value) {
|
||||
textureUrl = uploadedTextureUrl.value
|
||||
} else {
|
||||
textureUrl = currentSkin.value!.texture
|
||||
}
|
||||
|
||||
await unequip_skin()
|
||||
|
||||
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
||||
|
||||
if (mode.value === 'new') {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved')
|
||||
} else {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
await remove_custom_skin(currentSkin.value!)
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
hide()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([uploadedTextureUrl, currentSkin], async () => {
|
||||
await loadPreviewSkin()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.capes,
|
||||
() => {
|
||||
initVisibleCapeList()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'saved'): void
|
||||
(event: 'deleted', skin: Skin): void
|
||||
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
showNew,
|
||||
restoreWithNewTexture,
|
||||
hide,
|
||||
shouldRestoreModal,
|
||||
restoreModal,
|
||||
})
|
||||
</script>
|
||||
146
apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
Normal file
146
apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef, ref, computed } from 'vue'
|
||||
import type { Cape, SkinModel } from '@/helpers/skins.ts'
|
||||
import {
|
||||
ButtonStyled,
|
||||
ScrollablePanel,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
SkinPreviewRenderer,
|
||||
} from '@modrinth/ui'
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', cape: Cape | undefined): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
capes: Cape[]
|
||||
}>()
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...props.capes].sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase()
|
||||
const nameB = (b.name || '').toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
const currentSkinId = ref<string | undefined>()
|
||||
const currentSkinTexture = ref<string | undefined>()
|
||||
const currentSkinVariant = ref<SkinModel>('CLASSIC')
|
||||
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
|
||||
const currentCape = ref<Cape | undefined>()
|
||||
|
||||
function show(
|
||||
e: MouseEvent,
|
||||
skinId?: string,
|
||||
selected?: Cape,
|
||||
skinTexture?: string,
|
||||
variant?: SkinModel,
|
||||
) {
|
||||
currentSkinId.value = skinId
|
||||
currentSkinTexture.value = skinTexture
|
||||
currentSkinVariant.value = variant || 'CLASSIC'
|
||||
currentCape.value = selected
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function select() {
|
||||
emit('select', currentCape.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function updateSelectedCape(cape: Cape | undefined) {
|
||||
currentCape.value = cape
|
||||
}
|
||||
|
||||
function onModalHide() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="onModalHide">
|
||||
<template #title>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-extrabold text-heading">Change cape</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
v-if="currentSkinTexture"
|
||||
:slim-model-src="slimModelUrl"
|
||||
:wide-model-src="wideModelUrl"
|
||||
:cape-model-src="capeModelUrl"
|
||||
:cape-src="currentCapeTexture"
|
||||
:texture-src="currentSkinTexture"
|
||||
:variant="currentSkinVariant"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI + Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full my-auto">
|
||||
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
|
||||
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
|
||||
<CapeLikeTextButton
|
||||
tooltip="No Cape"
|
||||
:highlighted="!currentCape"
|
||||
@click="updateSelectedCape(undefined)"
|
||||
>
|
||||
<template #icon>
|
||||
<XIcon />
|
||||
</template>
|
||||
<span>None</span>
|
||||
</CapeLikeTextButton>
|
||||
<CapeButton
|
||||
v-for="cape in sortedCapes"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:name="cape.name"
|
||||
:texture="cape.texture"
|
||||
:selected="currentCape?.id === cape.id"
|
||||
@select="updateSelectedCape(cape)"
|
||||
/>
|
||||
</div>
|
||||
</ScrollablePanel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="select">
|
||||
<CheckIcon />
|
||||
Select
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
140
apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
Normal file
140
apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="hide(true)">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
|
||||
</template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
|
||||
<UploadIcon /> Select skin texture file
|
||||
</p>
|
||||
<p class="mx-auto mt-0 text-secondary text-sm text-center">
|
||||
Drag and drop or click here to browse
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="handleInputFileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, watch } from 'vue'
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { useNotifications } from '@/store/state'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_dragged_skin_data } from '@/helpers/skins'
|
||||
|
||||
const notifications = useNotifications()
|
||||
|
||||
const modal = ref()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const unlisten = ref<() => void>()
|
||||
const modalVisible = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'uploaded', data: ArrayBuffer): void
|
||||
(e: 'canceled'): void
|
||||
}>()
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
modalVisible.value = true
|
||||
setupDragDropListener()
|
||||
}
|
||||
|
||||
function hide(emitCanceled = false) {
|
||||
modal.value?.hide()
|
||||
modalVisible.value = false
|
||||
cleanupDragDropListener()
|
||||
resetState()
|
||||
if (emitCanceled) {
|
||||
emit('canceled')
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleInputFileChange(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
const file = files[0]
|
||||
const buffer = await file.arrayBuffer()
|
||||
await processData(buffer)
|
||||
}
|
||||
|
||||
async function setupDragDropListener() {
|
||||
try {
|
||||
if (modalVisible.value) {
|
||||
await cleanupDragDropListener()
|
||||
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== 'drop') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.payload.paths || event.payload.paths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = event.payload.paths[0]
|
||||
|
||||
try {
|
||||
const data = await get_dragged_skin_data(filePath)
|
||||
await processData(data.buffer)
|
||||
} catch (error) {
|
||||
notifications.addNotification({
|
||||
title: 'Error processing file',
|
||||
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set up drag and drop listener:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDragDropListener() {
|
||||
if (unlisten.value) {
|
||||
unlisten.value()
|
||||
unlisten.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function processData(buffer: ArrayBuffer) {
|
||||
emit('uploaded', buffer)
|
||||
hide()
|
||||
}
|
||||
|
||||
watch(modalVisible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
setupDragDropListener()
|
||||
} else {
|
||||
cleanupDragDropListener()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDragDropListener()
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
EyeIcon,
|
||||
@@ -42,6 +43,7 @@ const emit = defineEmits<{
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
last_played: Dayjs
|
||||
}>()
|
||||
|
||||
const loadingModpack = ref(!!props.instance.linked_data)
|
||||
@@ -147,12 +149,12 @@ onUnmounted(() => {
|
||||
: null
|
||||
"
|
||||
class="w-fit shrink-0"
|
||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
|
||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }"
|
||||
>
|
||||
<template v-if="instance.last_played">
|
||||
<template v-if="last_played">
|
||||
{{
|
||||
formatMessage(commonMessages.playedLabel, {
|
||||
time: formatRelativeTime(instance.last_played.toISOString()),
|
||||
time: formatRelativeTime(last_played.toISOString?.()),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
|
||||
@@ -84,7 +84,7 @@ async function populateJumpBackIn() {
|
||||
|
||||
worldItems.push({
|
||||
type: 'world',
|
||||
last_played: dayjs(world.last_played),
|
||||
last_played: dayjs(world.last_played ?? 0),
|
||||
world: world,
|
||||
instance: instance,
|
||||
})
|
||||
@@ -138,13 +138,13 @@ async function populateJumpBackIn() {
|
||||
|
||||
instanceItems.push({
|
||||
type: 'instance',
|
||||
last_played: dayjs(instance.last_played),
|
||||
last_played: dayjs(instance.last_played ?? 0),
|
||||
instance: instance,
|
||||
})
|
||||
}
|
||||
|
||||
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
|
||||
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
|
||||
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
|
||||
jumpBackInItems.value = items
|
||||
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
|
||||
.slice(0, MAX_JUMP_BACK_IN)
|
||||
@@ -291,7 +291,7 @@ onUnmounted(() => {
|
||||
"
|
||||
@stop="() => stopInstance(item.instance.path)"
|
||||
/>
|
||||
<InstanceItem v-else :instance="item.instance" />
|
||||
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
355
apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
Normal file
355
apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
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 capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
|
||||
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<RenderResult> {
|
||||
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<string> {
|
||||
this.camera.position.set(...cameraPosition)
|
||||
this.camera.lookAt(...lookAtPosition)
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
return new Promise<string>((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<void> {
|
||||
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 slimModelUrl
|
||||
case 'CLASSIC':
|
||||
case 'UNKNOWN':
|
||||
default:
|
||||
return wideModelUrl
|
||||
}
|
||||
}
|
||||
|
||||
export const map = reactive(new Map<string, RenderResult>())
|
||||
export const headMap = reactive(new Map<string, string>())
|
||||
const DEBUG_MODE = false
|
||||
|
||||
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
|
||||
const validKeys = new Set<string>()
|
||||
const validHeadKeys = new Set<string>()
|
||||
|
||||
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<Blob> {
|
||||
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<string> {
|
||||
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 {
|
||||
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<string> {
|
||||
return await generateHeadRender(skin)
|
||||
}
|
||||
|
||||
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
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,
|
||||
capeModelUrl,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export type AppSettings = {
|
||||
theme: ColorTheme
|
||||
default_page: 'home' | 'library'
|
||||
collapsed_navigation: boolean
|
||||
hide_nametag_skins_page: boolean
|
||||
advanced_rendering: boolean
|
||||
native_decorations: boolean
|
||||
toggle_sidebar: boolean
|
||||
|
||||
163
apps/app-frontend/src/helpers/skins.ts
Normal file
163
apps/app-frontend/src/helpers/skins.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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<string, SkinModel> = {
|
||||
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.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<Cape[]> {
|
||||
return invoke('plugin:minecraft-skins|get_available_capes', {})
|
||||
}
|
||||
|
||||
export async function get_available_skins(): Promise<Skin[]> {
|
||||
return invoke('plugin:minecraft-skins|get_available_skins', {})
|
||||
}
|
||||
|
||||
export async function add_and_equip_custom_skin(
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
capeOverride?: Cape,
|
||||
): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
|
||||
textureBlob,
|
||||
variant,
|
||||
capeOverride,
|
||||
})
|
||||
}
|
||||
|
||||
export async function set_default_cape(cape?: Cape): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|set_default_cape', {
|
||||
cape,
|
||||
})
|
||||
}
|
||||
|
||||
export async function equip_skin(skin: Skin): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|equip_skin', {
|
||||
skin,
|
||||
})
|
||||
}
|
||||
|
||||
export async function remove_custom_skin(skin: Skin): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|remove_custom_skin', {
|
||||
skin,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
|
||||
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<Uint8Array> {
|
||||
return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
|
||||
}
|
||||
|
||||
export async function unequip_skin(): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|unequip_skin')
|
||||
}
|
||||
|
||||
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
|
||||
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
|
||||
return new Uint8Array(data)
|
||||
}
|
||||
118
apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
Normal file
118
apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<RenderResult | null> {
|
||||
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<string>): Promise<number> {
|
||||
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<IDBCursorWithValue>).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()
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import dayjs from 'dayjs'
|
||||
import { get_search_results } from '@/helpers/cache.js'
|
||||
import type { SearchResult } from '@modrinth/utils'
|
||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
@@ -82,13 +83,15 @@ async function refreshFeaturedProjects() {
|
||||
await fetchInstances()
|
||||
await refreshFeaturedProjects()
|
||||
|
||||
const unlistenProfile = await profile_listener(async (e) => {
|
||||
await fetchInstances()
|
||||
const unlistenProfile = await profile_listener(
|
||||
async (e: { event: string; profile_path_id: string }) => {
|
||||
await fetchInstances()
|
||||
|
||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||
await refreshFeaturedProjects()
|
||||
}
|
||||
})
|
||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||
await refreshFeaturedProjects()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProfile()
|
||||
@@ -97,8 +100,8 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="p-6 flex flex-col gap-2">
|
||||
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl">Welcome to AstralRinth App!</h1>
|
||||
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to AstralRinth App!</h1>
|
||||
<RecentWorldsList :recent-instances="recentInstances" />
|
||||
<RowDisplay
|
||||
v-if="hasFeaturedProjects"
|
||||
|
||||
528
apps/app-frontend/src/pages/Skins.vue
Normal file
528
apps/app-frontend/src/pages/Skins.vue
Normal file
@@ -0,0 +1,528 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
EditIcon,
|
||||
ExcitedRinthbot,
|
||||
LogInIcon,
|
||||
PlusIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
UpdatedIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
SkinButton,
|
||||
SkinLikeTextButton,
|
||||
SkinPreviewRenderer,
|
||||
} from '@modrinth/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
||||
import { handleError, useNotifications } from '@/store/notifications'
|
||||
import type { Cape, Skin } from '@/helpers/skins.ts'
|
||||
import {
|
||||
normalize_skin_texture,
|
||||
equip_skin,
|
||||
filterDefaultSkins,
|
||||
filterSavedSkins,
|
||||
get_available_capes,
|
||||
get_available_skins,
|
||||
get_normalized_skin_texture,
|
||||
remove_custom_skin,
|
||||
set_default_cape,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { get as getSettings } from '@/helpers/settings.ts'
|
||||
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
|
||||
const editSkinModal = useTemplateRef('editSkinModal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
const uploadSkinModal = useTemplateRef('uploadSkinModal')
|
||||
|
||||
const notifications = useNotifications()
|
||||
|
||||
const settings = ref(await getSettings())
|
||||
const skins = ref<Skin[]>([])
|
||||
const capes = ref<Cape[]>([])
|
||||
|
||||
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
|
||||
const currentUser = ref(undefined)
|
||||
const currentUserId = ref<string | undefined>(undefined)
|
||||
|
||||
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
|
||||
const selectedSkin = ref<Skin | null>(null)
|
||||
const defaultCape = ref<Cape>()
|
||||
|
||||
const originalSelectedSkin = ref<Skin | null>(null)
|
||||
const originalDefaultCape = ref<Cape>()
|
||||
|
||||
const savedSkins = computed(() => filterSavedSkins(skins.value))
|
||||
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
|
||||
|
||||
const currentCape = computed(() => {
|
||||
if (selectedSkin.value?.cape_id) {
|
||||
const overrideCape = capes.value.find((c) => c.id === selectedSkin.value?.cape_id)
|
||||
if (overrideCape) {
|
||||
return overrideCape
|
||||
}
|
||||
}
|
||||
return defaultCape.value
|
||||
})
|
||||
|
||||
const skinTexture = computedAsync(async () => {
|
||||
if (selectedSkin.value?.texture) {
|
||||
return await get_normalized_skin_texture(selectedSkin.value)
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
const capeTexture = computed(() => currentCape.value?.texture)
|
||||
const skinVariant = computed(() => selectedSkin.value?.variant)
|
||||
const skinNametag = computed(() =>
|
||||
settings.value.hide_nametag_skins_page ? undefined : username.value,
|
||||
)
|
||||
|
||||
let userCheckInterval: number | null = null
|
||||
|
||||
const deleteSkinModal = ref()
|
||||
const skinToDelete = ref<Skin | null>(null)
|
||||
|
||||
function confirmDeleteSkin(skin: Skin) {
|
||||
skinToDelete.value = skin
|
||||
deleteSkinModal.value?.show()
|
||||
}
|
||||
|
||||
async function deleteSkin() {
|
||||
if (!skinToDelete.value) return
|
||||
await remove_custom_skin(skinToDelete.value).catch(handleError)
|
||||
await loadSkins()
|
||||
skinToDelete.value = null
|
||||
}
|
||||
|
||||
async function loadCapes() {
|
||||
try {
|
||||
capes.value = (await get_available_capes()) ?? []
|
||||
defaultCape.value = capes.value.find((c) => c.is_equipped)
|
||||
originalDefaultCape.value = defaultCape.value
|
||||
} catch (error) {
|
||||
if (currentUser.value) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkins() {
|
||||
try {
|
||||
skins.value = (await get_available_skins()) ?? []
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
|
||||
originalSelectedSkin.value = selectedSkin.value
|
||||
} catch (error) {
|
||||
if (currentUser.value) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSkin(newSkin: Skin) {
|
||||
const previousSkin = selectedSkin.value
|
||||
const previousSkinsList = [...skins.value]
|
||||
|
||||
skins.value = skins.value.map((skin) => {
|
||||
return {
|
||||
...skin,
|
||||
is_equipped: skin.texture_key === newSkin.texture_key,
|
||||
}
|
||||
})
|
||||
|
||||
selectedSkin.value = skins.value.find((s) => s.texture_key === newSkin.texture_key) || null
|
||||
|
||||
try {
|
||||
await equip_skin(newSkin)
|
||||
if (accountsCard.value) {
|
||||
await accountsCard.value.refreshValues()
|
||||
}
|
||||
} catch (error) {
|
||||
selectedSkin.value = previousSkin
|
||||
skins.value = previousSkinsList
|
||||
|
||||
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
|
||||
notifications.addNotification({
|
||||
type: 'error',
|
||||
title: 'Slow down!',
|
||||
text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
|
||||
})
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCapeSelected(cape: Cape | undefined) {
|
||||
const previousDefaultCape = defaultCape.value
|
||||
const previousCapesList = [...capes.value]
|
||||
|
||||
capes.value = capes.value.map((c) => ({
|
||||
...c,
|
||||
is_equipped: cape ? c.id === cape.id : false,
|
||||
}))
|
||||
|
||||
defaultCape.value = cape ? capes.value.find((c) => c.id === cape.id) : undefined
|
||||
|
||||
try {
|
||||
await set_default_cape(cape)
|
||||
} catch (error) {
|
||||
defaultCape.value = previousDefaultCape
|
||||
capes.value = previousCapesList
|
||||
|
||||
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
|
||||
notifications.addNotification({
|
||||
type: 'error',
|
||||
title: 'Slow down!',
|
||||
text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
|
||||
})
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onSkinSaved() {
|
||||
await Promise.all([loadCapes(), loadSkins()])
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
const defaultId = await get_default_user()
|
||||
currentUserId.value = defaultId
|
||||
|
||||
const allAccounts = await users()
|
||||
currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId)
|
||||
} catch (e) {
|
||||
handleError(e)
|
||||
currentUser.value = undefined
|
||||
currentUserId.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
return map.get(key)
|
||||
}
|
||||
|
||||
async function login() {
|
||||
accountsCard.value.setLoginDisabled(true)
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn && accountsCard) {
|
||||
await accountsCard.value.refreshValues()
|
||||
}
|
||||
|
||||
trackEvent('AccountLogIn')
|
||||
accountsCard.value.setLoginDisabled(false)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
uploadSkinModal.value?.show(e)
|
||||
}
|
||||
|
||||
function onSkinFileUploaded(buffer: ArrayBuffer) {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
|
||||
(skinTextureNormalized: Uint8Array) => {
|
||||
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
|
||||
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
|
||||
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
|
||||
} else {
|
||||
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function onUploadCanceled() {
|
||||
editSkinModal.value?.restoreModal()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedSkin.value?.cape_id,
|
||||
() => {},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
userCheckInterval = window.setInterval(checkUserChanges, 250)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (userCheckInterval !== null) {
|
||||
window.clearInterval(userCheckInterval)
|
||||
}
|
||||
})
|
||||
|
||||
async function checkUserChanges() {
|
||||
try {
|
||||
const defaultId = await get_default_user()
|
||||
if (defaultId !== currentUserId.value) {
|
||||
await loadCurrentUser()
|
||||
await loadCapes()
|
||||
await loadSkins()
|
||||
}
|
||||
} catch (error) {
|
||||
if (currentUser.value) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditSkinModal
|
||||
ref="editSkinModal"
|
||||
:capes="capes"
|
||||
:default-cape="defaultCape"
|
||||
@saved="onSkinSaved"
|
||||
@deleted="() => loadSkins()"
|
||||
@open-upload-modal="openUploadSkinModal"
|
||||
/>
|
||||
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected" />
|
||||
<UploadSkinModal
|
||||
ref="uploadSkinModal"
|
||||
@uploaded="onSkinFileUploaded"
|
||||
@canceled="onUploadCanceled"
|
||||
/>
|
||||
<ConfirmModal
|
||||
ref="deleteSkinModal"
|
||||
title="Are you sure you want to delete this skin?"
|
||||
description="This will permanently delete the selected skin. This action cannot be undone."
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteSkin"
|
||||
/>
|
||||
|
||||
<div v-if="currentUser" class="p-4 skin-layout">
|
||||
<div class="preview-panel">
|
||||
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
|
||||
Skins
|
||||
<span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
|
||||
</h1>
|
||||
<div class="preview-container">
|
||||
<SkinPreviewRenderer
|
||||
:wide-model-src="wideModelUrl"
|
||||
:slim-model-src="slimModelUrl"
|
||||
:cape-model-src="capeModelUrl"
|
||||
:cape-src="capeTexture"
|
||||
:texture-src="skinTexture || ''"
|
||||
:variant="skinVariant"
|
||||
:nametag="skinNametag"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
>
|
||||
<template #subtitle>
|
||||
<ButtonStyled :disabled="!!selectedSkin?.cape_id">
|
||||
<button
|
||||
v-tooltip="
|
||||
selectedSkin?.cape_id
|
||||
? 'The equipped skin is overriding the default cape.'
|
||||
: undefined
|
||||
"
|
||||
:disabled="!!selectedSkin?.cape_id"
|
||||
@click="
|
||||
(e: MouseEvent) =>
|
||||
selectCapeModal?.show(
|
||||
e,
|
||||
selectedSkin?.texture_key,
|
||||
currentCape,
|
||||
skinTexture,
|
||||
skinVariant,
|
||||
)
|
||||
"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
Change cape
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinPreviewRenderer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skins-container">
|
||||
<section class="flex flex-col gap-2 mt-1">
|
||||
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
|
||||
<div class="skin-card-grid">
|
||||
<SkinLikeTextButton class="skin-card" @click="openUploadSkinModal">
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
<span>Add a skin</span>
|
||||
</SkinLikeTextButton>
|
||||
|
||||
<SkinButton
|
||||
v-for="skin in savedSkins"
|
||||
:key="`saved-skin-${skin.texture_key}`"
|
||||
class="skin-card"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="selectedSkin === skin"
|
||||
@select="changeSkin(skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
<Button
|
||||
color="green"
|
||||
aria-label="Edit skin"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(e) => editSkinModal?.show(e, skin)"
|
||||
>
|
||||
<EditIcon /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
v-show="!skin.is_equipped"
|
||||
v-tooltip="'Delete skin'"
|
||||
aria-label="Delete skin"
|
||||
color="red"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
icon-only
|
||||
@click.stop="() => confirmDeleteSkin(skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2 mt-6">
|
||||
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
|
||||
<div class="skin-card-grid">
|
||||
<SkinButton
|
||||
v-for="skin in defaultSkins"
|
||||
:key="`default-skin-${skin.texture_key}`"
|
||||
class="skin-card"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="selectedSkin === skin"
|
||||
:tooltip="skin.name"
|
||||
@select="changeSkin(skin)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-center min-h-[50vh] pt-[25%]">
|
||||
<div
|
||||
class="bg-bg-raised rounded-lg p-7 flex flex-col gap-5 shadow-md relative max-w-xl w-full mx-auto"
|
||||
>
|
||||
<img
|
||||
:src="ExcitedRinthbot"
|
||||
alt="Excited Modrinth Bot"
|
||||
class="absolute -top-28 right-8 md:right-20 h-28 w-auto"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-[1px] opacity-40 bg-gradient-to-r from-transparent via-green-500 to-transparent"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 2rem,
|
||||
var(--color-green) calc(100% - 13rem),
|
||||
var(--color-green) calc(100% - 5rem),
|
||||
transparent calc(100% - 2rem)
|
||||
);
|
||||
"
|
||||
></div>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<h1 class="text-3xl font-extrabold m-0">Please sign-in</h1>
|
||||
<p class="text-lg m-0">
|
||||
Please sign into your Minecraft account to use the skin management features of the
|
||||
Modrinth app.
|
||||
</p>
|
||||
<ButtonStyled v-show="accountsCard" color="brand" :disabled="accountsCard.loginDisabled">
|
||||
<button :disabled="accountsCard.loginDisabled" @click="login">
|
||||
<LogInIcon v-if="!accountsCard.loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
Sign In
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$skin-card-width: 155px;
|
||||
$skin-card-gap: 4px;
|
||||
|
||||
.skin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
|
||||
gap: 2.5rem;
|
||||
|
||||
@media (max-width: 700px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
top: 1.5rem;
|
||||
position: sticky;
|
||||
align-self: start;
|
||||
padding: 0.5rem;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: calc((2.5rem / 2));
|
||||
|
||||
@media (max-width: 700px) {
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
.skins-container {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.skin-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $skin-card-gap;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 1300px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1750px) {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 2050px) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.skin-card {
|
||||
aspect-ratio: 0.95;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +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, Worlds }
|
||||
export { Index, Browse, Worlds, Skins }
|
||||
|
||||
@@ -34,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',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
"strict": true
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,10 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
4
apps/app/.gitignore
vendored
4
apps/app/.gitignore
vendored
@@ -1,6 +1,2 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by tauri, metadata generated at compile time
|
||||
/gen/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "0.9.5"
|
||||
version = "0.10.1"
|
||||
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/"
|
||||
@@ -17,13 +17,14 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_with.workspace = true
|
||||
|
||||
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
|
||||
tauri-plugin-window-state.workspace = true
|
||||
tauri-plugin-deep-link.workspace = true
|
||||
tauri-plugin-os.workspace = true
|
||||
tauri-plugin-opener.workspace = true
|
||||
tauri-plugin-dialog.workspace = true
|
||||
tauri-plugin-updater.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 = { workspace = true, features = ["time"] }
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -100,6 +100,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()
|
||||
@@ -152,7 +170,6 @@ fn main() {
|
||||
"profile_update_managed_modrinth_version",
|
||||
"profile_repair_managed_modrinth",
|
||||
"profile_run",
|
||||
"profile_run_credentials",
|
||||
"profile_kill",
|
||||
"profile_edit",
|
||||
"profile_edit_icon",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Option<JavaVersion>> {
|
||||
jre::check_jre(path).await.map_err(|e| e.into())
|
||||
pub async fn jre_get_jre(path: PathBuf) -> Result<JavaVersion> {
|
||||
Ok(jre::check_jre(path).await?)
|
||||
}
|
||||
|
||||
// Tests JRE of a certain version
|
||||
|
||||
104
apps/app/src/api/minecraft_skins.rs
Normal file
104
apps/app/src/api/minecraft_skins.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use crate::api::Result;
|
||||
|
||||
use std::path::Path;
|
||||
use theseus::minecraft_skins::{
|
||||
self, Bytes, Cape, MinecraftSkinVariant, Skin, UrlOrBlob,
|
||||
};
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("minecraft-skins")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
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,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|get_available_capes')`
|
||||
///
|
||||
/// See also: [minecraft_skins::get_available_capes]
|
||||
#[tauri::command]
|
||||
pub async fn get_available_capes() -> Result<Vec<Cape>> {
|
||||
Ok(minecraft_skins::get_available_capes().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|get_available_skins')`
|
||||
///
|
||||
/// See also: [minecraft_skins::get_available_skins]
|
||||
#[tauri::command]
|
||||
pub async fn get_available_skins() -> Result<Vec<Skin>> {
|
||||
Ok(minecraft_skins::get_available_skins().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)`
|
||||
///
|
||||
/// See also: [minecraft_skins::add_and_equip_custom_skin]
|
||||
#[tauri::command]
|
||||
pub async fn add_and_equip_custom_skin(
|
||||
texture_blob: Bytes,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_override: Option<Cape>,
|
||||
) -> Result<()> {
|
||||
Ok(minecraft_skins::add_and_equip_custom_skin(
|
||||
texture_blob,
|
||||
variant,
|
||||
cape_override,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|set_default_cape', cape)`
|
||||
///
|
||||
/// See also: [minecraft_skins::set_default_cape]
|
||||
#[tauri::command]
|
||||
pub async fn set_default_cape(cape: Option<Cape>) -> Result<()> {
|
||||
Ok(minecraft_skins::set_default_cape(cape).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|equip_skin', skin)`
|
||||
///
|
||||
/// See also: [minecraft_skins::equip_skin]
|
||||
#[tauri::command]
|
||||
pub async fn equip_skin(skin: Skin) -> Result<()> {
|
||||
Ok(minecraft_skins::equip_skin(skin).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|remove_custom_skin', skin)`
|
||||
///
|
||||
/// See also: [minecraft_skins::remove_custom_skin]
|
||||
#[tauri::command]
|
||||
pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
|
||||
Ok(minecraft_skins::remove_custom_skin(skin).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|unequip_skin')`
|
||||
///
|
||||
/// See also: [minecraft_skins::unequip_skin]
|
||||
#[tauri::command]
|
||||
pub async fn unequip_skin() -> Result<()> {
|
||||
Ok(minecraft_skins::unequip_skin().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|normalize_skin_texture')`
|
||||
///
|
||||
/// See also: [minecraft_skins::normalize_skin_texture]
|
||||
#[tauri::command]
|
||||
pub async fn normalize_skin_texture(texture: UrlOrBlob) -> Result<Bytes> {
|
||||
Ok(minecraft_skins::normalize_skin_texture(&texture).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|get_dragged_skin_data', path)`
|
||||
///
|
||||
/// See also: [minecraft_skins::get_dragged_skin_data]
|
||||
#[tauri::command]
|
||||
pub async fn get_dragged_skin_data(path: String) -> Result<Bytes> {
|
||||
let path = Path::new(&path);
|
||||
Ok(minecraft_skins::get_dragged_skin_data(path).await?)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod import;
|
||||
pub mod jre;
|
||||
pub mod logs;
|
||||
pub mod metadata;
|
||||
pub mod minecraft_skins;
|
||||
pub mod mr_auth;
|
||||
pub mod pack;
|
||||
pub mod process;
|
||||
|
||||
@@ -28,7 +28,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
profile_update_managed_modrinth_version,
|
||||
profile_repair_managed_modrinth,
|
||||
profile_run,
|
||||
profile_run_credentials,
|
||||
profile_kill,
|
||||
profile_edit,
|
||||
profile_edit_icon,
|
||||
@@ -256,22 +255,6 @@ pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
// Run Minecraft using a profile using chosen credentials
|
||||
// Returns the UUID, which can be used to poll
|
||||
// for the actual Child in the state.
|
||||
// invoke('plugin:profile|profile_run_credentials', {path, credentials})')
|
||||
#[tauri::command]
|
||||
pub async fn profile_run_credentials(
|
||||
path: &str,
|
||||
credentials: Credentials,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let process =
|
||||
profile::run_credentials(path, &credentials, &QuickPlayType::None)
|
||||
.await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn profile_kill(path: &str) -> Result<()> {
|
||||
profile::kill(path).await?;
|
||||
|
||||
@@ -183,6 +183,7 @@ fn main() {
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
@@ -261,6 +262,7 @@ fn main() {
|
||||
.plugin(api::logs::init())
|
||||
.plugin(api::jre::init())
|
||||
.plugin(api::metadata::init())
|
||||
.plugin(api::minecraft_skins::init())
|
||||
.plugin(api::pack::init())
|
||||
.plugin(api::process::init())
|
||||
.plugin(api::profile::init())
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
"externalBin": [],
|
||||
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com",
|
||||
"nsis": {
|
||||
"installMode": "perMachine",
|
||||
"installerHooks": "./nsis/hooks.nsi"
|
||||
@@ -30,7 +27,6 @@
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"linux": {
|
||||
"deb": {
|
||||
@@ -45,7 +41,7 @@
|
||||
]
|
||||
},
|
||||
"productName": "AstralRinth App",
|
||||
"version": "0.9.501",
|
||||
"version": "0.10.1",
|
||||
"mainBinaryName": "AstralRinth App",
|
||||
"identifier": "AstralRinthApp",
|
||||
"plugins": {
|
||||
@@ -90,9 +86,9 @@
|
||||
"capabilities": ["core", "plugins"],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset:",
|
||||
"connect-src": "https://git.astralium.su ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
|
||||
"connect-src": "https://git.astralium.su ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
|
||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
|
||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||
"style-src": "'unsafe-inline' 'self'",
|
||||
"script-src": "https://*.posthog.com 'self'",
|
||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.87.0 AS build
|
||||
FROM rust:1.88.0 AS build
|
||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
Support: https://support.modrinth.com
|
||||
Status page: https://status.modrinth.com
|
||||
Roadmap: https://roadmap.modrinth.com
|
||||
Blog and newsletter: https://blog.modrinth.com/subscribe?utm_medium=social&utm_source=discord&utm_campaign=welcome
|
||||
Blog and newsletter: https://modrinth.com/news
|
||||
API documentation: https://docs.modrinth.com
|
||||
Modrinth source code: https://github.com/modrinth
|
||||
Help translate Modrinth: https://crowdin.com/project/modrinth
|
||||
|
||||
@@ -85,11 +85,10 @@ During development, you might notice that changes made directly to entities in t
|
||||
|
||||
#### CDN options
|
||||
|
||||
`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local`, `backblaze`, or `s3`, but defaults to `local`
|
||||
`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local` or `s3`, but defaults to `local`
|
||||
|
||||
The Backblaze and S3 configuration options are fairly self-explanatory in name, so here's simply their names:
|
||||
`BACKBLAZE_KEY_ID`, `BACKBLAZE_KEY`, `BACKBLAZE_BUCKET_ID`
|
||||
`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_BUCKET_NAME`
|
||||
The S3 configuration options are fairly self-explanatory in name, so here's simply their names:
|
||||
`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_PUBLIC_BUCKET_NAME`, `S3_PRIVATE_BUCKET_NAME`, `S3_USES_PATH_STYLE_BUCKETS`
|
||||
|
||||
#### Search, OAuth, and miscellaneous options
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@modrinth/blog": "workspace:*",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
@@ -59,7 +61,6 @@
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"three": "^0.172.0",
|
||||
"@types/three": "^0.172.0",
|
||||
"vue-multiselect": "3.0.0-alpha.2",
|
||||
"vue-typed-virtual-list": "^1.0.10",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<template>
|
||||
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
|
||||
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
|
||||
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
|
||||
<nuxt-link to="/plus" class="mt-auto items-center gap-1 text-purple hover:underline">
|
||||
<span>
|
||||
Support creators and Modrinth ad-free with
|
||||
<span class="font-bold">Modrinth+</span>
|
||||
</span>
|
||||
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<nuxt-link
|
||||
to="/servers"
|
||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
||||
>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="light-image hidden rounded-[inherit]"
|
||||
/>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="dark-image rounded-[inherit]"
|
||||
/>
|
||||
</nuxt-link>
|
||||
<div
|
||||
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
|
||||
>
|
||||
@@ -18,8 +23,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ChevronRightIcon } from "@modrinth/assets";
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
// {
|
||||
@@ -137,3 +140,16 @@ iframe[id^="google_ads_iframe"] {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.light,
|
||||
.light-mode {
|
||||
.dark-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.light-image {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</h1>
|
||||
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
||||
<button v-tooltip="`Exit moderation`" @click="exitModeration">
|
||||
<CrossIcon />
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
@@ -306,7 +306,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="!done">
|
||||
<button aria-label="Skip" @click="goToNextProject">
|
||||
<ExitIcon aria-hidden="true" />
|
||||
<XIcon aria-hidden="true" />
|
||||
<template v-if="futureProjects.length > 0">Skip</template>
|
||||
<template v-else>Exit</template>
|
||||
</button>
|
||||
@@ -335,7 +335,7 @@
|
||||
<div class="joined-buttons">
|
||||
<ButtonStyled color="red">
|
||||
<button @click="sendMessage('rejected')">
|
||||
<CrossIcon aria-hidden="true" /> Reject
|
||||
<XIcon aria-hidden="true" /> Reject
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
@@ -373,9 +373,8 @@ import {
|
||||
UpdatedIcon,
|
||||
CheckIcon,
|
||||
DropdownIcon,
|
||||
XIcon as CrossIcon,
|
||||
EyeOffIcon,
|
||||
ExitIcon,
|
||||
XIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";
|
||||
|
||||
51
apps/frontend/src/components/ui/NewsletterButton.vue
Normal file
51
apps/frontend/src/components/ui/NewsletterButton.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { MailIcon, CheckIcon } from "@modrinth/assets";
|
||||
import { ref, watchEffect } from "vue";
|
||||
import { useBaseFetch } from "~/composables/fetch.js";
|
||||
|
||||
const auth = await useAuth();
|
||||
const showSubscriptionConfirmation = ref(false);
|
||||
const subscribed = ref(false);
|
||||
|
||||
async function checkSubscribed() {
|
||||
if (auth.value?.user) {
|
||||
try {
|
||||
const { data } = await useBaseFetch("auth/email/subscribe", {
|
||||
method: "GET",
|
||||
});
|
||||
subscribed.value = data?.subscribed || false;
|
||||
} catch {
|
||||
subscribed.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
checkSubscribed();
|
||||
});
|
||||
|
||||
async function subscribe() {
|
||||
try {
|
||||
await useBaseFetch("auth/email/subscribe", {
|
||||
method: "POST",
|
||||
});
|
||||
showSubscriptionConfirmation.value = true;
|
||||
} catch {
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
showSubscriptionConfirmation.value = false;
|
||||
subscribed.value = true;
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonStyled v-if="auth?.user && !subscribed" color="brand" type="outlined">
|
||||
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
|
||||
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
|
||||
<template v-else> <CheckIcon /> Subscribed! </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="vue-notification-group experimental-styles-within">
|
||||
<div
|
||||
class="vue-notification-group experimental-styles-within"
|
||||
:class="{ 'intercom-present': isIntercomPresent }"
|
||||
>
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
v-for="(item, index) in notifications"
|
||||
@@ -80,6 +83,8 @@ import {
|
||||
} from "@modrinth/assets";
|
||||
const notifications = useNotifications();
|
||||
|
||||
const isIntercomPresent = ref(false);
|
||||
|
||||
function stopTimer(notif) {
|
||||
clearTimeout(notif.timer);
|
||||
}
|
||||
@@ -106,6 +111,27 @@ const createNotifText = (notif) => {
|
||||
return text;
|
||||
};
|
||||
|
||||
function checkIntercomPresence() {
|
||||
isIntercomPresent.value = !!document.querySelector(".intercom-lightweight-app");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkIntercomPresence();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
checkIntercomPresence();
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
function copyToClipboard(notif) {
|
||||
const text = createNotifText(notif);
|
||||
|
||||
@@ -130,6 +156,10 @@ function copyToClipboard(notif) {
|
||||
bottom: 0.75rem;
|
||||
}
|
||||
|
||||
&.intercom-present {
|
||||
bottom: 5rem;
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="table-cell">
|
||||
<BoxIcon />
|
||||
<span>{{
|
||||
$formatProjectType(
|
||||
formatProjectType(
|
||||
$getProjectTypeForDisplay(
|
||||
project.project_types?.[0] ?? "project",
|
||||
project.loaders,
|
||||
@@ -111,6 +111,7 @@
|
||||
<script setup>
|
||||
import { BoxIcon, SettingsIcon, TransferIcon, XIcon } from "@modrinth/assets";
|
||||
import { Button, Modal, Checkbox, CopyCode, Avatar } from "@modrinth/ui";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
|
||||
const modalOpen = ref(null);
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
ScaleIcon,
|
||||
DropdownIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatProjectType } from "~/plugins/shorthands.js";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
86
apps/frontend/src/components/ui/ShareArticleButtons.vue
Normal file
86
apps/frontend/src/components/ui/ShareArticleButtons.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="`Share on Bluesky`"
|
||||
:href="`https://bsky.app/intent/compose?text=${encodedUrl}`"
|
||||
target="_blank"
|
||||
>
|
||||
<BlueskyIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="`Share on Mastodon`"
|
||||
:href="`https://tootpick.org/#text=${encodedUrl}`"
|
||||
target="_blank"
|
||||
>
|
||||
<MastodonIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="`Share on X`"
|
||||
:href="`https://www.x.com/intent/post?url=${encodedUrl}`"
|
||||
target="_blank"
|
||||
>
|
||||
<TwitterIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="`Share via email`"
|
||||
:href="`mailto:${encodedTitle ? `?subject=${encodedTitle}&` : `?`}body=${encodedUrl}`"
|
||||
target="_blank"
|
||||
>
|
||||
<MailIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="copied ? `Copied to clipboard` : `Copy link`"
|
||||
:disabled="copied"
|
||||
class="relative grid place-items-center overflow-hidden"
|
||||
@click="copyToClipboard(url)"
|
||||
>
|
||||
<CheckIcon
|
||||
class="absolute transition-all ease-in-out"
|
||||
:class="copied ? 'translate-y-0' : 'translate-y-7'"
|
||||
/>
|
||||
<LinkIcon
|
||||
class="absolute transition-all ease-in-out"
|
||||
:class="copied ? '-translate-y-7' : 'translate-y-0'"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BlueskyIcon,
|
||||
CheckIcon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
MastodonIcon,
|
||||
TwitterIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string;
|
||||
url: string;
|
||||
}>();
|
||||
|
||||
const copied = ref(false);
|
||||
const encodedUrl = computed(() => encodeURIComponent(props.url));
|
||||
const encodedTitle = computed(() => (props.title ? encodeURIComponent(props.title) : undefined));
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
49
apps/frontend/src/components/ui/news/LatestNewsRow.vue
Normal file
49
apps/frontend/src/components/ui/news/LatestNewsRow.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="mx-2 p-4 !py-8 sm:mx-8 sm:p-32">
|
||||
<div class="my-8 flex items-center justify-between">
|
||||
<h2 class="m-0 mx-auto text-3xl font-extrabold sm:text-4xl">Latest news from Modrinth</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="latestArticles" class="grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4">
|
||||
<div
|
||||
v-for="(article, index) in latestArticles"
|
||||
:key="article.slug"
|
||||
:class="{ 'max-xl:hidden': index === 2 }"
|
||||
>
|
||||
<NewsArticleCard :article="article" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-2 my-8 flex w-full items-center justify-center">
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<nuxt-link to="/news">
|
||||
<NewspaperIcon />
|
||||
View all news
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NewspaperIcon } from "@modrinth/assets";
|
||||
import { articles as rawArticles } from "@modrinth/blog";
|
||||
import { ButtonStyled, NewsArticleCard } from "@modrinth/ui";
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
const articles = ref(
|
||||
rawArticles
|
||||
.map((article) => ({
|
||||
...article,
|
||||
path: `/news/article/${article.slug}/`,
|
||||
thumbnail: article.thumbnail
|
||||
? `/news/article/${article.slug}/thumbnail.webp`
|
||||
: `/news/default.webp`,
|
||||
title: article.title,
|
||||
summary: article.summary,
|
||||
date: article.date,
|
||||
}))
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
|
||||
);
|
||||
|
||||
const latestArticles = computed(() => articles.value.slice(0, 3));
|
||||
</script>
|
||||
@@ -11,8 +11,8 @@
|
||||
<div class="stacked">
|
||||
<span class="title">{{ report.project.title }}</span>
|
||||
<span>{{
|
||||
$formatProjectType(
|
||||
$getProjectTypeForUrl(report.project.project_type, report.project.loaders),
|
||||
formatProjectType(
|
||||
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
@@ -53,8 +53,8 @@
|
||||
<div class="stacked">
|
||||
<span class="title">{{ report.project.title }}</span>
|
||||
<span>{{
|
||||
$formatProjectType(
|
||||
$getProjectTypeForUrl(report.project.project_type, report.project.loaders),
|
||||
formatProjectType(
|
||||
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
@@ -105,8 +105,10 @@
|
||||
<script setup>
|
||||
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
|
||||
import { Avatar, Badge, CopyCode, useRelativeTime } from "@modrinth/ui";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
import { renderHighlightedString } from "~/helpers/highlight.js";
|
||||
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
|
||||
import { getProjectTypeForUrl } from "~/helpers/projects.js";
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
<span
|
||||
v-for="category in categoriesFiltered"
|
||||
:key="category.name"
|
||||
v-html="category.icon + $formatCategory(category.name)"
|
||||
v-html="category.icon + formatCategory(category.name)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatCategory } from "@modrinth/utils";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
categories: {
|
||||
@@ -38,6 +40,7 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: { formatCategory },
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from "@modrinth/ui";
|
||||
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||
import { ref } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Backup } from "@modrinth/utils";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
@@ -52,9 +52,10 @@ const failedToCreate = computed(() => props.backup.interrupted);
|
||||
const preparedDownloadStates = ["ready", "done"];
|
||||
const inactiveStates = ["failed", "cancelled"];
|
||||
|
||||
const hasPreparedDownload = computed(() =>
|
||||
preparedDownloadStates.includes(props.backup.task?.file?.state ?? ""),
|
||||
);
|
||||
const hasPreparedDownload = computed(() => {
|
||||
const fileState = props.backup.task?.file?.state ?? "";
|
||||
return preparedDownloadStates.includes(fileState);
|
||||
});
|
||||
|
||||
const creating = computed(() => {
|
||||
const task = props.backup.task?.create;
|
||||
@@ -81,6 +82,10 @@ const restoring = computed(() => {
|
||||
const initiatedPrepare = ref(false);
|
||||
|
||||
const preparingFile = computed(() => {
|
||||
if (hasPreparedDownload.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const task = props.backup.task?.file;
|
||||
return (
|
||||
(!task && initiatedPrepare.value) ||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
|
||||
<template #title>
|
||||
<div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]">
|
||||
<UiAvatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
|
||||
<Avatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
|
||||
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -185,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.
|
||||
</span>
|
||||
<LazyUiCopyCode class="!mt-2 !break-all" :text="versionsError" />
|
||||
<CopyCode class="!mt-2 !break-all" :text="versionsError" />
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
@@ -236,7 +236,7 @@ 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 Mod, type Version } from "@modrinth/utils";
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="
|
||||
item.status === 'error' ||
|
||||
item.status.includes('error') ||
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'incorrect-type'
|
||||
"
|
||||
@@ -54,9 +54,14 @@
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error'">
|
||||
<template v-else-if="item.status === 'error-file-exists'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error-generic'">
|
||||
<span class="text-red"
|
||||
>Failed - {{ item.error?.message || "An unexpected error occured." }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</span>
|
||||
</template>
|
||||
@@ -104,9 +109,17 @@ import { FSModule } from "~/composables/servers/modules/fs.ts";
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
progress: number;
|
||||
status: "pending" | "uploading" | "completed" | "error" | "cancelled" | "incorrect-type";
|
||||
status:
|
||||
| "pending"
|
||||
| "uploading"
|
||||
| "completed"
|
||||
| "error-file-exists"
|
||||
| "error-generic"
|
||||
| "cancelled"
|
||||
| "incorrect-type";
|
||||
size: string;
|
||||
uploader?: any;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -245,8 +258,18 @@ const uploadFile = async (file: File) => {
|
||||
console.error("Error uploading file:", error);
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status =
|
||||
error instanceof Error && error.message === badFileTypeMsg ? "incorrect-type" : "error";
|
||||
const target = uploadQueue.value[index];
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === badFileTypeMsg) {
|
||||
target.status = "incorrect-type";
|
||||
} else if (target.progress === 100 && error.message.includes("401")) {
|
||||
target.status = "error-file-exists";
|
||||
} else {
|
||||
target.status = "error-generic";
|
||||
target.error = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
|
||||
@@ -1,124 +1,176 @@
|
||||
<template>
|
||||
<NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @show="onShow">
|
||||
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<p
|
||||
v-if="isMrpackModalSecondPhase"
|
||||
:style="{
|
||||
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
|
||||
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
|
||||
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
|
||||
}"
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
This will reinstall your server and erase all data. You may want to back up your server
|
||||
before proceeding. Are you sure you want to continue?
|
||||
</p>
|
||||
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
|
||||
<div class="mx-auto flex flex-row items-center gap-4">
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<UploadIcon class="size-10" />
|
||||
<div v-if="isLoading" class="w-full">
|
||||
<div class="mb-2 flex justify-between text-sm">
|
||||
<Transition name="phrase-fade" mode="out-in">
|
||||
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
|
||||
currentPhrase
|
||||
}}</span>
|
||||
</Transition>
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
|
||||
<span class="text-xs text-secondary"
|
||||
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M5 9v6" />
|
||||
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
||||
</svg>
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||
>
|
||||
<ServerIcon class="size-10" />
|
||||
<div class="h-2 w-full rounded-full bg-divider">
|
||||
<div
|
||||
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
|
||||
:style="{ width: `${uploadProgress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".mrpack"
|
||||
class=""
|
||||
:disabled="isLoading"
|
||||
@change="uploadMrpack"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<input
|
||||
id="hard-reset"
|
||||
v-model="hardReset"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||
then reinstalls it with the selected version.
|
||||
</div>
|
||||
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
||||
</div>
|
||||
|
||||
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
||||
</div>
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="canInstall || backupInProgress"
|
||||
@click="handleReinstall"
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="!isLoading" class="flex flex-col gap-4">
|
||||
<p
|
||||
v-if="isMrpackModalSecondPhase"
|
||||
:style="{
|
||||
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
|
||||
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
|
||||
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
|
||||
}"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isMrpackModalSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
This will reinstall your server and erase all data. You may want to back up your server
|
||||
before proceeding. Are you sure you want to continue?
|
||||
</p>
|
||||
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
|
||||
<div class="mx-auto flex flex-row items-center gap-4">
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<UploadIcon class="size-10" />
|
||||
</div>
|
||||
<ArrowBigRightDashIcon class="size-10" />
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||
>
|
||||
<ServerIcon class="size-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".mrpack"
|
||||
class=""
|
||||
:disabled="isLoading"
|
||||
@change="uploadMrpack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<input
|
||||
id="hard-reset"
|
||||
v-model="hardReset"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Removes all data on your server, including your worlds, mods, and configuration
|
||||
files, then reinstalls it with the selected version.
|
||||
</div>
|
||||
<div class="font-bold">
|
||||
This does not affect your backups, which are stored off-site.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
||||
</div>
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? backupInProgress.tooltip : undefined"
|
||||
:disabled="canInstall || !!backupInProgress"
|
||||
@click="handleReinstall"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isMrpackModalSecondPhase
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
@click="
|
||||
() => {
|
||||
if (isMrpackModalSecondPhase) {
|
||||
isMrpackModalSecondPhase = false;
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
@click="
|
||||
() => {
|
||||
if (isMrpackModalSecondPhase) {
|
||||
isMrpackModalSecondPhase = false;
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
|
||||
import { ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import {
|
||||
UploadIcon,
|
||||
RightArrowIcon,
|
||||
XIcon,
|
||||
ServerIcon,
|
||||
ArrowBigRightDashIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
import type { ModrinthServer } from "~/composables/servers/modrinth-servers";
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (isLoading.value) {
|
||||
event.preventDefault();
|
||||
return "Upload in progress. Are you sure you want to leave?";
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
@@ -135,6 +187,49 @@ const hardReset = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
const mrpackFile = ref<File | null>(null);
|
||||
const uploadProgress = ref(0);
|
||||
const uploadedBytes = ref(0);
|
||||
const totalBytes = ref(0);
|
||||
|
||||
const uploadPhrases = [
|
||||
"Removing Herobrine...",
|
||||
"Feeding parrots...",
|
||||
"Teaching villagers new trades...",
|
||||
"Convincing creepers to be friendly...",
|
||||
"Polishing diamonds...",
|
||||
"Training wolves to fetch...",
|
||||
"Building pixel art...",
|
||||
"Explaining redstone to beginners...",
|
||||
"Collecting all the cats...",
|
||||
"Negotiating with endermen...",
|
||||
"Planting suspicious stew ingredients...",
|
||||
"Calibrating TNT blast radius...",
|
||||
"Teaching chickens to fly...",
|
||||
"Sorting inventory alphabetically...",
|
||||
"Convincing iron golems to smile...",
|
||||
];
|
||||
|
||||
const currentPhrase = ref("Uploading...");
|
||||
let phraseInterval: NodeJS.Timeout | null = null;
|
||||
const usedPhrases = ref(new Set<number>());
|
||||
|
||||
const getNextPhrase = () => {
|
||||
if (usedPhrases.value.size >= uploadPhrases.length) {
|
||||
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
|
||||
usedPhrases.value.clear();
|
||||
if (currentPhraseIndex !== -1) {
|
||||
usedPhrases.value.add(currentPhraseIndex);
|
||||
}
|
||||
}
|
||||
const availableIndices = uploadPhrases
|
||||
.map((_, index) => index)
|
||||
.filter((index) => !usedPhrases.value.has(index));
|
||||
|
||||
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
|
||||
usedPhrases.value.add(randomIndex);
|
||||
|
||||
return uploadPhrases[randomIndex];
|
||||
};
|
||||
|
||||
const isDangerous = computed(() => hardReset.value);
|
||||
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
|
||||
@@ -153,18 +248,46 @@ const handleReinstall = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mrpackFile.value) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "No file selected",
|
||||
text: "Choose a .mrpack file before installing.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
uploadProgress.value = 0;
|
||||
uploadedBytes.value = 0;
|
||||
totalBytes.value = mrpackFile.value.size;
|
||||
|
||||
currentPhrase.value = getNextPhrase();
|
||||
phraseInterval = setInterval(() => {
|
||||
currentPhrase.value = getNextPhrase();
|
||||
}, 4500);
|
||||
|
||||
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
|
||||
mrpackFile.value,
|
||||
hardReset.value,
|
||||
);
|
||||
|
||||
onProgress(({ loaded, total, progress }) => {
|
||||
uploadProgress.value = progress;
|
||||
uploadedBytes.value = loaded;
|
||||
totalBytes.value = total;
|
||||
|
||||
if (phraseInterval && progress >= 100) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
currentPhrase.value = "Installing modpack...";
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (!mrpackFile.value) {
|
||||
throw new Error("No mrpack file selected");
|
||||
}
|
||||
|
||||
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
|
||||
type: mrpackFile.value.type,
|
||||
});
|
||||
|
||||
await props.server.general?.reinstallFromMrpack(mrpack, hardReset.value);
|
||||
await promise;
|
||||
|
||||
emit("reinstall", {
|
||||
loader: "mrpack",
|
||||
@@ -176,36 +299,44 @@ const handleReinstall = async () => {
|
||||
window.scrollTo(0, 0);
|
||||
hide();
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
|
||||
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Cannot reinstall server",
|
||||
title: "Cannot upload and install modpack to server",
|
||||
text: "You are being rate limited. Please try again later.",
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Reinstall Failed",
|
||||
text: "An unexpected error occurred while reinstalling. Please try again later.",
|
||||
title: "Modpack upload and install failed",
|
||||
text: "An unexpected error occurred while uploading/installing. Please try again later.",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onShow = () => {
|
||||
hardReset.value = false;
|
||||
isMrpackModalSecondPhase.value = false;
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
mrpackFile.value = null;
|
||||
};
|
||||
|
||||
const onHide = () => {
|
||||
onShow();
|
||||
uploadProgress.value = 0;
|
||||
uploadedBytes.value = 0;
|
||||
totalBytes.value = 0;
|
||||
currentPhrase.value = "Uploading...";
|
||||
usedPhrases.value.clear();
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const show = () => mrpackModal.value?.show();
|
||||
@@ -218,4 +349,14 @@ defineExpose({ show, hide });
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
|
||||
.phrase-fade-enter-active,
|
||||
.phrase-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.phrase-fade-enter-from,
|
||||
.phrase-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
||||
>
|
||||
<UiAvatar
|
||||
<Avatar
|
||||
:src="iconUrl"
|
||||
no-shadow
|
||||
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
||||
@@ -74,7 +74,7 @@
|
||||
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
|
||||
update your billing information or contact Modrinth Support for more information.
|
||||
</div>
|
||||
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -83,6 +83,7 @@
|
||||
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
|
||||
import type { Project, Server } from "@modrinth/utils";
|
||||
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
|
||||
import { Avatar, CopyCode } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
|
||||
@@ -50,9 +50,7 @@
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="loading ? 'div' : 'NuxtLink'"
|
||||
<nuxt-link
|
||||
:to="loading ? undefined : `/servers/manage/${serverId}/files`"
|
||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
|
||||
@@ -64,16 +62,17 @@
|
||||
</div>
|
||||
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
|
||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||
</component>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, shallowRef } from "vue";
|
||||
import { FolderOpenIcon, CPUIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { FolderOpenIcon, CpuIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import type { Stats } from "@modrinth/utils";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id;
|
||||
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
||||
@@ -127,7 +126,7 @@ const metrics = computed(() => {
|
||||
title: "CPU usage",
|
||||
value: "0.00%",
|
||||
max: "100%",
|
||||
icon: CPUIcon,
|
||||
icon: CpuIcon,
|
||||
data: cpuData.value,
|
||||
showGraph: false,
|
||||
warning: null,
|
||||
@@ -158,17 +157,21 @@ const metrics = computed(() => {
|
||||
title: "CPU usage",
|
||||
value: `${cpuPercent.toFixed(2)}%`,
|
||||
max: "100%",
|
||||
icon: CPUIcon,
|
||||
icon: CpuIcon,
|
||||
data: cpuData.value,
|
||||
showGraph: true,
|
||||
warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
|
||||
},
|
||||
{
|
||||
title: "Memory usage",
|
||||
value: userPreferences.value.ramAsNumber
|
||||
? formatBytes(stats.value.ram_usage_bytes)
|
||||
: `${ramPercent.toFixed(2)}%`,
|
||||
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
|
||||
value:
|
||||
userPreferences.value.ramAsNumber || flags.developerMode
|
||||
? formatBytes(stats.value.ram_usage_bytes)
|
||||
: `${ramPercent.toFixed(2)}%`,
|
||||
max:
|
||||
userPreferences.value.ramAsNumber || flags.developerMode
|
||||
? formatBytes(stats.value.ram_total_bytes)
|
||||
: "100%",
|
||||
icon: DatabaseIcon,
|
||||
data: ramData.value,
|
||||
showGraph: true,
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { createFormatter, type Formatter } from "@vintl/compact-number";
|
||||
import type { IntlController } from "@vintl/vintl/controller";
|
||||
const formatters = new WeakMap<object, Intl.NumberFormat>();
|
||||
|
||||
const formatters = new WeakMap<IntlController<any>, Formatter>();
|
||||
export function useCompactNumber(truncate = false, fractionDigits = 2, locale?: string) {
|
||||
const context = {};
|
||||
|
||||
export function useCompactNumber(): Formatter {
|
||||
const vintl = useVIntl();
|
||||
let formatter = formatters.get(context);
|
||||
|
||||
let formatter = formatters.get(vintl);
|
||||
|
||||
if (formatter == null) {
|
||||
const formatterRef = computed(() => createFormatter(vintl.intl));
|
||||
formatter = (value, options) => formatterRef.value(value, options);
|
||||
formatters.set(vintl, formatter);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
formatters.set(context, formatter);
|
||||
}
|
||||
|
||||
return formatter;
|
||||
function format(value: number): string {
|
||||
let formattedValue = value;
|
||||
if (truncate) {
|
||||
const scale = Math.pow(10, fractionDigits);
|
||||
formattedValue = Math.floor(value * scale) / scale;
|
||||
}
|
||||
return formatter!.format(formattedValue);
|
||||
}
|
||||
|
||||
return format;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
projectBackground: false,
|
||||
searchBackground: false,
|
||||
advancedDebugInfo: false,
|
||||
showProjectPageDownloadModalServersPromo: false,
|
||||
showProjectPageCreateServersTooltip: true,
|
||||
showProjectPageQuickServerButton: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
|
||||
@@ -98,28 +98,67 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
}
|
||||
}
|
||||
|
||||
async reinstallFromMrpack(mrpack: File, hardReset: boolean = false): Promise<void> {
|
||||
reinstallFromMrpack(
|
||||
mrpack: File,
|
||||
hardReset: boolean = false,
|
||||
): {
|
||||
promise: Promise<void>;
|
||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void;
|
||||
} {
|
||||
const hardResetParam = hardReset ? "true" : "false";
|
||||
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", mrpack);
|
||||
const progressSubject = new EventTarget();
|
||||
|
||||
const response = await fetch(
|
||||
`https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30 * 60 * 1000),
|
||||
},
|
||||
);
|
||||
const uploadPromise = (async () => {
|
||||
try {
|
||||
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`[pyroservers] native fetch err status: ${response.status}`);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) {
|
||||
progressSubject.dispatchEvent(
|
||||
new CustomEvent("progress", {
|
||||
detail: {
|
||||
loaded: e.loaded,
|
||||
total: e.total,
|
||||
progress: (e.loaded / e.total) * 100,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () =>
|
||||
xhr.status >= 200 && xhr.status < 300
|
||||
? resolve()
|
||||
: reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`));
|
||||
|
||||
xhr.onerror = () => reject(new Error("[pyroservers] .mrpack upload failed"));
|
||||
xhr.onabort = () => reject(new Error("[pyroservers] .mrpack upload cancelled"));
|
||||
xhr.ontimeout = () => reject(new Error("[pyroservers] .mrpack upload timed out"));
|
||||
xhr.timeout = 30 * 60 * 1000;
|
||||
|
||||
xhr.open("POST", `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`);
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${auth.token}`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", mrpack);
|
||||
xhr.send(formData);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error reinstalling from mrpack:", err);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
promise: uploadPromise,
|
||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) =>
|
||||
progressSubject.addEventListener("progress", ((e: CustomEvent) =>
|
||||
cb(e.detail)) as EventListener),
|
||||
};
|
||||
}
|
||||
|
||||
async suspend(status: boolean): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatBytes } from "~/plugins/shorthands.js";
|
||||
import { formatBytes } from "@modrinth/utils";
|
||||
|
||||
export const fileIsValid = (file, validationOptions) => {
|
||||
const { maxSize, alertOnInvalid } = validationOptions;
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
"
|
||||
>
|
||||
<nuxt-link to="/resourcepacks">
|
||||
<PaintBrushIcon aria-hidden="true" /> Resource Packs
|
||||
<PaintbrushIcon aria-hidden="true" /> Resource Packs
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
@@ -221,7 +221,7 @@
|
||||
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PaintBrushIcon
|
||||
<PaintbrushIcon
|
||||
v-else-if="
|
||||
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
|
||||
"
|
||||
@@ -250,7 +250,7 @@
|
||||
|
||||
<template #mods> <BoxIcon aria-hidden="true" /> Mods </template>
|
||||
<template #resourcepacks>
|
||||
<PaintBrushIcon aria-hidden="true" /> Resource Packs
|
||||
<PaintbrushIcon aria-hidden="true" /> Resource Packs
|
||||
</template>
|
||||
<template #datapacks> <BracesIcon aria-hidden="true" /> Data Packs </template>
|
||||
<template #plugins> <PlugIcon aria-hidden="true" /> Plugins </template>
|
||||
@@ -696,14 +696,14 @@ import {
|
||||
CurrencyIcon,
|
||||
BracesIcon,
|
||||
GlassesIcon,
|
||||
PaintBrushIcon,
|
||||
PaintbrushIcon,
|
||||
PackageOpenIcon,
|
||||
DiscordIcon,
|
||||
BlueskyIcon,
|
||||
TumblrIcon,
|
||||
TwitterIcon,
|
||||
MastodonIcon,
|
||||
GitHubIcon,
|
||||
GithubIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
@@ -1202,7 +1202,7 @@ const socialLinks = [
|
||||
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
|
||||
),
|
||||
href: "https://github.com/modrinth",
|
||||
icon: GitHubIcon,
|
||||
icon: GithubIcon,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1211,9 +1211,9 @@ const footerLinks = [
|
||||
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
|
||||
links: [
|
||||
{
|
||||
href: "https://blog.modrinth.com",
|
||||
href: "/news",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }),
|
||||
defineMessage({ id: "layout.footer.about.news", defaultMessage: "News" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -383,8 +383,8 @@
|
||||
"layout.footer.about": {
|
||||
"message": "About"
|
||||
},
|
||||
"layout.footer.about.blog": {
|
||||
"message": "Blog"
|
||||
"layout.footer.about.news": {
|
||||
"message": "News"
|
||||
},
|
||||
"layout.footer.about.careers": {
|
||||
"message": "Careers"
|
||||
|
||||
@@ -452,6 +452,16 @@
|
||||
{{ formatCategory(currentPlatform) }}.
|
||||
</p>
|
||||
</AutomaticAccordion>
|
||||
<ServersPromo
|
||||
v-if="flags.showProjectPageDownloadModalServersPromo"
|
||||
:link="`/servers#plan`"
|
||||
@close="
|
||||
() => {
|
||||
flags.showProjectPageDownloadModalServersPromo = false;
|
||||
saveFeatureFlags();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
@@ -495,6 +505,64 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
|
||||
theme="dismissable-prompt"
|
||||
:triggers="[]"
|
||||
:shown="flags.showProjectPageCreateServersTooltip"
|
||||
:auto-hide="false"
|
||||
placement="bottom-start"
|
||||
>
|
||||
<ButtonStyled size="large" circular>
|
||||
<nuxt-link
|
||||
v-tooltip="'Create a server'"
|
||||
:to="`/servers?project=${project.id}#plan`"
|
||||
@click="
|
||||
() => {
|
||||
flags.showProjectPageCreateServersTooltip = false;
|
||||
saveFeatureFlags();
|
||||
}
|
||||
"
|
||||
>
|
||||
<ServerPlusIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<template #popper>
|
||||
<div class="experimental-styles-within flex max-w-60 flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="m-0 flex items-center gap-2 text-base font-bold text-contrast">
|
||||
Create a server
|
||||
<TagItem
|
||||
:style="{
|
||||
'--_color': 'var(--color-brand)',
|
||||
'--_bg-color': 'var(--color-brand-highlight)',
|
||||
}"
|
||||
>New</TagItem
|
||||
>
|
||||
</h3>
|
||||
<ButtonStyled size="small" circular>
|
||||
<button
|
||||
v-tooltip="`Don't show again`"
|
||||
@click="
|
||||
() => {
|
||||
flags.showProjectPageCreateServersTooltip = false;
|
||||
saveFeatureFlags();
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||
Modrinth Servers is the easiest way to play with your friends without hassle!
|
||||
</p>
|
||||
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
||||
Starting at $5<span class="text-xs"> / month</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<ClientOnly>
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
@@ -694,12 +762,7 @@
|
||||
:tags="tags"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<!-- <AdPlaceholder
|
||||
v-if="
|
||||
(!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus) &&
|
||||
tags.approvedStatuses.includes(project.status)
|
||||
"
|
||||
/> -->
|
||||
<!-- <AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" /> -->
|
||||
<ProjectSidebarLinks
|
||||
:project="project"
|
||||
:link-target="$external()"
|
||||
@@ -850,12 +913,14 @@ import {
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
SearchIcon,
|
||||
ServerPlusIcon,
|
||||
SettingsIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
ModrinthIcon,
|
||||
XIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -872,12 +937,22 @@ import {
|
||||
ProjectSidebarLinks,
|
||||
ProjectStatusBadge,
|
||||
ScrollablePanel,
|
||||
TagItem,
|
||||
ServersPromo,
|
||||
useRelativeTime,
|
||||
} from "@modrinth/ui";
|
||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
||||
import {
|
||||
formatCategory,
|
||||
formatProjectType,
|
||||
isRejected,
|
||||
isStaff,
|
||||
isUnderReview,
|
||||
renderString,
|
||||
} from "@modrinth/utils";
|
||||
import { navigateTo } from "#app";
|
||||
import dayjs from "dayjs";
|
||||
import { Tooltip } from "floating-vue";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
@@ -891,6 +966,7 @@ import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
||||
import { userCollectProject } from "~/composables/user.js";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||
|
||||
const data = useNuxtApp();
|
||||
const route = useNativeRoute();
|
||||
@@ -1286,7 +1362,7 @@ featuredVersions.value.sort((a, b) => {
|
||||
});
|
||||
|
||||
const projectTypeDisplay = computed(() =>
|
||||
data.$formatProjectType(
|
||||
formatProjectType(
|
||||
data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders),
|
||||
),
|
||||
);
|
||||
@@ -1304,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,
|
||||
@@ -1672,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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
Listed in search results
|
||||
</li>
|
||||
<li v-else>
|
||||
<ExitIcon class="bad" />
|
||||
<XIcon class="bad" />
|
||||
Not listed in search results
|
||||
</li>
|
||||
<li v-if="isListed(project)">
|
||||
@@ -58,11 +58,11 @@
|
||||
Listed on the profiles of members
|
||||
</li>
|
||||
<li v-else>
|
||||
<ExitIcon class="bad" />
|
||||
<XIcon class="bad" />
|
||||
Not listed on the profiles of members
|
||||
</li>
|
||||
<li v-if="isPrivate(project)">
|
||||
<ExitIcon class="bad" />
|
||||
<XIcon class="bad" />
|
||||
Not accessible with a direct link
|
||||
</li>
|
||||
<li v-else>
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ExitIcon, CheckIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { XIcon, CheckIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { Badge } from "@modrinth/ui";
|
||||
import ConversationThread from "~/components/ui/thread/ConversationThread.vue";
|
||||
import {
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<span class="label__title">Client-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
client side. Just because a mod works in Singleplayer doesn't mean it has actual
|
||||
client-side functionality.
|
||||
</span>
|
||||
@@ -128,7 +128,7 @@
|
||||
<span class="label__title">Server-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
|
||||
server.
|
||||
</span>
|
||||
@@ -239,7 +239,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatProjectStatus } from "@modrinth/utils";
|
||||
import { formatProjectStatus, formatProjectType } from "@modrinth/utils";
|
||||
import { UploadIcon, SaveIcon, TrashIcon, XIcon, IssuesIcon, CheckIcon } from "@modrinth/assets";
|
||||
import { Multiselect } from "vue-multiselect";
|
||||
import { ConfirmModal, Avatar } from "@modrinth/ui";
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
|
||||
list or provide a custom license. You may also provide a custom URL to your chosen license;
|
||||
otherwise, the license text will be displayed. See our
|
||||
<a
|
||||
href="https://blog.modrinth.com/licensing-guide/"
|
||||
<nuxt-link
|
||||
to="/news/article/licensing-guide/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-link"
|
||||
>
|
||||
licensing guide
|
||||
</a>
|
||||
</nuxt-link>
|
||||
for more information.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<p>
|
||||
Accurate tagging is important to help people find your
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
|
||||
that apply.
|
||||
</p>
|
||||
<p v-if="project.versions.length === 0" class="known-errors">
|
||||
@@ -18,25 +18,25 @@
|
||||
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
|
||||
<div class="label">
|
||||
<h4>
|
||||
<span class="label__title">{{ $formatCategoryHeader(header) }}</span>
|
||||
<span class="label__title">{{ formatCategoryHeader(header) }}</span>
|
||||
</h4>
|
||||
<span class="label__description">
|
||||
<template v-if="header === 'categories'">
|
||||
Select all categories that reflect the themes or function of your
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }}.
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }}.
|
||||
</template>
|
||||
<template v-else-if="header === 'features'">
|
||||
Select all of the features that your
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }} makes use of.
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} makes use of.
|
||||
</template>
|
||||
<template v-else-if="header === 'resolutions'">
|
||||
Select the resolution(s) of textures in your
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }}.
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }}.
|
||||
</template>
|
||||
<template v-else-if="header === 'performance impact'">
|
||||
Select the realistic performance impact of your
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }} is configurable to
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} is configurable to
|
||||
different levels of performance impact.
|
||||
</template>
|
||||
</span>
|
||||
@@ -46,7 +46,7 @@
|
||||
v-for="category in categoryLists[header]"
|
||||
:key="`category-${header}-${category.name}`"
|
||||
:model-value="selectedTags.includes(category)"
|
||||
:description="$formatCategory(category.name)"
|
||||
:description="formatCategory(category.name)"
|
||||
class="category-selector"
|
||||
@update:model-value="toggleCategory(category)"
|
||||
>
|
||||
@@ -57,7 +57,7 @@
|
||||
class="icon"
|
||||
v-html="category.icon"
|
||||
/>
|
||||
<span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
|
||||
<span aria-hidden="true"> {{ formatCategory(category.name) }}</span>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@
|
||||
:key="`featured-category-${category.name}`"
|
||||
class="category-selector"
|
||||
:model-value="featuredTags.includes(category)"
|
||||
:description="$formatCategory(category.name)"
|
||||
:description="formatCategory(category.name)"
|
||||
:disabled="featuredTags.length >= 3 && !featuredTags.includes(category)"
|
||||
@update:model-value="toggleFeaturedCategory(category)"
|
||||
>
|
||||
@@ -91,7 +91,7 @@
|
||||
class="icon"
|
||||
v-html="category.icon"
|
||||
/>
|
||||
<span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
|
||||
<span aria-hidden="true"> {{ formatCategory(category.name) }}</span>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
@@ -114,6 +114,7 @@
|
||||
|
||||
<script>
|
||||
import { StarIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { formatCategory, formatCategoryHeader, formatProjectType } from "@modrinth/utils";
|
||||
import Checkbox from "~/components/ui/Checkbox.vue";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
@@ -222,6 +223,9 @@ export default defineNuxtComponent({
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatProjectType,
|
||||
formatCategoryHeader,
|
||||
formatCategory,
|
||||
toggleCategory(category) {
|
||||
if (this.selectedTags.includes(category)) {
|
||||
this.selectedTags = this.selectedTags.filter((x) => x !== category);
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
<div v-else class="input-group">
|
||||
<ButtonStyled v-if="primaryFile" color="brand">
|
||||
<a
|
||||
v-tooltip="primaryFile.filename + ' (' + $formatBytes(primaryFile.size) + ')'"
|
||||
v-tooltip="primaryFile.filename + ' (' + formatBytes(primaryFile.size) + ')'"
|
||||
:href="primaryFile.url"
|
||||
@click="emit('onDownload')"
|
||||
>
|
||||
@@ -320,7 +320,7 @@
|
||||
<FileIcon aria-hidden="true" />
|
||||
<span class="filename">
|
||||
<strong>{{ replaceFile.name }}</strong>
|
||||
<span class="file-size">({{ $formatBytes(replaceFile.size) }})</span>
|
||||
<span class="file-size">({{ formatBytes(replaceFile.size) }})</span>
|
||||
</span>
|
||||
<FileInput
|
||||
class="iconified-button raised-button"
|
||||
@@ -345,7 +345,7 @@
|
||||
<FileIcon aria-hidden="true" />
|
||||
<span class="filename">
|
||||
<strong>{{ file.filename }}</strong>
|
||||
<span class="file-size">({{ $formatBytes(file.size) }})</span>
|
||||
<span class="file-size">({{ formatBytes(file.size) }})</span>
|
||||
<span v-if="primaryFile.hashes.sha1 === file.hashes.sha1" class="file-type">
|
||||
Primary
|
||||
</span>
|
||||
@@ -412,7 +412,7 @@
|
||||
<FileIcon aria-hidden="true" />
|
||||
<span class="filename">
|
||||
<strong>{{ file.name }}</strong>
|
||||
<span class="file-size">({{ $formatBytes(file.size) }})</span>
|
||||
<span class="file-size">({{ formatBytes(file.size) }})</span>
|
||||
</span>
|
||||
<multiselect
|
||||
v-if="version.loaders.some((x) => tags.loaderData.dataPackLoaders.includes(x))"
|
||||
@@ -533,7 +533,7 @@
|
||||
)
|
||||
.map((it) => it.name)
|
||||
"
|
||||
:custom-label="(value) => $formatCategory(value)"
|
||||
:custom-label="formatCategory"
|
||||
:loading="tags.loaders.length === 0"
|
||||
:multiple="true"
|
||||
:searchable="true"
|
||||
@@ -657,6 +657,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Multiselect } from "vue-multiselect";
|
||||
import { formatBytes, formatCategory } from "@modrinth/utils";
|
||||
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
|
||||
import { inferVersionInfo } from "~/helpers/infer.js";
|
||||
import { createDataPackVersion } from "~/helpers/package.js";
|
||||
@@ -962,6 +963,8 @@ export default defineNuxtComponent({
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatBytes,
|
||||
formatCategory,
|
||||
async onImageUpload(file) {
|
||||
const response = await useImageUpload(file, { context: "version" });
|
||||
|
||||
|
||||
@@ -153,10 +153,7 @@
|
||||
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
||||
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
||||
</span>
|
||||
<div
|
||||
v-if="flags.developerMode"
|
||||
class="flex w-full items-center gap-1 text-xs text-secondary"
|
||||
>
|
||||
<div class="flex w-full items-center gap-1 text-xs text-secondary">
|
||||
{{ charge.status }}
|
||||
⋅
|
||||
{{ charge.type }}
|
||||
@@ -219,7 +216,6 @@ import dayjs from "dayjs";
|
||||
import { products } from "~/generated/state.json";
|
||||
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
const route = useRoute();
|
||||
const data = useNuxtApp();
|
||||
const vintl = useVIntl();
|
||||
@@ -289,13 +285,13 @@ const selectedCharge = ref(null);
|
||||
const refundType = ref("full");
|
||||
const refundTypes = ref(["full", "partial", "none"]);
|
||||
const refundAmount = ref(0);
|
||||
const unprovision = ref(false);
|
||||
const unprovision = ref(true);
|
||||
|
||||
function showRefundModal(charge) {
|
||||
selectedCharge.value = charge;
|
||||
refundType.value = "full";
|
||||
refundAmount.value = 0;
|
||||
unprovision.value = false;
|
||||
unprovision.value = true;
|
||||
refundModal.value.show();
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -248,9 +248,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- <AdPlaceholder
|
||||
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
|
||||
/> -->
|
||||
<!-- <AdPlaceholder v-if="!auth.user" /> -->
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<nav class="navigation-card">
|
||||
@@ -492,7 +490,6 @@ const route = useNativeRoute();
|
||||
const auth = await useAuth();
|
||||
const cosmetics = useCosmetics();
|
||||
const tags = useTags();
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
const isEditing = ref(false);
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
v-if="notifTypes.length > 1"
|
||||
v-model="selectedType"
|
||||
:items="notifTypes"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : $formatProjectType(x).replace('_', ' ') + 's')"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x).replace('_', ' ') + 's')"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<p v-if="pending">Loading notifications...</p>
|
||||
@@ -58,6 +58,7 @@
|
||||
<script setup>
|
||||
import { Button, Pagination, Chips } from "@modrinth/ui";
|
||||
import { HistoryIcon, CheckCheckIcon } from "@modrinth/assets";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
import {
|
||||
fetchExtraNotificationData,
|
||||
groupNotifications,
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
/>
|
||||
<div class="push-right input-group">
|
||||
<button class="iconified-button" @click="$refs.editLinksModal.hide()">
|
||||
<CrossIcon />
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button class="iconified-button brand-button" @click="bulkEditLinks()">
|
||||
@@ -199,8 +199,8 @@
|
||||
class="square-button"
|
||||
@click="updateDescending()"
|
||||
>
|
||||
<DescendingIcon v-if="descending" />
|
||||
<AscendingIcon v-else />
|
||||
<SortDescIcon v-if="descending" />
|
||||
<SortAscIcon v-else />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,7 +239,7 @@
|
||||
<div>
|
||||
<nuxt-link
|
||||
tabindex="-1"
|
||||
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}`"
|
||||
>
|
||||
@@ -261,7 +261,7 @@
|
||||
|
||||
<nuxt-link
|
||||
class="hover-link wrap-as-needed"
|
||||
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}`"
|
||||
>
|
||||
@@ -275,7 +275,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ $formatProjectType($getProjectTypeForUrl(project.project_type, project.loaders)) }}
|
||||
{{ formatProjectType(getProjectTypeForUrl(project.project_type, project.loaders)) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -285,7 +285,7 @@
|
||||
<div>
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link
|
||||
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/settings`"
|
||||
>
|
||||
@@ -306,12 +306,12 @@ import {
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
XIcon as CrossIcon,
|
||||
XIcon,
|
||||
IssuesIcon,
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
SortAscendingIcon as AscendingIcon,
|
||||
SortDescendingIcon as DescendingIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -321,9 +321,11 @@ import {
|
||||
ProjectStatusBadge,
|
||||
commonMessages,
|
||||
} from "@modrinth/ui";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
|
||||
import Modal from "~/components/ui/Modal.vue";
|
||||
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
||||
import { getProjectTypeForUrl } from "~/helpers/projects.js";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
@@ -335,15 +337,15 @@ export default defineNuxtComponent({
|
||||
Checkbox,
|
||||
IssuesIcon,
|
||||
PlusIcon,
|
||||
CrossIcon,
|
||||
XIcon,
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
Modal,
|
||||
ModalCreation,
|
||||
Multiselect,
|
||||
CopyCode,
|
||||
AscendingIcon,
|
||||
DescendingIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
},
|
||||
async setup() {
|
||||
const { formatMessage } = useVIntl();
|
||||
@@ -395,6 +397,8 @@ export default defineNuxtComponent({
|
||||
this.DELETE_PROJECT = 1 << 7;
|
||||
},
|
||||
methods: {
|
||||
getProjectTypeForUrl,
|
||||
formatProjectType,
|
||||
updateDescending() {
|
||||
this.descending = !this.descending;
|
||||
this.projects = this.updateSort(this.projects, this.sortBy, this.descending);
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</span>
|
||||
<template v-if="payout.method">
|
||||
<span>⋅</span>
|
||||
<span>{{ $formatWallet(payout.method) }} ({{ payout.method_address }})</span>
|
||||
<span>{{ formatWallet(payout.method) }} ({{ payout.method_address }})</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { XIcon, PayPalIcon, UnknownIcon } from "@modrinth/assets";
|
||||
import { capitalizeString } from "@modrinth/utils";
|
||||
import { capitalizeString, formatWallet } from "@modrinth/utils";
|
||||
import { Badge, Breadcrumbs, DropdownSelect } from "@modrinth/ui";
|
||||
import dayjs from "dayjs";
|
||||
import TremendousIcon from "~/assets/images/external/tremendous.svg?component";
|
||||
|
||||
@@ -139,8 +139,8 @@
|
||||
<template v-if="knownErrors.length === 0 && amount">
|
||||
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
|
||||
I acknowledge that an estimated
|
||||
{{ $formatMoney(fees) }} will be deducted from the amount I receive to cover
|
||||
{{ $formatWallet(selectedMethod.type) }} processing fees.
|
||||
{{ formatMoney(fees) }} will be deducted from the amount I receive to cover
|
||||
{{ formatWallet(selectedMethod.type) }} processing fees.
|
||||
</Checkbox>
|
||||
<Checkbox v-model="agreedTransfer" description="Confirm transfer">
|
||||
<template v-if="selectedMethod.type === 'tremendous'">
|
||||
@@ -149,7 +149,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
I confirm that I am initiating a transfer to the following
|
||||
{{ $formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
|
||||
{{ formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
|
||||
</template>
|
||||
</Checkbox>
|
||||
<Checkbox v-model="agreedTerms" class="rewards-checkbox">
|
||||
@@ -198,6 +198,7 @@ import {
|
||||
} from "@modrinth/assets";
|
||||
import { Chips, Checkbox, Breadcrumbs } from "@modrinth/ui";
|
||||
import { all } from "iso-3166-1";
|
||||
import { formatMoney, formatWallet } from "@modrinth/utils";
|
||||
import VenmoIcon from "~/assets/images/external/venmo.svg?component";
|
||||
|
||||
const auth = await useAuth();
|
||||
@@ -360,9 +361,7 @@ async function withdraw() {
|
||||
text:
|
||||
selectedMethod.value.type === "tremendous"
|
||||
? "An email has been sent to your account with further instructions on how to redeem your payout!"
|
||||
: `Payment has been sent to your ${data.$formatWallet(
|
||||
selectedMethod.value.type,
|
||||
)} account!`,
|
||||
: `Payment has been sent to your ${formatWallet(selectedMethod.value.type)} account!`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -147,9 +147,9 @@
|
||||
<tbody>
|
||||
<tr v-for="item in platformRevenueData" :key="item.time">
|
||||
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
|
||||
<td>{{ formatMoney(item.revenue) }}</td>
|
||||
<td>{{ formatMoney(item.creator_revenue) }}</td>
|
||||
<td>{{ formatMoney(item.revenue - item.creator_revenue) }}</td>
|
||||
<td>{{ formatMoney(Number(item.revenue) + Number(item.creator_revenue)) }}</td>
|
||||
<td>{{ formatMoney(Number(item.creator_revenue)) }}</td>
|
||||
<td>{{ formatMoney(Number(item.revenue)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -187,6 +187,6 @@ const { data: transparencyInformation } = await useAsyncData("payout/platform_re
|
||||
}),
|
||||
);
|
||||
|
||||
const platformRevenue = transparencyInformation.value.all_time;
|
||||
const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
|
||||
const platformRevenue = (transparencyInformation.value as any)?.all_time;
|
||||
const platformRevenueData = (transparencyInformation.value as any)?.data?.slice(0, 5) ?? [];
|
||||
</script>
|
||||
|
||||
@@ -122,8 +122,8 @@
|
||||
<h3>Creator Monetization Program data</h3>
|
||||
<p>
|
||||
When you sign up for our
|
||||
<a href="https://blog.modrinth.com/p/creator-monetization-beta">
|
||||
Creator Monetization Program</a
|
||||
<nuxt-link to="/news/article/creator-monetization-beta">
|
||||
Creator Monetization Program</nuxt-link
|
||||
>
|
||||
(the "CMP"), we collect:
|
||||
</p>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { formatNumber } from "~/plugins/shorthands.js";
|
||||
import { formatNumber } from "@modrinth/utils";
|
||||
|
||||
useHead({
|
||||
title: "Staff overview - Modrinth",
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
<Chips
|
||||
v-model="projectType"
|
||||
:items="projectTypes"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : $formatProjectType(x) + 's')"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x) + 's')"
|
||||
/>
|
||||
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
|
||||
<SortDescendingIcon />
|
||||
<SortDescIcon />
|
||||
Sorting by oldest
|
||||
</button>
|
||||
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
|
||||
<SortAscendingIcon />
|
||||
<SortAscIcon />
|
||||
Sorting by newest
|
||||
</button>
|
||||
<button
|
||||
@@ -56,7 +56,7 @@
|
||||
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
|
||||
<span class="stacked">
|
||||
<span class="title">{{ project.name }}</span>
|
||||
<span>{{ $formatProjectType(project.inferred_project_type) }}</span>
|
||||
<span>{{ formatProjectType(project.inferred_project_type) }}</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
@@ -109,12 +109,12 @@ import { Avatar, ProjectStatusBadge, Chips, useRelativeTime } from "@modrinth/ui
|
||||
import {
|
||||
UnknownIcon,
|
||||
EyeIcon,
|
||||
SortAscendingIcon,
|
||||
SortDescendingIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
IssuesIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatProjectType } from "~/plugins/shorthands.js";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
||||
|
||||
useHead({
|
||||
|
||||
264
apps/frontend/src/pages/news/article/[slug].vue
Normal file
264
apps/frontend/src/pages/news/article/[slug].vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { RssIcon, GitGraphIcon } from "@modrinth/assets";
|
||||
import dayjs from "dayjs";
|
||||
import { articles as rawArticles } from "@modrinth/blog";
|
||||
import { computed } from "vue";
|
||||
import ShareArticleButtons from "~/components/ui/ShareArticleButtons.vue";
|
||||
import NewsletterButton from "~/components/ui/NewsletterButton.vue";
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const route = useRoute();
|
||||
|
||||
const rawArticle = rawArticles.find((article) => article.slug === route.params.slug);
|
||||
|
||||
if (!rawArticle) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: "The requested article could not be found.",
|
||||
});
|
||||
}
|
||||
|
||||
const html = await rawArticle.html();
|
||||
|
||||
const article = computed(() => ({
|
||||
...rawArticle,
|
||||
path: `/news/${rawArticle.slug}`,
|
||||
thumbnail: rawArticle.thumbnail
|
||||
? `/news/article/${rawArticle.slug}/thumbnail.webp`
|
||||
: `/news/default.webp`,
|
||||
title: rawArticle.title,
|
||||
summary: rawArticle.summary,
|
||||
date: rawArticle.date,
|
||||
html,
|
||||
}));
|
||||
|
||||
const articleTitle = computed(() => article.value.title);
|
||||
const articleUrl = computed(() => `https://modrinth.com/news/article/${route.params.slug}`);
|
||||
|
||||
const thumbnailPath = computed(() =>
|
||||
article.value.thumbnail
|
||||
? `${config.public.siteUrl}${article.value.thumbnail}`
|
||||
: `${config.public.siteUrl}/news/default.jpg`,
|
||||
);
|
||||
|
||||
const dayjsDate = computed(() => dayjs(article.value.date));
|
||||
|
||||
useSeoMeta({
|
||||
title: () => `${articleTitle.value} - Modrinth News`,
|
||||
ogTitle: () => articleTitle.value,
|
||||
description: () => article.value.summary,
|
||||
ogDescription: () => article.value.summary,
|
||||
ogType: "article",
|
||||
ogImage: () => thumbnailPath.value,
|
||||
articlePublishedTime: () => dayjsDate.value.toISOString(),
|
||||
twitterCard: "summary_large_image",
|
||||
twitterImage: () => thumbnailPath.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page experimental-styles-within py-6">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-4 border-0 border-b-[1px] border-solid border-divider px-6 pb-6"
|
||||
>
|
||||
<nuxt-link :to="`/news`">
|
||||
<h1 class="m-0 text-3xl font-extrabold hover:underline">News</h1>
|
||||
</nuxt-link>
|
||||
<div class="flex gap-2">
|
||||
<NewsletterButton />
|
||||
<ButtonStyled circular>
|
||||
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
|
||||
<RssIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular icon-only>
|
||||
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
|
||||
<GitGraphIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<article class="mt-6 flex flex-col gap-4 px-6">
|
||||
<h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">{{ article.title }}</h2>
|
||||
<p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p>
|
||||
<div class="mt-auto text-sm text-secondary sm:text-base">
|
||||
Posted on {{ dayjsDate.format("MMMM D, YYYY") }}
|
||||
</div>
|
||||
<ShareArticleButtons :title="article.title" :url="articleUrl" />
|
||||
<img
|
||||
:src="article.thumbnail"
|
||||
class="aspect-video w-full rounded-xl border-[1px] border-solid border-button-border object-cover sm:rounded-2xl"
|
||||
:alt="article.title"
|
||||
/>
|
||||
<div class="markdown-body" v-html="article.html" />
|
||||
<h3
|
||||
class="mb-0 mt-4 border-0 border-t-[1px] border-solid border-divider pt-4 text-base font-extrabold sm:text-lg"
|
||||
>
|
||||
Share this article
|
||||
</h3>
|
||||
<ShareArticleButtons :title="article.title" :url="articleUrl" />
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
> *:not(.full-width-bg),
|
||||
> .full-width-bg > * {
|
||||
max-width: 56rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-gradient-bg {
|
||||
background: var(--brand-gradient-bg);
|
||||
border-color: var(--brand-gradient-border);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
article {
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.markdown-body) {
|
||||
h1,
|
||||
h2 {
|
||||
border-bottom: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul > li:not(:last-child) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
strong {
|
||||
color: var(--color-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.125rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
a {
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
a {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
border: 1px solid var(--color-button-border);
|
||||
border-radius: var(--radius-md);
|
||||
@media (min-width: 640px) {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
> img,
|
||||
> :has(img:first-child:last-child) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,21 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
useSeoMeta({
|
||||
title: "Modrinth Changelog",
|
||||
ogTitle: "Modrinth Changelog",
|
||||
description: "Keep up-to-date on what's new with Modrinth.",
|
||||
ogDescription: "Keep up-to-date on what's new with Modrinth.",
|
||||
ogType: "website",
|
||||
ogImage: () => `${config.public.siteUrl}/news/changelog.webp`,
|
||||
twitterCard: "summary_large_image",
|
||||
twitterImage: () => `${config.public.siteUrl}/news/changelog.webp`,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
padding: 1rem;
|
||||
|
||||
161
apps/frontend/src/pages/news/index.vue
Normal file
161
apps/frontend/src/pages/news/index.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewsArticleCard } from "@modrinth/ui";
|
||||
import { ChevronRightIcon, RssIcon, GitGraphIcon } from "@modrinth/assets";
|
||||
import dayjs from "dayjs";
|
||||
import { articles as rawArticles } from "@modrinth/blog";
|
||||
import { computed, ref } from "vue";
|
||||
import NewsletterButton from "~/components/ui/NewsletterButton.vue";
|
||||
|
||||
const articles = ref(
|
||||
rawArticles
|
||||
.map((article) => ({
|
||||
...article,
|
||||
path: `/news/article/${article.slug}/`,
|
||||
thumbnail: article.thumbnail
|
||||
? `/news/article/${article.slug}/thumbnail.webp`
|
||||
: `/news/default.webp`,
|
||||
title: article.title,
|
||||
summary: article.summary,
|
||||
date: article.date,
|
||||
}))
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
|
||||
);
|
||||
|
||||
const featuredArticle = computed(() => articles.value?.[0]);
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
useSeoMeta({
|
||||
title: "Modrinth News",
|
||||
ogTitle: "Modrinth News",
|
||||
description: "Keep up-to-date on the latest news from Modrinth.",
|
||||
ogDescription: "Keep up-to-date on the latest news from Modrinth.",
|
||||
ogType: "website",
|
||||
ogImage: () => `${config.public.siteUrl}/news/thumbnail.webp`,
|
||||
twitterCard: "summary_large_image",
|
||||
twitterImage: () => `${config.public.siteUrl}/news/thumbnail.webp`,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page experimental-styles-within py-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 px-6">
|
||||
<div>
|
||||
<h1 class="m-0 text-3xl font-extrabold">News</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NewsletterButton />
|
||||
<ButtonStyled circular>
|
||||
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
|
||||
<RssIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular icon-only>
|
||||
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
|
||||
<GitGraphIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="articles && articles.length">
|
||||
<div
|
||||
v-if="featuredArticle"
|
||||
class="full-width-bg brand-gradient-bg mt-6 border-0 border-y-[1px] border-solid py-4"
|
||||
>
|
||||
<nuxt-link
|
||||
:to="`${featuredArticle.path}`"
|
||||
class="active:scale-[0.99]! group flex cursor-pointer transition-all ease-in-out hover:brightness-125"
|
||||
>
|
||||
<article class="featured-article px-6">
|
||||
<div class="featured-image-container">
|
||||
<img
|
||||
:src="featuredArticle.thumbnail"
|
||||
class="aspect-video w-full rounded-2xl border-[1px] border-solid border-button-border object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="featured-content">
|
||||
<p class="m-0 font-bold">Featured article</p>
|
||||
<h3 class="m-0 text-3xl leading-tight group-hover:underline">
|
||||
{{ featuredArticle?.title }}
|
||||
</h3>
|
||||
<p class="m-0 text-lg leading-tight">{{ featuredArticle?.summary }}</p>
|
||||
<div class="mt-auto text-secondary">
|
||||
{{ dayjs(featuredArticle?.date).format("MMMM D, YYYY") }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 px-6">
|
||||
<div class="group flex w-fit items-center gap-1">
|
||||
<h2 class="m-0 text-xl font-extrabold">More articles</h2>
|
||||
<ChevronRightIcon
|
||||
v-if="false"
|
||||
class="ml-0 h-6 w-6 transition-all group-hover:ml-1 group-hover:text-brand"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4">
|
||||
<NewsArticleCard
|
||||
v-for="article in articles.slice(1)"
|
||||
:key="article.path"
|
||||
:article="article"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="pt-4">Error: Articles could not be loaded.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
> *:not(.full-width-bg),
|
||||
> .full-width-bg > * {
|
||||
max-width: 56rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-gradient-bg {
|
||||
background: var(--brand-gradient-bg);
|
||||
border-color: var(--brand-gradient-border);
|
||||
}
|
||||
|
||||
.featured-article {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featured-image-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.featured-content {
|
||||
flex: 1;
|
||||
min-width: 16rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.featured-article {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.featured-image-container {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.featured-content {
|
||||
order: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user