Merge commit '81ec068747a39e927c42273011252daaa58f1e14' into feature-clean

This commit is contained in:
2024-12-26 16:51:17 +03:00
361 changed files with 25873 additions and 23923 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -31,19 +31,21 @@ window.addEventListener('online', () => {
const getInstances = async () => {
const profiles = await list().catch(handleError)
recentInstances.value = profiles.sort((a, b) => {
const dateA = dayjs(a.last_played ?? 0)
const dateB = dayjs(b.last_played ?? 0)
recentInstances.value = profiles
.filter((x) => x.last_played)
.sort((a, b) => {
const dateA = dayjs(a.last_played)
const dateB = dayjs(b.last_played)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
return dateB - dateA
})
const filters = []
for (const instance of recentInstances.value) {
for (const instance of profiles) {
if (instance.linked_data && instance.linked_data.project_id) {
filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`)
}
@@ -99,37 +101,34 @@ onUnmounted(() => {
</script>
<template>
<div class="page-container">
<RowDisplay v-if="total > 0" :instances="[
{
label: 'Jump back in',
route: '/library',
instances: recentInstances,
instance: true,
downloaded: true,
},
{
label: 'Popular packs',
route: '/browse/modpack',
instances: featuredModpacks,
downloaded: false,
},
{
label: 'Popular mods',
route: '/browse/mod',
instances: featuredMods,
downloaded: false,
},
]" :can-paginate="true" />
<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>
<RowDisplay
v-if="total > 0"
:instances="[
{
label: 'Recently played',
route: '/library',
instances: recentInstances,
instance: true,
downloaded: true,
compact: true,
},
{
label: 'Discover a modpack',
route: '/browse/modpack',
instances: featuredModpacks,
downloaded: false,
},
{
label: 'Discover mods',
route: '/browse/mod',
instances: featuredMods,
downloaded: false,
},
]"
:can-paginate="true"
/>
</div>
</template>
<style lang="scss" scoped>
.page-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
</style>

View File

@@ -1,669 +0,0 @@
<script setup>
import { ref, watch } from 'vue'
import { LogOutIcon, LogInIcon, BoxIcon, FolderSearchIcon, TrashIcon, PirateShipIcon, UpdatedIcon } from '@modrinth/assets'
import { Card, Slider, DropdownSelect, Toggle, Button } from '@modrinth/ui'
import { handleError, useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings'
import { get_java_versions, get_max_memory, set_java_version } from '@/helpers/jre'
import { get as getCreds, logout } from '@/helpers/mr_auth.js'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import ModrinthLoginScreen from '@/components/ui/tutorial/ModrinthLoginScreen.vue'
import { optOutAnalytics } from '@/helpers/analytics'
import { open } from '@tauri-apps/plugin-dialog'
import { getOS } from '@/helpers/utils.js'
// import { getVersion } from '@tauri-apps/api/app'
import { get_user, purge_cache_types } from '@/helpers/cache.js'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { version, development_build } from '../../package.json'
import {
getRemote,
getBranches,
launcherUrl,
latestBetaCommitLink,
latestBetaCommitTruncatedSha,
} from '@/helpers/update.js'
const pageOptions = ['Home', 'Library']
const themeStore = useTheming()
const accessSettings = async () => {
const settings = await get()
settings.launchArgs = settings.extra_launch_args.join(' ')
settings.envVars = settings.custom_env_vars.map((x) => x.join('=')).join(' ')
return settings
}
const fetchSettings = await accessSettings().catch(handleError)
const settings = ref(fetchSettings)
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
watch(
settings,
async (oldSettings, newSettings) => {
if (oldSettings.loaded_config_dir !== newSettings.loaded_config_dir) {
return
}
const setSettings = JSON.parse(JSON.stringify(newSettings))
if (setSettings.telemetry) {
// optInAnalytics()
} else {
optOutAnalytics()
}
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = setSettings.envVars
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null
}
if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null
}
if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
const javaVersions = ref(await get_java_versions().catch(handleError))
async function updateJavaVersion(version) {
if (version?.path === '') {
version.path = undefined
}
if (version?.path) {
version.path = version.path.replace('java.exe', 'javaw.exe')
}
await set_java_version(version).catch(handleError)
}
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
creds.user = await get_user(creds.user_id).catch(handleError)
}
credentials.value = creds
}
const credentials = ref()
await fetchCredentials()
const loginScreenModal = ref()
async function logOut() {
await logout().catch(handleError)
await fetchCredentials()
}
async function signInAfter() {
await fetchCredentials()
}
async function findLauncherDir() {
const newDir = await open({
multiple: false,
directory: true,
title: 'Select a new app directory',
})
if (newDir) {
settings.value.custom_dir = newDir
}
}
async function purgeCache() {
await purge_cache_types([
'project',
'version',
'user',
'team',
'organization',
'loader_manifest',
'minecraft_manifest',
'categories',
'report_types',
'loaders',
'game_versions',
'donation_platforms',
'file_update',
'search_results',
]).catch(handleError)
}
await getRemote(false, false)
await getBranches()
</script>
<template>
<div class="settings-page">
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">General settings</span>
</h3>
</div>
<ModrinthLoginScreen ref="loginScreenModal" :callback="signInAfter" />
<div class="adjacent-input">
<label for="sign-in">
<span class="label__title">Manage account</span>
<span v-if="credentials" class="label__description">
You are currently logged in as {{ credentials.user.username }}.
</span>
<span v-else> Sign in to your Modrinth account. </span>
</label>
<button v-if="credentials" id="sign-in" class="btn" @click="logOut">
<LogOutIcon />
Sign out
</button>
<button v-else id="sign-in" class="btn" @click="$refs.loginScreenModal.show()">
<LogInIcon />
Sign in
</button>
</div>
<ConfirmModalWrapper ref="purgeCacheConfirmModal" title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false" proceed-label="Purge cache" @proceed="purgeCache" />
<div class="adjacent-input">
<label for="purge-cache">
<span class="label__title">App cache</span>
<span class="label__description">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force
the app to reload data. <br />
This may slow down the app temporarily.
</span>
</label>
<button id="purge-cache" class="btn" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon />
Purge cache
</button>
</div>
<label for="appDir">
<span class="label__title">App directory</span>
<span class="label__description">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</span>
</label>
<div class="app-directory">
<div class="iconified-input">
<BoxIcon />
<input id="appDir" v-model="settings.custom_dir" type="text" class="input" />
<Button class="r-btn" @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</div>
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Display</span>
</h3>
</div>
<div class="adjacent-input">
<label for="theme">
<span class="label__title">Color theme</span>
<span class="label__description">Change the global launcher color theme.</span>
</label>
<DropdownSelect id="theme" name="Theme dropdown" :options="themeStore.themeOptions"
:default-value="settings.theme" :model-value="settings.theme" class="theme-dropdown" @change="(e) => {
themeStore.setThemeState(e.option.toLowerCase())
settings.theme = themeStore.selectedTheme
}
" />
</div>
<div class="adjacent-input">
<label for="advanced-rendering">
<span class="label__title">Advanced rendering</span>
<span class="label__description">
Enables advanced rendering such as blur effects that may cause performance issues
without hardware-accelerated rendering.
</span>
</label>
<Toggle id="advanced-rendering" :model-value="themeStore.advancedRendering"
:checked="themeStore.advancedRendering" @update:model-value="(e) => {
themeStore.advancedRendering = e
settings.advanced_rendering = themeStore.advancedRendering
}
" />
</div>
<div class="adjacent-input">
<label for="minimize-launcher">
<span class="label__title">Minimize launcher</span>
<span class="label__description">Minimize the launcher when a Minecraft process starts.</span>
</label>
<Toggle id="minimize-launcher" :model-value="settings.hide_on_process_start"
:checked="settings.hide_on_process_start" @update:model-value="(e) => {
settings.hide_on_process_start = e
}
" />
</div>
<div v-if="getOS() != 'MacOS'" class="adjacent-input">
<label for="native-decorations">
<span class="label__title">Native decorations</span>
<span class="label__description">Use system window frame (app restart required).</span>
</label>
<Toggle id="native-decorations" :model-value="settings.native_decorations"
:checked="settings.native_decorations" @update:model-value="(e) => {
settings.native_decorations = e
}
" />
</div>
<div class="adjacent-input">
<label for="opening-page">
<span class="label__title">Default landing page</span>
<span class="label__description">Change the page to which the launcher opens on.</span>
</label>
<DropdownSelect id="opening-page" name="Opening page dropdown" :options="pageOptions"
:default-value="settings.default_page" :model-value="settings.default_page" class="opening-page" @change="(e) => {
settings.default_page = e.option
}
" />
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Resource management</span>
</h3>
</div>
<div class="adjacent-input">
<label for="max-downloads">
<span class="label__title">Maximum concurrent downloads</span>
<span class="label__description">
The maximum amount of files the launcher can download at the same time. Set this to a
lower value if you have a poor internet connection. (app restart required to take
effect)
</span>
</label>
<Slider id="max-downloads" v-model="settings.max_concurrent_downloads" :min="1" :max="10" :step="1" />
</div>
<div class="adjacent-input">
<label for="max-writes">
<span class="label__title">Maximum concurrent writes</span>
<span class="label__description">
The maximum amount of files the launcher can write to the disk at once. Set this to a
lower value if you are frequently getting I/O errors. (app restart required to take
effect)
</span>
</label>
<Slider id="max-writes" v-model="settings.max_concurrent_writes" :min="1" :max="50" :step="1" />
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Privacy</span>
</h3>
</div>
<div class="adjacent-input">
<label for="opt-out-analytics">
<span class="label__title">Personalized ads</span>
<span class="label__description">
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
option, you opt out and ads will no longer be shown based on your interests.
</span>
</label>
<Toggle
id="opt-out-analytics"
:model-value="settings.personalized_ads"
:checked="settings.personalized_ads"
@update:model-value="
(e) => {
settings.personalized_ads = e
}
"
/>
</div>
<div class="adjacent-input">
<label for="opt-out-analytics">
<span class="label__title">Telemetry</span>
<span class="label__description">
(Always disabled by AstralRinth) • Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. By disabling this option, you opt out and your data will no
longer be collected.
</span>
</label>
<Toggle id="opt-out-analytics" :model-value="settings.telemetry" :disabled="!settings.telemetry" :checked="settings.telemetry"
@update:model-value="(e) => {
settings.telemetry = e
}
" />
</div>
<div class="adjacent-input">
<label for="disable-discord-rpc">
<span class="label__title">Discord RPC</span>
<span class="label__description">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to
no longer show up as a game or app you are using on your Discord profile. This does not
disable any instance-specific Discord Rich Presence integrations, such as those added by
mods. (app restart required to take effect)
</span>
</label>
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" :checked="settings.discord_rpc" />
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Java settings</span>
</h3>
</div>
<template v-for="javaVersion in [21, 17, 8]" :key="`java-${javaVersion}`">
<label :for="'java-' + javaVersion">
<span class="label__title">Java {{ javaVersion }} location</span>
</label>
<JavaSelector
:id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]"
:version="javaVersion"
@update:model-value="updateJavaVersion"
/>
</template>
<hr class="card-divider" />
<label for="java-args">
<span class="label__title">Java arguments</span>
</label>
<input id="java-args" v-model="settings.launchArgs" autocomplete="off" type="text" class="installation-input"
placeholder="Enter java arguments..." />
<label for="env-vars">
<span class="label__title">Environmental variables</span>
</label>
<input id="env-vars" v-model="settings.envVars" autocomplete="off" type="text" class="installation-input"
placeholder="Enter environmental variables..." />
<hr class="card-divider" />
<div class="adjacent-input">
<label for="max-memory">
<span class="label__title">Java memory</span>
<span class="label__description">
The memory allocated to each instance when it is ran.
</span>
</label>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="8"
:max="maxMemory"
:step="64"
unit="MB"
/>
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Hooks</span>
</h3>
</div>
<div class="adjacent-input">
<label for="pre-launch">
<span class="label__title">Pre launch</span>
<span class="label__description"> Ran before the instance is launched. </span>
</label>
<input id="pre-launch" v-model="settings.hooks.pre_launch" autocomplete="off" type="text"
placeholder="Enter pre-launch command..." />
</div>
<div class="adjacent-input">
<label for="wrapper">
<span class="label__title">Wrapper</span>
<span class="label__description"> Wrapper command for launching Minecraft. </span>
</label>
<input id="wrapper" v-model="settings.hooks.wrapper" autocomplete="off" type="text"
placeholder="Enter wrapper command..." />
</div>
<div class="adjacent-input">
<label for="post-exit">
<span class="label__title">Post exit</span>
<span class="label__description"> Ran after the game closes. </span>
</label>
<input id="post-exit" v-model="settings.hooks.post_exit" autocomplete="off" type="text"
placeholder="Enter post-exit command..." />
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Window size</span>
</h3>
</div>
<div class="adjacent-input">
<label for="fullscreen">
<span class="label__title">Fullscreen</span>
<span class="label__description">
Overwrites the options.txt file to start in full screen when launched.
</span>
</label>
<Toggle id="fullscreen" :model-value="settings.force_fullscreen" :checked="settings.force_fullscreen"
@update:model-value="(e) => {
settings.force_fullscreen = e
}
" />
</div>
<div class="adjacent-input">
<label for="width">
<span class="label__title">Width</span>
<span class="label__description"> The width of the game window when launched. </span>
</label>
<input id="width" v-model="settings.game_resolution[0]" :disabled="settings.force_fullscreen" autocomplete="off"
type="number" placeholder="Enter width..." />
</div>
<div class="adjacent-input">
<label for="height">
<span class="label__title">Height</span>
<span class="label__description"> The height of the game window when launched. </span>
</label>
<input id="height" v-model="settings.game_resolution[1]" :disabled="settings.force_fullscreen"
autocomplete="off" type="number" class="input" placeholder="Enter height..." />
</div>
</Card>
<Card>
<div class="label inline-fix">
<h3>
<span class="label__title size-card-header in"
> About
<p v-if="development_build" class="development option">
You are using a development version, there may be errors.
</p>
</span>
</h3>
</div>
<div>
<label>
<span class="label__title inl">AstralRinth <PirateShipIcon /> Version • {{ version }}</span>
<span class="label__description"
>Latest beta commit •
<a class="github" :href="latestBetaCommitLink">{{
latestBetaCommitTruncatedSha
}}</a></span
>
<span class="label__description"
>All latest versions always published on GitHub
<a class="github" :href="launcherUrl">Our GitHub repository</a></span
>
<span class="label__title">Update Checker</span>
<span class="label__description"
>Version on remote server •
<p id="releaseData" class="cosmic inline-fix"></p>
</span>
<span class="label__description"
>Version on local device •
<p class="cosmic inline-fix">v{{ version }}</p></span
>
</label>
<div class="inline-item-group">
<Button icon-only @click="getRemote(false, false), getBranches()">
<UpdatedIcon /> Check for updates
</Button>
</div>
</div>
</Card>
</div>
</template>
<style lang="scss" scoped>
.settings-page {
margin: 1rem;
}
.installation-input {
width: 100% !important;
flex-grow: 1;
}
.theme-dropdown {
text-transform: capitalize;
}
.card-divider {
margin: 1rem 0;
}
.app-directory {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
.iconified-input {
flex-grow: 1;
input {
flex-basis: auto;
}
}
}
.development {
color: #ff6a00;
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 1.5s ease;
}
.development:hover,
.development:focus,
.development:active {
color: #4800d3;
text-shadow: #801313;
}
.cosmic {
color: #3e8cde;
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 0.35s ease;
}
.cosmic:hover,
.cosmic:focus,
.cosmic:active {
color: #10fae5;
text-shadow: #26065e;
}
.download {
color: #3e8cde;
border: none;
padding: var(--gap-sm) var(--gap-lg);
//background-color: rgba(0, 0, 0, 0.0);
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 0.35s ease;
}
.download:hover,
.download:focus,
.download:active {
color: #10fae5;
text-shadow: #26065e;
}
a.github {
color: #3e8cde;
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 0.35s ease;
}
a.github:hover,
a.github:focus,
a.github:active {
color: #10fae5;
text-shadow: #26065e;
}
.inline-item-group {
display: inline-flex;
gap: 0.25rem;
}
.inline-fix {
display: inline-flex;
margin-top: -2rem;
margin-bottom: -2rem;
}
.download-modal {
color: #3e8cde;
padding: var(--gap-sm) var(--gap-lg);
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 0.35s ease;
}
.download-modal:hover,
.download-modal:focus,
.download-modal:active {
color: #10fae5;
text-shadow: #26065e;
}
.option {
background: var(--color-bg);
border-radius: var(--radius-lg);
width: auto;
display: inline-flex;
align-items: center;
margin-top: auto;
margin-left: 0.5rem;
font-size: 1rem;
padding: 0.5rem;
}
</style>

View File

@@ -1,6 +1,4 @@
import Index from './Index.vue'
import Browse from './Browse.vue'
import Library from './Library.vue'
import Settings from './Settings.vue'
export { Index, Browse, Library, Settings }
export { Index, Browse }

View File

@@ -1,97 +1,129 @@
<template>
<div class="instance-container">
<div class="side-cards pb-4" @scroll="$refs.promo.scroll()">
<Card class="instance-card" @contextmenu.prevent.stop="handleRightClick">
<Avatar size="md" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : null" />
<div class="instance-info">
<h2 class="name">{{ instance.name }}</h2>
<span class="metadata"> {{ instance.loader }} {{ instance.game_version }} </span>
<div
class="p-6 pr-2 pb-4"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
>
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
<ContentPageHeader>
<template #icon>
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
</template>
<template #title>
{{ instance.name }}
</template>
<template #summary> </template>
<template #stats>
<div
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
>
<GameIcon class="h-6 w-6 text-secondary" />
{{ instance.loader }} {{ instance.game_version }}
</div>
<span class="button-group">
<Button v-if="instance.install_stage !== 'installed'" disabled class="instance-button">
Installing...
</Button>
<Button
v-else-if="playing === true"
color="danger"
class="instance-button"
@click="stopInstance('InstancePage')"
>
<StopCircleIcon />
Stop
</Button>
<Button
<div class="flex items-center gap-2 font-semibold">
<TimerIcon class="h-6 w-6 text-secondary" />
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
</template>
<template #actions>
<div class="flex gap-2">
<ButtonStyled v-if="instance.install_stage !== 'installed'" color="brand" size="large">
<button disabled>Installing...</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button @click="stopInstance('InstancePage')">
<StopCircleIcon />
Stop
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false"
color="primary"
class="instance-button"
@click="startInstance('InstancePage')"
color="brand"
size="large"
>
<PlayIcon />
Play
</Button>
<Button
<button @click="startInstance('InstancePage')">
<PlayIcon />
Play
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="loading === true && playing === false"
disabled
class="instance-button"
color="brand"
size="large"
>
Loading...
</Button>
<Button
v-tooltip="'Open instance folder'"
class="instance-button"
@click="showProfileInFolder(instance.path)"
>
<FolderOpenIcon />
Folder
</Button>
</span>
<hr class="card-divider" />
<div class="pages-list">
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/`" class="btn">
<BoxIcon />
Content
</RouterLink>
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/logs`" class="btn">
<FileIcon />
Logs
</RouterLink>
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/options`" class="btn">
<SettingsIcon />
Options
</RouterLink>
<button disabled>Loading...</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
},
{
id: 'export-mrpack',
action: () => $refs.exportModal.show(),
},
]"
>
<MoreVerticalIcon />
<template #share-instance> <UserPlusIcon /> Share instance </template>
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
</OverflowMenu>
</ButtonStyled>
</div>
</Card>
</div>
<div class="content">
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()">
<component
:is="Component"
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
></component>
</Suspense>
</template>
</RouterView>
</div>
</template>
</ContentPageHeader>
</div>
<div class="px-6">
<NavTabs :links="tabs" />
</div>
<div class="p-6 pt-4">
<RouterView v-slot="{ Component }" :key="instance.path">
<template v-if="Component">
<Suspense
:key="instance.path"
@pending="loadingBar.startLoading()"
@resolve="loadingBar.stopLoading()"
>
<component
:is="Component"
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
></component>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add Content </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EditIcon /> Edit </template>
<template #copy_path> <ClipboardCopyIcon /> Copy Path </template>
<template #open_folder> <ClipboardCopyIcon /> Open Folder </template>
<template #copy_link> <ClipboardCopyIcon /> Copy Link </template>
<template #open_link> <ClipboardCopyIcon /> Open In Modrinth <ExternalIcon /> </template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_names><EditIcon />Copy names</template>
<template #copy_slugs><HashIcon />Copy slugs</template>
<template #copy_links><GlobeIcon />Copy Links</template>
<template #copy_links><GlobeIcon />Copy links</template>
<template #toggle><EditIcon />Toggle selected</template>
<template #disable><XIcon />Disable selected</template>
<template #enable><CheckCircleIcon />Enable selected</template>
@@ -103,11 +135,18 @@
</ContextMenu>
</template>
<script setup>
import { Button, Avatar, Card } from '@modrinth/ui'
import {
BoxIcon,
Avatar,
ContentPageHeader,
ButtonStyled,
OverflowMenu,
LoadingIndicator,
} from '@modrinth/ui'
import {
UserPlusIcon,
ServerIcon,
PackageIcon,
SettingsIcon,
FileIcon,
PlayIcon,
StopCircleIcon,
EditIcon,
@@ -121,27 +160,94 @@ import {
XIcon,
CheckCircleIcon,
UpdatedIcon,
MoreVerticalIcon,
GameIcon,
TimerIcon,
} from '@modrinth/assets'
import { get, kill, run } from '@/helpers/profile'
import { get, get_full_path, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener, profile_listener } from '@/helpers/events'
import { useRoute, useRouter } from 'vue-router'
import { ref, onUnmounted } from 'vue'
import { ref, onUnmounted, computed, watch } from 'vue'
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics'
import { convertFileSrc } from '@tauri-apps/api/core'
import { handleSevereError } from '@/store/error.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const instance = ref(await get(route.params.id).catch(handleError))
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
window.addEventListener('online', () => {
offline.value = false
})
const instance = ref()
const modrinthVersions = ref([])
const playing = ref(false)
const loading = ref(false)
async function fetchInstance() {
instance.value = await get(route.params.id).catch(handleError)
if (!offline.value && instance.value.linked_data && instance.value.linked_data.project_id) {
get_project(instance.value.linked_data.project_id, 'must_revalidate')
.catch(handleError)
.then((project) => {
if (project && project.versions) {
get_version_many(project.versions, 'must_revalidate')
.catch(handleError)
.then((versions) => {
modrinthVersions.value = versions.sort(
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
)
})
}
})
}
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
playing.value = runningProcesses.length > 0
}
await fetchInstance()
watch(
() => route.params.id,
async () => {
if (route.params.id && route.path.startsWith('/instance')) {
await fetchInstance()
}
},
)
const tabs = computed(() => [
{
label: 'Content',
href: `/instance/${encodeURIComponent(route.params.id)}`,
},
{
label: 'Logs',
href: `/instance/${encodeURIComponent(route.params.id)}/logs`,
},
])
breadcrumbs.setName(
'Instance',
@@ -156,18 +262,8 @@ breadcrumbs.setContext({
query: route.query,
})
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
window.addEventListener('online', () => {
offline.value = false
})
const loadingBar = useLoading()
const playing = ref(false)
const loading = ref(false)
const options = ref(null)
const startInstance = async (context) => {
@@ -187,32 +283,6 @@ const startInstance = async (context) => {
})
}
const checkProcess = async () => {
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
playing.value = runningProcesses.length > 0
}
// Get information on associated modrinth versions, if any
const modrinthVersions = ref([])
if (!offline.value && instance.value.linked_data && instance.value.linked_data.project_id) {
get_project(instance.value.linked_data.project_id, 'must_revalidate')
.catch(handleError)
.then((project) => {
if (project && project.versions) {
get_version_many(project.versions, 'must_revalidate')
.catch(handleError)
.then((versions) => {
modrinthVersions.value = versions.sort(
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
)
})
}
})
}
await checkProcess()
const stopInstance = async (context) => {
playing.value = false
await kill(route.params.id).catch(handleError)
@@ -276,9 +346,11 @@ const handleOptionsClick = async (args) => {
case 'open_folder':
await showProfileInFolder(instance.value.path)
break
case 'copy_path':
await navigator.clipboard.writeText(instance.value.path)
case 'copy_path': {
const fullPath = await get_full_path(instance.value.path)
await navigator.clipboard.writeText(fullPath)
break
}
}
}
@@ -295,7 +367,35 @@ const unlistenProfiles = await profile_listener(async (event) => {
})
const unlistenProcesses = await process_listener((e) => {
if (e.event === 'finished' && e.profile_path_id === route.params.id) playing.value = false
if (e.event === 'finished' && e.profile_path_id === route.params.id) {
playing.value = false
}
})
const icon = computed(() =>
instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
)
const settingsModal = ref()
const timePlayed = computed(() => {
return instance.value.recent_time_played + instance.value.submitted_time_played
})
const timePlayedHumanized = computed(() => {
const duration = dayjs.duration(timePlayed.value, 'seconds')
const hours = Math.floor(duration.asHours())
if (hours >= 1) {
return hours + ' hour' + (hours > 1 ? 's' : '')
}
const minutes = Math.floor(duration.asMinutes())
if (minutes >= 1) {
return minutes + ' minute' + (minutes > 1 ? 's' : '')
}
const seconds = Math.floor(duration.asSeconds())
return seconds + ' second' + (seconds > 1 ? 's' : '')
})
onUnmounted(() => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,974 +0,0 @@
<template>
<ConfirmModalWrapper
ref="modal_confirm"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
@proceed="removeProfile"
/>
<ModalWrapper ref="modalConfirmUnlock" header="Are you sure you want to unlock this instance?">
<div class="modal-delete">
<div
class="markdown-body"
v-html="
'If you proceed, you will not be able to re-lock it without using the `Reinstall modpack` button.'
"
/>
<div class="input-group push-right">
<button class="btn" @click="$refs.modalConfirmUnlock.hide()">
<XIcon />
Cancel
</button>
<button class="btn btn-danger" @click="unlockProfile">
<LockIcon />
Unlock
</button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="modalConfirmUnpair" header="Are you sure you want to unpair this instance?">
<div class="modal-delete">
<div
class="markdown-body"
v-html="
'If you proceed, you will not be able to re-pair it without creating an entirely new instance.'
"
/>
<div class="input-group push-right">
<button class="btn" @click="$refs.modalConfirmUnpair.hide()">
<XIcon />
Cancel
</button>
<button class="btn btn-danger" @click="unpairProfile">
<XIcon />
Unpair
</button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="changeVersionsModal" header="Change instance versions">
<div class="change-versions-modal universal-body">
<div class="input-row">
<p class="input-label">Loader</p>
<Chips v-model="loader" :items="loaders" :never-empty="false" />
</div>
<div class="input-row">
<p class="input-label">Game Version</p>
<div class="versions">
<DropdownSelect
v-model="gameVersion"
:options="selectableGameVersions"
name="Game Version Dropdown"
render-up
/>
<Checkbox v-model="showSnapshots" class="filter-checkbox" label="Include snapshots" />
</div>
</div>
<div v-if="loader !== 'vanilla'" class="input-row">
<p class="input-label">Loader Version</p>
<DropdownSelect
:model-value="selectableLoaderVersions[loaderVersionIndex]"
:options="selectableLoaderVersions"
:display-name="(option) => option?.id"
name="Version selector"
render-up
@change="(value) => (loaderVersionIndex = value.index)"
/>
</div>
<div class="push-right input-group">
<button class="btn" @click="$refs.changeVersionsModal.hide()">
<XIcon />
Cancel
</button>
<button
class="btn btn-primary"
:disabled="!isValid || !isChanged || editing"
@click="saveGvLoaderEdits()"
>
<SaveIcon />
{{ editing ? 'Saving...' : 'Save changes' }}
</button>
</div>
</div>
</ModalWrapper>
<section class="card">
<div class="label">
<h3>
<span class="label__title size-card-header">Instance</span>
</h3>
</div>
<label for="instance-icon">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar :src="icon ? convertFileSrc(icon) : icon" size="md" class="project__icon" />
<div class="input-stack">
<button id="instance-icon" class="btn" @click="setIcon">
<UploadIcon />
Select icon
</button>
<button :disabled="!icon" class="btn" @click="resetIcon">
<TrashIcon />
Remove icon
</button>
</div>
</div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<input
id="profile-name"
v-model="title"
autocomplete="off"
maxlength="80"
type="text"
:disabled="instance.linked_data"
/>
<div class="adjacent-input">
<label for="edit-versions">
<span class="label__title">Edit mod loader/game versions</span>
<span class="label__description">
Allows you to change the mod loader, loader version, or game version of the instance.
</span>
</label>
<button
id="edit-versions"
class="btn"
:disabled="offline"
@click="$refs.changeVersionsModal.show()"
>
<EditIcon />
Edit versions
</button>
</div>
<div class="adjacent-input">
<label>
<span class="label__title">Categories</span>
<span class="label__description">
Set the categories of this instance, for display in the library page. This is purely
cosmetic.
</span>
</label>
<multiselect
v-model="groups"
:options="availableGroups"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-search-on-select="false"
:show-labels="false"
:taggable="true"
tag-placeholder="Add new category"
placeholder="Select categories..."
@tag="
(newTag) => {
groups.push(newTag.trim().substring(0, 32))
availableGroups.push(newTag.trim().substring(0, 32))
}
"
/>
</div>
</section>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Java</span>
</h3>
</div>
<div class="settings-group">
<h3>Installation</h3>
<Checkbox v-model="overrideJavaInstall" label="Override global java installations" />
<JavaSelector v-model="javaInstall" :disabled="!overrideJavaInstall" />
</div>
<hr class="card-divider" />
<div class="settings-group">
<h3>Java arguments</h3>
<Checkbox v-model="overrideJavaArgs" label="Override global java arguments" />
<input
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
type="text"
class="installation-input"
placeholder="Enter java arguments..."
/>
</div>
<div class="settings-group">
<h3>Environment variables</h3>
<Checkbox v-model="overrideEnvVars" label="Override global environment variables" />
<input
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
type="text"
class="installation-input"
placeholder="Enter environment variables..."
/>
</div>
<hr class="card-divider" />
<div class="settings-group">
<h3>Java memory</h3>
<Checkbox v-model="overrideMemorySettings" label="Override global memory settings" />
<Slider
v-model="memory.maximum"
:disabled="!overrideMemorySettings"
:min="8"
:max="maxMemory"
:step="64"
unit="mb"
/>
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Window</span>
</h3>
</div>
<div class="adjacent-input">
<Checkbox v-model="overrideWindowSettings" label="Override global window settings" />
</div>
<div class="adjacent-input">
<label for="fullscreen">
<span class="label__title">Fullscreen</span>
<span class="label__description">
Make the game start in full screen when launched (using options.txt).
</span>
</label>
<Toggle
id="fullscreen"
:model-value="fullscreenSetting"
:checked="fullscreenSetting"
:disabled="!overrideWindowSettings"
@update:model-value="
(e) => {
fullscreenSetting = e
}
"
/>
</div>
<div class="adjacent-input">
<label for="width">
<span class="label__title">Width</span>
<span class="label__description"> The width of the game window when launched. </span>
</label>
<input
id="width"
v-model="resolution[0]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
placeholder="Enter width..."
/>
</div>
<div class="adjacent-input">
<label for="height">
<span class="label__title">Height</span>
<span class="label__description"> The height of the game window when launched. </span>
</label>
<input
id="height"
v-model="resolution[1]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
class="input"
placeholder="Enter height..."
/>
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Hooks</span>
</h3>
</div>
<div class="adjacent-input">
<Checkbox v-model="overrideHooks" label="Override global hooks" />
</div>
<div class="adjacent-input">
<label for="pre-launch">
<span class="label__title">Pre launch</span>
<span class="label__description"> Ran before the instance is launched. </span>
</label>
<input
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
placeholder="Enter pre-launch command..."
/>
</div>
<div class="adjacent-input">
<label for="wrapper">
<span class="label__title">Wrapper</span>
<span class="label__description"> Wrapper command for launching Minecraft. </span>
</label>
<input
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
placeholder="Enter wrapper command..."
/>
</div>
<div class="adjacent-input">
<label for="post-exit">
<span class="label__title">Post exit</span>
<span class="label__description"> Ran after the game closes. </span>
</label>
<input
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
placeholder="Enter post-exit command..."
/>
</div>
</Card>
<Card v-if="instance.linked_data">
<div class="label">
<h3>
<span class="label__title size-card-header">Modpack</span>
</h3>
</div>
<div class="adjacent-input">
<label for="general-modpack-info">
<span class="label__description"> <strong>Modpack: </strong> {{ instance.name }} </span>
<span class="label__description">
<strong>Version: </strong>
{{
installedVersionData?.name != null
? installedVersionData.name.charAt(0).toUpperCase() +
installedVersionData.name.slice(1)
: getLocalVersion(props.instance.path)
}}
</span>
</label>
</div>
<div v-if="!isPackLocked" class="adjacent-input">
<Card class="unlocked-instance">
This is an unlocked instance. There may be unexpected behaviour unintended by the modpack
creator.
</Card>
</div>
<div v-else class="adjacent-input">
<label for="unlock-profile">
<span class="label__title">Unlock instance</span>
<span class="label__description">
Allows modifications to the instance, which allows you to add projects to the modpack. The
pack will remain linked, and you can still change versions. Only mods listed in the
modpack will be modified on version changes.
</span>
</label>
<Button id="unlock-profile" @click="$refs.modalConfirmUnlock.show()">
<LockIcon /> Unlock
</Button>
</div>
<div class="adjacent-input">
<label for="unpair-profile">
<span class="label__title">Unpair instance</span>
<span class="label__description">
Removes the link to an external Modrinth modpack on the instance. This allows you to edit
modpacks you download through the browse page but you will not be able to update the
instance from a new version of a modpack if you do this.
</span>
</label>
<Button id="unpair-profile" @click="$refs.modalConfirmUnpair.show()">
<XIcon /> Unpair
</Button>
</div>
<div v-if="instance.linked_data.project_id" class="adjacent-input">
<label for="change-modpack-version">
<span class="label__title">Change modpack version</span>
<span class="label__description">
Changes to another version of the modpack, allowing upgrading or downgrading. This will
replace all files marked as relevant to the modpack.
</span>
</label>
<Button
id="change-modpack-version"
:disabled="inProgress || installing"
@click="modpackVersionModal.show()"
>
<SwapIcon />
Change modpack version
</Button>
</div>
<div class="adjacent-input">
<label for="repair-modpack">
<span class="label__title">Reinstall modpack</span>
<span class="label__description">
Removes all projects and reinstalls Modrinth modpack. Use this to fix unexpected behaviour
if your instance is diverging from the Modrinth modpack. This also re-locks the instance.
</span>
</label>
<Button id="repair-modpack" color="highlight" :disabled="offline" @click="repairModpack">
<DownloadIcon /> Reinstall
</Button>
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Instance management</span>
</h3>
</div>
<div v-if="instance.install_stage == 'installed'" class="adjacent-input">
<label for="duplicate-profile">
<span class="label__title">Duplicate instance</span>
<span class="label__description">
Creates another copy of the instance, including saves, configs, mods, and everything.
</span>
</label>
<Button
id="repair-profile"
:disabled:="installing || inProgress || offline"
@click="duplicateProfile"
>
<ClipboardCopyIcon /> Duplicate
</Button>
</div>
<div class="adjacent-input">
<label for="repair-profile">
<span class="label__title">Repair instance</span>
<span class="label__description">
Reinstalls Minecraft dependencies and checks for corruption. Use this if your game is not
launching due to launcher-related errors.
</span>
</label>
<Button
id="repair-profile"
color="highlight"
:disabled="installing || inProgress || repairing || offline"
@click="repairProfile(true)"
>
<HammerIcon /> Repair
</Button>
</div>
<div class="adjacent-input">
<label for="delete-profile">
<span class="label__title">Delete instance</span>
<span class="label__description">
Fully removes a instance from the disk. Be careful, as once you delete a instance there is
no way to recover it.
</span>
</label>
<Button
id="delete-profile"
color="danger"
:disabled="removing"
@click="$refs.modal_confirm.show()"
>
<TrashIcon /> Delete
</Button>
</div>
</Card>
<ModpackVersionModal
v-if="instance.linked_data"
ref="modpackVersionModal"
:instance="instance"
:versions="props.versions"
/>
</template>
<script setup>
import {
TrashIcon,
UploadIcon,
EditIcon,
XIcon,
SaveIcon,
LockIcon,
HammerIcon,
DownloadIcon,
ClipboardCopyIcon,
} from '@modrinth/assets'
import { Button, Toggle, Card, Slider, Checkbox, Avatar, Chips, DropdownSelect } from '@modrinth/ui'
import { SwapIcon } from '@/assets/icons'
import { Multiselect } from 'vue-multiselect'
import { useRouter } from 'vue-router'
import {
duplicate,
edit,
edit_icon,
get_optimal_jre_key,
install,
list,
remove,
update_repair_modrinth,
} from '@/helpers/profile.js'
import { computed, readonly, ref, shallowRef, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre.js'
import { get } from '@/helpers/settings.js'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { get_loader_versions } from '@/helpers/metadata.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
const breadcrumbs = useBreadcrumbs()
const router = useRouter()
const props = defineProps({
instance: {
type: Object,
required: true,
},
offline: {
type: Boolean,
default: false,
},
versions: {
type: Array,
required: true,
},
})
const title = ref(props.instance.name)
const icon = ref(props.instance.icon_path)
const groups = ref(props.instance.groups)
const modpackVersionModal = ref(null)
const instancesList = await list()
const availableGroups = ref([
...new Set(
instancesList.reduce((acc, obj) => {
return acc.concat(obj.groups)
}, []),
),
])
async function resetIcon() {
icon.value = null
await edit_icon(props.instance.path, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
}
async function setIcon() {
const value = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
},
],
})
if (!value) return
icon.value = value.path ?? value
await edit_icon(props.instance.path, icon.value).catch(handleError)
trackEvent('InstanceSetIcon')
}
const globalSettings = await get().catch(handleError)
const modalConfirmUnlock = ref(null)
const modalConfirmUnpair = ref(null)
const overrideJavaInstall = ref(!!props.instance.java_path)
const optimalJava = readonly(await get_optimal_jre_key(props.instance.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? props.instance.java_path })
const overrideJavaArgs = ref(props.instance.extra_launch_args?.length !== undefined)
const javaArgs = ref(
(props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
)
const overrideEnvVars = ref(props.instance.custom_env_vars?.length !== undefined)
const envVars = ref(
(props.instance.custom_env_vars ?? globalSettings.custom_env_vars)
.map((x) => x.join('='))
.join(' '),
)
const overrideMemorySettings = ref(!!props.instance.memory)
const memory = ref(props.instance.memory ?? globalSettings.memory)
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
const overrideWindowSettings = ref(
!!props.instance.game_resolution || !!props.instance.force_fullscreen,
)
const resolution = ref(props.instance.game_resolution ?? globalSettings.game_resolution)
const overrideHooks = ref(
props.instance.hooks.pre_launch || props.instance.hooks.wrapper || props.instance.hooks.post_exit,
)
const hooks = ref(props.instance.hooks ?? globalSettings.hooks)
const fullscreenSetting = ref(!!props.instance.force_fullscreen)
const unlinkModpack = ref(false)
const inProgress = ref(false)
const installing = computed(() => props.instance.install_stage !== 'installed')
const installedVersion = computed(() => props.instance?.linked_data?.version_id)
const installedVersionData = computed(() => {
if (!installedVersion.value) return null
return props.versions.find((version) => version.id === installedVersion.value)
})
watch(
[
title,
groups,
groups,
overrideJavaInstall,
javaInstall,
overrideJavaArgs,
javaArgs,
overrideEnvVars,
envVars,
overrideMemorySettings,
memory,
overrideWindowSettings,
resolution,
fullscreenSetting,
overrideHooks,
hooks,
unlinkModpack,
],
async () => {
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
const getLocalVersion = (path) => {
const pathSlice = path.split(' ').slice(-1).toString()
// If the path ends in (1), (2), etc. it's a duplicate instance and no version can be obtained.
if (/^\(\d\)/.test(pathSlice)) {
return 'Unknown'
}
return pathSlice
}
const editProfileObject = computed(() => {
const editProfile = {
name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
loader_version: props.instance.loader_version,
linked_data: props.instance.linked_data,
java: {},
hooks: {},
}
if (overrideJavaInstall.value) {
if (javaInstall.value.path !== '') {
editProfile.java_path = javaInstall.value.path.replace('java.exe', 'javaw.exe')
}
}
if (overrideJavaArgs.value) {
editProfile.extra_launch_args = javaArgs.value.trim().split(/\s+/).filter(Boolean)
}
if (overrideEnvVars.value) {
editProfile.custom_env_vars = envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
}
if (overrideMemorySettings.value) {
editProfile.memory = memory.value
}
if (overrideWindowSettings.value) {
editProfile.force_fullscreen = fullscreenSetting.value
if (!fullscreenSetting.value) {
editProfile.game_resolution = resolution.value
}
}
if (overrideHooks.value) {
editProfile.hooks = hooks.value
}
if (unlinkModpack.value) {
editProfile.linked_data = null
}
breadcrumbs.setName('Instance', editProfile.name)
return editProfile
})
const repairing = ref(false)
async function duplicateProfile() {
await duplicate(props.instance.path).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
})
}
async function repairProfile(force) {
repairing.value = true
await install(props.instance.path, force).catch(handleError)
repairing.value = false
trackEvent('InstanceRepair', {
loader: props.instance.loader,
game_version: props.instance.game_version,
})
}
async function unpairProfile() {
const editProfile = props.instance
editProfile.linked_data = null
await edit(props.instance.path, editProfile)
installedVersion.value = null
installedVersionData.value = null
modalConfirmUnpair.value.hide()
}
async function unlockProfile() {
const editProfile = props.instance
editProfile.linked_data.locked = false
await edit(props.instance.path, editProfile)
modalConfirmUnlock.value.hide()
}
const isPackLocked = computed(() => {
return props.instance.linked_data && props.instance.linked_data.locked
})
async function repairModpack() {
inProgress.value = true
await update_repair_modrinth(props.instance.path).catch(handleError)
inProgress.value = false
trackEvent('InstanceRepair', {
loader: props.instance.loader,
game_version: props.instance.game_version,
})
}
const removing = ref(false)
async function removeProfile() {
removing.value = true
await remove(props.instance.path).catch(handleError)
removing.value = false
trackEvent('InstanceRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
})
await router.push({ path: '/' })
}
const changeVersionsModal = ref(null)
const showSnapshots = ref(false)
const [
fabric_versions,
forge_versions,
quilt_versions,
neoforge_versions,
all_game_versions,
loaders,
] = await Promise.all([
get_loader_versions('fabric').then(shallowRef).catch(handleError),
get_loader_versions('forge').then(shallowRef).catch(handleError),
get_loader_versions('quilt').then(shallowRef).catch(handleError),
get_loader_versions('neo').then(shallowRef).catch(handleError),
get_game_versions().then(shallowRef).catch(handleError),
get_loaders()
.then((value) =>
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
)
.then(ref)
.catch(handleError),
])
loaders.value.unshift('vanilla')
const loader = ref(props.instance.loader)
const gameVersion = ref(props.instance.game_version)
const selectableGameVersions = computed(() => {
return all_game_versions.value
.filter((item) => {
let defaultVal = item.version_type === 'release' || showSnapshots.value
if (loader.value === 'fabric') {
defaultVal &= fabric_versions.value.gameVersions.some((x) => item.version === x.id)
} else if (loader.value === 'forge') {
defaultVal &= forge_versions.value.gameVersions.some((x) => item.version === x.id)
} else if (loader.value === 'quilt') {
defaultVal &= quilt_versions.value.gameVersions.some((x) => item.version === x.id)
} else if (loader.value === 'neoforge') {
defaultVal &= neoforge_versions.value.gameVersions.some((x) => item.version === x.id)
}
return defaultVal
})
.map((item) => item.version)
})
const selectableLoaderVersions = computed(() => {
if (gameVersion.value) {
if (loader.value === 'fabric') {
return fabric_versions.value.gameVersions[0].loaders
} else if (loader.value === 'forge') {
return forge_versions.value.gameVersions.find((item) => item.id === gameVersion.value).loaders
} else if (loader.value === 'quilt') {
return quilt_versions.value.gameVersions[0].loaders
} else if (loader.value === 'neoforge') {
return neoforge_versions.value.gameVersions.find((item) => item.id === gameVersion.value)
.loaders
}
}
return []
})
const loaderVersionIndex = ref(
selectableLoaderVersions.value.findIndex((x) => x.id === props.instance.loader_version),
)
const isValid = computed(() => {
return (
selectableGameVersions.value.includes(gameVersion.value) &&
(loaderVersionIndex.value >= 0 || loader.value === 'vanilla')
)
})
const isChanged = computed(() => {
return (
loader.value !== props.instance.loader ||
gameVersion.value !== props.instance.game_version ||
(loaderVersionIndex.value >= 0 &&
selectableLoaderVersions.value[loaderVersionIndex.value].id !== props.instance.loader_version)
)
})
watch(loader, () => (loaderVersionIndex.value = 0))
const editing = ref(false)
async function saveGvLoaderEdits() {
editing.value = true
const editProfile = editProfileObject.value
editProfile.loader = loader.value
editProfile.game_version = gameVersion.value
if (loader.value !== 'vanilla') {
editProfile.loader_version = selectableLoaderVersions.value[loaderVersionIndex.value].id
} else {
loaderVersionIndex.value = -1
}
await edit(props.instance.path, editProfile).catch(handleError)
await repairProfile(false)
editing.value = false
changeVersionsModal.value.hide()
}
</script>
<style scoped lang="scss">
.change-versions-modal {
display: flex;
flex-direction: column;
padding: 1rem;
gap: 1rem;
:deep(.animated-dropdown .options) {
max-height: 13.375rem;
}
.input-label {
font-size: 1rem;
font-weight: bolder;
color: var(--color-contrast);
margin-bottom: 0.5rem;
}
.versions {
display: flex;
flex-direction: row;
gap: 1rem;
}
}
.settings-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1rem 0;
h3 {
margin: 0;
}
}
.installation-input {
width: 100%;
}
:deep(button.checkbox) {
border: none;
}
.unlocked-instance {
background-color: var(--color-bg);
}
.modal-delete {
padding: var(--gap-lg);
display: flex;
flex-direction: column;
.markdown-body {
margin-bottom: 1rem;
}
.confirmation-label {
margin-bottom: 0.5rem;
}
.confirmation-text {
padding-right: 0.25ch;
margin: 0 0.25rem;
}
.confirmation-input {
input {
width: 20rem;
max-width: 100%;
}
}
.button-group {
margin-left: auto;
margin-top: 1.5rem;
}
}
</style>

View File

@@ -1,6 +1,5 @@
import Index from './Index.vue'
import Mods from './Mods.vue'
import Options from './Options.vue'
import Logs from './Logs.vue'
export { Index, Mods, Options, Logs }
export { Index, Mods, Logs }

View File

@@ -0,0 +1,17 @@
<script setup>
import GridDisplay from '@/components/GridDisplay.vue'
defineProps({
instances: {
type: Array,
required: true,
},
})
</script>
<template>
<GridDisplay
v-if="instances.length > 0"
label="Instances"
:instances="instances.filter((i) => !i.linked_data)"
/>
</template>

View File

@@ -0,0 +1,17 @@
<script setup>
import GridDisplay from '@/components/GridDisplay.vue'
defineProps({
instances: {
type: Array,
required: true,
},
})
</script>
<template>
<GridDisplay
v-if="instances.length > 0"
label="Instances"
:instances="instances.filter((i) => i.linked_data)"
/>
</template>

View File

@@ -1,15 +1,15 @@
<script setup>
import { onUnmounted, ref, shallowRef } from 'vue'
import GridDisplay from '@/components/GridDisplay.vue'
import { list } from '@/helpers/profile.js'
import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { profile_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js'
import { Button } from '@modrinth/ui'
import { PlusIcon } from '@modrinth/assets'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { NewInstanceImage } from '@/assets/icons'
import NavTabs from '@/components/ui/NavTabs.vue'
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
@@ -35,17 +35,31 @@ onUnmounted(() => {
</script>
<template>
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
<div v-else class="no-instance">
<div class="icon">
<NewInstanceImage />
<div class="p-6 flex flex-col gap-3">
<h1 class="m-0 text-2xl hidden">Library</h1>
<NavTabs
:links="[
{ label: 'All instances', href: `/library` },
{ label: 'Downloaded', href: `/library/downloaded` },
{ label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<template v-if="instances.length > 0">
<RouterView :instances="instances" />
</template>
<div v-else class="no-instance">
<div class="icon">
<NewInstanceImage />
</div>
<h3>No instances found</h3>
<Button color="primary" :disabled="offline" @click="$refs.installationModal.show()">
<PlusIcon />
Create new instance
</Button>
<InstanceCreationModal ref="installationModal" />
</div>
<h3>No instances found</h3>
<Button color="primary" :disabled="offline" @click="$refs.installationModal.show()">
<PlusIcon />
Create new instance
</Button>
<InstanceCreationModal ref="installationModal" />
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
import GridDisplay from '@/components/GridDisplay.vue'
defineProps({
instances: {
type: Array,
required: true,
},
})
</script>
<template>
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
</template>

View File

@@ -0,0 +1,6 @@
import Index from './Index.vue'
import Overview from './Overview.vue'
import Downloaded from './Downloaded.vue'
import Custom from './Custom.vue'
export { Index, Overview, Downloaded, Custom }

View File

@@ -1,12 +1,11 @@
<template>
<Card>
<div class="markdown-body" v-html="renderHighlightedString(project?.body ?? '')" />
<ProjectPageDescription :description="project.body" />
</Card>
</template>
<script setup>
import { renderHighlightedString } from '@modrinth/utils'
import { Card } from '@modrinth/ui'
import { Card, ProjectPageDescription } from '@modrinth/ui'
defineProps({
project: {
@@ -21,22 +20,3 @@ export default {
name: 'Description',
}
</script>
<style scoped lang="scss">
.markdown-body {
:deep(table) {
width: auto;
}
:deep(hr),
:deep(h1),
:deep(h2) {
max-width: max(60rem, 90%);
}
:deep(ul),
:deep(ol) {
margin-left: 2rem;
}
}
</style>

View File

@@ -195,7 +195,7 @@ document.addEventListener('keypress', keyListener)
.expanded-image-modal {
position: fixed;
z-index: 10;
z-index: 11;
overflow: auto;
top: 0;
left: 0;

View File

@@ -1,247 +1,157 @@
<template>
<div class="root-container">
<div v-if="data" class="project-sidebar" @scroll="$refs.promo.scroll()">
<Card v-if="instance" class="small-instance">
<router-link class="instance" :to="`/instance/${encodeURIComponent(instance.path)}`">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:alt="instance.name"
size="sm"
/>
<div class="small-instance_info">
<span class="title">{{
instance.name.length > 20 ? instance.name.substring(0, 20) + '...' : instance.name
}}</span>
<span>
{{ instance.loader.charAt(0).toUpperCase() + instance.loader.slice(1) }}
{{ instance.game_version }}
</span>
</div>
</router-link>
</Card>
<Card class="sidebar-card" @contextmenu.prevent.stop="handleRightClick">
<Avatar size="md" :src="data.icon_url" />
<div class="instance-info">
<h2 class="name">{{ data.title }}</h2>
{{ data.description }}
</div>
<Categories
class="tags"
:categories="
categories.filter(
(cat) => data.categories.includes(cat.name) && cat.project_type === 'mod',
)
"
type="ignored"
<div>
<Teleport to="#sidebar-teleport-target">
<ProjectSidebarCompatibility
:project="data"
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
class="project-sidebar-section"
/>
<ProjectSidebarLinks link-target="_blank" :project="data" class="project-sidebar-section" />
<ProjectSidebarCreators
:organization="null"
:members="members"
:org-link="(slug) => `https://modrinth.com/organization/${slug}`"
:user-link="(username) => `https://modrinth.com/user/${username}`"
link-target="_blank"
class="project-sidebar-section"
/>
<ProjectSidebarDetails
:project="data"
:has-versions="versions.length > 0"
:link-target="`_blank`"
class="project-sidebar-section"
/>
</Teleport>
<div class="flex flex-col gap-4 p-6">
<InstanceIndicator v-if="instance" :instance="instance" />
<template v-if="data">
<Teleport
v-if="themeStore.featureFlags.project_background"
to="#background-teleport-target"
>
<EnvironmentIndicator
:client-side="data.client_side"
:server-side="data.server_side"
:type="data.project_type"
/>
</Categories>
<hr class="card-divider" />
<div class="button-group">
<Button
color="primary"
class="instance-button"
:disabled="installed === true || installing === true"
@click="install(null)"
>
<DownloadIcon v-if="!installed && !installing" />
<CheckIcon v-else-if="installed" />
{{ installing ? 'Installing...' : installed ? 'Installed' : 'Install' }}
</Button>
<a
:href="`https://modrinth.com/${data.project_type}/${data.slug}`"
rel="external"
class="btn"
>
<ExternalIcon />
Site
</a>
</div>
</Card>
<Card class="sidebar-card">
<div class="stats">
<div class="stat">
<DownloadIcon aria-hidden="true" />
<p>
<strong>{{ formatNumber(data.downloads) }}</strong>
<span class="stat-label"> download<span v-if="data.downloads !== '1'">s</span></span>
</p>
</div>
<div class="stat">
<HeartIcon aria-hidden="true" />
<p>
<strong>{{ formatNumber(data.followers) }}</strong>
<span class="stat-label"> follower<span v-if="data.followers !== '1'">s</span></span>
</p>
</div>
<div class="stat date">
<CalendarIcon aria-hidden="true" />
<span
><span class="date-label">Created </span> {{ dayjs(data.published).fromNow() }}</span
>
</div>
<div class="stat date">
<UpdatedIcon aria-hidden="true" />
<span
><span class="date-label">Updated </span> {{ dayjs(data.updated).fromNow() }}</span
>
</div>
</div>
<hr class="card-divider" />
<div class="button-group">
<Button class="instance-button" disabled>
<ReportIcon />
Report
</Button>
<Button class="instance-button" disabled>
<HeartIcon />
Follow
</Button>
</div>
<hr class="card-divider" />
<div class="links">
<a
v-if="data.issues_url"
:href="data.issues_url"
class="title"
rel="noopener nofollow ugc external"
>
<IssuesIcon aria-hidden="true" />
<span>Issues</span>
</a>
<a
v-if="data.source_url"
:href="data.source_url"
class="title"
rel="noopener nofollow ugc external"
>
<CodeIcon aria-hidden="true" />
<span>Source</span>
</a>
<a
v-if="data.wiki_url"
:href="data.wiki_url"
class="title"
rel="noopener nofollow ugc external"
>
<WikiIcon aria-hidden="true" />
<span>Wiki</span>
</a>
<a
v-if="data.discord_url"
:href="data.discord_url"
class="title"
rel="noopener nofollow ugc external"
>
<DiscordIcon aria-hidden="true" />
<span>Discord</span>
</a>
<a
v-for="(donation, index) in data.donation_urls"
:key="index"
:href="donation.url"
rel="noopener nofollow ugc external"
>
<BuyMeACoffeeIcon v-if="donation.id === 'bmac'" aria-hidden="true" />
<PatreonIcon v-else-if="donation.id === 'patreon'" aria-hidden="true" />
<KoFiIcon v-else-if="donation.id === 'ko-fi'" aria-hidden="true" />
<PaypalIcon v-else-if="donation.id === 'paypal'" aria-hidden="true" />
<OpenCollectiveIcon v-else-if="donation.id === 'open-collective'" aria-hidden="true" />
<HeartIcon v-else-if="donation.id === 'github'" />
<CoinsIcon v-else />
<span v-if="donation.id === 'bmac'">Buy Me a Coffee</span>
<span v-else-if="donation.id === 'patreon'">Patreon</span>
<span v-else-if="donation.id === 'paypal'">PayPal</span>
<span v-else-if="donation.id === 'ko-fi'">Ko-fi</span>
<span v-else-if="donation.id === 'github'">GitHub Sponsors</span>
<span v-else>Donate</span>
</a>
</div>
</Card>
</div>
<div v-if="data" class="content-container">
<Card class="tabs">
<NavRow
v-if="data.gallery.length > 0"
<ProjectBackgroundGradient :project="data" />
</Teleport>
<ProjectHeader :project="data" @contextmenu.prevent.stop="handleRightClick">
<template #actions>
<ButtonStyled size="large" color="brand">
<button
v-tooltip="installed ? `This project is already installed` : null"
:disabled="installed || installing"
@click="install(null)"
>
<DownloadIcon v-if="!installed && !installing" />
<CheckIcon v-else-if="installed" />
{{ installing ? 'Installing...' : installed ? 'Installed' : 'Install' }}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="`More options`"
:options="[
{
id: 'follow',
disabled: true,
tooltip: 'Coming soon',
action: () => {},
},
{
id: 'save',
disabled: true,
tooltip: 'Coming soon',
action: () => {},
},
{
id: 'open-in-browser',
link: `https://modrinth.com/${data.project_type}/${data.slug}`,
external: true,
},
{
divider: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=project&itemID=${data.id}`,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #follow> <HeartIcon /> Follow </template>
<template #save> <BookmarkIcon /> Save </template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectHeader>
<NavTabs
:links="[
{
label: 'Description',
href: `/project/${$route.params.id}/`,
href: `/project/${$route.params.id}`,
},
{
label: 'Versions',
href: `/project/${$route.params.id}/versions`,
href: {
path: `/project/${$route.params.id}/versions`,
query: { l: instance?.loader, g: instance?.game_version },
},
subpages: ['version'],
},
{
label: 'Gallery',
href: `/project/${$route.params.id}/gallery`,
shown: data.gallery.length > 0,
},
]"
/>
<NavRow
v-else
:links="[
{
label: 'Description',
href: `/project/${$route.params.id}/`,
},
{
label: 'Versions',
href: `/project/${$route.params.id}/versions`,
},
]"
<RouterView
:project="data"
:versions="versions"
:members="members"
:instance="instance"
:install="install"
:installed="installed"
:installing="installing"
:installed-version="installedVersion"
/>
</Card>
<RouterView
:project="data"
:versions="versions"
:members="members"
:instance="instance"
:install="install"
:installed="installed"
:installing="installing"
:installed-version="installedVersion"
/>
</template>
<template v-else> Project data couldn't not be loaded. </template>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #install> <DownloadIcon /> Install </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
</ContextMenu>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #install> <DownloadIcon /> Install </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
</ContextMenu>
</template>
<script setup>
import {
BookmarkIcon,
MoreVerticalIcon,
DownloadIcon,
ReportIcon,
HeartIcon,
UpdatedIcon,
CalendarIcon,
IssuesIcon,
WikiIcon,
CoinsIcon,
CodeIcon,
ExternalIcon,
CheckIcon,
GlobeIcon,
ClipboardCopyIcon,
} from '@modrinth/assets'
import { Categories, EnvironmentIndicator, Card, Avatar, Button, NavRow } from '@modrinth/ui'
import { formatNumber } from '@modrinth/utils'
import {
BuyMeACoffeeIcon,
DiscordIcon,
PatreonIcon,
PaypalIcon,
KoFiIcon,
OpenCollectiveIcon,
} from '@/assets/external'
import { get_categories } from '@/helpers/tags'
ProjectHeader,
ProjectSidebarCompatibility,
ButtonStyled,
OverflowMenu,
ProjectSidebarLinks,
ProjectSidebarCreators,
ProjectSidebarDetails,
ProjectBackgroundGradient,
} from '@modrinth/ui'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -249,17 +159,20 @@ import { useRoute } from 'vue-router'
import { ref, shallowRef, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
import { convertFileSrc } from '@tauri-apps/api/core'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { install as installVersion } from '@/store/install.js'
import { get_project, get_team, get_version_many } from '@/helpers/cache.js'
import NavTabs from '@/components/ui/NavTabs.vue'
import { useTheming } from '@/store/state.js'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
dayjs.extend(relativeTime)
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const options = ref(null)
const installing = ref(false)
const data = shallowRef(null)
const versions = shallowRef([])
@@ -271,6 +184,11 @@ const instanceProjects = ref(null)
const installed = ref(false)
const installedVersion = ref(null)
const [allLoaders, allGameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
])
async function fetchProjectData() {
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
@@ -300,13 +218,11 @@ async function fetchProjectData() {
await fetchProjectData()
const promo = ref(null)
watch(
() => route.params.id,
async () => {
if (route.params.id && route.path.startsWith('/project')) {
await fetchProjectData()
promo.value.scroll()
}
},
)
@@ -329,28 +245,30 @@ async function install(version) {
)
}
const handleRightClick = (e) => {
options.value.showMenu(e, data.value, [
{ name: 'install' },
{ type: 'divider' },
{ name: 'open_link' },
{ name: 'copy_link' },
const options = ref(null)
const handleRightClick = (event) => {
options.value.showMenu(event, data.value, [
{
name: 'install',
},
{
type: 'divider',
},
{
name: 'open_link',
},
{
name: 'copy_link',
},
])
}
const handleOptionsClick = (args) => {
switch (args.option) {
case 'install':
install(null)
break
case 'open_link':
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: `https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
},
})
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
break
case 'copy_link':
navigator.clipboard.writeText(
@@ -518,27 +436,7 @@ const handleOptionsClick = (args) => {
}
}
.small-instance {
padding: var(--gap-lg);
border-radius: var(--radius-md);
margin-bottom: var(--gap-md);
.instance {
display: flex;
gap: 0.5rem;
margin-bottom: 0;
.title {
font-weight: 600;
color: var(--color-contrast);
}
}
.small-instance_info {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0.25rem 0;
}
.project-sidebar-section {
@apply p-4 flex flex-col gap-2 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid;
}
</style>

View File

@@ -1,165 +1,82 @@
<template>
<Card class="filter-header">
<div class="manage">
<multiselect
v-model="filterLoader"
:options="
versions
.flatMap((value) => value.loaders)
.filter((value, index, self) => self.indexOf(value) === index)
"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-search-on-select="false"
:show-labels="false"
:selectable="() => versions.length <= 6"
placeholder="Filter loader..."
:custom-label="(option) => option.charAt(0).toUpperCase() + option.slice(1)"
/>
<multiselect
v-model="filterGameVersions"
:options="
versions
.flatMap((value) => value.game_versions)
.filter((value, index, self) => self.indexOf(value) === index)
"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-search-on-select="false"
:show-labels="false"
:selectable="() => versions.length <= 6"
placeholder="Filter versions..."
:custom-label="(option) => option.charAt(0).toUpperCase() + option.slice(1)"
/>
<multiselect
v-model="filterVersions"
:options="
versions
.map((value) => value.version_type)
.filter((value, index, self) => self.indexOf(value) === index)
"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-search-on-select="false"
:show-labels="false"
:selectable="() => versions.length <= 6"
placeholder="Filter release channel..."
:custom-label="(option) => option.charAt(0).toUpperCase() + option.slice(1)"
/>
</div>
<Button
class="no-wrap clear-filters"
:disabled="
filterVersions.length === 0 && filterLoader.length === 0 && filterGameVersions.length === 0
"
:action="clearFilters"
<div>
<ProjectPageVersions
:loaders="loaders"
:game-versions="gameVersions"
:versions="versions"
:project="project"
:version-link="(version) => `/project/${project.id}/version/${version.id}`"
>
<ClearIcon />
Clear filters
</Button>
</Card>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<Card class="mod-card">
<div class="table">
<div class="table-row table-head">
<div class="table-cell table-text download-cell" />
<div class="name-cell table-cell table-text">Name</div>
<div class="table-cell table-text">Supports</div>
<div class="table-cell table-text">Stats</div>
</div>
<div
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="version.id"
class="table-row selectable"
@click="$router.push(`/project/${$route.params.id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<Button
:color="installed && version.id === installedVersion ? '' : 'primary'"
icon-only
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<button
v-tooltip="`Install`"
:class="{
'group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted':
!installed || version.id !== installedVersion,
}"
:disabled="installing || (installed && version.id === installedVersion)"
@click.stop="() => install(version.id)"
>
<DownloadIcon v-if="!installed" />
<SwapIcon v-else-if="installed && version.id !== installedVersion" />
<CheckIcon v-else />
</Button>
</div>
<div class="name-cell table-cell table-text">
<div class="version-link">
{{ version.name.charAt(0).toUpperCase() + version.name.slice(1) }}
<div class="version-badge">
<div class="channel-indicator">
<Badge
:color="releaseColor(version.version_type)"
:type="
version.version_type.charAt(0).toUpperCase() + version.version_type.slice(1)
"
/>
</div>
<div>
{{ version.version_number }}
</div>
</div>
</div>
</div>
<div class="table-cell table-text stacked-text">
<span>
{{
version.loaders.map((str) => str.charAt(0).toUpperCase() + str.slice(1)).join(', ')
}}
</span>
<span>
{{ version.game_versions.join(', ') }}
</span>
</div>
<div class="table-cell table-text stacked-text">
<div>
<span> Published on </span>
<strong>
{{
new Date(version.date_published).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}}
</strong>
</div>
<div>
<strong>
{{ formatNumber(version.downloads) }}
</strong>
<span> Downloads </span>
</div>
</div>
</div>
</div>
</Card>
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
v-if="false"
class="group-hover:!bg-button-bg"
:options="[
{
id: 'install-elsewhere',
action: () => {},
shown: false && !!instance,
color: 'primary',
hoverFilled: true,
},
{
id: 'open-in-browser',
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #install-elsewhere>
<DownloadIcon aria-hidden="true" />
Add to another instance
</template>
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
</OverflowMenu>
<a
v-else
v-tooltip="`Open in browser`"
class="group-hover:!bg-button-bg"
:href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
target="_blank"
>
<ExternalIcon />
</a>
</ButtonStyled>
</template>
</ProjectPageVersions>
</div>
</template>
<script setup>
import { Card, Button, Pagination, Badge } from '@modrinth/ui'
import { CheckIcon, ClearIcon, DownloadIcon } from '@modrinth/assets'
import { formatNumber } from '@modrinth/utils'
import Multiselect from 'vue-multiselect'
import { releaseColor } from '@/helpers/utils'
import { computed, ref, watch } from 'vue'
import { ProjectPageVersions, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js'
const props = defineProps({
defineProps({
project: {
type: Object,
default: () => {},
},
versions: {
type: Array,
required: true,
@@ -186,40 +103,10 @@ const props = defineProps({
},
})
const filterVersions = ref([])
const filterLoader = ref(props.instance ? [props.instance?.loader] : [])
const filterGameVersions = ref(props.instance ? [props.instance?.game_version] : [])
const currentPage = ref(1)
const clearFilters = () => {
filterVersions.value = []
filterLoader.value = []
filterGameVersions.value = []
}
const filteredVersions = computed(() => {
return props.versions.filter(
(projectVersion) =>
(filterGameVersions.value.length === 0 ||
filterGameVersions.value.some((gameVersion) =>
projectVersion.game_versions.includes(gameVersion),
)) &&
(filterLoader.value.length === 0 ||
filterLoader.value.some((loader) => projectVersion.loaders.includes(loader))) &&
(filterVersions.value.length === 0 ||
filterVersions.value.includes(projectVersion.version_type)),
)
})
function switchPage(page) {
currentPage.value = page
}
//watch all the filters and if a value changes, reset to page 1
watch([filterVersions, filterLoader, filterGameVersions], () => {
currentPage.value = 1
})
const [loaders, gameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
])
</script>
<style scoped lang="scss">