More app fixes 0.9.0 (#3054)

* initial set of fixes (toggle sidebar, profile pagination)

* more fixes, bump version

* fix lint:

* fix quick switcher ordering
This commit is contained in:
Geometrically 2024-12-22 20:03:58 -07:00 committed by GitHub
parent ef08d8e538
commit cae6f12ea0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 502 additions and 1501 deletions

4
Cargo.lock generated
View File

@ -8956,7 +8956,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "0.9.0-1" version = "0.9.0-2"
dependencies = [ dependencies = [
"async-recursion", "async-recursion",
"async-tungstenite", "async-tungstenite",
@ -9007,7 +9007,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "0.9.0-1" version = "0.9.0-2"
dependencies = [ dependencies = [
"chrono", "chrono",
"cocoa 0.25.0", "cocoa 0.25.0",

View File

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

View File

@ -15,6 +15,7 @@ import {
MaximizeIcon, MaximizeIcon,
RestoreIcon, RestoreIcon,
LogOutIcon, LogOutIcon,
RightArrowIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui' import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state' import { useLoading, useTheming } from '@/store/state'
@ -54,40 +55,14 @@ import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js' import { hide_ads_window, init_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' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
const themeStore = useTheming() const themeStore = useTheming()
const news = ref([ const news = ref([])
{
title: 'Introducing Modrinth Servers',
summary: 'Host your next Minecraft server with Modrinth.',
thumbnail:
'https://media.beehiiv.com/cdn-cgi/image/format=auto,width=800,height=421,fit=scale-down,onerror=redirect/uploads/asset/file/eefddc59-b4c4-4e7d-92e8-c26bdef42984/Modrinth-Servers-Thumb.png',
date: '2024-11-02T00:00:00Z',
link: 'https://blog.modrinth.com/p/modrinth-servers-beta',
},
{
title: 'Becoming Sustainable',
summary: 'Announcing 5x creator revenue and updates to the monetization program.',
thumbnail:
'https://media.beehiiv.com/cdn-cgi/image/format=auto,width=800,height=421,fit=scale-down,onerror=redirect/uploads/asset/file/c99b9885-8248-4d7a-b19a-3ae2c902fdd5/revenue.png',
date: '2024-09-13T00:00:00Z',
link: 'https://blog.modrinth.com/p/creator-revenue-update',
},
{
title: 'Modrinth+ and New Ads',
summary:
'Introducing a new advertising system, a subscription to remove ads, and a redesign of the website!\n',
thumbnail:
'https://media.beehiiv.com/cdn-cgi/image/fit=scale-down,format=auto,onerror=redirect,quality=80/uploads/asset/file/38ce85e4-5d93-43eb-b61b-b6296f6b9e66/things.png?t=1724260059',
date: '2024-08-21T00:00:00Z',
link: 'https://blog.modrinth.com/p/introducing-modrinth-refreshed-site-look-new-advertising-system',
},
])
const urlModal = ref(null) const urlModal = ref(null)
@ -132,6 +107,9 @@ async function setupApp() {
advanced_rendering, advanced_rendering,
onboarded, onboarded,
default_page, default_page,
toggle_sidebar,
developer_mode,
feature_flags,
} = await get() } = await get()
if (default_page === 'Library') { if (default_page === 'Library') {
@ -149,6 +127,9 @@ async function setupApp() {
themeStore.setThemeState(theme) themeStore.setThemeState(theme)
themeStore.collapsedNavigation = collapsed_navigation themeStore.collapsedNavigation = collapsed_navigation
themeStore.advancedRendering = advanced_rendering themeStore.advancedRendering = advanced_rendering
themeStore.toggleSidebar = toggle_sidebar
themeStore.devMode = developer_mode
themeStore.featureFlags = feature_flags
isMaximized.value = await getCurrentWindow().isMaximized() isMaximized.value = await getCurrentWindow().isMaximized()
@ -190,6 +171,12 @@ async function setupApp() {
} }
}) })
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
if (res && res.articles) {
news.value = res.articles
}
})
get_opening_command().then(handleCommand) get_opening_command().then(handleCommand)
checkUpdates() checkUpdates()
fetchCredentials() fetchCredentials()
@ -263,13 +250,29 @@ const hasPlus = computed(
(credentials.value.user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG, (credentials.value.user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG,
) )
const sidebarToggled = ref(true)
themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
const forceSidebar = ref(false)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const showAd = computed(() => !(!sidebarVisible.value || hasPlus.value))
router.afterEach((to) => {
forceSidebar.value = to.path.startsWith('/browse') || to.path.startsWith('/project')
})
watch( watch(
hasPlus, showAd,
() => { () => {
if (hasPlus.value) { if (!showAd.value) {
hide_ads_window(true) hide_ads_window(true)
} else { } else {
show_ads_window() setTimeout(() => {
init_ads_window(true)
}, 400)
} }
}, },
{ immediate: true }, { immediate: true },
@ -367,21 +370,21 @@ function handleAuxClick(e) {
<InstanceCreationModal ref="installationModal" /> <InstanceCreationModal ref="installationModal" />
</Suspense> </Suspense>
<div <div
class="app-grid-navbar bg-bg-raised flex flex-col p-[1rem] pt-0 gap-[0.5rem] z-10 w-[--left-bar-width]" class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.5rem] w-[--left-bar-width]"
> >
<NavButton to="/"> <NavButton v-tooltip.right="'Home'" to="/">
<HomeIcon /> <HomeIcon />
<template #label>Home</template>
</NavButton> </NavButton>
<NavButton <NavButton
v-tooltip.right="'Discover content'"
to="/browse/modpack" to="/browse/modpack"
:is-primary="() => route.path.startsWith('/browse') && !route.query.i" :is-primary="() => route.path.startsWith('/browse') && !route.query.i"
:is-subpage="(route) => route.path.startsWith('/project') && !route.query.i" :is-subpage="(route) => route.path.startsWith('/project') && !route.query.i"
> >
<CompassIcon /> <CompassIcon />
<template #label>Discover content</template>
</NavButton> </NavButton>
<NavButton <NavButton
v-tooltip.right="'Library'"
to="/library" to="/library"
:is-subpage=" :is-subpage="
() => () =>
@ -391,24 +394,24 @@ function handleAuxClick(e) {
" "
> >
<LibraryIcon /> <LibraryIcon />
<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> <suspense>
<QuickInstanceSwitcher /> <QuickInstanceSwitcher />
</suspense> </suspense>
<NavButton :to="() => $refs.installationModal.show()" :disabled="offline"> <NavButton
v-tooltip.right="'Create new instance'"
:to="() => $refs.installationModal.show()"
:disabled="offline"
>
<PlusIcon /> <PlusIcon />
<template #label>Create new instance</template>
</NavButton> </NavButton>
<div class="flex flex-grow"></div> <div class="flex flex-grow"></div>
<NavButton v-if="updateAvailable" :to="() => restartApp()"> <NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
<DownloadIcon /> <DownloadIcon />
<template #label>Install update</template>
</NavButton> </NavButton>
<NavButton :to="() => $refs.settingsModal.show()"> <NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
<SettingsIcon /> <SettingsIcon />
<template #label>Settings</template>
</NavButton> </NavButton>
<ButtonStyled v-if="credentials" type="transparent" circular> <ButtonStyled v-if="credentials" type="transparent" circular>
<OverflowMenu <OverflowMenu
@ -430,17 +433,30 @@ function handleAuxClick(e) {
<template #sign-out> <LogOutIcon /> Sign out </template> <template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
<NavButton v-else :to="() => signIn()"> <NavButton v-else v-tooltip.right="'Sign in'" :to="() => signIn()">
<LogInIcon /> <LogInIcon />
<template #label>Sign in</template> <template #label>Sign in</template>
</NavButton> </NavButton>
</div> </div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex"> <div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex p-4"> <div data-tauri-drag-region class="flex p-3">
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" /> <ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<Breadcrumbs /> <Breadcrumbs />
</div> </div>
<section class="flex ml-auto"> <section class="flex ml-auto items-center">
<ButtonStyled
v-if="!forceSidebar && themeStore.toggleSidebar"
:type="sidebarToggled ? 'standard' : 'transparent'"
circular
>
<button
class="mr-3 transition-transform"
:class="{ 'rotate-180': !sidebarToggled }"
@click="sidebarToggled = !sidebarToggled"
>
<RightArrowIcon />
</button>
</ButtonStyled>
<div class="flex mr-3"> <div class="flex mr-3">
<Suspense> <Suspense>
<RunningAppBar /> <RunningAppBar />
@ -465,7 +481,11 @@ function handleAuxClick(e) {
</section> </section>
</div> </div>
</div> </div>
<div v-if="stateInitialized" class="app-contents experimental-styles-within"> <div
v-if="stateInitialized"
class="app-contents experimental-styles-within"
:class="{ 'sidebar-enabled': sidebarVisible }"
>
<div class="app-viewport flex-grow router-view"> <div class="app-viewport flex-grow router-view">
<div <div
class="loading-indicator-container h-8 fixed z-50" class="loading-indicator-container h-8 fixed z-50"
@ -478,7 +498,7 @@ function handleAuxClick(e) {
<ModrinthLoadingIndicator /> <ModrinthLoadingIndicator />
</div> </div>
<div <div
v-if="themeStore.featureFlag_pagePath" v-if="themeStore.featureFlags.page_path"
class="absolute bottom-0 left-0 m-2 bg-tooltip-bg text-tooltip-text font-semibold rounded-full px-2 py-1 text-xs z-50" class="absolute bottom-0 left-0 m-2 bg-tooltip-bg text-tooltip-text font-semibold rounded-full px-2 py-1 text-xs z-50"
> >
{{ route.fullPath }} {{ route.fullPath }}
@ -507,7 +527,7 @@ function handleAuxClick(e) {
:class="{ 'pb-12': !hasPlus }" :class="{ 'pb-12': !hasPlus }"
> >
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div> <div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content"> <div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"> <div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-lg m-0">Playing as</h3> <h3 class="text-lg m-0">Playing as</h3>
<suspense> <suspense>
@ -519,7 +539,7 @@ function handleAuxClick(e) {
<FriendsList :credentials="credentials" :sign-in="() => signIn()" /> <FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense> </suspense>
</div> </div>
<div class="pt-4 flex flex-col"> <div v-if="news && news.length > 0" class="pt-4 flex flex-col">
<h3 class="px-4 text-lg m-0">News</h3> <h3 class="px-4 text-lg m-0">News</h3>
<template v-for="(item, index) in news" :key="`news-${index}`"> <template v-for="(item, index) in news" :key="`news-${index}`">
<a <a
@ -550,7 +570,7 @@ function handleAuxClick(e) {
</div> </div>
</div> </div>
</div> </div>
<template v-if="!hasPlus"> <template v-if="showAd">
<a <a
href="https://modrinth.plus?app" href="https://modrinth.plus?app"
class="absolute bottom-[250px] w-full flex justify-center items-center gap-1 px-4 py-3 text-purple font-medium hover:underline z-10" class="absolute bottom-[250px] w-full flex justify-center items-center gap-1 px-4 py-3 text-purple font-medium hover:underline z-10"
@ -577,21 +597,6 @@ function handleAuxClick(e) {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.sleek-primary {
background-color: var(--color-brand-highlight);
transition: all ease-in-out 0.1s;
}
.navigation-controls {
flex-grow: 1;
width: min-content;
}
.appbar-row {
display: flex;
flex-direction: row;
}
.window-controls { .window-controls {
z-index: 20; z-index: 20;
display: none; display: none;
@ -658,139 +663,10 @@ function handleAuxClick(e) {
} }
} }
.app-container {
height: 100vh;
display: flex;
flex-direction: row;
overflow: hidden;
.view {
width: calc(100% - var(--sidebar-width));
background-color: var(--color-raised-bg);
.critical-error-banner {
margin-top: -1.25rem;
padding: 1rem;
background-color: rgba(203, 34, 69, 0.1);
border-left: 2px solid var(--color-red);
border-bottom: 2px solid var(--color-red);
border-right: 2px solid var(--color-red);
border-radius: 1rem;
}
.appbar {
display: flex;
align-items: center;
flex-grow: 1;
background: var(--color-raised-bg);
text-align: center;
padding: var(--gap-md);
height: 3.25rem;
gap: var(--gap-sm);
//no select
user-select: none;
-webkit-user-select: none;
}
.router-view {
width: 100%;
height: calc(100% - 3.125rem);
overflow: auto;
overflow-x: hidden;
background-color: var(--color-bg);
border-top-left-radius: var(--radius-xl);
}
}
}
.nav-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
height: 100%;
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
padding: var(--gap-md);
}
.pages-list {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
gap: 0.5rem;
a {
display: flex;
align-items: center;
word-spacing: 3px;
background: inherit;
transition: all ease-in-out 0.1s;
color: var(--color-base);
box-shadow: none;
&.router-link-active {
color: var(--color-contrast);
background: var(--color-button-bg);
box-shadow: var(--shadow-floating);
}
&:hover {
background-color: var(--color-button-bg);
color: var(--color-contrast);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
text-decoration: none;
}
}
&.primary {
color: var(--color-accent-contrast);
background-color: var(--color-brand);
}
}
.collapsed-button {
height: 3rem !important;
width: 3rem !important;
padding: 0.75rem;
border-radius: var(--radius-md);
box-shadow: none;
svg {
width: 1.5rem !important;
height: 1.5rem !important;
max-width: 1.5rem !important;
max-height: 1.5rem !important;
}
}
.nav-section {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 100%;
gap: 1rem;
}
.button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: var(--gap-md);
.transparent {
padding: var(--gap-sm) 0;
}
}
.app-grid-layout, .app-grid-layout,
.app-contents { .app-contents {
--top-bar-height: 3.75rem; --top-bar-height: 3rem;
--left-bar-width: 5rem; --left-bar-width: 4rem;
--right-bar-width: 300px; --right-bar-width: 300px;
} }
@ -816,18 +692,21 @@ function handleAuxClick(e) {
.app-contents { .app-contents {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
left: 5rem; left: var(--left-bar-width);
top: 3.75rem; top: var(--top-bar-height);
right: 0; right: 0;
bottom: 0; bottom: 0;
height: calc(100vh - 3.75rem); height: calc(100vh - var(--top-bar-height));
background-color: var(--color-bg); background-color: var(--color-bg);
border-top-left-radius: var(--radius-xl); border-top-left-radius: var(--radius-xl);
display: grid; display: grid;
grid-template-columns: 1fr 300px; grid-template-columns: 1fr 0px;
//grid-template-columns: 1fr 0px;
transition: grid-template-columns 0.4s ease-in-out; transition: grid-template-columns 0.4s ease-in-out;
&.sidebar-enabled {
grid-template-columns: 1fr 300px;
}
} }
.loading-indicator-container { .loading-indicator-container {
@ -839,7 +718,7 @@ function handleAuxClick(e) {
overflow: visible; overflow: visible;
width: 300px; width: 300px;
position: relative; position: relative;
height: calc(100vh - 3.75rem); height: calc(100vh - var(--top-bar-height));
background: var(--brand-gradient-bg); background: var(--brand-gradient-bg);
--color-button-bg: var(--brand-gradient-button); --color-button-bg: var(--brand-gradient-button);
@ -881,22 +760,15 @@ function handleAuxClick(e) {
overflow-x: hidden; overflow-x: hidden;
} }
//::-webkit-scrollbar-track {
// background-color: transparent; /* Make it transparent if needed */
// margin-block: 5px;
// margin-right: 5px;
//}
.app-contents::before { .app-contents::before {
z-index: 1; z-index: 1;
content: ''; content: '';
position: fixed; position: fixed;
left: 5rem; left: var(--left-bar-width);
top: 3.75rem; top: var(--top-bar-height);
right: -5rem; right: calc(-1 * var(--left-bar-width));
bottom: -5rem; bottom: calc(-1 * var(--left-bar-width));
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
//box-shadow: 1px 1px 15px rgba(0, 0, 0, 0.2) inset;
box-shadow: box-shadow:
1px 1px 15px rgba(0, 0, 0, 0.2) inset, 1px 1px 15px rgba(0, 0, 0, 0.2) inset,
inset 1px 1px 1px rgba(255, 255, 255, 0.23); inset 1px 1px 1px rgba(255, 255, 255, 0.23);
@ -911,7 +783,7 @@ function handleAuxClick(e) {
display: none; display: none;
} }
.sidebar-teleport-content:empty + .sidebar-default-content { .sidebar-teleport-content:empty + .sidebar-default-content.sidebar-enabled {
display: contents; display: contents;
} }
</style> </style>

View File

@ -218,14 +218,14 @@ const filteredResults = computed(() => {
}) })
</script> </script>
<template> <template>
<div class="iconified-input"> <div class="flex gap-2">
<div class="iconified-input flex-1">
<SearchIcon /> <SearchIcon />
<input v-model="search" type="text" class="h-12" placeholder="Search" /> <input v-model="search" type="text" placeholder="Search" />
<Button class="r-btn" @click="() => (search = '')"> <Button class="r-btn" @click="() => (search = '')">
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
<div class="flex gap-2">
<DropdownSelect <DropdownSelect
v-slot="{ selected }" v-slot="{ selected }"
v-model="sortBy" v-model="sortBy"
@ -363,7 +363,7 @@ const filteredResults = computed(() => {
.instances { .instances {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
width: 100%; width: 100%;
gap: 0.75rem; gap: 0.75rem;
margin-right: auto; margin-right: auto;

View File

@ -207,13 +207,18 @@ const calculateCardsPerRow = () => {
} }
} }
const rowContainer = ref(null)
const resizeObserver = ref(null)
onMounted(() => { onMounted(() => {
calculateCardsPerRow() calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
resizeObserver.value.observe(rowContainer.value)
window.addEventListener('resize', calculateCardsPerRow) window.addEventListener('resize', calculateCardsPerRow)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow) window.removeEventListener('resize', calculateCardsPerRow)
resizeObserver.value.unobserve(rowContainer.value)
}) })
</script> </script>
@ -226,7 +231,7 @@ onUnmounted(() => {
proceed-label="Delete" proceed-label="Delete"
@proceed="deleteProfile" @proceed="deleteProfile"
/> />
<div class="flex flex-col gap-4"> <div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="(row, rowIndex) in actualInstances" ref="rows" :key="row.label" class="row"> <div v-for="(row, rowIndex) in actualInstances" ref="rows" :key="row.label" class="row">
<router-link <router-link
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group" class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group"

View File

@ -12,11 +12,9 @@ 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 dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { formatCategory } from '@modrinth/utils' import { formatCategory } from '@modrinth/utils'
dayjs.extend(duration)
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const props = defineProps({ const props = defineProps({
@ -169,7 +167,7 @@ onUnmounted(() => unlisten())
> >
<div class="relative flex items-center justify-center"> <div class="relative flex items-center justify-center">
<Avatar <Avatar
size="96px" size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path" :tint-by="instance.path"
alt="Mod card" alt="Mod card"
@ -205,7 +203,7 @@ onUnmounted(() => unlisten())
</div> </div>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<p class="m-0 text-lg font-bold text-contrast leading-tight line-clamp-2"> <p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1">
{{ instance.name }} {{ instance.name }}
</p> </p>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
@ -214,17 +212,6 @@ onUnmounted(() => unlisten())
{{ formatCategory(instance.loader) }} {{ instance.game_version }} {{ formatCategory(instance.loader) }} {{ instance.game_version }}
</span> </span>
</div> </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> </div>

View File

@ -1,5 +1,4 @@
<template> <template>
<div class="tooltip-parent flex items-center justify-center">
<RouterLink <RouterLink
v-if="typeof to === 'string'" v-if="typeof to === 'string'"
:to="to" :to="to"
@ -20,10 +19,6 @@
> >
<slot /> <slot />
</button> </button>
<div class="tooltip-label">
<slot name="label" />
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -61,50 +56,4 @@ defineOptions({
.subpage-active { .subpage-active {
@apply text-contrast bg-button-bg; @apply text-contrast bg-button-bg;
} }
.tooltip-parent {
position: relative;
border-radius: var(--radius-max);
}
.tooltip-parent:hover .tooltip-label {
opacity: 1;
translate: 0 0;
scale: 1;
}
.tooltip-label:not(:empty) {
--_tooltip-bg: black;
--_tooltip-color: var(--dark-color-contrast);
position: absolute;
background-color: var(--_tooltip-bg);
color: var(--_tooltip-color);
text-wrap: nowrap;
padding: 0.5rem 0.5rem;
border-radius: var(--radius-sm);
left: calc(100% + 0.5rem);
font-size: 1rem;
line-height: 1;
font-weight: bold;
filter: drop-shadow(5px 5px 0.8rem rgba(0, 0, 0, 0.35));
pointer-events: none;
user-select: none;
opacity: 0;
translate: -0.5rem 0;
scale: 0.9;
transition: all ease-in-out 0.1s;
}
.tooltip-label:not(:empty)::after {
content: '';
position: absolute;
top: 50%;
right: 100%; /* To the left of the tooltip */
margin-top: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent var(--_tooltip-bg) transparent transparent;
}
</style> </style>

View File

@ -25,7 +25,7 @@ onMounted(() => {
function updateAdPosition() { function updateAdPosition() {
if (adsWrapper.value) { if (adsWrapper.value) {
init_ads_window(true) init_ads_window()
initDevicePixelRatioWatcher() initDevicePixelRatioWatcher()
} }
} }

View File

@ -15,8 +15,14 @@ const getInstances = async () => {
recentInstances.value = profiles recentInstances.value = profiles
.sort((a, b) => { .sort((a, b) => {
const dateA = dayjs(a.created > a.last_played ? a.last_played : a.created) const dateACreated = dayjs(a.created)
const dateB = dayjs(b.created > b.last_played ? b.last_played : b.created) const dateAPlayed = dayjs(a.last_played)
const dateBCreated = dayjs(b.created)
const dateBPlayed = dayjs(b.last_played)
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
if (dateA.isSame(dateB)) { if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
@ -42,6 +48,7 @@ onUnmounted(() => {
<NavButton <NavButton
v-for="instance in recentInstances" v-for="instance in recentInstances"
:key="instance.id" :key="instance.id"
v-tooltip.right="instance.name"
:to="`/instance/${encodeURIComponent(instance.path)}`" :to="`/instance/${encodeURIComponent(instance.path)}`"
> >
<Avatar <Avatar
@ -56,8 +63,8 @@ onUnmounted(() => {
> >
<SpinnerIcon class="animate-spin w-4 h-4" /> <SpinnerIcon class="animate-spin w-4 h-4" />
</div> </div>
<template #label>{{ instance.name }}</template>
</NavButton> </NavButton>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-button-bg"></div>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@ -1,9 +1,5 @@
<template> <template>
<div class="action-groups"> <div class="action-groups">
<a href="https://support.modrinth.com" class="link">
<ChatIcon />
<span> Get support </span>
</a>
<Button <Button
v-if="currentLoadingBars.length > 0" v-if="currentLoadingBars.length > 0"
ref="infoButton" ref="infoButton"
@ -123,7 +119,6 @@ import { useRouter } from 'vue-router'
import { progress_bars_list } from '@/helpers/state.js' import { progress_bars_list } from '@/helpers/state.js'
import ProgressBar from '@/components/ui/ProgressBar.vue' import ProgressBar from '@/components/ui/ProgressBar.vue'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { ChatIcon } from '@/assets/icons'
import { get_many } from '@/helpers/profile.js' import { get_many } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'

View File

@ -38,7 +38,7 @@
<div class="m-0 line-clamp-2"> <div class="m-0 line-clamp-2">
{{ project.description }} {{ project.description }}
</div> </div>
<div class="mt-auto flex items-center gap-1 no-wrap"> <div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
<TagsIcon class="h-4 w-4 shrink-0" /> <TagsIcon class="h-4 w-4 shrink-0" />
<div <div
v-for="tag in categories" v-for="tag in categories"

View File

@ -190,6 +190,7 @@ const messages = defineMessages({
description="If you proceed, all data for your instance will be permanently erased, including your worlds. You will not be able to recover it." description="If you proceed, all data for your instance will be permanently erased, including your worlds. You will not be able to recover it."
:has-to-type="false" :has-to-type="false"
proceed-label="Delete" proceed-label="Delete"
:show-ad-on-close="false"
@proceed="removeProfile" @proceed="removeProfile"
/> />
<div class="block"> <div class="block">

View File

@ -457,6 +457,7 @@ const messages = defineMessages({
:proceed-icon="HammerIcon" :proceed-icon="HammerIcon"
:proceed-label="formatMessage(messages.repairButton)" :proceed-label="formatMessage(messages.repairButton)"
:danger="false" :danger="false"
:show-ad-on-close="false"
@proceed="() => repairProfile(true)" @proceed="() => repairProfile(true)"
/> />
<ModpackVersionModal <ModpackVersionModal
@ -480,6 +481,7 @@ const messages = defineMessages({
:description="formatMessage(messages.unlinkInstanceConfirmDescription)" :description="formatMessage(messages.unlinkInstanceConfirmDescription)"
:proceed-icon="UnlinkIcon" :proceed-icon="UnlinkIcon"
:proceed-label="formatMessage(messages.unlinkInstanceButton)" :proceed-label="formatMessage(messages.unlinkInstanceButton)"
:show-ad-on-close="false"
@proceed="() => unpairProfile()" @proceed="() => unpairProfile()"
/> />
<ConfirmModalWrapper <ConfirmModalWrapper
@ -488,6 +490,7 @@ const messages = defineMessages({
:description="formatMessage(messages.reinstallModpackConfirmDescription)" :description="formatMessage(messages.reinstallModpackConfirmDescription)"
:proceed-icon="DownloadIcon" :proceed-icon="DownloadIcon"
:proceed-label="formatMessage(messages.reinstallModpackButton)" :proceed-label="formatMessage(messages.reinstallModpackButton)"
:show-ad-on-close="false"
@proceed="() => repairModpack()" @proceed="() => repairModpack()"
/> />
<div> <div>

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 { computed, ref } from 'vue' import { computed, ref, watch } 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'
@ -22,6 +22,7 @@ import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue' import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get, set } from '@/helpers/settings'
const themeStore = useTheming() const themeStore = useTheming()
@ -99,6 +100,28 @@ defineExpose({ show, isOpen })
const version = await getVersion() const version = await getVersion()
const osPlatform = getOsPlatform() const osPlatform = getOsPlatform()
const osVersion = getOsVersion() const osVersion = getOsVersion()
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
function devModeCount() {
devModeCounter.value++
if (devModeCounter.value > 5) {
themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0
if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) {
modal.value.setTab(0)
}
}
}
</script> </script>
<template> <template>
<ModalWrapper ref="modal"> <ModalWrapper ref="modal">
@ -118,19 +141,7 @@ const osVersion = getOsVersion()
<button <button
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation" class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }" :class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }"
@click=" @click="devModeCount"
() => {
devModeCounter++
if (devModeCounter > 5) {
themeStore.devMode = !themeStore.devMode
devModeCounter = 0
if (!themeStore.devMode && tabs[modal.selectedTab].developerOnly) {
modal.setTab(0)
}
}
}
"
> >
<ModrinthIcon class="w-6 h-6" /> <ModrinthIcon class="w-6 h-6" />
</button> </button>

View File

@ -6,7 +6,7 @@ import { useTheming } from '@/store/theme.js'
const themeStore = useTheming() const themeStore = useTheming()
defineProps({ const props = defineProps({
confirmationText: { confirmationText: {
type: String, type: String,
default: '', default: '',
@ -37,6 +37,10 @@ defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showAdOnClose: {
type: Boolean,
default: true,
},
}) })
const emit = defineEmits(['proceed']) const emit = defineEmits(['proceed'])
@ -54,7 +58,9 @@ defineExpose({
}) })
function onModalHide() { function onModalHide() {
if (props.showAdOnClose) {
show_ads_window() show_ads_window()
}
} }
function proceed() { function proceed() {

View File

@ -23,8 +23,13 @@ watch(
<p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p> <p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p>
<ThemeSelector <ThemeSelector
:update-color-theme="themeStore.setThemeState" :update-color-theme="
:current-theme="themeStore.selectedTheme" (theme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeStore.themeOptions" :theme-options="themeStore.themeOptions"
system-theme-color="system" system-theme-color="system"
/> />
@ -97,4 +102,22 @@ watch(
:options="['Home', 'Library']" :options="['Home', 'Library']"
/> />
</div> </div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>
<p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p>
</div>
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
:checked="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"
/>
</div>
</template> </template>

View File

@ -1,34 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { Toggle } from '@modrinth/ui' import { Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import { computed } from 'vue' import { ref, watch } from 'vue'
import type { Ref } from 'vue' import { get, set } from '@/helpers/settings'
const themeStore = useTheming() const themeStore = useTheming()
type ThemeStoreKeys = keyof typeof themeStore const settings = ref(await get())
const options = ref(['project_background', 'page_path'])
const options: Ref<ThemeStoreKeys[]> = computed(() => { function getStoreValue(key: string) {
return Object.keys(themeStore).filter((key) => key.startsWith('featureFlag_')) as ThemeStoreKeys[] return themeStore.featureFlags[key] ?? false
})
function getStoreValue<K extends ThemeStoreKeys>(key: K): (typeof themeStore)[K] {
return themeStore[key]
} }
function setStoreValue<K extends ThemeStoreKeys>(key: K, value: (typeof themeStore)[K]) { function setStoreValue(key: string, value: boolean) {
themeStore[key] = value themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value
} }
function formatFlagName(name: string) { watch(
return name.replace('featureFlag_', '') settings,
} async () => {
await set(settings.value)
},
{ deep: true },
)
</script> </script>
<template> <template>
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between"> <div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize"> <h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ formatFlagName(option) }} {{ option }}
</h2> </h2>
</div> </div>
@ -36,7 +38,7 @@ function formatFlagName(name: string) {
id="advanced-rendering" id="advanced-rendering"
:model-value="getStoreValue(option)" :model-value="getStoreValue(option)"
:checked="getStoreValue(option)" :checked="getStoreValue(option)"
@update:model-value="() => setStoreValue(option, !themeStore[option])" @update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
/> />
</div> </div>
</template> </template>

View File

@ -80,6 +80,7 @@ async function findLauncherDir() {
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily." description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false" :has-to-type="false"
proceed-label="Purge cache" proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache" @proceed="purgeCache"
/> />

View File

@ -169,10 +169,14 @@ window.addEventListener('online', () => {
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()
breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: route.query }) breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: route.query })
const loading = ref(false) const loading = ref(true)
const projectType = ref(route.params.projectType) const projectType = ref(route.params.projectType)
watch(projectType, () => {
loading.value = true
})
type SearchResult = { type SearchResult = {
project_id: string project_id: string
} }
@ -240,6 +244,7 @@ async function refreshSearch() {
query: params, query: params,
}) })
await router.replace({ path: route.path, query: params }) await router.replace({ path: route.path, query: params })
loading.value = false
} }
async function setPage(newPageNumber: number) { async function setPage(newPageNumber: number) {
@ -375,10 +380,12 @@ await refreshSearch()
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg" button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
content-class="mb-3" content-class="mb-3"
inner-panel-class="ml-2 mr-3" inner-panel-class="ml-2 mr-3"
:open-by-default="filter.id.startsWith('category') || filter.id === 'environment'" :open-by-default="
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
"
> >
<template #header> <template #header>
<h3 class="text-lg m-0">{{ filter.formatted_name }}</h3> <h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
</template> </template>
<template #locked-game_version> <template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByInstance) }} {{ formatMessage(messages.gameVersionProvidedByInstance) }}
@ -394,7 +401,6 @@ 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 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" />
@ -465,11 +471,10 @@ await refreshSearch()
loader.supported_project_types?.includes(projectType), loader.supported_project_types?.includes(projectType),
), ),
]" ]"
:installed="result.installed" :installed="result.installed || newlyInstalled.includes(result.project_id)"
@install=" @install="
(id) => { (id) => {
newlyInstalled.push(id) newlyInstalled.push(id)
refreshSearch()
} }
" "
/> />

View File

@ -16,6 +16,7 @@
</div> </div>
<AddContentButton :instance="instance" /> <AddContentButton :instance="instance" />
</div> </div>
<div class="flex items-center justify-between">
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4"> <div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
<FilterIcon class="text-secondary h-5 w-5 mr-1" /> <FilterIcon class="text-secondary h-5 w-5 mr-1" />
<button <button
@ -27,6 +28,15 @@
{{ filter.formattedName }} {{ filter.formattedName }}
</button> </button>
</div> </div>
<Pagination
v-if="search.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
:link-function="(page) => `?page=${page}`"
@switch-page="(page) => (currentPage = page)"
/>
</div>
<ContentListPanel <ContentListPanel
v-model="selectedFiles" v-model="selectedFiles"
:locked="isPackLocked" :locked="isPackLocked"
@ -70,6 +80,7 @@
:sort-column="sortColumn" :sort-column="sortColumn"
:sort-ascending="ascending" :sort-ascending="ascending"
:update-sort="sortProjects" :update-sort="sortProjects"
:current-page="currentPage"
> >
<template v-if="selectedProjects.length > 0" #headers> <template v-if="selectedProjects.length > 0" #headers>
<div class="flex gap-2"> <div class="flex gap-2">
@ -238,28 +249,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import {
ExternalIcon, CheckCircleIcon,
LinkIcon,
ClipboardCopyIcon, ClipboardCopyIcon,
TrashIcon,
SearchIcon,
UpdatedIcon,
XIcon,
ShareIcon,
DropdownIcon,
FileIcon,
CodeIcon, CodeIcon,
DownloadIcon, DownloadIcon,
DropdownIcon,
ExternalIcon,
FileIcon,
FilterIcon, FilterIcon,
LinkIcon,
MoreVerticalIcon, MoreVerticalIcon,
CheckCircleIcon, SearchIcon,
ShareIcon,
SlashIcon, SlashIcon,
TrashIcon,
UpdatedIcon,
XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Button, ButtonStyled, ContentListPanel, OverflowMenu } from '@modrinth/ui' import { Button, ButtonStyled, ContentListPanel, OverflowMenu, Pagination } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils' import { formatProjectType } from '@modrinth/utils'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useVIntl, defineMessages } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { import {
add_project_from_path, add_project_from_path,
get_projects, get_projects,
@ -405,7 +416,7 @@ const initProjects = async (cacheBehaviour?) => {
icon: null, icon: null,
disabled: file.file_name.endsWith('.disabled'), disabled: file.file_name.endsWith('.disabled'),
outdated: false, outdated: false,
project_type: file.project_type, project_type: file.project_type === 'shaderpack' ? 'shader' : file.project_type,
}) })
} }
@ -484,6 +495,15 @@ const filteredProjects = computed(() => {
}) })
}) })
watch(filterOptions, () => {
for (let i = 0; i < selectedFilters.value.length; i++) {
const option = selectedFilters.value[i]
if (!filterOptions.value.some((x) => x.id === option)) {
selectedFilters.value.splice(i, 1)
}
}
})
function toggleArray(array, value) { function toggleArray(array, value) {
if (array.includes(value)) { if (array.includes(value)) {
array.splice(array.indexOf(value), 1) array.splice(array.indexOf(value), 1)
@ -494,11 +514,10 @@ function toggleArray(array, value) {
const searchFilter = ref('') const searchFilter = ref('')
const selectAll = ref(false) const selectAll = ref(false)
const selectedProjectType = ref('All')
const hideNonSelected = ref(false)
const shareModal = ref(null) const shareModal = ref(null)
const ascending = ref(true) const ascending = ref(true)
const sortColumn = ref('Name') const sortColumn = ref('Name')
const currentPage = ref(1)
const selected = computed(() => const selected = computed(() =>
Array.from(selectionMap.value) Array.from(selectionMap.value)
@ -514,31 +533,9 @@ const functionValues = computed(() =>
selectedProjects.value.length > 0 ? selectedProjects.value : Array.from(projects.value.values()), selectedProjects.value.length > 0 ? selectedProjects.value : Array.from(projects.value.values()),
) )
const selectableProjectTypes = computed(() => {
const obj = { All: 'all' }
for (const project of projects.value) {
obj[project.project_type ? formatProjectType(project.project_type) + 's' : 'Other'] =
project.project_type
}
return obj
})
const search = computed(() => { const search = computed(() => {
const projectType = selectableProjectTypes.value[selectedProjectType.value] const filtered = filteredProjects.value.filter((mod) => {
const filtered = filteredProjects.value return mod.name.toLowerCase().includes(searchFilter.value.toLowerCase())
.filter((mod) => {
return (
mod.name.toLowerCase().includes(searchFilter.value.toLowerCase()) &&
(projectType === 'all' || mod.project_type === projectType)
)
})
.filter((mod) => {
if (hideNonSelected.value) {
return !mod.disabled
}
return true
}) })
switch (sortColumn.value) { switch (sortColumn.value) {
@ -553,18 +550,12 @@ const search = computed(() => {
return 0 return 0
}) })
default: default:
return filtered.slice().sort((a, b) => { return filtered.slice().sort((a, b) => a.name.localeCompare(b.name))
if (a.name < b.name) {
return ascending.value ? -1 : 1
}
if (a.name > b.name) {
return ascending.value ? 1 : -1
}
return 0
})
} }
}) })
watch(search, () => (currentPage.value = 1))
const sortProjects = (filter) => { const sortProjects = (filter) => {
if (sortColumn.value === filter) { if (sortColumn.value === filter) {
ascending.value = !ascending.value ascending.value = !ascending.value
@ -630,20 +621,20 @@ const locks = {}
const toggleDisableMod = async (mod) => { const toggleDisableMod = async (mod) => {
// Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property. // Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property.
if (!locks[mod.id]) { const lock = locks[mod.file_name]
locks[mod.id] = ref(null)
while (lock) {
await new Promise((resolve) => {
setTimeout((_) => resolve(), 100)
})
} }
const lock = locks[mod.id] locks[mod.file_name] = 'lock'
while (lock.value) { try {
await lock.value mod.path = await toggle_disable_project(props.instance.path, mod.path)
}
lock.value = toggle_disable_project(props.instance.path, mod.path)
.then((newPath) => {
mod.path = newPath
mod.disabled = !mod.disabled mod.disabled = !mod.disabled
trackEvent('InstanceProjectDisable', { trackEvent('InstanceProjectDisable', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
@ -652,13 +643,11 @@ const toggleDisableMod = async (mod) => {
project_type: mod.project_type, project_type: mod.project_type,
disabled: mod.disabled, disabled: mod.disabled,
}) })
}) } catch (err) {
.catch(handleError) handleError(err)
.finally(() => { }
lock.value = null
})
await lock.value locks[mod.file_name] = null
} }
const removeMod = async (mod) => { const removeMod = async (mod) => {

View File

@ -1,973 +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="512"
: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;
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 Index from './Index.vue'
import Mods from './Mods.vue' import Mods from './Mods.vue'
import Options from './Options.vue'
import Logs from './Logs.vue' import Logs from './Logs.vue'
export { Index, Mods, Options, Logs } export { Index, Mods, Logs }

View File

@ -36,7 +36,7 @@ onUnmounted(() => {
<template> <template>
<div class="p-6 flex flex-col gap-3"> <div class="p-6 flex flex-col gap-3">
<h1 class="m-0 text-2xl">Library</h1> <h1 class="m-0 text-2xl hidden">Library</h1>
<NavTabs <NavTabs
:links="[ :links="[
{ label: 'All instances', href: `/library` }, { label: 'All instances', href: `/library` },

View File

@ -25,7 +25,10 @@
<div class="flex flex-col gap-4 p-6"> <div class="flex flex-col gap-4 p-6">
<InstanceIndicator v-if="instance" :instance="instance" /> <InstanceIndicator v-if="instance" :instance="instance" />
<template v-if="data"> <template v-if="data">
<Teleport v-if="themeStore.featureFlag_projectBackground" to="#background-teleport-target"> <Teleport
v-if="themeStore.featureFlags.project_background"
to="#background-teleport-target"
>
<ProjectBackgroundGradient :project="data" /> <ProjectBackgroundGradient :project="data" />
</Teleport> </Teleport>
<ProjectHeader :project="data"> <ProjectHeader :project="data">

View File

@ -124,15 +124,6 @@ export default new createRouter({
breadcrumb: [{ name: '?Instance' }], breadcrumb: [{ name: '?Instance' }],
}, },
}, },
{
path: 'options',
name: 'Options',
component: Instance.Options,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Options' }],
},
},
{ {
path: 'logs', path: 'logs',
name: 'Logs', name: 'Logs',

View File

@ -2,13 +2,13 @@ import { defineStore } from 'pinia'
export const useTheming = defineStore('themeStore', { export const useTheming = defineStore('themeStore', {
state: () => ({ state: () => ({
themeOptions: ['dark', 'light', 'oled'], themeOptions: ['dark', 'light', 'oled', 'system'],
advancedRendering: true, advancedRendering: true,
selectedTheme: 'dark', selectedTheme: 'dark',
toggleSidebar: false,
devMode: false, devMode: false,
featureFlag_pagePath: false, featureFlags: {},
featureFlag_projectBackground: false,
}), }),
actions: { actions: {
setThemeState(newTheme) { setThemeState(newTheme) {
@ -21,7 +21,18 @@ export const useTheming = defineStore('themeStore', {
for (const theme of this.themeOptions) { for (const theme of this.themeOptions) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`) document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
} }
document.getElementsByTagName('html')[0].classList.add(`${this.selectedTheme}-mode`)
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
}, },
}, },
}) })

View File

@ -1,6 +1,6 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
version = "0.9.0-1" version = "0.9.0-2"
description = "The Modrinth App is a desktop application for managing your Minecraft mods" description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only" license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/" repository = "https://github.com/modrinth/code/apps/app/"
@ -18,7 +18,6 @@ serde = { version = "1.0", features = ["derive"] }
serde_with = "3.0.0" serde_with = "3.0.0"
tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] } tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
# tauri = { git = "https://github.com/modrinth/tauri", rev = "67911d5", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
tauri-plugin-window-state = "2.2.0" tauri-plugin-window-state = "2.2.0"
tauri-plugin-deep-link = "2.2.0" tauri-plugin-deep-link = "2.2.0"
tauri-plugin-os = "2.2.0" tauri-plugin-os = "2.2.0"

View File

@ -8,6 +8,7 @@ use tokio::sync::RwLock;
pub struct AdsState { pub struct AdsState {
pub shown: bool, pub shown: bool,
pub modal_shown: bool,
pub last_click: Option<Instant>, pub last_click: Option<Instant>,
pub malicious_origins: HashSet<String>, pub malicious_origins: HashSet<String>,
} }
@ -19,6 +20,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.setup(|app, _api| { .setup(|app, _api| {
app.manage(RwLock::new(AdsState { app.manage(RwLock::new(AdsState {
shown: true, shown: true,
modal_shown: false,
last_click: None, last_click: None,
malicious_origins: HashSet::new(), malicious_origins: HashSet::new(),
})); }));
@ -86,6 +88,10 @@ pub async fn init_ads_window<R: Runtime>(
state.shown = true; state.shown = true;
} }
if state.modal_shown {
return Ok(());
}
if let Ok((position, size)) = get_webview_position(&app, dpr) { if let Ok((position, size)) = get_webview_position(&app, dpr) {
if let Some(webview) = app.webviews().get("ads-window") { if let Some(webview) = app.webviews().get("ads-window") {
if state.shown { if state.shown {
@ -133,7 +139,9 @@ pub async fn show_ads_window<R: Runtime>(
) -> crate::api::Result<()> { ) -> crate::api::Result<()> {
if let Some(webview) = app.webviews().get("ads-window") { if let Some(webview) = app.webviews().get("ads-window") {
let state = app.state::<RwLock<AdsState>>(); let state = app.state::<RwLock<AdsState>>();
let state = state.read().await; let mut state = state.write().await;
state.modal_shown = false;
if state.shown { if state.shown {
let (position, size) = get_webview_position(&app, dpr)?; let (position, size) = get_webview_position(&app, dpr)?;
@ -151,11 +159,13 @@ pub async fn hide_ads_window<R: Runtime>(
reset: Option<bool>, reset: Option<bool>,
) -> crate::api::Result<()> { ) -> crate::api::Result<()> {
if let Some(webview) = app.webviews().get("ads-window") { if let Some(webview) = app.webviews().get("ads-window") {
if reset.unwrap_or(false) {
let state = app.state::<RwLock<AdsState>>(); let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await; let mut state = state.write().await;
if reset.unwrap_or(false) {
state.shown = false; state.shown = false;
} else {
state.modal_shown = true;
} }
let _ = webview.set_position(PhysicalPosition::new(-1000, -1000)); let _ = webview.set_position(PhysicalPosition::new(-1000, -1000));

View File

@ -7,7 +7,7 @@ use tauri::{
}; // 0.8 }; // 0.8
const WINDOW_CONTROL_PAD_X: f64 = 9.0; const WINDOW_CONTROL_PAD_X: f64 = 9.0;
const WINDOW_CONTROL_PAD_Y: f64 = 16.0; const WINDOW_CONTROL_PAD_Y: f64 = 10.0;
struct UnsafeWindowHandle(*mut std::ffi::c_void); struct UnsafeWindowHandle(*mut std::ffi::c_void);
unsafe impl Send for UnsafeWindowHandle {} unsafe impl Send for UnsafeWindowHandle {}

View File

@ -25,6 +25,7 @@ extern crate objc;
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
#[tauri::command] #[tauri::command]
async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> { async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
tracing::info!("Initializing app event state...");
theseus::EventState::init(app.clone()).await?; theseus::EventState::init(app.clone()).await?;
#[cfg(feature = "updater")] #[cfg(feature = "updater")]
@ -35,6 +36,7 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
let update_fut = updater.check(); let update_fut = updater.check();
tracing::info!("Initializing app state...");
State::init().await?; State::init().await?;
let check_bar = theseus::init_loading( let check_bar = theseus::init_loading(
@ -44,6 +46,7 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
) )
.await?; .await?;
tracing::info!("Checking for updates...");
let update = update_fut.await; let update = update_fut.await;
drop(check_bar); drop(check_bar);
@ -88,6 +91,7 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
State::init().await?; State::init().await?;
} }
tracing::info!("Finished checking for updates!");
let state = State::get().await?; let state = State::get().await?;
app.asset_protocol_scope() app.asset_protocol_scope()
.allow_directory(state.directories.caches_dir(), true)?; .allow_directory(state.directories.caches_dir(), true)?;
@ -169,19 +173,19 @@ fn main() {
} }
builder = builder builder = builder
// .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// if let Some(payload) = args.get(1) { if let Some(payload) = args.get(1) {
// tracing::info!("Handling deep link from arg {payload}"); tracing::info!("Handling deep link from arg {payload}");
// let payload = payload.clone(); let payload = payload.clone();
// tauri::async_runtime::spawn(api::utils::handle_command( tauri::async_runtime::spawn(api::utils::handle_command(
// payload, payload,
// )); ));
// } }
//
// if let Some(win) = app.get_window("main") { if let Some(win) = app.get_window("main") {
// let _ = win.set_focus(); let _ = win.set_focus();
// } }
// })) }))
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
@ -274,6 +278,7 @@ fn main() {
builder = builder.plugin(macos::window_ext::init()); builder = builder.plugin(macos::window_ext::init());
} }
tracing::info!("Initializing app...");
let app = builder.build(tauri::generate_context!()); let app = builder.build(tauri::generate_context!());
match app { match app {
@ -335,6 +340,7 @@ fn main() {
.show_alert() .show_alert()
.unwrap(); .unwrap();
tracing::error!("Error while running tauri application: {:?}", e);
panic!("{1}: {:?}", e, "error while running tauri application") panic!("{1}: {:?}", e, "error while running tauri application")
} }
} }

View File

@ -44,7 +44,7 @@
] ]
}, },
"productName": "Modrinth App", "productName": "Modrinth App",
"version": "0.9.0-1", "version": "0.9.0-2",
"mainBinaryName": "Modrinth App", "mainBinaryName": "Modrinth App",
"identifier": "ModrinthApp", "identifier": "ModrinthApp",
"plugins": { "plugins": {

View File

@ -373,7 +373,7 @@ export default defineNuxtComponent({
clear: false, clear: false,
}, },
}, },
commonMessages commonMessages,
}; };
}, },
head: { head: {

View File

@ -311,7 +311,6 @@ import {
ButtonStyled, ButtonStyled,
NewProjectCard, NewProjectCard,
SearchFilterControl, SearchFilterControl,
ContentPageHeader,
} from "@modrinth/ui"; } from "@modrinth/ui";
import { CheckIcon, DownloadIcon, GameIcon, LeftArrowIcon, XIcon } from "@modrinth/assets"; import { CheckIcon, DownloadIcon, GameIcon, LeftArrowIcon, XIcon } from "@modrinth/assets";
import { computed } from "vue"; import { computed } from "vue";

View File

@ -22,7 +22,11 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [false, false, false] "nullable": [
false,
false,
false
]
}, },
"hash": "1397c1825096fb402cdd3b5dae8cd3910b1719f433a0c34d40415dd7681ab272" "hash": "1397c1825096fb402cdd3b5dae8cd3910b1719f433a0c34d40415dd7681ab272"
} }

View File

@ -27,7 +27,12 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [false, false, false, false] "nullable": [
false,
false,
false,
false
]
}, },
"hash": "18881c0c2ec1b0cc73fa13b4c242dfc577061b92479ce96ffb30a457939b5ffe" "hash": "18881c0c2ec1b0cc73fa13b4c242dfc577061b92479ce96ffb30a457939b5ffe"
} }

View File

@ -27,7 +27,12 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [false, false, false, false] "nullable": [
false,
false,
false,
false
]
}, },
"hash": "265f9c9ad992da0aeaf69c3f0077b54a186b98796ec549c9d891089ea33cf3fc" "hash": "265f9c9ad992da0aeaf69c3f0077b54a186b98796ec549c9d891089ea33cf3fc"
} }

View File

@ -32,7 +32,13 @@
"parameters": { "parameters": {
"Right": 3 "Right": 3
}, },
"nullable": [false, false, null, true, false] "nullable": [
false,
false,
null,
true,
false
]
}, },
"hash": "28b3e3132d75e551c1fa14b8d3be36adca581f8ad1b90f85d3ec3d92ec61e65e" "hash": "28b3e3132d75e551c1fa14b8d3be36adca581f8ad1b90f85d3ec3d92ec61e65e"
} }

View File

@ -27,7 +27,12 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [false, false, false, false] "nullable": [
false,
false,
false,
false
]
}, },
"hash": "6d7ebc0f233dc730fa8c99c750421065f5e35f321954a9d5ae9cde907d5ce823" "hash": "6d7ebc0f233dc730fa8c99c750421065f5e35f321954a9d5ae9cde907d5ce823"
} }

View File

@ -41,13 +41,22 @@
{ {
"name": "display_claims!: serde_json::Value", "name": "display_claims!: serde_json::Value",
"ordinal": 7, "ordinal": 7,
"type_info": "Text" "type_info": "Null"
} }
], ],
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [false, false, false, false, false, false, false, null] "nullable": [
false,
false,
false,
false,
false,
false,
false,
null
]
}, },
"hash": "6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf" "hash": "6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf"
} }

View File

@ -37,7 +37,14 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [false, false, false, false, false, false] "nullable": [
false,
false,
false,
false,
false,
false
]
}, },
"hash": "727e3e1bc8625bbcb833920059bb8cea926ac6c65d613904eff1d740df30acda" "hash": "727e3e1bc8625bbcb833920059bb8cea926ac6c65d613904eff1d740df30acda"
} }

View File

@ -1,12 +1,12 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25\n ", "query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27\n ",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
"Right": 25 "Right": 27
}, },
"nullable": [] "nullable": []
}, },
"hash": "26e3ed8680f6c492b03b458aabfb3f94fddc753b343ef705263188945d0e578d" "hash": "759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d"
} }

View File

@ -37,7 +37,14 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [false, false, false, false, false, false] "nullable": [
false,
false,
false,
false,
false,
false
]
}, },
"hash": "bf7d47350092d87c478009adaab131168e87bb37aa65c2156ad2cb6198426d8c" "hash": "bf7d47350092d87c478009adaab131168e87bb37aa65c2156ad2cb6198426d8c"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated\n FROM settings\n ", "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -127,6 +127,16 @@
"name": "migrated", "name": "migrated",
"ordinal": 24, "ordinal": 24,
"type_info": "Integer" "type_info": "Integer"
},
{
"name": "feature_flags",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "toggle_sidebar",
"ordinal": 26,
"type_info": "Integer"
} }
], ],
"parameters": { "parameters": {
@ -157,8 +167,10 @@
true, true,
true, true,
true, true,
false,
null,
false false
] ]
}, },
"hash": "8e19c9cdb0aaa48509724e82f6e8f212c9cd2112fdba77cfeee206025af47761" "hash": "d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9"
} }

View File

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

View File

@ -0,0 +1,2 @@
ALTER TABLE settings ADD COLUMN toggle_sidebar INTEGER NOT NULL DEFAULT FALSE;
ALTER TABLE settings ADD COLUMN feature_flags JSONB NOT NULL default '{}';

View File

@ -474,7 +474,11 @@ impl CacheValue {
| CacheValue::DonationPlatforms(_) => DEFAULT_ID.to_string(), | CacheValue::DonationPlatforms(_) => DEFAULT_ID.to_string(),
CacheValue::FileHash(hash) => { CacheValue::FileHash(hash) => {
format!("{}-{}", hash.size, hash.path.replace(".disabled", "")) format!(
"{}-{}",
hash.size,
hash.path.trim_end_matches(".disabled")
)
} }
CacheValue::FileUpdate(hash) => { CacheValue::FileUpdate(hash) => {
format!("{}-{}-{}", hash.hash, hash.loader, hash.game_version) format!("{}-{}-{}", hash.hash, hash.loader, hash.game_version)

View File

@ -122,10 +122,12 @@ impl State {
#[tracing::instrument] #[tracing::instrument]
async fn initialize_state() -> crate::Result<Arc<Self>> { async fn initialize_state() -> crate::Result<Arc<Self>> {
tracing::info!("Connecting to app database");
let pool = db::connect().await?; let pool = db::connect().await?;
legacy_converter::migrate_legacy_data(&pool).await?; legacy_converter::migrate_legacy_data(&pool).await?;
tracing::info!("Fetching app settings");
let mut settings = Settings::get(&pool).await?; let mut settings = Settings::get(&pool).await?;
let fetch_semaphore = let fetch_semaphore =
@ -135,6 +137,7 @@ impl State {
let api_semaphore = let api_semaphore =
FetchSemaphore(Semaphore::new(settings.max_concurrent_downloads)); FetchSemaphore(Semaphore::new(settings.max_concurrent_downloads));
tracing::info!("Initializing directories");
DirectoryInfo::move_launcher_directory( DirectoryInfo::move_launcher_directory(
&mut settings, &mut settings,
&pool, &pool,
@ -145,6 +148,7 @@ impl State {
let discord_rpc = DiscordGuard::init()?; let discord_rpc = DiscordGuard::init()?;
tracing::info!("Initializing file watcher");
let file_watcher = fs_watcher::init_watcher().await?; let file_watcher = fs_watcher::init_watcher().await?;
fs_watcher::watch_profiles_init(&file_watcher, &directories).await; fs_watcher::watch_profiles_init(&file_watcher, &directories).await;

View File

@ -664,7 +664,7 @@ impl Profile {
path: format!( path: format!(
"{}/{folder}/{}", "{}/{folder}/{}",
self.path, self.path,
file_name.replace(".disabled", "") file_name.trim_end_matches(".disabled")
), ),
file_name: file_name.to_string(), file_name: file_name.to_string(),
project_type, project_type,
@ -725,8 +725,9 @@ impl Profile {
let info_index = file_info.iter().position(|x| x.hash == hash.hash); let info_index = file_info.iter().position(|x| x.hash == hash.hash);
let file = info_index.map(|x| file_info.remove(x)); let file = info_index.map(|x| file_info.remove(x));
if let Some(initial_file_index) = if let Some(initial_file_index) = keys
keys.iter().position(|x| x.path == hash.path) .iter()
.position(|x| x.path == hash.path.trim_end_matches(".disabled"))
{ {
let initial_file = keys.remove(initial_file_index); let initial_file = keys.remove(initial_file_index);
@ -890,7 +891,7 @@ impl Profile {
let path = crate::api::profile::get_full_path(profile_path).await?; let path = crate::api::profile::get_full_path(profile_path).await?;
let new_path = if project_path.ends_with(".disabled") { let new_path = if project_path.ends_with(".disabled") {
project_path.replace(".disabled", "") project_path.trim_end_matches(".disabled").to_string()
} else { } else {
format!("{project_path}.disabled") format!("{project_path}.disabled")
}; };

View File

@ -1,5 +1,7 @@
//! Theseus settings file //! Theseus settings file
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// Types // Types
/// Global Theseus settings /// Global Theseus settings
@ -13,10 +15,10 @@ pub struct Settings {
pub collapsed_navigation: bool, pub collapsed_navigation: bool,
pub advanced_rendering: bool, pub advanced_rendering: bool,
pub native_decorations: bool, pub native_decorations: bool,
pub toggle_sidebar: bool,
pub telemetry: bool, pub telemetry: bool,
pub discord_rpc: bool, pub discord_rpc: bool,
pub developer_mode: bool,
pub personalized_ads: bool, pub personalized_ads: bool,
pub onboarded: bool, pub onboarded: bool,
@ -32,6 +34,16 @@ pub struct Settings {
pub custom_dir: Option<String>, pub custom_dir: Option<String>,
pub prev_custom_dir: Option<String>, pub prev_custom_dir: Option<String>,
pub migrated: bool, pub migrated: bool,
pub developer_mode: bool,
pub feature_flags: HashMap<FeatureFlag, bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, Hash, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum FeatureFlag {
PagePath,
ProjectBackground,
} }
impl Settings { impl Settings {
@ -48,7 +60,7 @@ impl Settings {
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars, json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,
hook_pre_launch, hook_wrapper, hook_post_exit, hook_pre_launch, hook_wrapper, hook_post_exit,
custom_dir, prev_custom_dir, migrated custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar
FROM settings FROM settings
" "
) )
@ -63,6 +75,7 @@ impl Settings {
collapsed_navigation: res.collapsed_navigation == 1, collapsed_navigation: res.collapsed_navigation == 1,
advanced_rendering: res.advanced_rendering == 1, advanced_rendering: res.advanced_rendering == 1,
native_decorations: res.native_decorations == 1, native_decorations: res.native_decorations == 1,
toggle_sidebar: res.toggle_sidebar == 1,
telemetry: res.telemetry == 1, telemetry: res.telemetry == 1,
discord_rpc: res.discord_rpc == 1, discord_rpc: res.discord_rpc == 1,
developer_mode: res.developer_mode == 1, developer_mode: res.developer_mode == 1,
@ -95,6 +108,11 @@ impl Settings {
custom_dir: res.custom_dir, custom_dir: res.custom_dir,
prev_custom_dir: res.prev_custom_dir, prev_custom_dir: res.prev_custom_dir,
migrated: res.migrated == 1, migrated: res.migrated == 1,
feature_flags: res
.feature_flags
.as_ref()
.and_then(|x| serde_json::from_str(x).ok())
.unwrap_or_default(),
}) })
} }
@ -108,6 +126,7 @@ impl Settings {
let default_page = self.default_page.as_str(); let default_page = self.default_page.as_str();
let extra_launch_args = serde_json::to_string(&self.extra_launch_args)?; let extra_launch_args = serde_json::to_string(&self.extra_launch_args)?;
let custom_env_vars = serde_json::to_string(&self.custom_env_vars)?; let custom_env_vars = serde_json::to_string(&self.custom_env_vars)?;
let feature_flags = serde_json::to_string(&self.feature_flags)?;
sqlx::query!( sqlx::query!(
" "
@ -143,7 +162,10 @@ impl Settings {
custom_dir = $23, custom_dir = $23,
prev_custom_dir = $24, prev_custom_dir = $24,
migrated = $25 migrated = $25,
toggle_sidebar = $26,
feature_flags = $27
", ",
max_concurrent_writes, max_concurrent_writes,
max_concurrent_downloads, max_concurrent_downloads,
@ -169,7 +191,9 @@ impl Settings {
self.hooks.post_exit, self.hooks.post_exit,
self.custom_dir, self.custom_dir,
self.prev_custom_dir, self.prev_custom_dir,
self.migrated self.migrated,
self.toggle_sidebar,
feature_flags
) )
.execute(exec) .execute(exec)
.await?; .await?;
@ -185,6 +209,7 @@ pub enum Theme {
Dark, Dark,
Light, Light,
Oled, Oled,
System,
} }
impl Theme { impl Theme {
@ -193,6 +218,7 @@ impl Theme {
Theme::Dark => "dark", Theme::Dark => "dark",
Theme::Light => "light", Theme::Light => "light",
Theme::Oled => "oled", Theme::Oled => "oled",
Theme::System => "system",
} }
} }
@ -201,6 +227,7 @@ impl Theme {
"dark" => Theme::Dark, "dark" => Theme::Dark,
"light" => Theme::Light, "light" => Theme::Light,
"oled" => Theme::Oled, "oled" => Theme::Oled,
"system" => Theme::System,
_ => Theme::Dark, _ => Theme::Dark,
} }
} }

View File

@ -3,6 +3,7 @@
ref="dropdown" ref="dropdown"
no-auto-focus no-auto-focus
:aria-id="dropdownId || null" :aria-id="dropdownId || null"
placement="bottom-end"
@apply-hide="focusTrigger" @apply-hide="focusTrigger"
@apply-show="focusMenuChild" @apply-show="focusMenuChild"
> >

View File

@ -5,7 +5,6 @@ import Checkbox from '../base/Checkbox.vue'
import ContentListItem from './ContentListItem.vue' import ContentListItem from './ContentListItem.vue'
import type { ContentItem } from './ContentListItem.vue' import type { ContentItem } from './ContentListItem.vue'
import { DropdownIcon } from '@modrinth/assets' import { DropdownIcon } from '@modrinth/assets'
import { createVirtualScroller } from 'vue-typed-virtual-list'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -13,12 +12,11 @@ const props = withDefaults(
sortColumn: string sortColumn: string
sortAscending: boolean sortAscending: boolean
updateSort: (column: string) => void updateSort: (column: string) => void
currentPage: number
}>(), }>(),
{}, {},
) )
const VirtualScroller = createVirtualScroller()
const selectionStates: Ref<Record<string, boolean>> = ref({}) const selectionStates: Ref<Record<string, boolean>> = ref({})
const selected: Ref<string[]> = computed(() => const selected: Ref<string[]> = computed(() =>
Object.keys(selectionStates.value).filter( Object.keys(selectionStates.value).filter(
@ -42,6 +40,10 @@ function setSelected(value: boolean) {
} }
updateSelection() updateSelection()
} }
const paginatedItems = computed(() =>
props.items.slice((props.currentPage - 1) * 20, props.currentPage * 20),
)
</script> </script>
<template> <template>
@ -78,12 +80,12 @@ function setSelected(value: boolean) {
</slot> </slot>
</div> </div>
<div class="bg-bg-raised rounded-xl"> <div class="bg-bg-raised rounded-xl">
<VirtualScroller :items="items" :default-size="64" style="height: 100%">
<template #item="{ ref, index }">
<ContentListItem <ContentListItem
v-model="selectionStates[ref.filename]" v-for="(itemRef, index) in paginatedItems"
:item="ref" :key="itemRef.filename"
:last="index === items.length - 1" v-model="selectionStates[itemRef.filename]"
:item="itemRef"
:last="index === paginatedItems.length - 1"
class="mb-2" class="mb-2"
@update:model-value="updateSelection" @update:model-value="updateSelection"
> >
@ -91,8 +93,6 @@ function setSelected(value: boolean) {
<slot name="actions" :item="item" /> <slot name="actions" :item="item" />
</template> </template>
</ContentListItem> </ContentListItem>
</template>
</VirtualScroller>
</div> </div>
</div> </div>
</template> </template>

View File

@ -26,14 +26,15 @@
v-tooltip=" v-tooltip="
`${formatNumber(project.followers, false)} follower${project.downloads !== 1 ? 's' : ''}` `${formatNumber(project.followers, false)} follower${project.downloads !== 1 ? 's' : ''}`
" "
class="flex items-center gap-2 border-0 border-solid border-divider pr-4 md:border-r cursor-help" class="flex items-center gap-2 border-0 border-solid border-divider pr-4 cursor-help"
:class="{ 'md:border-r': project.categories.length > 0 }"
> >
<HeartIcon class="h-6 w-6 text-secondary" /> <HeartIcon class="h-6 w-6 text-secondary" />
<span class="font-semibold"> <span class="font-semibold">
{{ formatNumber(project.followers) }} {{ formatNumber(project.followers) }}
</span> </span>
</div> </div>
<div class="hidden items-center gap-2 md:flex"> <div v-if="project.categories.length > 0" class="hidden items-center gap-2 md:flex">
<TagsIcon class="h-6 w-6 text-secondary" /> <TagsIcon class="h-6 w-6 text-secondary" />
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<TagItem v-for="(category, index) in project.categories" :key="index"> <TagItem v-for="(category, index) in project.categories" :key="index">