Misc fixes, new instance & project cards (#3040)

* Fix some TS errors, and misc settings fixes

* New instance + project cards

* bug fixes + lint

* Quick instance switcher

---------

Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
Prospector
2024-12-18 15:09:16 -08:00
committed by GitHub
parent 02dd2a3980
commit 76b1d1df8c
32 changed files with 576 additions and 517 deletions

View File

@@ -57,6 +57,7 @@ import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js' import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue' import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
const themeStore = useTheming() const themeStore = useTheming()
@@ -393,6 +394,9 @@ function handleAuxClick(e) {
<template #label>Library</template> <template #label>Library</template>
</NavButton> </NavButton>
<div class="h-px w-6 mx-auto my-2 bg-button-bg"></div> <div class="h-px w-6 mx-auto my-2 bg-button-bg"></div>
<suspense>
<QuickInstanceSwitcher />
</suspense>
<NavButton :to="() => $refs.installationModal.show()" :disabled="offline"> <NavButton :to="() => $refs.installationModal.show()" :disabled="offline">
<PlusIcon /> <PlusIcon />
<template #label>Create new instance</template> <template #label>Create new instance</template>

View File

@@ -117,4 +117,8 @@ img {
-ms-user-select: none; -ms-user-select: none;
} }
.card-shadow {
box-shadow: var(--shadow-card);
}
@import '@modrinth/assets/omorphia.scss'; @import '@modrinth/assets/omorphia.scss';

View File

@@ -363,9 +363,9 @@ const filteredResults = computed(() => {
.instances { .instances {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
width: 100%; width: 100%;
gap: 1rem; gap: 0.75rem;
margin-right: auto; margin-right: auto;
scroll-behavior: smooth; scroll-behavior: smooth;
overflow-y: auto; overflow-y: auto;

View File

@@ -181,6 +181,7 @@ const handleOptionsClick = async (args) => {
} }
} }
const maxInstancesPerCompactRow = ref(1)
const maxInstancesPerRow = ref(1) const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1) const maxProjectsPerRow = ref(1)
@@ -190,8 +191,20 @@ const calculateCardsPerRow = () => {
// Convert container width from pixels to rem // Convert container width from pixels to rem
const containerWidthInRem = const containerWidthInRem =
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize) containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 1) / 11)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 1) / 19) maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
if (maxInstancesPerRow.value < 5) {
maxInstancesPerRow.value *= 2
}
if (maxInstancesPerCompactRow.value < 5) {
maxInstancesPerCompactRow.value *= 2
}
if (maxProjectsPerRow.value < 3) {
maxProjectsPerRow.value *= 2
}
} }
onMounted(() => { onMounted(() => {
@@ -213,17 +226,33 @@ onUnmounted(() => {
proceed-label="Delete" proceed-label="Delete"
@proceed="deleteProfile" @proceed="deleteProfile"
/> />
<div class="content"> <div class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row"> <div v-for="(row, rowIndex) in actualInstances" ref="rows" :key="row.label" class="row">
<div class="header"> <router-link
<router-link :to="row.route">{{ row.label }}</router-link> class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group"
<ChevronRightIcon /> :class="{ 'mt-1': rowIndex > 0 }"
</div> :to="row.route"
<section v-if="row.instance" ref="modsRow" class="instances"> >
{{ row.label }}
<ChevronRightIcon
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
/>
</router-link>
<section
v-if="row.instance"
ref="modsRow"
class="instances"
:class="{ compact: row.compact }"
>
<Instance <Instance
v-for="instance in row.instances.slice(0, maxInstancesPerRow)" v-for="(instance, instanceIndex) in row.instances.slice(
:key="(instance?.project_id || instance?.id) + instance.install_stage" 0,
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
)"
:key="row.label + instance.path"
:instance="instance" :instance="instance"
:compact="row.compact"
:first="instanceIndex === 0"
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)" @contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
/> />
</section> </section>
@@ -308,16 +337,21 @@ onUnmounted(() => {
.instances { .instances {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-gap: 1rem; grid-gap: 0.75rem;
width: 100%; width: 100%;
&.compact {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 0.75rem;
}
} }
.projects { .projects {
display: grid; display: grid;
width: 100%; width: 100%;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: 1rem; grid-gap: 0.75rem;
.item { .item {
width: 100%; width: 100%;

View File

@@ -1,8 +1,8 @@
<script setup> <script setup>
import { onUnmounted, ref, computed } from 'vue' import { onUnmounted, ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { StopCircleIcon, PlayIcon } from '@modrinth/assets' import { SpinnerIcon, GameIcon, TimerIcon, StopCircleIcon, PlayIcon } from '@modrinth/assets'
import { Card, Avatar, AnimatedLogo } from '@modrinth/ui' import { ButtonStyled, Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { kill, run } from '@/helpers/profile' import { kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process' import { get_by_profile_path } from '@/helpers/process'
@@ -11,6 +11,13 @@ import { handleError } from '@/store/state.js'
import { showProfileInFolder } from '@/helpers/utils.js' import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import { formatCategory } from '@modrinth/utils'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const props = defineProps({ const props = defineProps({
instance: { instance: {
@@ -19,11 +26,23 @@ const props = defineProps({
return {} return {}
}, },
}, },
compact: {
type: Boolean,
default: false,
},
first: {
type: Boolean,
default: false,
},
}) })
const playing = ref(false) const playing = ref(false)
const modLoading = computed(() => props.instance.install_stage !== 'installed') const modLoading = computed(
() =>
currentEvent.value === 'installing' || (currentEvent.value === 'launched' && !playing.value),
)
const installing = computed(() => props.instance.install_stage !== 'installed')
const router = useRouter() const router = useRouter()
@@ -39,17 +58,15 @@ const checkProcess = async () => {
const play = async (e, context) => { const play = async (e, context) => {
e?.stopPropagation() e?.stopPropagation()
modLoading.value = true await run(props.instance.path)
await run(props.instance.path).catch((err) => .catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
handleSevereError(err, { profilePath: props.instance.path }), .finally(() => {
) trackEvent('InstancePlay', {
modLoading.value = false loader: props.instance.loader,
game_version: props.instance.game_version,
trackEvent('InstancePlay', { source: context,
loader: props.instance.loader, })
game_version: props.instance.game_version, })
source: context,
})
} }
const stop = async (e, context) => { const stop = async (e, context) => {
@@ -85,175 +102,130 @@ defineExpose({
instance: props.instance, instance: props.instance,
}) })
const currentEvent = ref(null)
const unlisten = await process_listener((e) => { const unlisten = await process_listener((e) => {
if (e.event === 'finished' && e.profile_path_id === props.instance.path) playing.value = false if (e.profile_path_id === props.instance.path) {
currentEvent.value = e.event
if (e.event === 'finished') {
playing.value = false
}
}
}) })
onMounted(() => checkProcess())
onUnmounted(() => unlisten()) onUnmounted(() => unlisten())
</script> </script>
<template> <template>
<div class="instance"> <template v-if="compact">
<Card class="instance-card-item button-base" @click="seeInstance" @mouseenter="checkProcess">
<Avatar
size="lg"
:src="props.instance.icon_path ? convertFileSrc(props.instance.icon_path) : null"
alt="Mod card"
class="mod-image"
/>
<div class="project-info">
<p class="title">{{ props.instance.name }}</p>
<p
v-if="
props.instance.install_stage === 'installing' ||
props.instance.install_stage === 'not_installed' ||
props.instance.install_stage === 'pack_installing'
"
class="description"
>
Installing...
</p>
<p v-else class="description">
{{ props.instance.loader }}
{{ props.instance.game_version }}
</p>
</div>
</Card>
<div <div
v-if="playing === true" class="button-base card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer active:scale-[0.98] transition-transform"
class="stop cta button-base" @click="seeInstance"
@click="(e) => stop(e, 'InstanceCard')" @mouseenter="checkProcess"
@mousehover="checkProcess"
> >
<StopCircleIcon /> <Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path"
alt="Mod card"
/>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span>
</div>
<div class="flex items-center">
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular>
<button v-tooltip="'Instance is loading...'" disabled>
<SpinnerIcon class="animate-spin" />
</button>
</ButtonStyled>
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
<button
v-tooltip="'Play'"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<!-- Translate for optical centering -->
<PlayIcon class="translate-x-[1px]" />
</button>
</ButtonStyled>
</div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon />
<span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </span>
</div>
</div> </div>
<div v-else-if="modLoading === true && playing === false" class="cta loading-cta"> </template>
<AnimatedLogo class="loading-indicator" /> <div v-else>
</div> <div
<div v-else class="install cta button-base" @click="(e) => play(e, 'InstanceCard')"> class="button-base bg-bg-raised p-4 rounded-xl flex gap-3 group"
<PlayIcon /> @click="seeInstance"
@mouseenter="checkProcess"
>
<div class="relative flex items-center justify-center">
<Avatar
size="96px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path"
alt="Mod card"
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="playing" size="large" color="red" circular>
<button
v-tooltip="'Stop'"
:class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
class="animate-spin w-8 h-8"
/>
<ButtonStyled v-else size="large" color="brand" circular>
<button
v-tooltip="'Play'"
class="transition-all scale-75 group-hover:scale-100 origin-bottom opacity-0 group-hover:opacity-100 card-shadow"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<PlayIcon class="translate-x-[2px]" />
</button>
</ButtonStyled>
</div>
</div>
<div class="flex flex-col gap-1">
<p class="m-0 text-lg font-bold text-contrast leading-tight line-clamp-2">
{{ instance.name }}
</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>
</div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon class="shrink-0" />
<span class="text-sm line-clamp-1">
Played for
{{
dayjs
.duration(instance.recent_time_played + instance.submitted_time_played, 'seconds')
.humanize()
}}
</span>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss">
.loading-indicator {
width: 2.5rem !important;
height: 2.5rem !important;
svg {
width: 2.5rem !important;
height: 2.5rem !important;
}
}
</style>
<style lang="scss" scoped>
.instance {
position: relative;
&:hover {
.cta {
opacity: 1;
bottom: calc(var(--gap-md) + 4.25rem);
}
}
}
.cta {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
z-index: 1;
width: 3rem;
height: 3rem;
right: calc(var(--gap-md) * 2);
bottom: 3.25rem;
opacity: 0;
transition:
0.2s ease-in-out bottom,
0.2s ease-in-out opacity,
0.1s ease-in-out filter !important;
cursor: pointer;
box-shadow: var(--shadow-floating);
svg {
color: var(--color-accent-contrast);
width: 1.5rem !important;
height: 1.5rem !important;
}
&.install {
background: var(--color-brand);
display: flex;
}
&.stop {
background: var(--color-red);
display: flex;
}
&.loading-cta {
background: hsl(220, 11%, 10%) !important;
display: flex;
justify-content: center;
align-items: center;
}
}
.instance-card-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
padding: var(--gap-md);
transition: 0.1s ease-in-out all !important; /* overrides Omorphia defaults */
margin-bottom: 0;
.mod-image {
--size: 100%;
width: 100% !important;
height: auto !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1 !important;
}
.project-info {
margin-top: 1rem;
width: 100%;
.title {
color: var(--color-contrast);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 100%;
margin: 0;
font-weight: 600;
font-size: 1rem;
line-height: 110%;
display: inline-block;
}
.description {
color: var(--color-base);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: 500;
font-size: 0.775rem;
line-height: 125%;
margin: 0.25rem 0 0;
text-transform: capitalize;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
</style>

View File

@@ -237,7 +237,7 @@ const display_icon = ref(null)
const showAdvanced = ref(false) const showAdvanced = ref(false)
const creating = ref(false) const creating = ref(false)
const showSnapshots = ref(false) const showSnapshots = ref(false)
const creationType = ref('from file') const creationType = ref('custom')
const isShowing = ref(false) const isShowing = ref(false)
defineExpose({ defineExpose({

View File

@@ -38,6 +38,7 @@ defineProps<{
to: (() => void) | string to: (() => void) | string
isPrimary?: RouteFunction isPrimary?: RouteFunction
isSubpage?: RouteFunction isSubpage?: RouteFunction
highlightOverride?: boolean
}>() }>()
defineOptions({ defineOptions({

View File

@@ -157,8 +157,4 @@ watch(route, () => {
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s, all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms; opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
} }
.card-shadow {
box-shadow: var(--shadow-card);
}
</style> </style>

View File

@@ -1,17 +1,15 @@
<script setup> <script setup>
import { Card, Avatar, Button } from '@modrinth/ui' import { Avatar, TagItem } from '@modrinth/ui'
import { DownloadIcon, HeartIcon, CalendarIcon } from '@modrinth/assets' import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets'
import { formatNumber, formatCategory } from '@modrinth/utils' import { formatNumber, formatCategory } from '@modrinth/utils'
import { computed, ref } from 'vue' import { computed } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { install as installVersion } from '@/store/install.js'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const router = useRouter() const router = useRouter()
const installing = ref(false)
const props = defineProps({ const props = defineProps({
project: { project: {
@@ -22,6 +20,17 @@ const props = defineProps({
}, },
}) })
const featuredCategory = computed(() => {
if (props.project.categories.includes('optimization')) {
return 'optimization'
}
if (props.project.categories.length > 0) {
return props.project.categories[0]
}
return undefined
})
const toColor = computed(() => { const toColor = computed(() => {
let color = props.project.color let color = props.project.color
@@ -47,197 +56,66 @@ const toTransparent = computed(() => {
'))' '))'
) )
}) })
const install = async (e) => {
e?.stopPropagation()
installing.value = true
await installVersion(
props.project.project_id,
null,
props.instance ? props.instance.path : null,
'ProjectCard',
() => {
installing.value = false
},
)
}
</script> </script>
<template> <template>
<div class="wrapper"> <div
<Card class="project-card button-base" @click="router.push(`/project/${project.slug}`)"> class="card-shadow button-base bg-bg-raised rounded-xl overflow-clip cursor-pointer active:scale-[0.98] transition-transform"
@click="router.push(`/project/${project.slug}`)"
>
<div
class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat"
:style="{
'background-color': project.featured_gallery ?? project.gallery[0] ? null : toColor,
'background-image': `url(${
project.featured_gallery ??
project.gallery[0] ??
'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`,
}"
>
<div <div
class="banner" class="badges-wrapper"
:style="{ :class="{
'background-color': project.featured_gallery ?? project.gallery[0] ? null : toColor,
'background-image': `url(${
project.featured_gallery ??
project.gallery[0] ??
'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`,
'no-image': !project.featured_gallery && !project.gallery[0], 'no-image': !project.featured_gallery && !project.gallery[0],
}" }"
> :style="{
<div class="badges"> background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null,
<div class="badge"> }"
<DownloadIcon /> ></div>
{{ formatNumber(project.downloads) }} </div>
</div> <div class="flex flex-col justify-center gap-2 px-4 py-3">
<div class="badge"> <div class="flex gap-2 items-center">
<HeartIcon /> <Avatar size="48px" :src="project.icon_url" />
{{ formatNumber(project.follows) }} <div class="h-full flex items-center font-bold text-contrast leading-normal">
</div> <span class="line-clamp-2">{{ project.title }}</span>
<div class="badge"> </div>
<CalendarIcon /> </div>
{{ formatCategory(dayjs(project.date_modified).fromNow()) }} <p class="m-0 text-sm font-medium line-clamp-3 leading-tight h-[3.25rem]">
</div> {{ project.description }}
</p>
<div class="flex items-center gap-2 text-sm text-secondary font-semibold mt-auto">
<div
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
>
<DownloadIcon />
{{ formatNumber(project.downloads) }}
</div> </div>
<div <div
class="badges-wrapper" class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
:class="{ >
'no-image': !project.featured_gallery && !project.gallery[0], <HeartIcon />
}" {{ formatNumber(project.follows) }}
:style="{ </div>
background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null, <div class="flex items-center gap-1 pr-2">
}" <TagIcon />
></div> <TagItem>
</div> {{ formatCategory(featuredCategory) }}
<Avatar class="icon" size="sm" :src="project.icon_url" /> </TagItem>
<div class="title">
<div class="title-text">
{{ project.title }}
</div> </div>
<div class="author">by {{ project.author }}</div>
</div> </div>
<div class="description"> </div>
{{ project.description }}
</div>
</Card>
<Button color="primary" class="install" :disabled="installing" @click="install">
<DownloadIcon />
{{ installing ? 'Installing' : 'Install' }}
</Button>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss"></style>
.wrapper {
position: relative;
aspect-ratio: 1;
&:hover {
.install:enabled {
opacity: 1;
}
}
}
.project-card {
display: grid;
grid-gap: 1rem;
grid-template:
'. . . .' 0
'. icon title .' 3rem
'banner banner banner banner' auto
'. description description .' 3.5rem
'. . . .' 0 / 0 3rem minmax(0, 1fr) 0;
max-width: 100%;
height: 100%;
padding: 0;
margin: 0;
.icon {
grid-area: icon;
}
.title {
max-width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
grid-area: title;
white-space: nowrap;
.title-text {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--font-size-md);
font-weight: bold;
}
}
.author {
font-size: var(--font-size-sm);
grid-area: author;
}
.banner {
grid-area: banner;
background-size: cover;
background-position: center;
position: relative;
.badges-wrapper {
width: 100%;
height: 100%;
display: flex;
position: absolute;
top: 0;
left: 0;
mix-blend-mode: hard-light;
}
.badges {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: var(--gap-sm);
gap: var(--gap-xs);
display: flex;
z-index: 10;
flex-direction: row;
justify-content: flex-end;
align-items: flex-end;
}
}
.description {
grid-area: description;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
}
.badge {
background-color: var(--color-raised-bg);
font-size: var(--font-size-xs);
padding: var(--gap-xs) var(--gap-sm);
border-radius: var(--radius-sm);
svg {
width: 1rem;
height: 1rem;
margin-right: var(--gap-xs);
}
}
.install {
position: absolute;
top: calc(5rem + var(--gap-sm));
right: var(--gap-sm);
z-index: 10;
opacity: 0;
transition: opacity 0.2s ease-in-out;
svg {
width: 1rem;
height: 1rem;
}
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup>
import { list } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue'
import { profile_listener } from '@/helpers/events.js'
import NavButton from '@/components/ui/NavButton.vue'
import { Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { SpinnerIcon } from '@modrinth/assets'
const recentInstances = ref([])
const getInstances = async () => {
const profiles = await list().catch(handleError)
recentInstances.value = profiles
.sort((a, b) => {
const dateA = dayjs(a.created > a.last_played ? a.last_played : a.created)
const dateB = dayjs(b.created > b.last_played ? b.last_played : b.created)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
.slice(0, 4)
}
await getInstances()
const unlistenProfile = await profile_listener(async () => {
await getInstances()
})
onUnmounted(() => {
unlistenProfile()
})
</script>
<template>
<NavButton
v-for="instance in recentInstances"
:key="instance.id"
:to="`/instance/${encodeURIComponent(instance.path)}`"
>
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
circle
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
<template #label>{{ instance.name }}</template>
</NavButton>
</template>
<style scoped lang="scss"></style>

View File

@@ -14,7 +14,7 @@
<DownloadIcon /> <DownloadIcon />
</Button> </Button>
<div v-if="offline" class="status"> <div v-if="offline" class="status">
<span class="circle stopped" /> <UnplugIcon />
<div class="running-text"> <div class="running-text">
<span> Offline </span> <span> Offline </span>
</div> </div>
@@ -108,7 +108,13 @@
</template> </template>
<script setup> <script setup>
import { DownloadIcon, StopCircleIcon, TerminalSquareIcon, DropdownIcon } from '@modrinth/assets' import {
DownloadIcon,
StopCircleIcon,
TerminalSquareIcon,
DropdownIcon,
UnplugIcon,
} from '@modrinth/assets'
import { Button, Card } from '@modrinth/ui' import { Button, Card } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process' import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="button-base p-4 bg-bg-raised rounded-xl flex gap-3 group" class="card-shadow button-base p-4 bg-bg-raised rounded-xl flex gap-3 group"
@click=" @click="
() => { () => {
emit('open') emit('open')

View File

@@ -250,7 +250,7 @@ onUnmounted(() => {
</ModalWrapper> </ModalWrapper>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h3 class="text-lg m-0">Friends</h3> <h3 class="text-lg m-0">Friends</h3>
<ButtonStyled type="transparent" circular> <ButtonStyled v-if="userCredentials" type="transparent" circular>
<OverflowMenu <OverflowMenu
:options="[ :options="[
{ {

View File

@@ -17,19 +17,21 @@
<tr class="content"> <tr class="content">
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td> <td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
<td> <td>
<DropdownSelect <multiselect
v-if="versions?.length > 1" v-if="versions?.length > 1"
v-model="selectedVersion" v-model="selectedVersion"
:options="versions" :options="versions"
:searchable="true"
placeholder="Select version" placeholder="Select version"
name="Version select" open-direction="top"
:display-name=" :show-labels="false"
:custom-label="
(version) => (version) =>
`${version?.name} (${version?.loaders `${version?.name} (${version?.loaders
.map((name) => formatCategory(name)) .map((name) => formatCategory(name))
.join(', ')} - ${version?.game_versions.join(', ')})` .join(', ')} - ${version?.game_versions.join(', ')})`
" "
render-up :max-height="150"
/> />
<span v-else> <span v-else>
<span> <span>
@@ -56,12 +58,13 @@
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { XIcon, DownloadIcon } from '@modrinth/assets' import { XIcon, DownloadIcon } from '@modrinth/assets'
import { Button, DropdownSelect } from '@modrinth/ui' import { Button } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils' import { formatCategory } from '@modrinth/utils'
import { add_project_from_version as installMod } from '@/helpers/profile' import { add_project_from_version as installMod } from '@/helpers/profile'
import { ref } from 'vue' import { ref } from 'vue'
import { handleError } from '@/store/state.js' import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import Multiselect from 'vue-multiselect'
const instance = ref(null) const instance = ref(null)
const project = ref(null) const project = ref(null)

View File

@@ -357,7 +357,7 @@ const createInstance = async () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 1rem; min-width: 350px;
} }
.profiles { .profiles {
@@ -378,7 +378,6 @@ const createInstance = async () => {
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 0.5rem;
gap: 0.5rem; gap: 0.5rem;
img { img {

View File

@@ -10,15 +10,14 @@ import { open } from '@tauri-apps/plugin-dialog'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import type { InstanceSettingsTabProps, GameInstance } from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const router = useRouter() const router = useRouter()
const deleteConfirmModal = ref() const deleteConfirmModal = ref()
const props = defineProps<{ const props = defineProps<InstanceSettingsTabProps>()
instance: GameInstance
}>()
const title = ref(props.instance.name) const title = ref(props.instance.name)
const icon: Ref<string | undefined> = ref(props.instance.icon_path) const icon: Ref<string | undefined> = ref(props.instance.icon_path)
@@ -215,6 +214,7 @@ const messages = defineMessages({
:src="icon ? convertFileSrc(icon) : icon" :src="icon ? convertFileSrc(icon) : icon"
size="108px" size="108px"
class="!border-4 group-hover:brightness-75" class="!border-4 group-hover:brightness-75"
:tint-by="props.instance.path"
no-shadow no-shadow
/> />
<div class="absolute top-0 right-0 m-2"> <div class="absolute top-0 right-0 m-2">

View File

@@ -5,12 +5,11 @@ import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings' import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile' import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<{ const props = defineProps<InstanceSettingsTabProps>()
instance: GameInstance
}>()
const globalSettings = (await get().catch(handleError)) as AppSettings const globalSettings = (await get().catch(handleError)) as AppSettings

View File

@@ -29,7 +29,11 @@ import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { get_project, get_version_many } from '@/helpers/cache' import { get_project, get_version_many } from '@/helpers/cache'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue' import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { GameInstance, ManifestLoaderVersion, Manifest } from '@/helpers/types.d.ts' import type {
InstanceSettingsTabProps,
ManifestLoaderVersion,
Manifest,
} from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -38,10 +42,7 @@ const modpackVersionModal = ref()
const modalConfirmUnpair = ref() const modalConfirmUnpair = ref()
const modalConfirmReinstall = ref() const modalConfirmReinstall = ref()
const props = defineProps<{ const props = defineProps<InstanceSettingsTabProps>()
instance: GameInstance
offline?: boolean
}>()
const loader = ref(props.instance.loader) const loader = ref(props.instance.loader)
const gameVersion = ref(props.instance.game_version) const gameVersion = ref(props.instance.game_version)
@@ -307,11 +308,11 @@ const messages = defineMessages({
defaultMessage: '(unknown version)', defaultMessage: '(unknown version)',
}, },
repairConfirmTitle: { repairConfirmTitle: {
id: 'instance.settings.tabs.installation.repair.confirm-title', id: 'instance.settings.tabs.installation.repair.confirm.title',
defaultMessage: 'Repair instance?', defaultMessage: 'Repair instance?',
}, },
repairConfirmDescription: { repairConfirmDescription: {
id: 'instance.settings.tabs.installation.repair.description', id: 'instance.settings.tabs.installation.repair.confirm.description',
defaultMessage: defaultMessage:
'Repairing reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors, but will not resolve issues or crashes related to installed mods.', 'Repairing reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors, but will not resolve issues or crashes related to installed mods.',
}, },
@@ -347,10 +348,26 @@ const messages = defineMessages({
id: 'instance.settings.tabs.installation.change-version.button.installing', id: 'instance.settings.tabs.installation.change-version.button.installing',
defaultMessage: 'Installing', defaultMessage: 'Installing',
}, },
installInProgress: {
id: 'instance.settings.tabs.installation.install.in-progress',
defaultMessage: 'Installation in progress',
},
installButton: { installButton: {
id: 'instance.settings.tabs.installation.change-version.button.install', id: 'instance.settings.tabs.installation.change-version.button.install',
defaultMessage: 'Install', defaultMessage: 'Install',
}, },
alreadyInstalledVanilla: {
id: 'instance.settings.tabs.installation.change-version.already-installed.vanilla',
defaultMessage: 'Vanilla {game_version} already installed',
},
alreadyInstalledModded: {
id: 'instance.settings.tabs.installation.change-version.already-installed.modded',
defaultMessage: '{platform} {version} for Minecraft {game_version} already installed',
},
installAction: {
id: 'instance.settings.tabs.installation.tooltip.action.install',
defaultMessage: 'install',
},
installingNewVersion: { installingNewVersion: {
id: 'instance.settings.tabs.installation.change-version.in-progress', id: 'instance.settings.tabs.installation.change-version.in-progress',
defaultMessage: 'Installing new version', defaultMessage: 'Installing new version',
@@ -393,11 +410,11 @@ const messages = defineMessages({
defaultMessage: 'Unlink instance', defaultMessage: 'Unlink instance',
}, },
unlinkInstanceConfirmTitle: { unlinkInstanceConfirmTitle: {
id: 'instance.settings.tabs.installation.unlink.title', id: 'instance.settings.tabs.installation.unlink.confirm.title',
defaultMessage: 'Are you sure you want to unlink this instance?', defaultMessage: 'Are you sure you want to unlink this instance?',
}, },
unlinkInstanceConfirmDescription: { unlinkInstanceConfirmDescription: {
id: 'instance.settings.tabs.installation.unlink.description', id: 'instance.settings.tabs.installation.unlink.confirm.description',
defaultMessage: defaultMessage:
'If you proceed, you will not be able to re-link it without creating an entirely new instance. You will no longer receive modpack updates and it will become a normal.', 'If you proceed, you will not be able to re-link it without creating an entirely new instance. You will no longer receive modpack updates and it will become a normal.',
}, },
@@ -407,7 +424,7 @@ const messages = defineMessages({
}, },
reinstallModpackConfirmDescription: { reinstallModpackConfirmDescription: {
id: 'instance.settings.tabs.installation.reinstall.confirm.description', id: 'instance.settings.tabs.installation.reinstall.confirm.description',
defaultMessage: `Reinstalling will reset content provided by the modpack to their original state.`, defaultMessage: `Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation. This may fix unexpected behavior if changes have been made to the instance, but if your worlds now depend on additional installed content, it may break existing worlds.`,
}, },
reinstallModpackTitle: { reinstallModpackTitle: {
id: 'instance.settings.tabs.installation.reinstall.title', id: 'instance.settings.tabs.installation.reinstall.title',
@@ -415,7 +432,7 @@ const messages = defineMessages({
}, },
reinstallModpackDescription: { reinstallModpackDescription: {
id: 'instance.settings.tabs.installation.reinstall.description', id: 'instance.settings.tabs.installation.reinstall.description',
defaultMessage: `Resets all content provided by the modpack to their original state. This may fix unexpected behavior if changes have been made to the instance.`, defaultMessage: `Resets the instance's content to its original state, removing any mods or content you have added on top of the original modpack.`,
}, },
reinstallModpackButton: { reinstallModpackButton: {
id: 'instance.settings.tabs.installation.reinstall.button', id: 'instance.settings.tabs.installation.reinstall.button',
@@ -647,7 +664,34 @@ const messages = defineMessages({
</template> </template>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="!isValid || !isChanged || editing" @click="saveGvLoaderEdits()"> <button
v-tooltip="
installing || reinstalling
? formatMessage(messages.installInProgress)
: !isChanged
? formatMessage(
loader === 'vanilla'
? messages.alreadyInstalledVanilla
: messages.alreadyInstalledModded,
{
platform: formatCategory(loader),
version: instance.loader_version,
game_version: gameVersion,
},
)
: repairing
? formatMessage(messages.cannotWhileRepairing, {
action: formatMessage(messages.installAction),
})
: offline
? formatMessage(messages.cannotWhileOffline, {
action: formatMessage(messages.installAction),
})
: null
"
:disabled="!isValid || !isChanged || editing || offline || repairing"
@click="saveGvLoaderEdits()"
>
<SpinnerIcon v-if="editing" class="animate-spin" /> <SpinnerIcon v-if="editing" class="animate-spin" />
<DownloadIcon v-else /> <DownloadIcon v-else />
{{ {{

View File

@@ -8,12 +8,11 @@ import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue' import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_max_memory } from '@/helpers/jre' import { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings' import { get } from '@/helpers/settings'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<{ const props = defineProps<InstanceSettingsTabProps>()
instance: GameInstance
}>()
const globalSettings = (await get().catch(handleError)) as AppSettings const globalSettings = (await get().catch(handleError)) as AppSettings

View File

@@ -5,12 +5,11 @@ import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings' import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile' import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings } from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<{ const props = defineProps<InstanceSettingsTabProps>()
instance: GameInstance
}>()
const globalSettings = (await get().catch(handleError)) as AppSettings const globalSettings = (await get().catch(handleError)) as AppSettings

View File

@@ -10,7 +10,7 @@ import {
CoffeeIcon, CoffeeIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { TabbedModal } from '@modrinth/ui' import { TabbedModal } from '@modrinth/ui'
import { ref } from 'vue' import { computed, ref } from 'vue'
import { useVIntl, defineMessage } from '@vintl/vintl' import { useVIntl, defineMessage } from '@vintl/vintl'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue' import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue' import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
@@ -92,7 +92,9 @@ function show() {
modal.value.show() modal.value.show()
} }
defineExpose({ show }) const isOpen = computed(() => modal.value?.isOpen)
defineExpose({ show, isOpen })
const version = await getVersion() const version = await getVersion()
const osPlatform = getOsPlatform() const osPlatform = getOsPlatform()

View File

@@ -7,7 +7,7 @@ import {
MonitorIcon, MonitorIcon,
CodeIcon, CodeIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, TabbedModal } from '@modrinth/ui' import { Avatar, TabbedModal, type TabbedModalTab } from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
import { defineMessage, useVIntl } from '@vintl/vintl' import { defineMessage, useVIntl } from '@vintl/vintl'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -17,19 +17,13 @@ import InstallationSettings from '@/components/ui/instance_settings/Installation
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue' import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue' import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue' import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import type { InstanceSettingsTabProps } from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
defineProps({ const props = defineProps<InstanceSettingsTabProps>()
instance: {
type: Object,
default() {
return {}
},
},
})
const tabs = [ const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.general', id: 'instance.settings.tabs.general',
@@ -92,12 +86,13 @@ const titleMessage = defineMessage({
<Avatar <Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px" size="24px"
:tint-by="props.instance.path"
/> />
{{ instance.name }} <ChevronRightIcon /> {{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{ formatMessage(titleMessage) }}</span> <span class="font-extrabold text-contrast">{{ formatMessage(titleMessage) }}</span>
</span> </span>
</template> </template>
<TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props: { instance } }))" /> <TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" />
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@@ -103,3 +103,8 @@ type AppSettings = {
prev_custom_dir?: string prev_custom_dir?: string
migrated: boolean migrated: boolean
} }
export type InstanceSettingsTabProps = {
instance: GameInstance
offline?: boolean
}

View File

@@ -119,6 +119,12 @@
"instance.settings.tabs.installation": { "instance.settings.tabs.installation": {
"message": "Installation" "message": "Installation"
}, },
"instance.settings.tabs.installation.change-version.already-installed.modded": {
"message": "{platform} {version} for Minecraft {game_version} already installed"
},
"instance.settings.tabs.installation.change-version.already-installed.vanilla": {
"message": "Vanilla {game_version} already installed"
},
"instance.settings.tabs.installation.change-version.button": { "instance.settings.tabs.installation.change-version.button": {
"message": "Change version" "message": "Change version"
}, },
@@ -149,6 +155,9 @@
"instance.settings.tabs.installation.install": { "instance.settings.tabs.installation.install": {
"message": "Install" "message": "Install"
}, },
"instance.settings.tabs.installation.install.in-progress": {
"message": "Installation in progress"
},
"instance.settings.tabs.installation.loader-version": { "instance.settings.tabs.installation.loader-version": {
"message": "{loader} version" "message": "{loader} version"
}, },
@@ -174,13 +183,13 @@
"message": "Reinstalling modpack" "message": "Reinstalling modpack"
}, },
"instance.settings.tabs.installation.reinstall.confirm.description": { "instance.settings.tabs.installation.reinstall.confirm.description": {
"message": "Reinstalling will reset content provided by the modpack to their original state." "message": "Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation. This may fix unexpected behavior if changes have been made to the instance, but if your worlds now depend on additional installed content, it may break existing worlds."
}, },
"instance.settings.tabs.installation.reinstall.confirm.title": { "instance.settings.tabs.installation.reinstall.confirm.title": {
"message": "Are you sure you want to reinstall this instance?" "message": "Are you sure you want to reinstall this instance?"
}, },
"instance.settings.tabs.installation.reinstall.description": { "instance.settings.tabs.installation.reinstall.description": {
"message": "Resets all content provided by the modpack to their original state. This may fix unexpected behavior if changes have been made to the instance." "message": "Resets the instance's content to its original state, removing any mods or content you have added on top of the original modpack."
}, },
"instance.settings.tabs.installation.reinstall.title": { "instance.settings.tabs.installation.reinstall.title": {
"message": "Reinstall modpack" "message": "Reinstall modpack"
@@ -191,12 +200,12 @@
"instance.settings.tabs.installation.repair.button.repairing": { "instance.settings.tabs.installation.repair.button.repairing": {
"message": "Repairing" "message": "Repairing"
}, },
"instance.settings.tabs.installation.repair.confirm-title": { "instance.settings.tabs.installation.repair.confirm.description": {
"message": "Repair instance?"
},
"instance.settings.tabs.installation.repair.description": {
"message": "Repairing reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors, but will not resolve issues or crashes related to installed mods." "message": "Repairing reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors, but will not resolve issues or crashes related to installed mods."
}, },
"instance.settings.tabs.installation.repair.confirm.title": {
"message": "Repair instance?"
},
"instance.settings.tabs.installation.repair.in-progress": { "instance.settings.tabs.installation.repair.in-progress": {
"message": "Repair in progress" "message": "Repair in progress"
}, },
@@ -209,6 +218,9 @@
"instance.settings.tabs.installation.tooltip.action.change-version": { "instance.settings.tabs.installation.tooltip.action.change-version": {
"message": "change version" "message": "change version"
}, },
"instance.settings.tabs.installation.tooltip.action.install": {
"message": "install"
},
"instance.settings.tabs.installation.tooltip.action.reinstall": { "instance.settings.tabs.installation.tooltip.action.reinstall": {
"message": "reinstall" "message": "reinstall"
}, },
@@ -230,12 +242,18 @@
"instance.settings.tabs.installation.unlink.button": { "instance.settings.tabs.installation.unlink.button": {
"message": "Unlink instance" "message": "Unlink instance"
}, },
"instance.settings.tabs.installation.unlink.description": { "instance.settings.tabs.installation.unlink.confirm.description": {
"message": "If you proceed, you will not be able to re-link it without creating an entirely new instance. You will no longer receive modpack updates and it will become a normal." "message": "If you proceed, you will not be able to re-link it without creating an entirely new instance. You will no longer receive modpack updates and it will become a normal."
}, },
"instance.settings.tabs.installation.unlink.title": { "instance.settings.tabs.installation.unlink.confirm.title": {
"message": "Are you sure you want to unlink this instance?" "message": "Are you sure you want to unlink this instance?"
}, },
"instance.settings.tabs.installation.unlink.description": {
"message": "This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack."
},
"instance.settings.tabs.installation.unlink.title": {
"message": "Unlink from modpack"
},
"instance.settings.tabs.java": { "instance.settings.tabs.java": {
"message": "Java and memory" "message": "Java and memory"
}, },

View File

@@ -394,13 +394,13 @@ await refreshSearch()
<InstanceIndicator :instance="instance" /> <InstanceIndicator :instance="instance" />
<h1 class="m-0 mb-1 text-xl">Install content to instance</h1> <h1 class="m-0 mb-1 text-xl">Install content to instance</h1>
</template> </template>
<h1 v-else class="m-0 mb-1 text-2xl">Discover content</h1> <h1 v-else class="m-0 text-2xl">Discover content</h1>
<NavTabs :links="selectableProjectTypes" /> <NavTabs :links="selectableProjectTypes" />
<div class="iconified-input"> <div class="iconified-input">
<SearchIcon aria-hidden="true" class="text-lg" /> <SearchIcon aria-hidden="true" class="text-lg" />
<input <input
v-model="query" v-model="query"
class="h-12" class="h-12 card-shadow"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
type="text" type="text"

View File

@@ -31,19 +31,21 @@ window.addEventListener('online', () => {
const getInstances = async () => { const getInstances = async () => {
const profiles = await list().catch(handleError) const profiles = await list().catch(handleError)
recentInstances.value = profiles.sort((a, b) => { recentInstances.value = profiles
const dateA = dayjs(a.last_played ?? 0) .filter((x) => x.last_played)
const dateB = dayjs(b.last_played ?? 0) .sort((a, b) => {
const dateA = dayjs(a.last_played)
const dateB = dayjs(b.last_played)
if (dateA.isSame(dateB)) { if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
} }
return dateB - dateA return dateB - dateA
}) })
const filters = [] const filters = []
for (const instance of recentInstances.value) { for (const instance of profiles) {
if (instance.linked_data && instance.linked_data.project_id) { if (instance.linked_data && instance.linked_data.project_id) {
filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`) filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`)
} }
@@ -100,25 +102,27 @@ onUnmounted(() => {
<template> <template>
<div class="p-6 flex flex-col gap-2"> <div class="p-6 flex flex-col gap-2">
<h1 class="m-0 text-2xl">Welcome back!</h1> <h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
<RowDisplay <RowDisplay
v-if="total > 0" v-if="total > 0"
:instances="[ :instances="[
{ {
label: 'Jump back in', label: 'Recently played',
route: '/library', route: '/library',
instances: recentInstances, instances: recentInstances,
instance: true, instance: true,
downloaded: true, downloaded: true,
compact: true,
}, },
{ {
label: 'Popular packs', label: 'Discover a modpack',
route: '/browse/modpack', route: '/browse/modpack',
instances: featuredModpacks, instances: featuredModpacks,
downloaded: false, downloaded: false,
}, },
{ {
label: 'Popular mods', label: 'Discover mods',
route: '/browse/mod', route: '/browse/mod',
instances: featuredMods, instances: featuredMods,
downloaded: false, downloaded: false,

View File

@@ -4,10 +4,10 @@
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)" @contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
> >
<ExportModal ref="exportModal" :instance="instance" /> <ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal ref="settingsModal" :instance="instance" /> <InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
<ContentPageHeader> <ContentPageHeader>
<template #icon> <template #icon>
<Avatar :src="icon" :alt="instance.name" size="96px" /> <Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
</template> </template>
<template #title> <template #title>
{{ instance.name }} {{ instance.name }}
@@ -89,9 +89,13 @@
<NavTabs :links="tabs" /> <NavTabs :links="tabs" />
</div> </div>
<div class="p-6 pt-4"> <div class="p-6 pt-4">
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }" :key="instance.path">
<template v-if="Component"> <template v-if="Component">
<Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()"> <Suspense
:key="instance.path"
@pending="loadingBar.startLoading()"
@resolve="loadingBar.stopLoading()"
>
<component <component
:is="Component" :is="Component"
:instance="instance" :instance="instance"
@@ -164,7 +168,7 @@ import { get, get_full_path, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process' import { get_by_profile_path } from '@/helpers/process'
import { process_listener, profile_listener } from '@/helpers/events' import { process_listener, profile_listener } from '@/helpers/events'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ref, onUnmounted, computed } from 'vue' import { ref, onUnmounted, computed, watch } from 'vue'
import { handleError, useBreadcrumbs, useLoading } from '@/store/state' import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { showProfileInFolder } from '@/helpers/utils.js' import { showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
@@ -187,7 +191,52 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const breadcrumbs = useBreadcrumbs() 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(() => [ const tabs = computed(() => [
{ {
@@ -213,18 +262,8 @@ breadcrumbs.setContext({
query: route.query, query: route.query,
}) })
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
window.addEventListener('online', () => {
offline.value = false
})
const loadingBar = useLoading() const loadingBar = useLoading()
const playing = ref(false)
const loading = ref(false)
const options = ref(null) const options = ref(null)
const startInstance = async (context) => { const startInstance = async (context) => {
@@ -244,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) => { const stopInstance = async (context) => {
playing.value = false playing.value = false
await kill(route.params.id).catch(handleError) await kill(route.params.id).catch(handleError)
@@ -354,7 +367,9 @@ const unlistenProfiles = await profile_listener(async (event) => {
}) })
const unlistenProcesses = await process_listener((e) => { 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(() => const icon = computed(() =>

View File

@@ -91,7 +91,10 @@
}, },
{ {
label: 'Versions', 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'], subpages: ['version'],
}, },
{ {
@@ -212,13 +215,11 @@ async function fetchProjectData() {
await fetchProjectData() await fetchProjectData()
const promo = ref(null)
watch( watch(
() => route.params.id, () => route.params.id,
async () => { async () => {
if (route.params.id && route.path.startsWith('/project')) { if (route.params.id && route.path.startsWith('/project')) {
await fetchProjectData() await fetchProjectData()
promo.value.scroll()
} }
}, },
) )

View File

@@ -67,12 +67,12 @@
<script setup> <script setup>
import { ProjectPageVersions, ButtonStyled, OverflowMenu } from '@modrinth/ui' import { ProjectPageVersions, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets' import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import { ref, watch } from 'vue' import { ref } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js' import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js' import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
const props = defineProps({ defineProps({
project: { project: {
type: Object, type: Object,
default: () => {}, default: () => {},
@@ -107,17 +107,6 @@ const [loaders, gameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref), get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref), get_game_versions().catch(handleError).then(ref),
]) ])
const filterVersions = ref([])
const filterLoader = ref(props.instance ? [props.instance?.loader] : [])
const filterGameVersions = ref(props.instance ? [props.instance?.game_version] : [])
const currentPage = ref(1)
//watch all the filters and if a value changes, reset to page 1
watch([filterVersions, filterLoader, filterGameVersions], () => {
currentPage.value = 1
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -2,8 +2,8 @@
<img <img
v-if="src" v-if="src"
ref="img" ref="img"
:style="`--_size: ${cssSize}`"
class="`experimental-styles-within avatar" class="`experimental-styles-within avatar"
:style="`--_size: ${cssSize}`"
:class="{ :class="{
circle: circle, circle: circle,
'no-shadow': noShadow, 'no-shadow': noShadow,
@@ -18,8 +18,9 @@
<svg <svg
v-else v-else
class="`experimental-styles-within avatar" class="`experimental-styles-within avatar"
:style="`--_size: ${cssSize}`" :style="`--_size: ${cssSize}${tint ? `;--_tint:oklch(50% 75% ${tint})` : ''}`"
:class="{ :class="{
tint: tint,
circle: circle, circle: circle,
'no-shadow': noShadow, 'no-shadow': noShadow,
raised: raised, raised: raised,
@@ -44,7 +45,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { computed, ref } from 'vue'
const pixelated = ref(false) const pixelated = ref(false)
const img = ref(null) const img = ref(null)
@@ -78,6 +79,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
tintBy: {
type: String,
default: null,
},
}) })
const LEGACY_PRESETS = { const LEGACY_PRESETS = {
@@ -97,6 +102,24 @@ function updatePixelated() {
pixelated.value = false pixelated.value = false
} }
} }
const tint = computed(() => {
if (props.tintBy) {
return hash(props.tintBy) % 360
} else {
return null
}
})
function hash(str) {
let hash = 0
for (let i = 0, len = str.length; i < len; i++) {
const chr = str.charCodeAt(i)
hash = (hash << 5) - hash + chr
hash |= 0
}
return hash
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -108,13 +131,14 @@ function updatePixelated() {
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
object-fit: contain; object-fit: contain;
border-radius: calc(16 / 96 * var(--_size)); border-radius: calc(16 / 96 * var(--_size));
position: relative;
&.circle { &.circle {
border-radius: 50%; border-radius: 50%;
} }
&:not(.no-shadow) { &:not(.no-shadow) {
box-shadow: var(--shadow-inset-lg), var(--shadow-card); box-shadow: var(--shadow-card);
} }
&.no-shadow { &.no-shadow {
@@ -128,5 +152,9 @@ function updatePixelated() {
&.raised { &.raised {
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
} }
&.tint {
background-color: color-mix(in oklch, var(--color-button-bg) 100%, var(--_tint) 5%);
}
} }
</style> </style>

View File

@@ -50,6 +50,7 @@ export { default as Modal } from './modal/Modal.vue'
export { default as ConfirmModal } from './modal/ConfirmModal.vue' export { default as ConfirmModal } from './modal/ConfirmModal.vue'
export { default as ShareModal } from './modal/ShareModal.vue' export { default as ShareModal } from './modal/ShareModal.vue'
export { default as TabbedModal } from './modal/TabbedModal.vue' export { default as TabbedModal } from './modal/TabbedModal.vue'
export type { Tab as TabbedModalTab } from './modal/TabbedModal.vue'
// Navigation // Navigation
export { default as Breadcrumbs } from './nav/Breadcrumbs.vue' export { default as Breadcrumbs } from './nav/Breadcrumbs.vue'

View File

@@ -4,7 +4,7 @@ import { useVIntl, type MessageDescriptor } from '@vintl/vintl'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
type Tab<Props> = { export type Tab<Props> = {
name: MessageDescriptor name: MessageDescriptor
icon: Component icon: Component
content: Component<Props> content: Component<Props>
@@ -12,7 +12,8 @@ type Tab<Props> = {
} }
defineProps<{ defineProps<{
tabs: Tab<unknown>[] // eslint-disable-next-line @typescript-eslint/no-explicit-any
tabs: Tab<any>[]
}>() }>()
const selectedTab = ref(0) const selectedTab = ref(0)