App redesign (#2946)

* Start of app redesign

* format

* continue progress

* Content page nearly done

* Fix recursion issues with content page

* Fix update all alignment

* Discover page progress

* Settings progress

* Removed unlocked-size hack that breaks web

* Revamp project page, refactor web project page to share code with app, fixed loading bar, misc UI/UX enhancements, update ko-fi logo, update arrow icons, fix web issues caused by floating-vue migration, fix tooltip issues, update web tooltips, clean up web hydration issues

* Ads + run prettier

* Begin auth refactor, move common messages to ui lib, add i18n extraction to all apps, begin Library refactor

* fix ads not hiding when plus log in

* rev lockfile changes/conflicts

* Fix sign in page

* Add generated

* (mostly) Data driven search

* Fix search mobile issue

* profile fixes

* Project versions page, fix typescript on UI lib and misc fixes

* Remove unused gallery component

* Fix linkfunction err

* Search filter controls at top, localization for locked filters

* Fix provided filter names

* Fix navigating from instance browse to main browse

* Friends frontend (#2995)

* Friends system frontend

* (almost) finish frontend

* finish friends, fix lint

* Fix lint

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>

* Refresh macOS app icon

* Update web search UI more

* Fix link opens

* Fix frontend build

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
Prospector 2024-12-11 19:54:18 -08:00 committed by GitHub
parent 6ec1dcf088
commit c39bb78e38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
257 changed files with 15713 additions and 9475 deletions

View File

@ -26,15 +26,13 @@ jobs:
uses: docker/metadata-action@v3
with:
images: ghcr.io/modrinth/daedalus
-
name: Login to GitHub Images
- name: Login to GitHub Images
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:

View File

@ -21,7 +21,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker image from GHCR
run: docker pull ghcr.io/modrinth/daedalus:main

View File

@ -29,15 +29,13 @@ jobs:
uses: docker/metadata-action@v3
with:
images: ghcr.io/modrinth/labrinth
-
name: Login to GitHub Images
- name: Login to GitHub Images
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:

View File

@ -2,7 +2,7 @@ name: CI
on:
push:
branches: ["main"]
branches: ['main']
pull_request:
types: [opened, synchronize]
merge_group:

View File

@ -1,12 +1,7 @@
{
"prettier.endOfLine": "lf",
"editor.formatOnSave": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}

View File

@ -8,7 +8,8 @@
"build": "vue-tsc --noEmit && vite build",
"tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write ."
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
},
"dependencies": {
"@modrinth/assets": "workspace:*",
@ -28,12 +29,13 @@
"pinia": "^2.1.7",
"posthog-js": "^1.158.2",
"vite-svg-loader": "^5.1.0",
"vue": "^3.4.21",
"vue": "^3.5.13",
"vue-multiselect": "3.0.0",
"vue-router": "4.3.0",
"vue-virtual-scroller": "v2.0.0-beta.8"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@eslint/compat": "^1.1.1",
"@nuxt/eslint-config": "^0.5.6",
"@vitejs/plugin-vue": "^5.0.4",
@ -47,8 +49,9 @@
"tailwindcss": "^3.4.4",
"tsconfig": "workspace:*",
"typescript": "^5.5.4",
"vite": "^5.2.8",
"vite": "^5.4.6",
"vue-tsc": "^2.1.6"
},
"packageManager": "pnpm@9.4.0"
"packageManager": "pnpm@9.4.0",
"web-types": "../../web-types.json"
}

View File

@ -1,17 +1,24 @@
<script setup>
import { computed, ref, onMounted } from 'vue'
import { RouterView, RouterLink, useRouter, useRoute } from 'vue-router'
import { computed, ref, onMounted, watch, onUnmounted } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import {
ArrowBigUpDashIcon,
LogInIcon,
HomeIcon,
SearchIcon,
LibraryIcon,
PlusIcon,
SettingsIcon,
XIcon,
DownloadIcon,
CompassIcon,
MinimizeIcon,
MaximizeIcon,
RestoreIcon,
LogOutIcon,
} from '@modrinth/assets'
import { Button, Notifications } from '@modrinth/ui'
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings'
@ -19,10 +26,9 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import ModrinthLoadingIndicator from '@/components/modrinth-loading-indicator'
import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import { handleError, useNotifications } from '@/store/notifications.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { MinimizeIcon, MaximizeIcon } from '@/assets/icons'
import { type } from '@tauri-apps/plugin-os'
import { isDev, getOS, restartApp } from '@/helpers/utils.js'
import { initAnalytics, debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
@ -37,15 +43,51 @@ import IncompatibilityWarningModal from '@/components/ui/install_flow/Incompatib
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import { useInstall } from '@/store/install.js'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-shell'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, logout, login } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import dayjs from 'dayjs'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { open as openURL } from '@tauri-apps/plugin-shell'
const themeStore = useTheming()
const news = ref([
{
title: 'Modrinth App 0.9.0',
summary: 'An all-new UI, Modrinth Servers, and support for collections.',
thumbnail:
'https://media.beehiiv.com/cdn-cgi/image/format=auto,width=800,height=421,fit=scale-down,onerror=redirect/uploads/publication/thumbnail/a49f8e1b-3835-4ea1-a85b-118c6425ebc3/landscape_1667087426098458.png',
date: '2024-10-11T00:00:00Z',
link: 'https://blog.modrinth.com/p/creator-revenue-update',
},
{
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 offline = ref(!navigator.onLine)
@ -65,8 +107,18 @@ const stateInitialized = ref(false)
const criticalErrorMessage = ref()
const isMaximized = ref(false)
onMounted(async () => {
await useCheckDisableMouseover()
document.querySelector('body').addEventListener('click', handleClick)
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
})
onUnmounted(() => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
})
async function setupApp() {
@ -97,6 +149,12 @@ async function setupApp() {
themeStore.collapsedNavigation = collapsed_navigation
themeStore.advancedRendering = advanced_rendering
isMaximized.value = await getCurrentWindow().isMaximized()
await getCurrentWindow().onResized(async () => {
isMaximized.value = await getCurrentWindow().isMaximized()
})
initAnalytics()
if (!telemetry) {
optOutAnalytics()
@ -160,7 +218,6 @@ router.afterEach((to, from, failure) => {
trackEvent('PageView', { path: to.path, fromPath: from.path, failed: failure })
})
const route = useRoute()
const isOnBrowse = computed(() => route.path.startsWith('/browse'))
const loading = useLoading()
loading.setEnabled(false)
@ -176,6 +233,46 @@ const modInstallModal = ref()
const installConfirmModal = ref()
const incompatibilityWarningModal = ref()
const credentials = ref()
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
creds.user = await get_user(creds.user_id).catch(handleError)
}
credentials.value = creds
}
async function signIn() {
await login().catch(handleError)
await fetchCredentials()
}
async function logOut() {
await logout().catch(handleError)
await fetchCredentials()
}
const MIDAS_BITFLAG = 1 << 0
const hasPlus = computed(
() =>
credentials.value &&
credentials.value.user &&
(credentials.value.user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG,
)
watch(
hasPlus,
() => {
if (hasPlus.value) {
hide_ads_window(true)
} else {
show_ads_window()
}
},
{ immediate: true },
)
onMounted(() => {
invoke('show_window')
@ -186,41 +283,8 @@ onMounted(() => {
install.setIncompatibilityWarningModal(incompatibilityWarningModal)
install.setInstallConfirmModal(installConfirmModal)
install.setModInstallModal(modInstallModal)
})
document.querySelector('body').addEventListener('click', function (e) {
let target = e.target
while (target != null) {
if (target.matches('a')) {
if (
target.href &&
['http://', 'https://', 'mailto:', 'tel:'].some((v) => target.href.startsWith(v)) &&
!target.classList.contains('router-link-active') &&
!target.href.startsWith('http://localhost') &&
!target.href.startsWith('https://tauri.localhost') &&
!target.href.startsWith('http://tauri.localhost')
) {
open(target.href)
}
e.preventDefault()
break
}
target = target.parentElement
}
})
document.querySelector('body').addEventListener('auxclick', function (e) {
// disables middle click -> new tab
if (e.button === 1) {
e.preventDefault()
// instead do a left click
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
e.target.dispatchEvent(event)
}
fetchCredentials()
})
const accounts = ref(null)
@ -246,7 +310,6 @@ async function handleCommand(e) {
const updateAvailable = ref(false)
async function checkUpdates() {
const update = await check()
console.log(update)
updateAvailable.value = !!update
setTimeout(
@ -256,76 +319,129 @@ async function checkUpdates() {
5 * 1000 * 60,
)
}
function handleClick(e) {
let target = e.target
while (target != null) {
if (target.matches('a')) {
if (
target.href &&
['http://', 'https://', 'mailto:', 'tel:'].some((v) => target.href.startsWith(v)) &&
!target.classList.contains('router-link-active') &&
!target.href.startsWith('http://localhost') &&
!target.href.startsWith('https://tauri.localhost') &&
!target.href.startsWith('http://tauri.localhost') &&
target.target !== '_blank'
) {
openURL(target.href)
}
e.preventDefault()
break
}
target = target.parentElement
}
}
function handleAuxClick(e) {
// disables middle click -> new tab
if (e.button === 1) {
e.preventDefault()
// instead do a left click
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
e.target.dispatchEvent(event)
}
}
</script>
<template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<div v-if="stateInitialized" class="app-container">
<div class="nav-container">
<div class="nav-section">
<suspense>
<AccountsCard ref="accounts" mode="small" />
</suspense>
<div class="pages-list">
<RouterLink v-tooltip="'Home'" to="/" class="btn icon-only collapsed-button">
<HomeIcon />
</RouterLink>
<RouterLink
v-tooltip="'Browse'"
to="/browse/modpack"
class="btn icon-only collapsed-button"
:class="{
'router-link-active': isOnBrowse,
}"
>
<SearchIcon />
</RouterLink>
<RouterLink v-tooltip="'Library'" to="/library" class="btn icon-only collapsed-button">
<LibraryIcon />
</RouterLink>
<Suspense>
<AppSettingsModal ref="settingsModal" />
</Suspense>
<Suspense>
<InstanceCreationModal ref="installationModal" />
</Suspense>
</div>
</div>
<div class="settings pages-list">
<button
v-if="updateAvailable"
v-tooltip="'Install update'"
class="btn btn-outline btn-primary icon-only collapsed-button"
@click="restartApp()"
<div v-if="stateInitialized" class="app-grid-layout relative">
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[1rem] pt-0 gap-[0.5rem] z-10 w-[--left-bar-width]"
>
<DownloadIcon />
</button>
<Button
v-tooltip="'Create profile'"
class="sleek-primary collapsed-button"
icon-only
:disabled="offline"
@click="() => $refs.installationModal.show()"
<NavButton to="/">
<HomeIcon />
<template #label>Home</template>
</NavButton>
<NavButton
to="/browse/modpack"
:is-primary="() => route.path.startsWith('/browse') && !route.query.i"
:is-subpage="(route) => route.path.startsWith('/project') && !route.query.i"
>
<CompassIcon />
<template #label>Discover content</template>
</NavButton>
<NavButton
to="/library"
:is-subpage="
() =>
route.path.startsWith('/instance') ||
((route.path.startsWith('/browse') || route.path.startsWith('/project')) &&
route.query.i)
"
>
<LibraryIcon />
<template #label>Library</template>
</NavButton>
<div class="h-px w-6 mx-auto my-2 bg-button-bg"></div>
<NavButton :to="() => $refs.installationModal.show()" :disabled="offline">
<PlusIcon />
</Button>
<RouterLink v-tooltip="'Settings'" to="/settings" class="btn icon-only collapsed-button">
<template #label>Create new instance</template>
</NavButton>
<div class="flex flex-grow"></div>
<NavButton v-if="updateAvailable" :to="() => restartApp()">
<DownloadIcon />
<template #label>Install update</template>
</NavButton>
<NavButton :to="() => $refs.settingsModal.show()">
<SettingsIcon />
</RouterLink>
<template #label>Settings</template>
</NavButton>
<ButtonStyled v-if="credentials" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
direction="left"
>
<Avatar
:src="credentials.user.avatar_url"
:alt="credentials.user.username"
size="32px"
circle
/>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
</ButtonStyled>
<NavButton v-else :to="() => signIn()">
<LogInIcon />
<template #label>Sign in</template>
</NavButton>
</div>
<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">
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<Breadcrumbs />
</div>
<div class="view">
<div v-if="criticalErrorMessage" class="critical-error-banner" data-tauri-drag-region>
<h1>{{ criticalErrorMessage.header }}</h1>
<div class="markdown-body" v-html="renderString(criticalErrorMessage.body ?? '')"></div>
</div>
<div class="appbar-row">
<div data-tauri-drag-region class="appbar">
<section class="navigation-controls">
<Breadcrumbs data-tauri-drag-region />
</section>
<section class="mod-stats">
<section class="flex ml-auto">
<div class="flex mr-3">
<Suspense>
<RunningAppBar />
</Suspense>
</section>
</div>
<section v-if="!nativeDecorations" class="window-controls">
<Button class="titlebar-button" icon-only @click="() => getCurrentWindow().minimize()">
@ -336,18 +452,41 @@ async function checkUpdates() {
icon-only
@click="() => getCurrentWindow().toggleMaximize()"
>
<MaximizeIcon />
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</Button>
<Button class="titlebar-button close" icon-only @click="handleClose">
<XIcon />
</Button>
</section>
</section>
</div>
<div class="router-view">
<ModrinthLoadingIndicator
offset-height="var(--appbar-height)"
offset-width="var(--sidebar-width)"
/>
</div>
<div v-if="stateInitialized" class="app-contents experimental-styles-within">
<div class="app-viewport flex-grow router-view">
<div
class="loading-indicator-container h-8 fixed z-50"
:style="{
top: 'calc(var(--top-bar-height))',
left: 'calc(var(--left-bar-width))',
width: 'calc(100% - var(--left-bar-width) - var(--right-bar-width))',
}"
>
<ModrinthLoadingIndicator />
</div>
<div
v-if="themeStore.featureFlag_pagePath"
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 }}
</div>
<div
id="background-teleport-target"
class="absolute h-full -z-10 rounded-tl-[--radius-xl] overflow-hidden"
:style="{
width: 'calc(100% - var(--right-bar-width))',
}"
></div>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
@ -356,10 +495,78 @@ async function checkUpdates() {
</template>
</RouterView>
</div>
<div
class="app-sidebar mt-px shrink-0 flex flex-col border-0 border-l-[1px] border-[--brand-gradient-border] border-solid overflow-auto"
:class="{ 'has-plus': hasPlus }"
>
<div
class="app-sidebar-scrollable flex-grow shrink overflow-y-auto relative"
:class="{ 'pb-12': !hasPlus }"
>
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-lg m-0">Playing as</h3>
<suspense>
<AccountsCard ref="accounts" mode="small" />
</suspense>
</div>
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<suspense>
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense>
</div>
<div class="pt-4 flex flex-col">
<h3 class="px-4 text-lg m-0">News</h3>
<template v-for="(item, index) in news" :key="`news-${index}`">
<a
:class="`flex flex-col outline-offset-[-4px] hover:bg-[--brand-gradient-border] focus:bg-[--brand-gradient-border] px-4 transition-colors ${index === 0 ? 'pt-2 pb-4' : 'py-4'}`"
:href="item.link"
target="_blank"
rel="external"
>
<img
:src="item.thumbnail"
alt="News thumbnail"
aria-hidden="true"
class="w-full aspect-[3/1] object-cover rounded-2xl border-[1px] border-solid border-[--brand-gradient-border]"
/>
<h4 class="mt-2 mb-0 text-sm leading-none text-contrast font-semibold">
{{ item.title }}
</h4>
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
{{ dayjs(item.date).fromNow() }}
</p>
</a>
<hr
v-if="index !== news.length - 1"
class="h-px my-[-2px] mx-4 border-0 m-0 bg-[--brand-gradient-border]"
/>
</template>
</div>
</div>
</div>
<template v-if="!hasPlus">
<a
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"
target="_blank"
>
<ArrowBigUpDashIcon class="text-2xl" /> Upgrade to Modrinth+
</a>
<PromotionWrapper />
</template>
</div>
<div class="view">
<div v-if="criticalErrorMessage" class="critical-error-banner" data-tauri-drag-region>
<h1>{{ criticalErrorMessage.header }}</h1>
<div class="markdown-body" v-html="renderString(criticalErrorMessage.body ?? '')"></div>
</div>
</div>
</div>
<URLConfirmModal ref="urlModal" />
<Notifications ref="notificationsWrapper" />
<Notifications ref="notificationsWrapper" sidebar />
<ErrorModal ref="errorModal" />
<ModInstallModal ref="modInstallModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
@ -394,31 +601,61 @@ async function checkUpdates() {
justify-content: center;
cursor: pointer;
transition: all ease-in-out 0.1s;
background-color: var(--color-raised-bg);
background-color: transparent;
color: var(--color-base);
border-radius: 0;
height: 3.25rem;
height: 100%;
width: 3rem;
position: relative;
box-shadow: none;
&:last-child {
padding-right: 0.75rem;
width: 3.75rem;
}
svg {
width: 1.25rem;
height: 1.25rem;
}
&::before {
content: '';
border-radius: 999999px;
width: 3rem;
height: 3rem;
aspect-ratio: 1 / 1;
margin-block: auto;
position: absolute;
background-color: transparent;
scale: 0.9;
transition: all ease-in-out 0.2s;
z-index: -1;
}
&.close {
&:hover,
&:active {
background-color: var(--color-red);
color: var(--color-accent-contrast);
&::before {
background-color: var(--color-red);
}
}
}
&:hover,
&:active {
background-color: var(--color-button-bg);
color: var(--color-contrast);
&::before {
background-color: var(--color-button-bg);
scale: 1;
}
}
}
}
.app-container {
--appbar-height: 3.25rem;
--sidebar-width: 4.5rem;
height: 100vh;
display: flex;
flex-direction: row;
@ -546,16 +783,139 @@ async function checkUpdates() {
padding: var(--gap-sm) 0;
}
}
.app-grid-layout,
.app-contents {
--top-bar-height: 3.75rem;
--left-bar-width: 5rem;
--right-bar-width: 300px;
}
.app-grid-layout {
display: grid;
grid-template: 'status status' 'nav dummy';
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
position: relative;
//z-index: 0;
background-color: var(--color-raised-bg);
height: 100vh;
}
.app-grid-navbar {
grid-area: nav;
}
.app-grid-statusbar {
grid-area: status;
}
.app-contents {
position: absolute;
z-index: 1;
left: 5rem;
top: 3.75rem;
right: 0;
bottom: 0;
height: calc(100vh - 3.75rem);
background-color: var(--color-bg);
border-top-left-radius: var(--radius-xl);
display: grid;
grid-template-columns: 1fr 300px;
//grid-template-columns: 1fr 0px;
transition: grid-template-columns 0.4s ease-in-out;
}
.loading-indicator-container {
border-top-left-radius: var(--radius-xl);
overflow: hidden;
}
.app-sidebar {
overflow: visible;
width: 300px;
position: relative;
height: calc(100vh - 3.75rem);
background: var(--brand-gradient-bg);
--color-button-bg: var(--brand-gradient-button);
--color-button-bg-hover: var(--brand-gradient-border);
--color-divider: var(--brand-gradient-border);
--color-divider-dark: var(--brand-gradient-border);
}
.app-sidebar::after {
content: '';
position: absolute;
bottom: 250px;
left: 0;
right: 0;
height: 5rem;
background: var(--brand-gradient-fade-out-color);
pointer-events: none;
}
.app-sidebar.has-plus::after {
display: none;
}
.app-sidebar::before {
content: '';
box-shadow: -15px 0 15px -15px rgba(0, 0, 0, 0.2) inset;
top: 0;
bottom: 0;
left: -2rem;
width: 2rem;
position: absolute;
pointer-events: none;
}
.app-viewport {
flex-grow: 1;
height: 100%;
overflow: auto;
overflow-x: hidden;
}
//::-webkit-scrollbar-track {
// background-color: transparent; /* Make it transparent if needed */
// margin-block: 5px;
// margin-right: 5px;
//}
.app-contents::before {
z-index: 1;
content: '';
position: fixed;
left: 5rem;
top: 3.75rem;
right: -5rem;
bottom: -5rem;
border-radius: var(--radius-xl);
//box-shadow: 1px 1px 15px rgba(0, 0, 0, 0.2) inset;
box-shadow:
1px 1px 15px rgba(0, 0, 0, 0.2) inset,
inset 1px 1px 1px rgba(255, 255, 255, 0.23);
pointer-events: none;
}
.sidebar-teleport-content {
display: contents;
}
.sidebar-default-content {
display: none;
}
.sidebar-teleport-content:empty + .sidebar-default-content {
display: contents;
}
</style>
<style>
.mac {
.nav-container {
padding-top: calc(var(--gap-md) + 1.75rem);
}
.account-card,
.card-section {
top: calc(var(--gap-md) + 1.75rem);
.app-grid-statusbar {
padding-left: 5rem;
}
}

View File

@ -1,14 +1,9 @@
export { default as ServerIcon } from './server.svg'
export { default as MinimizeIcon } from './minimize.svg'
export { default as MaximizeIcon } from './maximize.svg'
export { default as SwapIcon } from './arrow-left-right.svg'
export { default as ToggleIcon } from './toggle.svg'
export { default as PackageIcon } from './package.svg'
export { default as VersionIcon } from './milestone.svg'
export { default as MoreIcon } from './more.svg'
export { default as TextInputIcon } from './text-cursor-input.svg'
export { default as AddProjectImage } from './add-project.svg'
export { default as NewInstanceImage } from './new-instance.svg'
export { default as MenuIcon } from './menu.svg'
export { default as BugIcon } from './bug.svg'
export { default as ChatIcon } from './messages-square.svg'

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" version="1.1" viewBox="0 0 1793 199">
<g>
<g id="Layer_1">
<g id="green" fill="var(--color-brand)">
<path d="M1184.1,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"/>
<path d="M1291.1,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"/>
<path d="M1357.2,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"/>
<path d="M1460,165.3l-40.8-95.1h23.2l35.1,83.9h-11.4l36.3-83.9h21.4l-40.8,95.1h-23Z"/>
<path d="M1579.6,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"/>
<path d="M1645.7,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"/>
<path d="M1749.9,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"/>
<g>
<path d="M9.8,143l63.4-38.1-5.8-15.3,18.1-18.6,22.9-4.9,6.6,8.2-10.6,10.7-9.2,2.9-6.6,6.8,3.2,9,6.5,6.9,9.2-2.5,6.6-7.2,14.3-4.5,4.3,9.6-14.8,18.1-24.8,7.8-11.1-12.4-63.6,38.2c-3-3.9-6.5-9.4-8.8-14.7ZM192.8,65.4l-50.4,13.6c2.8,7.4,3.7,11.7,4.5,16.5l50.3-13.6c-.8-5.4-2.2-10.8-4.4-16.5Z" fill-rule="evenodd"/>
<path d="M17.3,106.5c3.6,42.1,38.9,75.2,82,75.2s60.7-18.9,74-46.3l16.4,5.7c-15.8,34.1-50.3,57.9-90.4,57.9S3.6,158.2,0,106.5h17.3ZM.3,89.4C5.3,39.2,47.8,0,99.3,0s99.5,44.6,99.5,99.5-1.1,17.4-3.3,25.5l-16.3-5.7c1.6-6.5,2.4-13.1,2.4-19.8,0-45.4-36.9-82.3-82.3-82.3S22.6,48.7,17.6,89.4H.3Z" fill-rule="evenodd"/>
<path d="M99,51.6c-26.4,0-47.9,21.5-47.9,48s21.5,48,48,48,2.7,0,4-.2l4.8,16.8c-2.9.4-5.8.6-8.8.6-36,0-65.2-29.2-65.2-65.2S63.1,34.4,99,34.4s1.8,0,2.7,0l-2.7,17.1ZM118.6,37.4c26.4,8.3,45.6,33,45.6,62.2s-16.4,50.2-39.8,60l-4.8-16.7c16.2-7.7,27.4-24.2,27.4-43.3s-13-38.1-31.1-44.9l2.7-17.2Z" fill-rule="evenodd"/>
</g>
</g>
<g id="black" fill="currentColor">
<path d="M354.8,69.2c12,0,21.7,3.4,28.6,10.4,7,7.2,10.6,17.5,10.6,31.5v54.8h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,5-6.8,12.2-6.8,21.3v48.5h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,4.8-6.8,12-6.8,21.3v48.5h-22.4v-95.6h21.3v12.2c3.6-4.3,8.1-7.5,13.4-9.8,5.4-2.3,11.3-3.4,17.9-3.4s13.6,1.3,19.2,3.9c5.5,2.9,9.8,6.8,13.1,12,3.9-5,8.9-8.9,15.2-11.8,6.3-2.7,13.1-4.1,20.6-4.1ZM466,167.2c-9.7,0-18.4-2.1-26.1-6.3-7.6-4-13.8-10.1-18.1-17.5-4.5-7.3-6.6-15.7-6.6-25.2s2.1-17.9,6.6-25.2c4.3-7.4,10.6-13.4,18.1-17.4,7.7-4.1,16.5-6.3,26.1-6.3s18.6,2.1,26.3,6.3c7.7,4.1,13.8,10,18.3,17.4,4.3,7.3,6.4,15.7,6.4,25.2s-2.1,17.9-6.4,25.2c-4.5,7.5-10.6,13.4-18.3,17.5-7.7,4.1-16.5,6.3-26.3,6.3h0ZM466,148c8.2,0,15-2.7,20.4-8.2,5.4-5.5,8.1-12.7,8.1-21.7s-2.7-16.1-8.1-21.7c-5.4-5.5-12.2-8.2-20.4-8.2s-15,2.7-20.2,8.2c-5.4,5.5-8.1,12.7-8.1,21.7s2.7,16.1,8.1,21.7c5.2,5.5,12,8.2,20.2,8.2ZM631.5,33.1v132.8h-21.5v-12.3c-3.7,4.4-8.3,7.9-13.6,10.2-5.5,2.3-11.5,3.4-18.1,3.4s-17.4-2-24.7-6.1c-7.3-4.1-13.2-9.8-17.4-17.4-4.1-7.3-6.3-15.9-6.3-25.6s2.1-18.3,6.3-25.6c4.1-7.3,10-13.1,17.4-17.2,7.3-4.1,15.6-6.1,24.7-6.1s12.2,1.1,17.4,3.2c5.2,2.1,9.8,5.4,13.4,9.7v-49h22.4ZM581.1,148c5.4,0,10.2-1.3,14.5-3.8,4.3-2.3,7.7-5.9,10.2-10.4,2.5-4.5,3.8-9.8,3.8-15.7s-1.3-11.3-3.8-15.7c-2.5-4.5-5.9-8.1-10.2-10.6-4.3-2.3-9.1-3.6-14.5-3.6s-10.2,1.3-14.5,3.6c-4.3,2.5-7.7,6.1-10.2,10.6-2.5,4.5-3.8,9.8-3.8,15.7s1.3,11.3,3.8,15.7c2.5,4.5,5.9,8.1,10.2,10.4,4.3,2.5,9.1,3.8,14.5,3.8ZM681.6,84.3c6.4-10,17.7-15,34-15v21.3c-1.7-.3-3.4-.5-5.2-.5-8.8,0-15.6,2.5-20.4,7.5-4.8,5.2-7.3,12.5-7.3,22v46.4h-22.4v-95.6h21.3v14h0ZM734.1,70.3h22.4v95.6h-22.4v-95.6ZM745.4,54.6c-4.1,0-7.5-1.3-10.2-3.9-2.7-2.4-4.2-5.9-4.1-9.5,0-3.8,1.4-7,4.1-9.7,2.7-2.5,6.1-3.8,10.2-3.8s7.5,1.3,10.2,3.6c2.7,2.5,4.1,5.5,4.1,9.3s-1.3,7.2-3.9,9.8c-2.7,2.7-6.3,4.1-10.4,4.1ZM839.5,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4v-95.6h21.3v12.3c3.8-4.5,8.4-7.7,14-10,5.5-2.3,12-3.4,19-3.4ZM964.8,160.7c-2.8,2.2-6,3.9-9.5,4.8-3.9,1.1-7.9,1.6-12,1.6-10.6,0-18.6-2.7-24.3-8.2-5.7-5.5-8.6-13.4-8.6-24v-46h-15.7v-17.9h15.7v-21.8h22.4v21.8h25.6v17.9h-25.6v45.5c0,4.7,1.1,8.2,3.4,10.6,2.3,2.5,5.5,3.8,9.8,3.8s9.1-1.3,12.5-3.9l6.3,15.9ZM1036.9,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4V33.1h22.4v48.3c3.8-3.9,8.2-7,13.8-9.1,5.4-2,11.5-3,18.1-3Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View File

@ -80,19 +80,25 @@ input {
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-scrollbar);
width: 16px;
border: 3px solid transparent;
opacity: 0.5;
transition: opacity 0.2s ease-in-out;
}
*::-webkit-scrollbar:hover {
opacity: 1;
}
*::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
border-radius: var(--radius-lg);
border: 3px solid var(--color-bg);
border: 5px solid transparent;
background-clip: content-box;
}
.highlighted {

View File

@ -12,7 +12,7 @@ import {
SearchIcon,
XIcon,
} from '@modrinth/assets'
import { Button, Card, DropdownSelect } from '@modrinth/ui'
import { Button, DropdownSelect } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs'
@ -121,11 +121,10 @@ const handleOptionsClick = async (args) => {
const search = ref('')
const group = ref('Category')
const filters = ref('All profiles')
const sortBy = ref('Name')
const filteredResults = computed(() => {
let instances = props.instances.filter((instance) => {
const instances = props.instances.filter((instance) => {
return instance.name.toLowerCase().includes(search.value.toLowerCase())
})
@ -159,16 +158,6 @@ const filteredResults = computed(() => {
})
}
if (filters.value === 'Custom instances') {
instances = instances.filter((instance) => {
return !instance.linked_data
})
} else if (filters.value === 'Downloaded modpacks') {
instances = instances.filter((instance) => {
return instance.linked_data
})
}
const instanceMap = new Map()
if (group.value === 'Loader') {
@ -229,53 +218,37 @@ const filteredResults = computed(() => {
})
</script>
<template>
<ConfirmModalWrapper
ref="confirmModal"
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="deleteProfile"
/>
<Card class="header">
<div class="iconified-input">
<SearchIcon />
<input v-model="search" type="text" placeholder="Search" class="search-input" />
<input v-model="search" type="text" class="h-12" placeholder="Search" />
<Button class="r-btn" @click="() => (search = '')">
<XIcon />
</Button>
</div>
<div class="labeled_button">
<span>Sort by</span>
<div class="flex gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="sortBy"
class="sort-dropdown"
name="Sort Dropdown"
class="max-w-[16rem]"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
/>
</div>
<div class="labeled_button">
<span>Filter by</span>
<DropdownSelect
v-model="filters"
class="filter-dropdown"
name="Filter Dropdown"
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
placeholder="Select..."
/>
</div>
<div class="labeled_button">
<span>Group by</span>
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="group"
class="group-dropdown"
class="max-w-[16rem]"
name="Group Dropdown"
:options="['Category', 'Loader', 'Game version', 'None']"
placeholder="Select..."
/>
>
<span class="font-semibold text-primary">Group by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
</div>
</Card>
<div
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key,
@ -298,6 +271,14 @@ const filteredResults = computed(() => {
/>
</section>
</div>
<ConfirmModalWrapper
ref="confirmModal"
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="deleteProfile"
/>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
@ -315,7 +296,6 @@ const filteredResults = computed(() => {
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 1rem;
.divider {
display: flex;

View File

@ -0,0 +1,141 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useLoading } from '@/store/state.js'
const props = defineProps({
throttle: {
type: Number,
default: 0,
},
duration: {
type: Number,
default: 1000,
},
height: {
type: Number,
default: 2,
},
color: {
type: String,
default: 'var(--loading-bar-gradient)',
},
})
const indicator = useLoadingIndicator({
duration: props.duration,
throttle: props.throttle,
})
onBeforeUnmount(() => indicator.clear)
const loading = useLoading()
watch(loading, (newValue) => {
if (newValue.barEnabled) {
if (newValue.loading) {
indicator.start()
} else {
indicator.finish()
}
}
})
function useLoadingIndicator(opts) {
const progress = ref(0)
const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration)
let _timer = null
let _throttle = null
function start() {
clear()
progress.value = 0
if (opts.throttle) {
_throttle = setTimeout(() => {
isLoading.value = true
_startTimer()
}, opts.throttle)
} else {
isLoading.value = true
_startTimer()
}
}
function finish() {
progress.value = 100
_hide()
}
function clear() {
clearInterval(_timer)
clearTimeout(_throttle)
_timer = null
_throttle = null
}
function _increase(num) {
progress.value = Math.min(100, progress.value + num)
}
function _hide() {
clear()
setTimeout(() => {
isLoading.value = false
setTimeout(() => {
progress.value = 0
}, 400)
}, 500)
}
function _startTimer() {
_timer = setInterval(() => {
_increase(step.value)
}, 100)
}
return { progress, isLoading, start, finish, clear }
}
</script>
<template>
<div
class="loading-indicator-bar"
:style="{
'--_width': `${indicator.progress.value}%`,
'--_height': `${indicator.isLoading.value ? props.height : 0}px`,
'--_opacity': `${indicator.isLoading.value ? 1 : 0}`,
top: `0`,
right: `0`,
left: `${props.offsetWidth}`,
pointerEvents: 'none',
width: `var(--_width)`,
height: `var(--_height)`,
borderRadius: `var(--_height)`,
// opacity: `var(--_opacity)`,
background: `${props.color}`,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s ease-in-out, height 0.1s ease-out',
zIndex: 6,
}"
>
<slot />
</div>
</template>
<style lang="scss" scoped>
.loading-indicator-bar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
width: var(--_width);
bottom: 0;
background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%);
opacity: calc(var(--_opacity) * 0.1);
z-index: 5;
transition:
width 0.1s ease-in-out,
opacity 0.1s ease-out;
}
</style>

View File

@ -260,7 +260,6 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
width: 100%;
padding: 1rem;
gap: 1rem;
-ms-overflow-style: none;
@ -294,16 +293,16 @@ onUnmounted(() => {
a {
margin: 0;
font-size: var(--font-size-lg);
font-size: var(--font-size-md);
font-weight: bolder;
white-space: nowrap;
color: var(--color-contrast);
color: var(--color-base);
}
svg {
height: 1.5rem;
width: 1.5rem;
color: var(--color-contrast);
height: 1.25rem;
width: 1.25rem;
color: var(--color-base);
}
}

View File

@ -1,136 +0,0 @@
import { computed, defineComponent, h, onBeforeUnmount, ref, watch } from 'vue'
import { useLoading } from '@/store/state.js'
export default defineComponent({
props: {
throttle: {
type: Number,
default: 50,
},
duration: {
type: Number,
default: 500,
},
height: {
type: Number,
default: 3,
},
color: {
type: [String, Boolean],
default:
'repeating-linear-gradient(to right, var(--color-brand) 0%, var(--color-brand) 100%)',
},
offsetWidth: {
type: String,
default: '208px',
},
offsetHeight: {
type: String,
default: '52px',
},
},
setup(props, { slots }) {
const indicator = useLoadingIndicator({
duration: props.duration,
throttle: props.throttle,
})
onBeforeUnmount(() => indicator.clear)
const loading = useLoading()
watch(loading, (newValue) => {
if (newValue.barEnabled) {
if (newValue.loading) {
indicator.start()
} else {
indicator.finish()
}
}
})
return () =>
h(
'div',
{
style: {
position: 'fixed',
top: props.offsetHeight,
right: 0,
left: props.offsetWidth,
pointerEvents: 'none',
width: `calc((100vw - ${props.offsetWidth}) * ${indicator.progress.value / 100})`,
height: `${props.height}px`,
opacity: indicator.isLoading.value ? 1 : 0,
background: props.color || undefined,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s, height 0.4s, opacity 0.4s',
zIndex: 6,
},
},
slots,
)
},
})
function useLoadingIndicator(opts) {
const progress = ref(0)
const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration)
let _timer = null
let _throttle = null
function start() {
clear()
progress.value = 0
if (opts.throttle) {
_throttle = setTimeout(() => {
isLoading.value = true
_startTimer()
}, opts.throttle)
} else {
isLoading.value = true
_startTimer()
}
}
function finish() {
progress.value = 100
_hide()
}
function clear() {
clearInterval(_timer)
clearTimeout(_throttle)
_timer = null
_throttle = null
}
function _increase(num) {
progress.value = Math.min(100, progress.value + num)
}
function _hide() {
clear()
setTimeout(() => {
isLoading.value = false
setTimeout(() => {
progress.value = 0
}, 400)
}, 500)
}
function _startTimer() {
_timer = setInterval(() => {
_increase(step.value)
}, 100)
}
return {
progress,
isLoading,
start,
finish,
clear,
}
}

View File

@ -2,19 +2,23 @@
<div
v-if="mode !== 'isolated'"
ref="button"
v-tooltip.right="'Minecraft accounts'"
class="button-base avatar-button"
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
:class="{ expanded: mode === 'expanded' }"
@click="toggleMenu"
>
<Avatar
:size="mode === 'expanded' ? 'xs' : 'sm'"
size="36px"
:src="
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div class="flex flex-col w-full">
<span>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
<span class="text-secondary text-xs">Minecraft account</span>
</div>
<DropdownIcon class="w-5 h-5 shrink-0" />
</div>
<transition name="fade">
<Card
@ -59,7 +63,7 @@
</template>
<script setup>
import { PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {
@ -73,7 +77,6 @@ import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
defineProps({
mode: {
@ -151,13 +154,8 @@ const handleClickOutside = (event) => {
function toggleMenu(override = true) {
if (showCard.value || !override) {
if (showCard.value) {
show_ads_window()
}
showCard.value = false
} else {
hide_ads_window()
showCard.value = true
}
}
@ -209,11 +207,11 @@ onUnmounted(() => {
}
.account-card {
position: absolute;
position: fixed;
display: flex;
flex-direction: column;
top: 0.5rem;
left: 5.5rem;
margin-top: 0.5rem;
right: 2rem;
z-index: 11;
gap: 0.5rem;
padding: 1rem;
@ -288,12 +286,17 @@ onUnmounted(() => {
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
transition:
opacity 0.25s ease,
translate 0.25s ease,
scale 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
translate: 0 -2rem;
scale: 0.9;
}
.avatar-button {
@ -301,9 +304,10 @@ onUnmounted(() => {
align-items: center;
gap: 0.5rem;
color: var(--color-base);
background-color: var(--color-raised-bg);
background-color: var(--color-button-bg);
border-radius: var(--radius-md);
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
&.expanded {

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { DropdownIcon, FolderOpenIcon, SearchIcon } from '@modrinth/assets'
import { Button, OverflowMenu } from '@modrinth/ui'
import { DropdownIcon, PlusIcon, FolderOpenIcon } from '@modrinth/assets'
import { ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { add_project_from_path } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
@ -26,7 +26,7 @@ const handleAddContentFromFile = async () => {
const handleSearchContent = async () => {
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.path },
})
}
@ -34,30 +34,27 @@ const handleSearchContent = async () => {
<template>
<div class="joined-buttons">
<Button color="primary" @click="handleSearchContent"><SearchIcon /> Add content </Button>
<ButtonStyled>
<button @click="handleSearchContent">
<PlusIcon />
Install content
</button>
</ButtonStyled>
<ButtonStyled>
<OverflowMenu
:options="[
{
id: 'search',
action: handleSearchContent,
},
{
id: 'from_file',
action: handleAddContentFromFile,
},
]"
class="btn btn-primary btn-dropdown-animation icon-only"
>
<DropdownIcon />
<template #search>
<SearchIcon />
<span class="no-wrap"> Search </span>
</template>
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>

View File

@ -1,13 +1,18 @@
<template>
<div class="breadcrumbs">
<Button class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<div data-tauri-drag-region class="flex items-center gap-1 pl-3">
<Button v-if="false" class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<ChevronLeftIcon />
</Button>
<Button class="breadcrumbs__forward transparent" icon-only @click="$router.forward()">
<Button
v-if="false"
class="breadcrumbs__forward transparent"
icon-only
@click="$router.forward()"
>
<ChevronRightIcon />
</Button>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<div v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name" class="breadcrumbs__item">
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
@ -20,13 +25,18 @@
: breadcrumb.name
}}
</router-link>
<span v-else class="selected">{{
<span
v-else
data-tauri-drag-region
class="text-contrast font-semibold cursor-default select-none"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}</span>
<ChevronRightIcon v-if="breadcrumb.link" class="chevron" />
</div>
}}</span
>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5" />
</template>
</div>
</template>
@ -50,38 +60,3 @@ const breadcrumbs = computed(() => {
return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb
})
</script>
<style scoped lang="scss">
.breadcrumbs {
display: flex;
flex-direction: row;
.breadcrumbs__item {
display: flex;
flex-direction: row;
vertical-align: center;
margin: auto 0;
.chevron,
a {
margin: auto 0;
}
}
.breadcrumbs__back,
.breadcrumbs__forward {
margin: auto 0;
color: var(--color-base);
height: unset;
width: unset;
}
.breadcrumbs__forward {
margin-right: 1rem;
}
}
.selected {
color: var(--color-contrast);
}
</style>

View File

@ -25,7 +25,6 @@
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
const emit = defineEmits(['menu-closed', 'option-clicked'])
@ -38,7 +37,6 @@ const shown = ref(false)
defineExpose({
showMenu: (event, passedItem, passedOptions) => {
hide_ads_window()
item.value = passedItem
options.value = passedOptions
@ -71,9 +69,6 @@ const isLinkedData = (item) => {
}
const hideContextMenu = () => {
if (shown.value) {
show_ads_window()
}
shown.value = false
emit('menu-closed')
}

View File

@ -323,7 +323,6 @@ async function repairInstance() {
display: flex;
flex-direction: column;
gap: var(--gap-md);
padding: var(--gap-lg);
}
.markdown-body {

View File

@ -211,7 +211,6 @@ const exportPack = async () => {
<style scoped lang="scss">
.modal-body {
padding: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-md);
@ -286,6 +285,7 @@ const exportPack = async () => {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.textarea-wrapper {

View File

@ -28,7 +28,7 @@ const modLoading = computed(() => props.instance.install_stage !== 'installed')
const router = useRouter()
const seeInstance = async () => {
await router.push(`/instance/${encodeURIComponent(props.instance.path)}/`)
await router.push(`/instance/${encodeURIComponent(props.instance.path)}`)
}
const checkProcess = async () => {

View File

@ -525,8 +525,8 @@ const next = async () => {
.modal-body {
display: flex;
flex-direction: column;
padding: var(--gap-lg);
gap: var(--gap-md);
margin-top: var(--gap-lg);
}
.input-label {
@ -595,7 +595,6 @@ const next = async () => {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: var(--gap-lg);
padding-bottom: 0;
}

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core'
import { formatCategory } from '@modrinth/utils'
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled } from '@modrinth/ui'
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
}
defineProps<{
instance: Instance
}>()
</script>
<template>
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link
:to="`/instance/${encodeURIComponent(instance.path)}`"
tabindex="-1"
class="flex flex-col gap-4 text-primary"
>
<span class="flex items-center gap-2">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
:alt="instance.name"
size="48px"
/>
<span class="flex flex-col gap-2">
<span class="font-extrabold bold text-contrast">
{{ instance.name }}
</span>
<span class="text-secondary flex items-center gap-2 font-semibold">
<GameIcon class="h-5 w-5 text-secondary" />
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
</span>
</span>
</span>
</router-link>
<ButtonStyled>
<router-link :to="`/instance/${encodeURIComponent(instance.path)}`">
<LeftArrowIcon /> Back to instance
</router-link>
</ButtonStyled>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -73,8 +73,6 @@ function setJavaInstall(javaInstall) {
</script>
<style lang="scss" scoped>
.auto-detect-modal {
padding: 1rem;
.table {
.table-row {
grid-template-columns: 1fr 4fr min-content;

View File

@ -28,7 +28,7 @@
</Button>
<Button :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Auto detect
Detect
</Button>
<Button :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
@ -187,6 +187,7 @@ async function reinstallJava() {
.toggle-setting {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: space-between;
align-items: center;

View File

@ -173,7 +173,6 @@ const switchVersion = async (versionId) => {
}
.modal-body {
padding: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-md);

View File

@ -0,0 +1,109 @@
<template>
<div class="tooltip-parent flex items-center justify-center">
<RouterLink
v-if="typeof to === 'string'"
:to="to"
v-bind="$attrs"
:class="{
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),
}"
class="w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
>
<slot />
</RouterLink>
<button
v-else
v-bind="$attrs"
class="button-animation border-none text-primary cursor-pointer w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
@click="to"
>
<slot />
</button>
<div class="tooltip-label">
<slot name="label" />
</div>
</div>
</template>
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
type RouteFunction = (route: RouteLocationNormalizedLoaded) => boolean
defineProps<{
to: (() => void) | string
isPrimary?: RouteFunction
isSubpage?: RouteFunction
}>()
defineOptions({
inheritAttrs: false,
})
</script>
<style lang="scss" scoped>
.router-link-active,
.subpage-active {
svg {
filter: drop-shadow(0 0 0.5rem black);
}
}
.router-link-active {
@apply text-[--color-button-text-selected] bg-[--color-button-bg-selected];
}
.subpage-active {
@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>

View File

@ -0,0 +1,164 @@
<template>
<nav
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<RouterLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
:class="`button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full ${activeIndex === index && !subpageSelected ? 'text-button-textSelected' : activeIndex === index && subpageSelected ? 'text-contrast' : 'text-primary'}`"
>
<component :is="link.icon" v-if="link.icon" class="size-5" />
<span class="text-nowrap">{{ link.label }}</span>
</RouterLink>
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'}`"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useRoute, RouterLink } from 'vue-router'
const route = useRoute()
interface Tab {
label: string
href: string | RouteLocationRaw
shown?: boolean
icon?: unknown
subpages?: string[]
}
const props = defineProps<{
links: Tab[]
query?: string
}>()
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const activeIndex = ref(-1)
const oldIndex = ref(-1)
const subpageSelected = ref(false)
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
)
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
function pickLink() {
let index = -1
subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) {
index = i
break
} else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) {
index = i
subpageSelected.value = true
break
}
}
activeIndex.value = index
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderLeft.value = 0
sliderRight.value = 0
}
}
const tabLinkElements = ref()
function startAnimation() {
const el = tabLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
} else {
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
}
}
}
onMounted(() => {
window.addEventListener('resize', pickLink)
pickLink()
})
onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(route, () => {
pickLink()
})
</script>
<style scoped>
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
.card-shadow {
box-shadow: var(--shadow-card);
}
</style>

View File

@ -1,75 +1,23 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { get as getCreds } from '@/helpers/mr_auth.js'
import { handleError } from '@/store/notifications.js'
import { get_user } from '@/helpers/cache.js'
import { ref, onMounted } from 'vue'
import { ChevronRightIcon } from '@modrinth/assets'
import { init_ads_window, open_ads_link, record_ads_click } from '@/helpers/ads.js'
import { listen } from '@tauri-apps/api/event'
const showAd = ref(true)
defineExpose({
scroll() {
updateAdPosition()
},
})
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
const user = await get_user(creds.user_id).catch(handleError)
const MIDAS_BITFLAG = 1 << 0
if (user && (user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG) {
showAd.value = false
}
}
const adsWrapper = ref(null)
let resizeObserver
let scrollHandler
let intersectionObserver
let mutationObserver
onMounted(() => {
if (showAd.value) {
updateAdPosition(true)
updateAdPosition()
resizeObserver = new ResizeObserver(() => updateAdPosition())
resizeObserver.observe(adsWrapper.value)
intersectionObserver = new IntersectionObserver(() => updateAdPosition())
intersectionObserver.observe(adsWrapper.value)
mutationObserver = new MutationObserver(() => updateAdPosition())
mutationObserver.observe(adsWrapper.value, { attributes: true, childList: true, subtree: true })
// Add scroll event listener
scrollHandler = () => {
requestAnimationFrame(() => updateAdPosition())
}
window.addEventListener('scroll', scrollHandler, { passive: true })
}
window.addEventListener('resize', updateAdPosition)
})
function updateAdPosition(overrideShown = false) {
function updateAdPosition() {
if (adsWrapper.value) {
const rect = adsWrapper.value.getBoundingClientRect()
let y = rect.top + window.scrollY
let height = rect.bottom - rect.top
const x = rect.left + window.scrollX
const y = rect.top + window.scrollY
// Prevent ad from overlaying the app bar
if (y <= 52) {
y = 52
height = rect.bottom - 52
if (height < 0) {
height = 0
y = -1000
}
}
init_ads_window(rect.left + window.scrollX, y, rect.right - rect.left, height, overrideShown)
init_ads_window(x, y, 300, 250, true)
}
}
@ -77,38 +25,10 @@ async function openPlusLink() {
await record_ads_click()
await open_ads_link('https://modrinth.com/plus', 'https://modrinth.com')
}
const unlisten = await listen('ads-scroll', (event) => {
if (adsWrapper.value) {
adsWrapper.value.parentNode.scrollTop += event.payload.scroll
updateAdPosition()
}
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
if (intersectionObserver) {
intersectionObserver.disconnect()
}
if (mutationObserver) {
mutationObserver.disconnect()
}
if (scrollHandler) {
window.removeEventListener('scroll', scrollHandler)
}
unlisten()
})
</script>
<template>
<div
v-if="showAd"
ref="adsWrapper"
class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised cursor-pointer"
>
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
<button

View File

@ -1,74 +1,110 @@
<template>
<Card
class="card button-base"
<div
class="button-base p-4 bg-bg-raised rounded-xl flex gap-3 group"
@click="
() => {
emits('open')
emit('open')
$router.push({
path: `/project/${project.project_id ?? project.id}/`,
path: `/project/${project.project_id ?? project.id}`,
query: { i: props.instance ? props.instance.path : undefined },
})
}
"
>
<div class="icon">
<Avatar :src="project.icon_url" size="md" class="search-icon" />
<Avatar :src="project.icon_url" size="96px" class="search-icon" />
</div>
<div class="content-wrapper">
<div class="title joined-text">
<h2>{{ project.title }}</h2>
<span v-if="project.author">by {{ project.author }}</span>
<div class="flex flex-col gap-2 overflow-hidden">
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
<span class="text-lg font-extrabold text-contrast m-0 leading-none">{{
project.title
}}</span>
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
</div>
<div class="description">
<div class="m-0 line-clamp-2">
{{ project.description }}
</div>
<div class="tags">
<Categories :categories="categories" :type="project.project_type">
<EnvironmentIndicator
:type-only="project.moderation"
:client-side="project.client_side"
:server-side="project.server_side"
:type="project.project_type"
:search="true"
/>
</Categories>
<div class="mt-auto flex items-center gap-1 no-wrap">
<TagsIcon class="h-4 w-4 shrink-0" />
<div
v-for="tag in categories"
:key="tag"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
>
{{ formatCategory(tag.name) }}
</div>
</div>
<div class="stats button-group">
<div v-if="featured" class="badge">
<StarIcon />
Featured
</div>
<div class="badge">
<DownloadIcon />
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
<div class="flex items-center gap-2">
<DownloadIcon class="shrink-0" />
<span>
{{ formatNumber(project.downloads) }}
<span class="text-secondary">downloads</span>
</span>
</div>
<div class="badge">
<HeartIcon />
<div class="flex items-center gap-2">
<HeartIcon class="shrink-0" />
<span>
{{ formatNumber(project.follows ?? project.followers) }}
<span class="text-secondary">followers</span>
</span>
</div>
<div class="badge">
<CalendarIcon />
{{ formatCategory(dayjs(project.date_modified ?? project.updated).fromNow()) }}
<div class="mt-auto relative">
<div
class="flex items-center gap-2 group-hover:-translate-y-3 group-hover:opacity-0 group-focus-within:opacity-0 group-hover:scale-95 group-focus-within:scale-95 transition-all"
>
<HistoryIcon class="shrink-0" />
<span>
<span class="text-secondary">Updated</span>
{{ dayjs(project.date_modified ?? project.updated).fromNow() }}
</span>
</div>
</div>
<div v-if="project.author" class="install">
<Button color="primary" :disabled="installed || installing" @click.stop="install()">
<DownloadIcon v-if="!installed" />
<div
class="opacity-0 scale-95 translate-y-3 group-hover:translate-y-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:opacity-100 group-focus-within:scale-100 absolute bottom-0 right-0 transition-all w-fit"
>
<ButtonStyled color="brand">
<button
:disabled="installed || installing"
class="shrink-0 no-wrap"
@click.stop="install()"
>
<template v-if="!installed">
<DownloadIcon v-if="modpack || instance" />
<PlusIcon v-else />
</template>
<CheckIcon v-else />
{{ installing ? 'Installing' : installed ? 'Installed' : 'Install' }}
</Button>
{{
installing
? 'Installing'
: installed
? 'Installed'
: modpack || instance
? 'Install'
: 'Add to an instance'
}}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</Card>
</template>
<script setup>
import { DownloadIcon, HeartIcon, CalendarIcon, CheckIcon, StarIcon } from '@modrinth/assets'
import { Avatar, Card, Categories, EnvironmentIndicator, Button } from '@modrinth/ui'
import {
TagsIcon,
DownloadIcon,
HeartIcon,
PlusIcon,
CheckIcon,
HistoryIcon,
} from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui'
import { formatNumber, formatCategory } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { install as installVersion } from '@/store/install.js'
dayjs.extend(relativeTime)
@ -99,10 +135,9 @@ const props = defineProps({
},
})
const emits = defineEmits(['open'])
const emit = defineEmits(['open', 'install'])
const installing = ref(false)
const installed = ref(props.installed)
async function install() {
installing.value = true
@ -111,87 +146,12 @@ async function install() {
null,
props.instance ? props.instance.path : null,
'SearchCard',
(version) => {
() => {
installing.value = false
if (props.instance && version) {
installed.value = true
}
emit('install', props.project.project_id)
},
)
}
const modpack = computed(() => props.project.project_type === 'modpack')
</script>
<style scoped lang="scss">
.icon {
grid-column: 1;
grid-row: 1;
align-self: center;
height: 6rem;
}
.content-wrapper {
display: flex;
justify-content: space-between;
grid-column: 2 / 4;
flex-direction: column;
grid-row: 1;
gap: 0.5rem;
.description {
word-wrap: break-word;
overflow-wrap: anywhere;
}
}
.stats {
grid-column: 1 / 3;
grid-row: 2;
justify-self: stretch;
align-self: start;
}
.install {
grid-column: 3 / 4;
grid-row: 2;
justify-self: end;
align-self: start;
}
.card {
margin-bottom: 0;
display: grid;
grid-template-columns: 6rem auto 7rem;
gap: 0.75rem;
padding: 1rem;
&:active:not(&:disabled) {
scale: 0.98 !important;
}
}
.joined-text {
display: inline-flex;
flex-wrap: wrap;
flex-direction: row;
column-gap: 0.5rem;
align-items: baseline;
overflow: hidden;
text-overflow: ellipsis;
h2 {
margin-bottom: 0 !important;
word-wrap: break-word;
overflow-wrap: anywhere;
}
}
.button-group {
display: inline-flex;
flex-direction: row;
gap: 0.5rem;
align-items: flex-start;
flex-wrap: wrap;
justify-content: flex-start;
}
</style>

View File

@ -72,7 +72,7 @@
</g>
</g>
</svg>
<ProgressBar class="loading-bar" :progress="loadingProgress" />
<ProgressBar class="loading-bar" :progress="Math.min(loadingProgress, 100)" />
<span v-if="message">{{ message }}</span>
</div>
<div class="gradient-bg" data-tauri-drag-region></div>
@ -86,8 +86,7 @@ import { ref, watch } from 'vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { loading_listener } from '@/helpers/events.js'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { XIcon } from '@modrinth/assets'
import { MaximizeIcon, MinimizeIcon } from '@/assets/icons/index.js'
import { XIcon, MaximizeIcon, MinimizeIcon } from '@modrinth/assets'
import { getOS } from '@/helpers/utils.js'
import { useLoading } from '@/store/loading.js'

View File

@ -71,7 +71,6 @@ async function install() {
align-items: center;
justify-content: center;
gap: var(--gap-md);
padding: var(--gap-lg);
}
.button-row {

View File

@ -0,0 +1,359 @@
<script setup lang="ts">
import { Avatar, ButtonStyled, NewModal, OverflowMenu } from '@modrinth/ui'
import {
UserPlusIcon,
MoreVerticalIcon,
MailIcon,
SettingsIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import { ref, onUnmounted, watch, computed } from 'vue'
import { friend_listener } from '@/helpers/events'
import { friends, friend_statuses, add_friend, remove_friend } from '@/helpers/friends'
import { get_user_many } from '@/helpers/cache'
import { handleError } from '@/store/notifications.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
const props = defineProps<{
credentials: unknown | null
signIn: () => void2
}>()
const userCredentials = computed(() => props.credentials)
const search = ref('')
const manageFriendsModal = ref()
const friendInvitesModal = ref()
const username = ref('')
const addFriendModal = ref()
async function addFriendFromModal() {
addFriendModal.value.hide()
await add_friend(username.value).catch(handleError)
username.value = ''
await loadFriends()
}
const friendOptions = ref()
async function handleFriendOptions(args) {
switch (args.option) {
case 'remove-friend':
await removeFriend(args.item)
break
}
}
async function addFriend(friend: Friend) {
await add_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError)
await loadFriends()
}
async function removeFriend(friend: Friend) {
await remove_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError)
await loadFriends()
}
type Friend = {
id: string
friend_id: string | null
status: string | null
last_updated: Dayjs | null
created: Dayjs
username: string
accepted: boolean
online: boolean
avatar: string
}
const userFriends = ref<Friend[]>([])
const acceptedFriends = computed(() =>
userFriends.value
.filter((x) => x.accepted)
.toSorted((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
return 0 // Both are null, equal in sorting
}
if (a.last_updated === null) {
return 1 // `a` is null, move it after `b`
}
if (b.last_updated === null) {
return -1 // `b` is null, move it after `a`
}
// Both are non-null, sort by date
return b.last_updated.diff(a.last_updated)
}),
)
const pendingFriends = computed(() =>
userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)),
)
const loading = ref(true)
async function loadFriends(timeout = false) {
loading.value = timeout
try {
const friendsList = await friends()
if (friendsList.length === 0) {
userFriends.value = []
} else {
const friendStatuses = await friend_statuses()
const users = await get_user_many(
friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)),
)
userFriends.value = friendsList.map((friend) => {
const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id)
const status = friendStatuses.find(
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
)
return {
id: friend.id,
friend_id: friend.friend_id,
status: status?.profile_name,
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
created: dayjs(friend.created),
avatar: user?.avatar_url,
username: user?.username,
online: !!status,
accepted: friend.accepted,
}
})
}
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
if (timeout) {
setTimeout(() => loadFriends(), 15 * 1000)
}
}
}
watch(
userCredentials,
() => {
console.log('watch', userCredentials.value)
if (userCredentials.value === undefined) {
userFriends.value = []
} else if (userCredentials.value === null) {
userFriends.value = []
loading.value = false
} else {
loadFriends(true)
}
},
{ immediate: true },
)
const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
unlisten()
})
// TODO: Remove friends menu
</script>
<template>
<NewModal ref="manageFriendsModal" header="Manage friends">
<p v-if="acceptedFriends.length === 0">You have no friends :C</p>
<div v-else class="flex flex-col gap-4">
<input type="text" placeholder="Search friends..." class="w-full" />
<div
v-for="friend in acceptedFriends.filter((x) =>
x.username.toLowerCase().includes(search.value),
)"
:key="friend.username"
class="flex gap-2 items-center min-w-[20rem]"
>
<div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span
v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div>{{ friend.username }}</div>
<div class="ml-auto">
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Remove
</button>
</ButtonStyled>
</div>
</div>
</div>
</NewModal>
<NewModal ref="friendInvitesModal" header="View friend requests">
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4">
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<div class="flex flex-col gap-2">
<div>
<p class="m-0">
<template v-if="friend.id === userCredentials.user_id">
<span class="font-bold">{{ friend.username }}</span> sent you a friend request
</template>
<template v-else>
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
</template>
</p>
<p class="m-0 text-sm text-secondary">{{ friend.created.fromNow() }}</p>
</div>
<div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id">
<ButtonStyled color="brand">
<button @click="addFriend(friend)">
<UserPlusIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Ignore
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Cancel
</button>
</ButtonStyled>
</template>
</div>
</div>
</div>
</div>
</NewModal>
<NewModal ref="addFriendModal" header="Add a friend">
<div class="mb-4">
<h2 class="m-0 text-xl">Username</h2>
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p>
<input v-model="username" class="mt-2" type="text" placeholder="Enter username..." />
</div>
<ButtonStyled color="brand">
<button class="ml-auto" :disabled="username.length === 0" @click="addFriendFromModal">
<UserPlusIcon />
Add friend
</button>
</ButtonStyled>
</NewModal>
<div class="flex justify-between items-center">
<h3 class="text-lg m-0">Friends</h3>
<ButtonStyled type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'add-friend',
action: () => addFriendModal.show(),
},
{
id: 'manage-friends',
action: () => manageFriendsModal.show(),
shown: acceptedFriends.length > 0,
},
{
id: 'view-requests',
action: () => friendInvitesModal.show(),
shown: pendingFriends.length > 0,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #add-friend>
<UserPlusIcon aria-hidden="true" />
Add friend
</template>
<template #manage-friends>
<SettingsIcon aria-hidden="true" />
Manage friends
<div
v-if="acceptedFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
>
{{ acceptedFriends.length }}
</div>
</template>
<template #view-requests>
<MailIcon aria-hidden="true" />
View friend requests
<div
v-if="pendingFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
>
{{ pendingFriends.length }}
</div>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2 mt-2">
<template v-if="loading">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full">
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
<div class="h-2.5 bg-button-bg rounded-full w-3/4"></div>
</div>
</div>
</template>
<template v-else-if="acceptedFriends.length === 0">
<div class="text-sm">
<div class="mb-2">You have no friends :C</div>
<div v-if="!userCredentials">
<span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends!
</div>
<div v-else>
Why don't you
<span class="text-link cursor-pointer" @click="addFriendModal.show()">add one</span>?
</div>
</div>
</template>
<template v-else>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #remove-friend> <TrashIcon /> Remove friend </template>
</ContextMenu>
<div
v-for="friend in acceptedFriends.slice(0, 5)"
:key="friend.username"
class="flex gap-2 items-center"
:class="{ grayscale: !friend.online }"
@contextmenu.prevent.stop="
(event) =>
friendOptions.showMenu(event, friend, [
{
name: 'remove-friend',
color: 'danger',
},
])
"
>
<div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span
v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }">
{{ friend.username }}
</span>
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
</div>
</template>
</div>
</template>

View File

@ -151,7 +151,6 @@ td:first-child {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
:deep(.animated-dropdown .options) {
max-height: 13.375rem;

View File

@ -68,6 +68,5 @@ async function install() {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
</style>

View File

@ -243,7 +243,7 @@ const createInstance = async () => {
"
>
<Button
:disabled="profile.installedMod || profile.installing || profile.linked_data?.locked"
:disabled="profile.installedMod || profile.installing"
@click="install(profile)"
>
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
@ -253,8 +253,6 @@ const createInstance = async () => {
? 'Installing...'
: profile.installedMod
? 'Installed'
: profile.linked_data && profile.linked_data.locked
? 'Paired'
: 'Install'
}}
</Button>
@ -308,7 +306,6 @@ const createInstance = async () => {
flex-direction: column;
gap: 1rem;
margin: 0;
padding: 1rem;
background-color: var(--color-bg);
}

View File

@ -0,0 +1,161 @@
<script setup lang="ts">
import { NewModal } from '@modrinth/ui'
import {
ReportIcon,
ModrinthIcon,
ShieldIcon,
SettingsIcon,
GaugeIcon,
PaintBrushIcon,
GameIcon,
CoffeeIcon,
} from '@modrinth/assets'
import { ref } from 'vue'
import { useVIntl, defineMessage } from '@vintl/vintl'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import { getVersion } from '@tauri-apps/api/app'
import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/plugin-os'
import { useTheming } from '@/store/state'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
const themeStore = useTheming()
const modal = ref()
function show() {
modal.value.show()
}
const { formatMessage } = useVIntl()
const selectedTab = ref(0)
const devModeCounter = ref(0)
const developerModeEnabled = defineMessage({
id: 'app.settings.developer-mode-enabled',
defaultMessage: 'Developer mode enabled.',
})
const tabs = [
{
name: defineMessage({
id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance',
}),
icon: PaintBrushIcon,
content: AppearanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.privacy',
defaultMessage: 'Privacy',
}),
icon: ShieldIcon,
content: PrivacySettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.java-versions',
defaultMessage: 'Java versions',
}),
icon: CoffeeIcon,
content: JavaSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default instance options',
}),
icon: GameIcon,
content: DefaultInstanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.resource-management',
defaultMessage: 'Resource management',
}),
icon: GaugeIcon,
content: ResourceManagementSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.feature-flags',
defaultMessage: 'Feature flags',
}),
icon: ReportIcon,
content: FeatureFlagSettings,
developerOnly: true,
},
]
defineExpose({ show })
const version = await getVersion()
const osPlatform = getOsPlatform()
const osVersion = getOsVersion()
</script>
/
<template>
<NewModal ref="modal">
<template #title>
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
<SettingsIcon /> Settings
</span>
</template>
<div class="grid grid-cols-[auto_1fr] gap-4">
<div class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider">
<button
v-for="(tab, index) in tabs.filter((t) => !t.developerOnly || themeStore.devMode)"
:key="index"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-transform ${selectedTab === index ? 'bg-highlight text-brand' : 'bg-transparent text-button-text'}`"
@click="() => (selectedTab = index)"
>
<component :is="tab.icon" class="w-4 h-4" />
<span>{{ formatMessage(tab.name) }}</span>
</button>
<div class="mt-auto text-secondary text-sm">
<p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2">
{{ formatMessage(developerModeEnabled) }}
</p>
<div class="flex items-center gap-3">
<button
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }"
@click="
() => {
devModeCounter++
if (devModeCounter > 5) {
themeStore.devMode = !themeStore.devMode
devModeCounter = 0
if (!themeStore.devMode && tabs[selectedTab].developerOnly === true) {
selectedTab = 0
}
}
}
"
>
<ModrinthIcon class="w-6 h-6" />
</button>
<div>
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">MacOS</span>
<span v-else class="capitalize">{{ osPlatform }}</span>
{{ osVersion }}
</p>
</div>
</div>
</div>
</div>
<div class="w-[600px] h-[500px] overflow-y-auto">
<component :is="tabs[selectedTab].content" />
</div>
</div>
</NewModal>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Modal } from '@modrinth/ui'
import { NewModal as Modal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.js'

View File

@ -0,0 +1,103 @@
<script setup lang="ts">
import { DropdownSelect, Toggle, ThemeSelector } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings'
import { watch, ref } from 'vue'
import { getOS } from '@/helpers/utils'
const themeStore = useTheming()
const os = ref(await getOS())
const settings = ref(await get())
watch(settings, async () => {
await set(settings.value)
})
</script>
<template>
<h2 class="m-0 text-2xl">Color theme</h2>
<p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p>
<ThemeSelector
:update-color-theme="themeStore.setThemeState"
:current-theme="themeStore.selectedTheme"
:theme-options="themeStore.themeOptions"
system-theme-color="system"
/>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-2xl">Advanced rendering</h2>
<p class="m-0 mt-1">
Enables advanced rendering such as blur effects that may cause performance issues without
hardware-accelerated rendering.
</p>
</div>
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
:checked="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = e
settings.advanced_rendering = themeStore.advancedRendering
}
"
/>
</div>
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 mt-4 text-2xl">Native Decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div>
<Toggle
id="native-decorations"
:model-value="settings.native_decorations"
:checked="settings.native_decorations"
@update:model-value="
(e) => {
settings.native_decorations = e
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 mt-4 text-2xl">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div>
<Toggle
id="minimize-launcher"
:model-value="settings.hide_on_process_start"
:checked="settings.hide_on_process_start"
@update:model-value="
(e) => {
settings.hide_on_process_start = e
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 mt-4 text-2xl">Default landing page</h2>
<p class="m-0 mt-1">Change the page to which the launcher opens on.</p>
</div>
<DropdownSelect
id="opening-page"
name="Opening page dropdown"
:options="['Home', 'Library']"
:default-value="settings.default_page"
:model-value="settings.default_page"
class="opening-page"
@change="
(e) => {
settings.default_page = e.option
}
"
/>
</div>
</template>

View File

@ -0,0 +1,166 @@
<script setup lang="ts">
import { get, set } from '@/helpers/settings'
import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
import { Slider, Toggle } from '@modrinth/ui'
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' ')
const settings = ref(fetchSettings)
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = setSettings.envVars
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null
}
if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null
}
if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
</script>
<template>
<h2 class="m-0 text-2xl">Java arguments</h2>
<input
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
class="installation-input"
placeholder="Enter java arguments..."
/>
<h2 class="mt-4 m-0 text-2xl">Environmental variables</h2>
<input
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
class="installation-input"
placeholder="Enter environmental variables..."
/>
<h2 class="mt-4 m-0 text-2xl">Java memory</h2>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="8"
:max="maxMemory"
:step="64"
unit="MB"
/>
<h2 class="mt-4 m-0 text-2xl">Hooks</h2>
<h3 class="mt-2 m-0 text-lg">Pre launch</h3>
<p class="m-0 mt-1 leading-tight">Ran before the instance is launched.</p>
<input
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
/>
<h3 class="mt-2 m-0 text-lg">Wrapper</h3>
<p class="m-0 mt-1 leading-tight">Wrapper command for launching Minecraft.</p>
<input
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
/>
<h3 class="mt-2 m-0 text-lg">Post exit</h3>
<p class="m-0 mt-1 leading-tight">Ran after the game closes.</p>
<input
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
/>
<h2 class="mt-4 m-0 text-2xl">Window size</h2>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-lg">Fullscreen</h3>
<p class="m-0 mt-1 leading-tight">
Overwrites the options.txt file to start in full screen when launched.
</p>
</div>
<Toggle
id="fullscreen"
:model-value="settings.force_fullscreen"
:checked="settings.force_fullscreen"
@update:model-value="
(e) => {
settings.force_fullscreen = e
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-lg">Width</h3>
<p class="m-0 mt-1 leading-tight">The width of the game window when launched.</p>
</div>
<input
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-lg">Height</h3>
<p class="m-0 mt-1 leading-tight">The height of the game window when launched.</p>
</div>
<input
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
class="input"
placeholder="Enter height..."
/>
</div>
</template>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { computed } from 'vue'
import type { Ref } from 'vue'
const themeStore = useTheming()
type ThemeStoreKeys = keyof typeof themeStore
const options: Ref<ThemeStoreKeys[]> = computed(() => {
return Object.keys(themeStore).filter((key) => key.startsWith('featureFlag_')) as ThemeStoreKeys[]
})
function getStoreValue<K extends ThemeStoreKeys>(key: K): (typeof themeStore)[K] {
return themeStore[key]
}
function setStoreValue<K extends ThemeStoreKeys>(key: K, value: (typeof themeStore)[K]) {
themeStore[key] = value
}
function formatFlagName(name: string) {
return name.replace('featureFlag_', '')
}
</script>
<template>
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-xl capitalize">{{ formatFlagName(option) }}</h2>
</div>
<Toggle
id="advanced-rendering"
:model-value="getStoreValue(option)"
:checked="getStoreValue(option)"
@update:model-value="() => setStoreValue(option, !themeStore[option])"
/>
</div>
</template>

View File

@ -0,0 +1,30 @@
<script setup>
import { ref } from 'vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
import JavaSelector from '@/components/ui/JavaSelector.vue'
const javaVersions = ref(await get_java_versions().catch(handleError))
async function updateJavaVersion(version) {
if (version?.path === '') {
version.path = undefined
}
if (version?.path) {
version.path = version.path.replace('java.exe', 'javaw.exe')
}
await set_java_version(version).catch(handleError)
}
</script>
<template>
<div v-for="(javaVersion, index) in [21, 17, 8]" :key="`java-${javaVersion}`">
<h2 class="m-0 text-2xl" :class="{ 'mt-4': index !== 0 }">Java {{ javaVersion }} location</h2>
<JavaSelector
:id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]"
:version="javaVersion"
@update:model-value="updateJavaVersion"
/>
</div>
</template>

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings'
import { Toggle } from '@modrinth/ui'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
const settings = ref(await get())
watch(settings, async () => {
if (settings.value.telemetry) {
optInAnalytics()
} else {
optOutAnalytics()
}
await set(settings.value)
})
</script>
<template>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-2xl">Personalized ads</h2>
<p class="m-0 mt-1 leading-tight">
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
option, you opt out and ads will no longer be shown based on your interests.
</p>
</div>
<Toggle
id="personalized-ads"
:model-value="settings.personalized_ads"
:checked="settings.personalized_ads"
@update:model-value="
(e) => {
settings.personalized_ads = e
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-2xl">Telemetry</h2>
<p class="m-0 mt-1 leading-tight">
Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. By disabling this option, you opt out and your data will no
longer be collected.
</p>
</div>
<Toggle
id="opt-out-analytics"
:model-value="settings.telemetry"
:checked="settings.telemetry"
@update:model-value="
(e) => {
settings.telemetry = e
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-2xl">Discord RPC</h2>
<p class="m-0 mt-1 leading-tight">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no
longer show up as a game or app you are using on your Discord profile. This does not disable
any instance-specific Discord Rich Presence integrations, such as those added by mods. (app
restart required to take effect)
</p>
</div>
<Toggle
id="disable-discord-rpc"
v-model="settings.discord_rpc"
:checked="settings.discord_rpc"
/>
</div>
</template>

View File

@ -0,0 +1,114 @@
<script setup>
import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.js'
import { purge_cache_types } from '@/helpers/cache.js'
import { handleError } from '@/store/notifications.js'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { open } from '@tauri-apps/plugin-dialog'
const settings = ref(await get())
watch(settings, async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
})
async function purgeCache() {
await purge_cache_types([
'project',
'version',
'user',
'team',
'organization',
'loader_manifest',
'minecraft_manifest',
'categories',
'report_types',
'loaders',
'game_versions',
'donation_platforms',
'file_update',
'search_results',
]).catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({
multiple: false,
directory: true,
title: 'Select a new app directory',
})
if (newDir) {
settings.value.custom_dir = newDir
}
}
</script>
<template>
<h2 class="m-0 text-2xl">App directory</h2>
<p class="m-0 mt-1">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
<div class="m-1 mt-2">
<div class="iconified-input w-full">
<BoxIcon />
<input id="appDir" v-model="settings.custom_dir" type="text" class="input" />
<Button class="r-btn" @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</div>
</div>
<div class="flex items-center justify-between gap-4 mt-4">
<div>
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
@proceed="purgeCache"
/>
<h2 class="m-0 text-2xl">App cache</h2>
<p class="m-0 mt-1 leading-tight">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon />
Purge cache
</button>
</div>
<h2 class="m-0 text-2xl mt-4">Maximum concurrent downloads</h2>
<p class="m-0 mt-1">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<h2 class="m-0 mt-4 text-2xl">Maximum concurrent writes</h2>
<p class="m-0 mt-1">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
<Slider id="max-writes" v-model="settings.max_concurrent_writes" :min="1" :max="50" :step="1" />
</template>

View File

@ -1,368 +0,0 @@
<script setup>
import { UserIcon, LockIcon, MailIcon } from '@modrinth/assets'
import { Button, Card, Checkbox } from '@modrinth/ui'
import {
DiscordIcon,
GithubIcon,
MicrosoftIcon,
GoogleIcon,
SteamIcon,
GitLabIcon,
} from '@/assets/external'
import { login } from '@/helpers/mr_auth.js'
import { handleError, useNotifications } from '@/store/state.js'
import { ref } from 'vue'
import { handleSevereError } from '@/store/error.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const props = defineProps({
callback: {
type: Function,
required: true,
},
})
const modal = ref()
const turnstileToken = ref()
const widgetId = ref()
defineExpose({
show: () => {
modal.value.show()
if (window.turnstile === null || !window.turnstile) {
const script = document.createElement('script')
script.src =
'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback'
script.async = true
script.defer = true
document.head.appendChild(script)
window.onloadTurnstileCallback = loadWidget
} else {
loadWidget()
}
},
})
function loadWidget() {
widgetId.value = window.turnstile.render('#turnstile-container', {
sitekey: '0x4AAAAAAAW3guHM6Eunbgwu',
callback: (token) => (turnstileToken.value = token),
expiredCallback: () => (turnstileToken.value = null),
})
}
function removeWidget() {
if (widgetId.value) {
window.turnstile.remove(widgetId.value)
widgetId.value = null
turnstileToken.value = null
}
}
const loggingIn = ref(true)
const twoFactorFlow = ref(null)
const twoFactorCode = ref('')
const email = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const subscribe = ref(true)
async function signInOauth() {
const creds = await login().catch(handleSevereError)
if (creds && creds.type === 'two_factor_required') {
twoFactorFlow.value = creds.flow
} else if (creds && creds.session) {
props.callback()
modal.value.hide()
}
}
async function signIn2fa() {
const creds = await login_2fa(twoFactorCode.value, twoFactorFlow.value).catch(handleError)
if (creds && creds.session) {
props.callback()
modal.value.hide()
}
}
async function signIn() {
const creds = await login_pass(username.value, password.value, turnstileToken.value).catch(
handleError,
)
window.turnstile.reset(widgetId.value)
if (creds && creds.type === 'two_factor_required') {
twoFactorFlow.value = creds.flow
} else if (creds && creds.session) {
props.callback()
modal.value.hide()
}
}
async function createAccount() {
if (password.value !== confirmPassword.value) {
const notifs = useNotifications()
notifs.addNotification({
title: 'An error occurred',
text: 'Passwords do not match!',
type: 'error',
})
return
}
const creds = await create_account(
username.value,
email.value,
password.value,
turnstileToken.value,
subscribe.value,
).catch(handleError)
window.turnstile.reset(widgetId.value)
if (creds && creds.session) {
props.callback()
modal.value.hide()
}
}
</script>
<template>
<ModalWrapper ref="modal" :on-hide="removeWidget">
<Card>
<template v-if="twoFactorFlow">
<h1>Enter two-factor code</h1>
<p>Please enter a two-factor code to proceed.</p>
<input v-model="twoFactorCode" maxlength="11" type="text" placeholder="Enter code..." />
</template>
<template v-else>
<h1 v-if="loggingIn">Login to Modrinth</h1>
<h1 v-else>Create an account</h1>
<div class="button-grid">
<Button class="discord" large @click="signInOauth('discord')">
<DiscordIcon />
Discord
</Button>
<Button class="github" large @click="signInOauth('github')">
<GithubIcon />
Github
</Button>
<Button class="white" large @click="signInOauth('microsoft')">
<MicrosoftIcon />
Microsoft
</Button>
<Button class="google" large @click="signInOauth('google')">
<GoogleIcon />
Google
</Button>
<Button class="white" large @click="signInOauth('steam')">
<SteamIcon />
Steam
</Button>
<Button class="gitlab" large @click="signInOauth('gitlab')">
<GitLabIcon />
GitLab
</Button>
</div>
<div class="divider">
<hr />
<p>Or</p>
</div>
<div v-if="!loggingIn" class="iconified-input username">
<MailIcon />
<input v-model="email" type="text" placeholder="Email" />
</div>
<div class="iconified-input username">
<UserIcon />
<input
v-model="username"
type="text"
:placeholder="loggingIn ? 'Email or username' : 'Username'"
/>
</div>
<div class="iconified-input" :class="{ username: !loggingIn }">
<LockIcon />
<input v-model="password" type="password" placeholder="Password" />
</div>
<div v-if="!loggingIn" class="iconified-input username">
<LockIcon />
<input v-model="confirmPassword" type="password" placeholder="Confirm password" />
</div>
<div class="turnstile">
<div id="turnstile-container"></div>
<div id="turnstile-container-2"></div>
</div>
<Checkbox
v-if="!loggingIn"
v-model="subscribe"
class="subscribe-btn"
label="Subscribe to updates about Modrinth"
/>
<div class="link-row">
<a v-if="loggingIn" class="button-base" @click="loggingIn = false"> Create account </a>
<a v-else class="button-base" @click="loggingIn = true">Sign in</a>
<a class="button-base" href="https://modrinth.com/auth/reset-password">
Forgot password?
</a>
</div>
</template>
<div class="button-row">
<Button class="transparent" large>Close</Button>
<Button v-if="twoFactorCode" color="primary" large @click="signIn2fa"> Login </Button>
<Button
v-else-if="loggingIn"
color="primary"
large
:disabled="!turnstileToken"
@click="signIn"
>
Login
</Button>
<Button v-else color="primary" large :disabled="!turnstileToken" @click="createAccount">
Create account
</Button>
</div>
</Card>
</ModalWrapper>
</template>
<style scoped lang="scss">
:deep(.modal-container) {
.modal-body {
width: auto;
.content {
background: none;
}
}
}
.card {
width: 25rem;
}
.button-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--gap-md);
.btn {
width: 100%;
justify-content: center;
}
.discord {
background-color: #5865f2;
color: var(--color-contrast);
}
.github {
background-color: #8740f1;
color: var(--color-contrast);
}
.white {
background-color: var(--color-contrast);
color: var(--color-accent-contrast);
}
.google {
background-color: #4285f4;
color: var(--color-contrast);
}
.gitlab {
background-color: #fc6d26;
color: var(--color-contrast);
}
}
.divider {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin: var(--gap-md) 0;
p {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-raised-bg);
padding: 0 1rem;
margin: 0;
}
hr {
border: none;
width: 100%;
border-top: 2px solid var(--color-button-bg);
}
}
.iconified-input {
width: 100%;
input {
width: 100%;
flex-basis: auto;
}
}
.username {
margin-bottom: var(--gap-sm);
}
.link-row {
display: flex;
justify-content: space-between;
margin: var(--gap-md) 0;
a {
color: var(--color-blue);
text-decoration: underline;
&:hover {
cursor: pointer;
}
}
}
.button-row {
display: flex;
justify-content: space-between;
.btn {
flex-basis: auto;
}
.transparent {
padding: var(--gap-md) 0;
}
}
:deep(.checkbox) {
border: none;
}
.turnstile {
display: flex;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-md);
border: 2px solid var(--color-button-bg);
height: 66px;
margin-top: var(--gap-md);
iframe {
margin: -1px;
min-width: calc(100% + 2px);
}
}
</style>

View File

@ -93,3 +93,7 @@ export async function command_listener(callback) {
export async function warning_listener(callback) {
return await listen('warning', (event) => callback(event.payload))
}
export async function friend_listener(callback) {
return await listen('friend', (event) => callback(event.payload))
}

View File

@ -0,0 +1,17 @@
import { invoke } from '@tauri-apps/api/core'
export async function friends() {
return await invoke('plugin:friends|friends')
}
export async function friend_statuses() {
return await invoke('plugin:friends|friend_statuses')
}
export async function add_friend(userId) {
return await invoke('plugin:friends|add_friend', { userId })
}
export async function remove_friend(userId) {
return await invoke('plugin:friends|remove_friend', { userId })
}

View File

@ -0,0 +1,38 @@
{
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
"app.settings.tabs.appearance": {
"message": "Appearance"
},
"app.settings.tabs.default-instance-options": {
"message": "Default instance options"
},
"app.settings.tabs.feature-flags": {
"message": "Feature flags"
},
"app.settings.tabs.java-versions": {
"message": "Java versions"
},
"app.settings.tabs.privacy": {
"message": "Privacy"
},
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"instance.filter.updates-available": {
"message": "Updates available"
},
"search.filter.locked.instance": {
"message": "Provided by the instance"
},
"search.filter.locked.instance-game-version.title": {
"message": "Game version is provided by the instance"
},
"search.filter.locked.instance-loader.title": {
"message": "Loader is provided by the instance"
},
"search.filter.locked.instance.sync": {
"message": "Sync with instance"
}
}

View File

@ -37,7 +37,17 @@ Sentry.init({
app.use(router)
app.use(pinia)
app.use(FloatingVue)
app.use(FloatingVue, {
themes: {
'ribbit-popout': {
$extend: 'dropdown',
placement: 'bottom-end',
instantMove: true,
distance: 8,
triggers: ['click'],
},
},
})
app.use(VIntlPlugin)
app.mount('#app')

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue'
import { list } from '@/helpers/profile.js'
@ -8,11 +8,6 @@ import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
import dayjs from 'dayjs'
import { get_search_results } from '@/helpers/cache.js'
import { hide_ads_window } from '@/helpers/ads.js'
onMounted(() => {
hide_ads_window(true)
})
const featuredModpacks = ref({})
const featuredMods = ref({})
@ -104,7 +99,8 @@ onUnmounted(() => {
</script>
<template>
<div class="page-container">
<div class="p-6 flex flex-col gap-2">
<h1 class="m-0 text-2xl">Welcome back!</h1>
<RowDisplay
v-if="total > 0"
:instances="[
@ -132,13 +128,3 @@ onUnmounted(() => {
/>
</div>
</template>
<style lang="scss" scoped>
.page-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
</style>

View File

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

View File

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

View File

@ -1,69 +1,89 @@
<template>
<div class="instance-container">
<div class="side-cards pb-4" @scroll="$refs.promo.scroll()">
<Card class="instance-card" @contextmenu.prevent.stop="handleRightClick">
<Avatar size="md" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : null" />
<div class="instance-info">
<h2 class="name">{{ instance.name }}</h2>
<span class="metadata"> {{ instance.loader }} {{ instance.game_version }} </span>
</div>
<span class="button-group">
<Button v-if="instance.install_stage !== 'installed'" disabled class="instance-button">
Installing...
</Button>
<Button
v-else-if="playing === true"
color="danger"
class="instance-button"
@click="stopInstance('InstancePage')"
<div
class="p-6 pr-2 pb-4"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
>
<ExportModal ref="exportModal" :instance="instance" />
<ContentPageHeader>
<template #icon>
<Avatar :src="icon" :alt="instance.name" size="96px" />
</template>
<template #title>
{{ instance.name }}
</template>
<template #summary> </template>
<template #stats>
<div class="flex items-center gap-2 font-semibold transform capitalize">
<GameIcon class="h-6 w-6 text-secondary" />
{{ instance.loader }} {{ instance.game_version }}
</div>
</template>
<template #actions>
<ButtonStyled v-if="instance.install_stage !== 'installed'" color="brand" size="large">
<button disabled>Installing...</button>
</ButtonStyled>
<template v-else>
<div class="flex gap-2">
<ButtonStyled v-if="playing === true" color="red" size="large">
<button @click="stopInstance('InstancePage')">
<StopCircleIcon />
Stop
</Button>
<Button
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false"
color="primary"
class="instance-button"
@click="startInstance('InstancePage')"
color="brand"
size="large"
>
<button @click="startInstance('InstancePage')">
<PlayIcon />
Play
</Button>
<Button
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="loading === true && playing === false"
disabled
class="instance-button"
color="brand"
size="large"
>
Loading...
</Button>
<Button
v-tooltip="'Open instance folder'"
class="instance-button"
@click="showProfileInFolder(instance.path)"
<button disabled>Loading...</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<RouterLink
v-tooltip="'Instance settings'"
:to="`/instance/${encodeURIComponent(route.params.id)}/options`"
>
<FolderOpenIcon />
Folder
</Button>
</span>
<hr class="card-divider" />
<div class="pages-list">
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/`" class="btn">
<BoxIcon />
Content
</RouterLink>
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/logs`" class="btn">
<FileIcon />
Logs
</RouterLink>
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/options`" class="btn">
<SettingsIcon />
Options
</RouterLink>
</ButtonStyled>
<ButtonStyled size="large" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
},
{
id: 'export-mrpack',
action: () => $refs.exportModal.show(),
},
]"
>
<MoreVerticalIcon />
<template #share-instance> <UserPlusIcon /> Share instance </template>
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
</OverflowMenu>
</ButtonStyled>
</div>
</Card>
<PromotionWrapper ref="promo" class="mt-4" />
</template>
</template>
</ContentPageHeader>
</div>
<div class="content">
<div class="px-6">
<NavTabs :links="tabs" />
</div>
<div class="p-6 pt-4">
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()">
@ -76,11 +96,13 @@
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
></component>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
</div>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
@ -104,11 +126,18 @@
</ContextMenu>
</template>
<script setup>
import { Button, Avatar, Card } from '@modrinth/ui'
import {
BoxIcon,
Avatar,
ContentPageHeader,
ButtonStyled,
OverflowMenu,
LoadingIndicator,
} from '@modrinth/ui'
import {
UserPlusIcon,
ServerIcon,
PackageIcon,
SettingsIcon,
FileIcon,
PlayIcon,
StopCircleIcon,
EditIcon,
@ -122,21 +151,24 @@ import {
XIcon,
CheckCircleIcon,
UpdatedIcon,
MoreVerticalIcon,
GameIcon,
} from '@modrinth/assets'
import { get, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener, profile_listener } from '@/helpers/events'
import { useRoute, useRouter } from 'vue-router'
import { ref, onUnmounted } from 'vue'
import { ref, onUnmounted, computed } from 'vue'
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics'
import { convertFileSrc } from '@tauri-apps/api/core'
import { handleSevereError } from '@/store/error.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import dayjs from 'dayjs'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
const route = useRoute()
@ -145,6 +177,17 @@ const breadcrumbs = useBreadcrumbs()
const instance = ref(await get(route.params.id).catch(handleError))
const tabs = computed(() => [
{
label: 'Content',
href: `/instance/${encodeURIComponent(route.params.id)}`,
},
{
label: 'Logs',
href: `/instance/${encodeURIComponent(route.params.id)}/logs`,
},
])
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
@ -300,6 +343,10 @@ const unlistenProcesses = await process_listener((e) => {
if (e.event === 'finished' && e.profile_path_id === route.params.id) playing.value = false
})
const icon = computed(() =>
instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
)
onUnmounted(() => {
unlistenProcesses()
unlistenProfiles()

View File

@ -1,331 +1,227 @@
<template>
<Card v-if="projects.length > 0" class="mod-card">
<div class="dropdown-input">
<DropdownSelect
v-model="selectedProjectType"
:options="Object.keys(selectableProjectTypes)"
default-value="All"
name="project-type-dropdown"
color="primary"
/>
<div class="iconified-input">
<template v-if="projects.length > 0">
<div class="flex items-center gap-2 mb-4">
<div class="iconified-input flex-grow">
<SearchIcon />
<input
v-model="searchFilter"
type="text"
:placeholder="`Search ${search.length} ${(['All', 'Other'].includes(selectedProjectType)
? 'projects'
: selectedProjectType.toLowerCase()
).slice(0, search.length === 1 ? -1 : 64)}...`"
class="text-input"
:placeholder="`Search content...`"
class="text-input search-input"
autocomplete="off"
/>
<Button class="r-btn" @click="() => (searchFilter = '')">
<XIcon />
</Button>
</div>
</div>
<Button
v-tooltip="'Refresh projects'"
icon-only
:disabled="refreshingProjects"
@click="refreshProjects"
>
<UpdatedIcon />
</Button>
<Button
v-if="canUpdatePack"
:disabled="installing"
color="secondary"
@click="modpackVersionModal.show()"
>
<DownloadIcon />
{{ installing ? 'Updating' : 'Update modpack' }}
</Button>
<Button v-else-if="!isPackLocked" @click="exportModal.show()">
<PackageIcon />
Export modpack
</Button>
<Button v-if="!isPackLocked && projects.some((m) => m.outdated)" @click="updateAll">
<DownloadIcon />
Update all
</Button>
<AddContentButton v-if="!isPackLocked" :instance="instance" />
</Card>
<Pagination
v-if="projects.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<Card v-if="projects.length > 0" class="list-card">
<div class="table">
<div class="table-row table-head" :class="{ 'show-options': selected.length > 0 }">
<div class="table-cell table-text">
<Checkbox v-model="selectAll" class="select-checkbox" />
</div>
<div v-if="selected.length === 0" class="table-cell table-text name-cell actions-cell">
<Button class="transparent" @click="sortProjects('Name')">
Name
<DropdownIcon v-if="sortColumn === 'Name'" :class="{ down: ascending }" />
</Button>
</div>
<div v-if="selected.length === 0" class="table-cell table-text version">
<Button class="transparent" @click="sortProjects('Version')">
Version
<DropdownIcon v-if="sortColumn === 'Version'" :class="{ down: ascending }" />
</Button>
</div>
<div v-if="selected.length === 0" class="table-cell table-text actions-cell">
<Button class="transparent" @click="sortProjects('Enabled')">
Actions
<DropdownIcon v-if="sortColumn === 'Enabled'" :class="{ down: ascending }" />
</Button>
</div>
<div v-else class="options table-cell name-cell">
<div>
<Button
class="transparent share"
@click="() => (showingOptions = !showingOptions)"
@mouseover="selectedOption = 'Share'"
>
<MenuIcon :class="{ open: showingOptions }" />
</Button>
</div>
<Button
class="transparent share"
@click="shareNames()"
@mouseover="selectedOption = 'Share'"
>
<ShareIcon />
Share
</Button>
<div v-tooltip="isPackLocked ? 'Unlock this instance to remove mods' : ''">
<Button
:disabled="isPackLocked"
class="transparent trash"
@click="deleteWarning.show()"
@mouseover="selectedOption = 'Delete'"
>
<TrashIcon />
Delete
</Button>
</div>
<div v-tooltip="isPackLocked ? 'Unlock this instance to update mods' : ''">
<Button
:disabled="isPackLocked || offline"
class="transparent update"
@click="updateSelected()"
@mouseover="selectedOption = 'Update'"
>
<UpdatedIcon />
Update
</Button>
</div>
<div v-tooltip="isPackLocked ? 'Unlock this instance to toggle mods' : ''">
<Button
:disabled="isPackLocked"
class="transparent"
@click="toggleSelected()"
@mouseover="selectedOption = 'Toggle'"
>
<ToggleIcon />
Toggle
</Button>
</div>
</div>
</div>
<div v-if="showingOptions && selected.length > 0" class="more-box">
<section v-if="selectedOption === 'Share'" class="options">
<Button class="transparent" @click="shareNames()">
<TextInputIcon />
Share names
</Button>
<Button class="transparent" @click="shareUrls()">
<GlobeIcon />
Share URLs
</Button>
<Button class="transparent" @click="shareFileNames()">
<FileIcon />
Share file names
</Button>
<Button class="transparent" @click="shareMarkdown()">
<CodeIcon />
Share as markdown
</Button>
</section>
<section v-if="selectedOption === 'Delete'" class="options">
<Button class="transparent" @click="deleteWarning.show()">
<TrashIcon />
Delete selected
</Button>
<Button class="transparent" @click="deleteDisabledWarning.show()">
<ToggleIcon />
Delete disabled
</Button>
</section>
<section v-if="selectedOption === 'Update'" class="options">
<Button class="transparent" :disabled="offline" @click="updateAll()">
<UpdatedIcon />
Update all
</Button>
<Button class="transparent" @click="selectUpdatable()">
<CheckIcon />
Select updatable
</Button>
</section>
<section v-if="selectedOption === 'Toggle'" class="options">
<Button class="transparent" @click="enableAll()">
<CheckIcon />
Toggle on
</Button>
<Button class="transparent" @click="disableAll()">
<XIcon />
Toggle off
</Button>
<Button class="transparent" @click="hideShowAll()">
<EyeIcon v-if="hideNonSelected" />
<EyeOffIcon v-else />
{{ hideNonSelected ? 'Show' : 'Hide' }} untoggled
</Button>
</section>
</div>
<div
v-for="mod in search.slice((currentPage - 1) * 20, currentPage * 20)"
:key="mod.file_name"
class="table-row"
@contextmenu.prevent.stop="(c) => handleRightClick(c, mod)"
>
<div class="table-cell table-text checkbox">
<Checkbox
:model-value="selectionMap.get(mod.path)"
class="select-checkbox"
@update:model-value="(newValue) => selectionMap.set(mod.path, newValue)"
/>
</div>
<div class="table-cell table-text name-cell">
<router-link
v-if="mod.slug"
:to="{ path: `/project/${mod.slug}/`, query: { i: props.instance.path } }"
:disabled="offline"
class="mod-content"
>
<Avatar :src="mod.icon" />
<div class="mod-text">
<div class="title">{{ mod.name }}</div>
<span v-if="mod.author" class="no-wrap">by {{ mod.author }}</span>
</div>
</router-link>
<div v-else class="mod-content">
<Avatar :src="mod.icon" />
<span v-tooltip="`${mod.name}`" class="title">{{ mod.name }}</span>
</div>
</div>
<div class="table-cell table-text version">
<span v-tooltip="`${mod.version}`">{{ mod.version }}</span>
</div>
<div class="table-cell table-text manage">
<div v-tooltip="isPackLocked ? 'Unlock this instance to remove mods.' : 'Remove project'">
<Button :disabled="isPackLocked" icon-only @click="removeMod(mod)">
<TrashIcon />
</Button>
</div>
<AnimatedLogo v-if="mod.updating" class="btn icon-only updating-indicator" />
<div
v-else
v-tooltip="isPackLocked ? 'Unlock this instance to update mods.' : 'Update project'"
>
<Button
:disabled="!mod.outdated || offline || isPackLocked"
icon-only
@click="updateProject(mod)"
>
<UpdatedIcon v-if="mod.outdated" />
<CheckIcon v-else />
</Button>
</div>
<div v-tooltip="isPackLocked ? 'Unlock this instance to toggle mods.' : ''">
<input
id="switch-1"
:disabled="isPackLocked"
autocomplete="off"
type="checkbox"
class="switch stylized-toggle"
:checked="!mod.disabled"
@change="toggleDisableMod(mod)"
/>
</div>
<Button
v-tooltip="`Show ${mod.file_name}`"
icon-only
@click="highlightModInProfile(instance.path, mod.path)"
>
<FolderOpenIcon />
</Button>
</div>
</div>
</div>
</Card>
<div v-else class="empty-prompt">
<div class="empty-icon">
<AddProjectImage />
</div>
<h3>No projects found</h3>
<p class="empty-subtitle">Add a project to get started</p>
<AddContentButton :instance="instance" />
</div>
<Pagination
v-if="projects.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
class="pagination-after"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<ModalWrapper ref="deleteWarning" header="Are you sure?">
<div class="modal-body">
<div class="markdown-body">
<p>
Are you sure you want to remove
<strong>{{ functionValues.length }} project(s)</strong> from {{ instance.name }}?
<br />
This action <strong>cannot</strong> be undone.
</p>
</div>
<div class="button-group push-right">
<Button @click="deleteWarning.hide()"> Cancel </Button>
<Button color="danger" @click="deleteSelected">
<TrashIcon />
Remove
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="deleteDisabledWarning" header="Are you sure?">
<div class="modal-body">
<div class="markdown-body">
<p>
Are you sure you want to remove
<strong
>{{ Array.from(projects.values()).filter((x) => x.disabled).length }} disabled
project(s)</strong
<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" />
<button
v-for="filter in filterOptions"
:key="filter"
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
@click="toggleArray(selectedFilters, filter.id)"
>
from {{ instance.name }}?
<br />
This action <strong>cannot</strong> be undone.
</p>
{{ filter.formattedName }}
</button>
</div>
<div class="button-group push-right">
<Button @click="deleteDisabledWarning.hide()"> Cancel </Button>
<Button color="danger" @click="deleteDisabled">
<TrashIcon />
Remove
</Button>
<ContentListPanel
v-model="selectedFiles"
:locked="isPackLocked"
:items="
search.map((x) => {
const item: ContentItem<any> = {
path: x.path,
disabled: x.disabled,
filename: x.file_name,
icon: x.icon,
title: x.name,
data: x,
}
if (x.version) {
item.version = x.version
item.versionId = x.version
}
if (x.id) {
item.project = {
id: x.id,
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
linkProps: {},
}
}
if (x.author) {
item.creator = {
name: x.author,
type: 'user',
id: x.author,
link: 'https://modrinth.com/user/' + x.author,
linkProps: { target: '_blank' },
}
}
return item
})
"
:sort-column="sortColumn"
:sort-ascending="ascending"
:update-sort="sortProjects"
>
<template v-if="selectedProjects.length > 0" #headers>
<div class="flex gap-2">
<ButtonStyled
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
color="brand"
color-fill="text"
hover-color-fill="text"
>
<button @click="updateSelected()"><DownloadIcon /> Update</button>
</ButtonStyled>
<ButtonStyled>
<OverflowMenu
:options="[
{
id: 'share-names',
action: () => shareNames(),
},
{
id: 'share-file-names',
action: () => shareFileNames(),
},
{
id: 'share-urls',
action: () => shareUrls(),
},
{
id: 'share-markdown',
action: () => shareMarkdown(),
},
]"
>
<ShareIcon /> Share <DropdownIcon />
<template #share-names> <TextInputIcon /> Project names </template>
<template #share-file-names> <FileIcon /> File names </template>
<template #share-urls> <LinkIcon /> Project links </template>
<template #share-markdown> <CodeIcon /> Markdown links </template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
</ButtonStyled>
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
<button @click="disableAll()"><SlashIcon /> Disable</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
</ButtonStyled>
</div>
</template>
<template #header-actions>
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
<UpdatedIcon />
Refresh
</button>
</ButtonStyled>
<ButtonStyled
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
type="transparent"
color="brand"
color-fill="text"
hover-color-fill="text"
@click="updateAll"
>
<button class="w-max"><DownloadIcon /> Update all</button>
</ButtonStyled>
<ButtonStyled
v-if="canUpdatePack"
type="transparent"
color="brand"
color-fill="text"
hover-color-fill="text"
>
<button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
<DownloadIcon /> Update pack
</button>
</ButtonStyled>
</template>
<template #actions="{ item }">
<ButtonStyled
v-if="!isPackLocked && (item.data as any).outdated"
type="transparent"
color="brand"
circular
>
<button
v-tooltip="`Update`"
:disabled="(item.data as any).updating"
@click="updateProject(item.data)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<div v-else class="w-[36px]"></div>
<ButtonStyled type="transparent" circular>
<button
v-tooltip="item.disabled ? `Enable` : `Disable`"
@click="toggleDisableMod(item.data)"
>
<CheckCircleIcon v-if="item.disabled" />
<SlashIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'show-file',
action: () => highlightModInProfile(instance.path, item.path),
},
{
id: 'copy-link',
shown: item.project !== undefined,
action: () => toggleDisableMod(item.data),
},
{
divider: true,
},
{
id: 'remove',
color: 'red',
action: () => removeMod(item),
},
]"
direction="left"
>
<MoreVerticalIcon />
<template #show-file> <ExternalIcon /> Show file </template>
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
<template v-if="item.disabled" #toggle> <CheckCircleIcon /> Enable </template>
<template v-else #toggle> <SlashIcon /> Disable </template>
<template #remove> <TrashIcon /> Remove </template>
</OverflowMenu>
</ButtonStyled>
</template>
</ContentListPanel>
</template>
<div v-else class="w-full flex flex-col items-center justify-center mt-6 max-w-[48rem] mx-auto">
<div class="top-box w-full">
<div class="flex items-center gap-6 w-[32rem] mx-auto">
<img src="@/assets/sad-modrinth-bot.webp" class="h-24" />
<span class="text-contrast font-bold text-xl"
>You haven't added any content to this instance yet.</span
>
</div>
</div>
<div class="top-box-divider"></div>
<div class="flex items-center gap-6 py-4">
<AddContentButton :instance="instance" />
</div>
</div>
</ModalWrapper>
<ShareModalWrapper
ref="shareModal"
share-title="Sharing modpack content"
@ -340,34 +236,30 @@
:versions="props.versions"
/>
</template>
<script setup>
<script setup lang="ts">
import {
ExternalIcon,
LinkIcon,
ClipboardCopyIcon,
TrashIcon,
CheckIcon,
SearchIcon,
UpdatedIcon,
FolderOpenIcon,
XIcon,
ShareIcon,
DropdownIcon,
GlobeIcon,
FileIcon,
EyeIcon,
EyeOffIcon,
CodeIcon,
DownloadIcon,
FilterIcon,
MoreVerticalIcon,
CheckCircleIcon,
SlashIcon,
} from '@modrinth/assets'
import {
Pagination,
DropdownSelect,
Checkbox,
AnimatedLogo,
Avatar,
Button,
Card,
} from '@modrinth/ui'
import { Button, ButtonStyled, ContentListPanel, OverflowMenu } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useVIntl, defineMessages } from '@vintl/vintl'
import {
add_project_from_path,
get_projects,
@ -379,7 +271,7 @@ import {
import { handleError } from '@/store/notifications.js'
import { trackEvent } from '@/helpers/analytics'
import { highlightModInProfile } from '@/helpers/utils.js'
import { MenuIcon, ToggleIcon, TextInputIcon, AddProjectImage, PackageIcon } from '@/assets/icons'
import { TextInputIcon } from '@/assets/icons'
import ExportModal from '@/components/ui/ExportModal.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import AddContentButton from '@/components/ui/AddContentButton.vue'
@ -390,9 +282,9 @@ import {
get_version_many,
} from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import dayjs from 'dayjs'
const props = defineProps({
instance: {
@ -433,7 +325,6 @@ onUnmounted(() => {
unlistenProfiles()
})
const showingOptions = ref(false)
const isPackLocked = computed(() => {
return props.instance.linked_data && props.instance.linked_data.locked
})
@ -444,9 +335,14 @@ const canUpdatePack = computed(() => {
const exportModal = ref(null)
const projects = ref([])
const selectedFiles = ref([])
const selectedProjects = computed(() =>
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
)
const selectionMap = ref(new Map())
const initProjects = async (cacheBehaviour) => {
const initProjects = async (cacheBehaviour?) => {
const newProjects = []
const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
@ -504,6 +400,7 @@ const initProjects = async (cacheBehaviour) => {
icon: project.icon_url,
disabled: file.file_name.endsWith('.disabled'),
updateVersion: file.update_version_id,
updated: dayjs(version.date_published),
outdated: !!file.update_version_id,
project_type: project.project_type,
id: project.id,
@ -545,19 +442,77 @@ await initProjects()
const modpackVersionModal = ref(null)
const installing = computed(() => props.instance.install_stage !== 'installed')
const vintl = useVIntl()
const { formatMessage } = vintl
type FilterOption = {
id: string
formattedName: string
}
const messages = defineMessages({
updatesAvailableFilter: {
id: 'instance.filter.updates-available',
defaultMessage: 'Updates available',
},
})
const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
const options: FilterOption[] = []
const frequency = projects.value.reduce((map, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1
return map
}, {})
const types = Object.keys(frequency).sort((a, b) => frequency[b] - frequency[a])
types.forEach((type) => {
options.push({
id: type,
formattedName: formatProjectType(type) + 's',
})
})
if (!isPackLocked.value && projects.value.some((m) => m.outdated)) {
options.push({
id: 'updates',
formattedName: formatMessage(messages.updatesAvailableFilter),
})
}
return options
})
const selectedFilters = ref([])
const filteredProjects = computed(() => {
const updatesFilter = selectedFilters.value.includes('updates')
const typeFilters = selectedFilters.value.filter((filter) => filter !== 'updates')
return projects.value.filter((project) => {
return (
(typeFilters.length === 0 || typeFilters.includes(project.project_type)) &&
(!updatesFilter || project.outdated)
)
})
})
function toggleArray(array, value) {
if (array.includes(value)) {
array.splice(array.indexOf(value), 1)
} else {
array.push(value)
}
}
const searchFilter = ref('')
const selectAll = ref(false)
const selectedProjectType = ref('All')
const deleteWarning = ref(null)
const deleteDisabledWarning = ref(null)
const hideNonSelected = ref(false)
const selectedOption = ref('Share')
const shareModal = ref(null)
const ascending = ref(true)
const sortColumn = ref('Name')
const currentPage = ref(1)
watch([searchFilter, selectedProjectType], () => (currentPage.value = 1))
const selected = computed(() =>
Array.from(selectionMap.value)
@ -570,7 +525,7 @@ const selected = computed(() =>
)
const functionValues = computed(() =>
selected.value.length > 0 ? selected.value : Array.from(projects.value.values()),
selectedProjects.value.length > 0 ? selectedProjects.value : Array.from(projects.value.values()),
)
const selectableProjectTypes = computed(() => {
@ -586,7 +541,7 @@ const selectableProjectTypes = computed(() => {
const search = computed(() => {
const projectType = selectableProjectTypes.value[selectedProjectType.value]
const filtered = projects.value
const filtered = filteredProjects.value
.filter((mod) => {
return (
mod.name.toLowerCase().includes(searchFilter.value.toLowerCase()) &&
@ -600,43 +555,19 @@ const search = computed(() => {
return true
})
return updateSort(filtered)
})
const updateSort = (projects) => {
switch (sortColumn.value) {
case 'Version':
return projects.slice().sort((a, b) => {
if (a.version < b.version) {
return ascending.value ? -1 : 1
}
if (a.version > b.version) {
case 'Updated':
return filtered.slice().sort((a, b) => {
if (a.updated < b.updated) {
return ascending.value ? 1 : -1
}
return 0
})
case 'Author':
return projects.slice().sort((a, b) => {
if (a.author < b.author) {
return ascending.value ? -1 : 1
}
if (a.author > b.author) {
return ascending.value ? 1 : -1
}
return 0
})
case 'Enabled':
return projects.slice().sort((a, b) => {
if (a.disabled && !b.disabled) {
return ascending.value ? 1 : -1
}
if (!a.disabled && b.disabled) {
if (a.updated > b.updated) {
return ascending.value ? -1 : 1
}
return 0
})
default:
return projects.slice().sort((a, b) => {
return filtered.slice().sort((a, b) => {
if (a.name < b.name) {
return ascending.value ? -1 : 1
}
@ -646,7 +577,7 @@ const updateSort = (projects) => {
return 0
})
}
}
})
const sortProjects = (filter) => {
if (sortColumn.value === filter) {
@ -690,14 +621,6 @@ const updateAll = async () => {
})
}
const selectUpdatable = () => {
for (const project of projects.value) {
if (project.outdated) {
selectionMap.value.set(project.path, true)
}
}
}
const updateProject = async (mod) => {
mod.updating = true
await new Promise((resolve) => setTimeout(resolve, 0))
@ -753,6 +676,7 @@ const toggleDisableMod = async (mod) => {
}
const removeMod = async (mod) => {
console.log(mod)
await remove_project(props.instance.path, mod.path).catch(handleError)
projects.value = projects.value.filter((x) => mod.path !== x.path)
@ -771,20 +695,9 @@ const deleteSelected = async () => {
}
projects.value = projects.value.filter((x) => !x.selected)
deleteWarning.value.hide()
}
const deleteDisabled = async () => {
for (const project of Array.of(projects.value.values().filter((x) => x.disabled))) {
await remove_project(props.instance.path, project.path).catch(handleError)
}
projects.value = projects.value.filter((x) => !x.selected)
deleteDisabledWarning.value.hide()
}
const shareNames = async () => {
console.log(functionValues.value)
await shareModal.value.show(functionValues.value.map((x) => x.name).join('\n'))
}
@ -814,12 +727,6 @@ const shareMarkdown = async () => {
)
}
const toggleSelected = async () => {
for (const project of functionValues.value) {
await toggleDisableMod(project, !project.disabled)
}
}
const updateSelected = async () => {
const promises = []
for (const project of functionValues.value) {
@ -829,35 +736,23 @@ const updateSelected = async () => {
}
const enableAll = async () => {
const promises = []
for (const project of functionValues.value) {
if (project.disabled) {
await toggleDisableMod(project, false)
promises.push(toggleDisableMod(project))
}
}
await Promise.all(promises).catch(handleError)
}
const disableAll = async () => {
const promises = []
for (const project of functionValues.value) {
if (!project.disabled) {
await toggleDisableMod(project, false)
promises.push(toggleDisableMod(project))
}
}
}
const hideShowAll = async () => {
hideNonSelected.value = !hideNonSelected.value
}
const handleRightClick = (event, mod) => {
if (mod.slug && mod.project_type) {
props.options.showMenu(
event,
{
link: `https://modrinth.com/${mod.project_type}/${mod.slug}`,
},
[{ name: 'open_link' }, { name: 'copy_link' }],
)
}
await Promise.all(promises).catch(handleError)
}
watch(selectAll, () => {
@ -868,10 +763,6 @@ watch(selectAll, () => {
}
})
const switchPage = (page) => {
currentPage.value = page
}
const refreshingProjects = ref(false)
async function refreshProjects() {
refreshingProjects.value = true
@ -1173,16 +1064,6 @@ onUnmounted(() => {
</style>
<style lang="scss">
.updating-indicator {
height: 2.25rem !important;
width: 2.25rem !important;
svg {
height: 1.25rem !important;
width: 1.25rem !important;
}
}
.select-checkbox {
button.checkbox {
border: none;
@ -1190,13 +1071,23 @@ onUnmounted(() => {
}
}
.dropdown-input {
.selected {
height: 2.5rem;
}
.search-input {
min-height: 2.25rem;
background-color: var(--color-raised-bg);
}
.pagination-after {
margin-bottom: 5rem;
.top-box {
background-image: radial-gradient(
50% 100% at 50% 100%,
var(--color-brand-highlight) 10%,
#ffffff00 100%
);
}
.top-box-divider {
background-image: linear-gradient(90deg, #ffffff00 0%, var(--color-brand) 50%, #ffffff00 100%);
width: 100%;
height: 1px;
opacity: 0.8;
}
</style>

View File

@ -897,7 +897,6 @@ async function saveGvLoaderEdits() {
.change-versions-modal {
display: flex;
flex-direction: column;
padding: 1rem;
gap: 1rem;
:deep(.animated-dropdown .options) {

View File

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

View File

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

View File

@ -1,20 +1,15 @@
<script setup>
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
import GridDisplay from '@/components/GridDisplay.vue'
import { onUnmounted, ref, shallowRef } from 'vue'
import { list } from '@/helpers/profile.js'
import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { profile_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js'
import { Button } from '@modrinth/ui'
import { PlusIcon } from '@modrinth/assets'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { NewInstanceImage } from '@/assets/icons'
import { hide_ads_window } from '@/helpers/ads.js'
onMounted(() => {
hide_ads_window(true)
})
import NavTabs from '@/components/ui/NavTabs.vue'
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
@ -40,7 +35,20 @@ onUnmounted(() => {
</script>
<template>
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
<div class="p-6 flex flex-col gap-3">
<h1 class="m-0 text-2xl">Library</h1>
<NavTabs
:links="[
{ label: 'All instances', href: `/library` },
{ label: 'Downloaded', href: `/library/downloaded` },
{ label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<template v-if="instances.length > 0">
<RouterView :instances="instances" />
</template>
<div v-else class="no-instance">
<div class="icon">
<NewInstanceImage />
@ -52,6 +60,7 @@ onUnmounted(() => {
</Button>
<InstanceCreationModal ref="installationModal" />
</div>
</div>
</template>
<style lang="scss" scoped>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import * as Pages from '@/pages'
import * as Project from '@/pages/project'
import * as Instance from '@/pages/instance'
import * as Library from '@/pages/library'
/**
* Configures application routing. Add page to pages/index and then add to route table here.
@ -19,27 +20,36 @@ export default new createRouter({
},
{
path: '/browse/:projectType',
name: 'Browse',
name: 'Discover content',
component: Pages.Browse,
meta: {
breadcrumb: [{ name: 'Browse' }],
breadcrumb: [{ name: 'Discover content' }],
},
},
{
path: '/library',
name: 'Library',
component: Pages.Library,
component: Library.Index,
meta: {
breadcrumb: [{ name: 'Library' }],
},
children: [
{
path: '',
name: 'Overview',
component: Library.Overview,
},
{
path: '/settings',
name: 'Settings',
component: Pages.Settings,
meta: {
breadcrumb: [{ name: 'Settings' }],
path: 'downloaded',
name: 'Downloaded',
component: Library.Downloaded,
},
{
path: 'custom',
name: 'Custom',
component: Library.Custom,
},
],
},
{
path: '/project/:id',

View File

@ -5,6 +5,10 @@ export const useTheming = defineStore('themeStore', {
themeOptions: ['dark', 'light', 'oled'],
advancedRendering: true,
selectedTheme: 'dark',
devMode: false,
featureFlag_pagePath: false,
featureFlag_projectBackground: false,
}),
actions: {
setThemeState(newTheme) {

View File

@ -5,7 +5,7 @@ export default {
'./src/layouts/**/*.vue',
'./src/pages/**/*.vue',
'./src/plugins/**/*.{js,ts}',
'./src/app.vue',
'./src/App.vue',
'./src/error.vue',
// monorepo - TODO: migrate this to its own package
'../../packages/**/*.{js,vue,ts}',
@ -65,6 +65,9 @@ export default {
textHover: 'var(--color-button-text-hover)',
bgActive: 'var(--color-button-bg-active)',
textActive: 'var(--color-button-text-active)',
border: 'var(--color-button-border)',
bgSelected: 'var(--color-button-bg-selected)',
textSelected: 'var(--color-button-text-selected)',
},
toggleHandle: 'var(--color-toggle-handle)',
dropdown: {

View File

@ -1,3 +1,3 @@
HTML Testing playground for Theseus:
<br><br><a href="modrinth://mod/test_id">Install mod 'test_id'</a>
<br /><br /><a href="modrinth://mod/test_id">Install mod 'test_id'</a>

View File

@ -227,6 +227,19 @@ fn main() {
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"friends",
InlinedPlugin::new()
.commands(&[
"friends",
"friend_statuses",
"add_friend",
"remove_friend",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
),
)
.expect("Failed to run tauri-build");

View File

@ -5,10 +5,6 @@
"remote": {
"urls": ["https://modrinth.com/*", "http://localhost:3000/*"]
},
"webviews": [
"ads-window"
],
"permissions": [
"ads:default"
]
"webviews": ["ads-window"],
"permissions": ["ads:default"]
}

View File

@ -2,9 +2,7 @@
"identifier": "core",
"description": "",
"local": true,
"windows": [
"main"
],
"windows": ["main"],
"permissions": [
"core:default",
"core:path:default",

View File

@ -34,6 +34,7 @@
"settings:default",
"tags:default",
"utils:default",
"ads:default"
"ads:default",
"friends:default"
]
}

View File

@ -2,10 +2,6 @@
"identifier": "updater",
"description": "",
"local": true,
"windows": [
"main"
],
"permissions": [
"updater:default"
]
"windows": ["main"],
"permissions": ["updater:default"]
}

Binary file not shown.

View File

@ -1,8 +1,7 @@
use serde::Serialize;
use std::collections::HashSet;
use std::time::{Duration, Instant};
use tauri::plugin::TauriPlugin;
use tauri::{Emitter, LogicalPosition, LogicalSize, Manager, Runtime};
use tauri::{LogicalPosition, LogicalSize, Manager, Runtime};
use tauri_plugin_shell::ShellExt;
use theseus::settings;
use tokio::sync::RwLock;
@ -47,7 +46,6 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
init_ads_window,
hide_ads_window,
scroll_ads_window,
show_ads_window,
record_ads_click,
open_link,
@ -154,21 +152,6 @@ pub async fn hide_ads_window<R: Runtime>(
Ok(())
}
#[derive(Serialize, Clone)]
struct ScrollEvent {
scroll: f32,
}
#[tauri::command]
pub async fn scroll_ads_window<R: Runtime>(
app: tauri::AppHandle<R>,
scroll: f32,
) -> crate::api::Result<()> {
let _ = app.emit("ads-scroll", ScrollEvent { scroll });
Ok(())
}
#[tauri::command]
pub async fn record_ads_click<R: Runtime>(
app: tauri::AppHandle<R>,

View File

@ -0,0 +1,33 @@
use tauri::plugin::TauriPlugin;
use theseus::prelude::{UserFriend, UserStatus};
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("friends")
.invoke_handler(tauri::generate_handler![
friends,
friend_statuses,
add_friend,
remove_friend
])
.build()
}
#[tauri::command]
pub async fn friends() -> crate::api::Result<Vec<UserFriend>> {
Ok(theseus::friends::friends().await?)
}
#[tauri::command]
pub async fn friend_statuses() -> crate::api::Result<Vec<UserStatus>> {
Ok(theseus::friends::friend_statuses().await?)
}
#[tauri::command]
pub async fn add_friend(user_id: &str) -> crate::api::Result<()> {
Ok(theseus::friends::add_friend(user_id).await?)
}
#[tauri::command]
pub async fn remove_friend(user_id: &str) -> crate::api::Result<()> {
Ok(theseus::friends::remove_friend(user_id).await?)
}

View File

@ -18,6 +18,7 @@ pub mod utils;
pub mod ads;
pub mod cache;
pub mod friends;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;

View File

@ -42,7 +42,7 @@ fn position_traffic_lights(
let title_bar_container_view = close.superview().superview();
let close_rect: NSRect = msg_send![close, frame];
let button_height = close_rect.size.height;
let button_height = close_rect.size.height + 12.0;
let title_bar_frame_height = button_height + y;
let mut title_bar_rect = NSView::frame(title_bar_container_view);
@ -58,7 +58,7 @@ fn position_traffic_lights(
for (i, button) in window_buttons.into_iter().enumerate() {
let mut rect: NSRect = NSView::frame(button);
rect.origin.x = x + (i as f64 * space_between);
rect.origin.x = x + (i as f64 * space_between) + 6.0;
button.setFrameOrigin(rect.origin);
}
}

View File

@ -169,19 +169,19 @@ fn main() {
}
builder = builder
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(payload) = args.get(1) {
tracing::info!("Handling deep link from arg {payload}");
let payload = payload.clone();
tauri::async_runtime::spawn(api::utils::handle_command(
payload,
));
}
if let Some(win) = app.get_window("main") {
let _ = win.set_focus();
}
}))
// .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// if let Some(payload) = args.get(1) {
// tracing::info!("Handling deep link from arg {payload}");
// let payload = payload.clone();
// tauri::async_runtime::spawn(api::utils::handle_command(
// payload,
// ));
// }
//
// if let Some(win) = app.get_window("main") {
// let _ = win.set_focus();
// }
// }))
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_deep_link::init())
@ -260,6 +260,7 @@ fn main() {
.plugin(api::utils::init())
.plugin(api::cache::init())
.plugin(api::ads::init())
.plugin(api::friends::init())
.invoke_handler(tauri::generate_handler![
initialize_state,
is_dev,

View File

@ -11,12 +11,7 @@
"copyright": "",
"targets": "all",
"externalBin": [],
"icon": [
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
@ -72,7 +67,7 @@
"resizable": true,
"title": "Modrinth App",
"width": 1280,
"minHeight": 750,
"minHeight": 700,
"minWidth": 1100,
"visible": false,
"zoomHotkeysEnabled": false,
@ -81,20 +76,14 @@
],
"security": {
"assetProtocol": {
"scope": [
"$APPDATA/caches/icons/*",
"$APPCONFIG/caches/icons/*",
"$CONFIG/caches/icons/*"
],
"scope": ["$APPDATA/caches/icons/*", "$APPCONFIG/caches/icons/*", "$CONFIG/caches/icons/*"],
"enable": true
},
"capabilities": ["ads", "core", "plugins"],
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
"font-src": [
"https://cdn-raw.modrinth.com/fonts/inter/"
],
"font-src": ["https://cdn-raw.modrinth.com/fonts/inter/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
"style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com 'self'",

View File

@ -2,6 +2,7 @@
Daedalus is a powerful tool which queries and generates metadata for the Minecraft (and other games in the future!) game
and mod loaders for:
- Performance (Serving static files can be easily cached and is extremely quick)
- Ease for Launcher Devs (Metadata is served in an easy to query and use format)
- Reliability (Provides a versioning system which ensures no breakage with updates)

View File

@ -6,8 +6,8 @@ services:
volumes:
- minio-data:/data
ports:
- "9000:9000"
- "9001:9001"
- '9000:9000'
- '9001:9001'
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: miniosecret

View File

@ -63,9 +63,7 @@
},
{
"_comment": "Add missing tinyfd to the broken LWJGL 3.2.2 variant",
"match": [
"org.lwjgl:lwjgl:3.2.2"
],
"match": ["org.lwjgl:lwjgl:3.2.2"],
"additionalLibraries": [
{
"downloads": {
@ -114,9 +112,7 @@
},
{
"_comment": "Add additional library just for osx-arm64. No override needed",
"match": [
"ca.weblite:java-objc-bridge:1.0.0"
],
"match": ["ca.weblite:java-objc-bridge:1.0.0"],
"additionalLibraries": [
{
"downloads": {
@ -140,9 +136,7 @@
},
{
"_comment": "Add additional classifiers for jinput-platform",
"match": [
"net.java.jinput:jinput-platform:2.0.5"
],
"match": ["net.java.jinput:jinput-platform:2.0.5"],
"override": {
"downloads": {
"classifiers": {
@ -1628,9 +1622,7 @@
},
{
"_comment": "Only allow osx-arm64 for existing java-objc-bridge:1.1",
"match": [
"ca.weblite:java-objc-bridge:1.1"
],
"match": ["ca.weblite:java-objc-bridge:1.1"],
"override": {
"rules": [
{
@ -1666,9 +1658,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-glfw:3.3.1"
],
"match": ["org.lwjgl:lwjgl-glfw:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1692,9 +1682,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-jemalloc:3.3.1"
],
"match": ["org.lwjgl:lwjgl-jemalloc:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1718,9 +1706,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-openal:3.3.1"
],
"match": ["org.lwjgl:lwjgl-openal:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1744,9 +1730,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-opengl:3.3.1"
],
"match": ["org.lwjgl:lwjgl-opengl:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1770,9 +1754,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-stb:3.3.1"
],
"match": ["org.lwjgl:lwjgl-stb:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1796,9 +1778,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-tinyfd:3.3.1"
],
"match": ["org.lwjgl:lwjgl-tinyfd:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1822,9 +1802,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl:3.3.1"
],
"match": ["org.lwjgl:lwjgl:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1848,9 +1826,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-glfw:3.3.1"
],
"match": ["org.lwjgl:lwjgl-glfw:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1874,9 +1850,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-jemalloc:3.3.1"
],
"match": ["org.lwjgl:lwjgl-jemalloc:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1900,9 +1874,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-openal:3.3.1"
],
"match": ["org.lwjgl:lwjgl-openal:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1926,9 +1898,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-opengl:3.3.1"
],
"match": ["org.lwjgl:lwjgl-opengl:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1952,9 +1922,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-stb:3.3.1"
],
"match": ["org.lwjgl:lwjgl-stb:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -1978,9 +1946,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl-tinyfd:3.3.1"
],
"match": ["org.lwjgl:lwjgl-tinyfd:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -2004,9 +1970,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.1",
"match": [
"org.lwjgl:lwjgl:3.3.1"
],
"match": ["org.lwjgl:lwjgl:3.3.1"],
"additionalLibraries": [
{
"downloads": {
@ -2030,9 +1994,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-freetype:3.3.2"
],
"match": ["org.lwjgl:lwjgl-freetype:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2056,9 +2018,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-glfw:3.3.2"
],
"match": ["org.lwjgl:lwjgl-glfw:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2082,9 +2042,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-jemalloc:3.3.2"
],
"match": ["org.lwjgl:lwjgl-jemalloc:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2108,9 +2066,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-openal:3.3.2"
],
"match": ["org.lwjgl:lwjgl-openal:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2134,9 +2090,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-opengl:3.3.2"
],
"match": ["org.lwjgl:lwjgl-opengl:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2160,9 +2114,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-stb:3.3.2"
],
"match": ["org.lwjgl:lwjgl-stb:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2186,9 +2138,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-tinyfd:3.3.2"
],
"match": ["org.lwjgl:lwjgl-tinyfd:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2212,9 +2162,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl:3.3.2"
],
"match": ["org.lwjgl:lwjgl:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2238,9 +2186,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-freetype:3.3.2"
],
"match": ["org.lwjgl:lwjgl-freetype:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2264,9 +2210,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-glfw:3.3.2"
],
"match": ["org.lwjgl:lwjgl-glfw:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2290,9 +2234,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-jemalloc:3.3.2"
],
"match": ["org.lwjgl:lwjgl-jemalloc:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2316,9 +2258,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-openal:3.3.2"
],
"match": ["org.lwjgl:lwjgl-openal:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2342,9 +2282,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-opengl:3.3.2"
],
"match": ["org.lwjgl:lwjgl-opengl:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2368,9 +2306,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-stb:3.3.2"
],
"match": ["org.lwjgl:lwjgl-stb:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2394,9 +2330,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl-tinyfd:3.3.2"
],
"match": ["org.lwjgl:lwjgl-tinyfd:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2420,9 +2354,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.2",
"match": [
"org.lwjgl:lwjgl:3.3.2"
],
"match": ["org.lwjgl:lwjgl:3.3.2"],
"additionalLibraries": [
{
"downloads": {
@ -2446,9 +2378,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-freetype:3.3.3"
],
"match": ["org.lwjgl:lwjgl-freetype:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2472,9 +2402,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-glfw:3.3.3"
],
"match": ["org.lwjgl:lwjgl-glfw:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2498,9 +2426,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-jemalloc:3.3.3"
],
"match": ["org.lwjgl:lwjgl-jemalloc:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2524,9 +2450,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-openal:3.3.3"
],
"match": ["org.lwjgl:lwjgl-openal:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2550,9 +2474,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-opengl:3.3.3"
],
"match": ["org.lwjgl:lwjgl-opengl:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2576,9 +2498,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-stb:3.3.3"
],
"match": ["org.lwjgl:lwjgl-stb:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2602,9 +2522,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-tinyfd:3.3.3"
],
"match": ["org.lwjgl:lwjgl-tinyfd:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2628,9 +2546,7 @@
},
{
"_comment": "Add linux-arm64 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl:3.3.3"
],
"match": ["org.lwjgl:lwjgl:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2654,9 +2570,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-freetype:3.3.3"
],
"match": ["org.lwjgl:lwjgl-freetype:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2680,9 +2594,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-glfw:3.3.3"
],
"match": ["org.lwjgl:lwjgl-glfw:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2706,9 +2618,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-jemalloc:3.3.3"
],
"match": ["org.lwjgl:lwjgl-jemalloc:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2732,9 +2642,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-openal:3.3.3"
],
"match": ["org.lwjgl:lwjgl-openal:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2758,9 +2666,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-opengl:3.3.3"
],
"match": ["org.lwjgl:lwjgl-opengl:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2784,9 +2690,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-stb:3.3.3"
],
"match": ["org.lwjgl:lwjgl-stb:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2810,9 +2714,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl-tinyfd:3.3.3"
],
"match": ["org.lwjgl:lwjgl-tinyfd:3.3.3"],
"additionalLibraries": [
{
"downloads": {
@ -2836,9 +2738,7 @@
},
{
"_comment": "Add linux-arm32 support for LWJGL 3.3.3",
"match": [
"org.lwjgl:lwjgl:3.3.3"
],
"match": ["org.lwjgl:lwjgl:3.3.3"],
"additionalLibraries": [
{
"downloads": {

View File

@ -354,7 +354,7 @@ components:
items:
type: string
description: The mod loaders that this version supports. In case of resource packs, use "minecraft"
example: ["fabric", "forge", "minecraft"]
example: ['fabric', 'forge', 'minecraft']
featured:
type: boolean
description: Whether the version is featured or not

View File

@ -6,19 +6,21 @@
- title: __Welcome to Modrinth's Discord server!__
url: https://modrinth.com
color: 0x1bd96a
description: "Modrinth is the place for Minecraft mods, plugins, data packs, shaders, resource packs, and
modpacks. Discover, play, and share Minecraft content through our open-source platform built for the community."
description:
'Modrinth is the place for Minecraft mods, plugins, data packs, shaders, resource packs, and
modpacks. Discover, play, and share Minecraft content through our open-source platform built for the community.'
- type: embed
embeds:
- title: "**:scroll: __Rules__**"
- title: '**:scroll: __Rules__**'
color: 0x4f9cff
description: "Modrinth's rules are easy to follow. Despite this, please keep in mind that this is not an entirely
description:
"Modrinth's rules are easy to follow. Despite this, please keep in mind that this is not an entirely
open forum. First and foremost, this Discord server is intended to facilitate the development of Modrinth and
for communication regarding Modrinth. Ultimately, it is up to the discretion of the moderators whether your
messages are in violation of our rules.\n\n
Modrinth's rules are split up into two categories: the **__DOs__** and the **__DO NOTs__**."
- title: ":white_check_mark: Do:"
- title: ':white_check_mark: Do:'
color: 0x1bd96a
description: >-
1. Treat every user with respect and consider the opinions and viewpoints of others
@ -33,7 +35,7 @@
4. Contact the moderators at any time via the <@&895382919772766219> ping
5. Respect the use of accessibility and self-identity tools such as [PluralKit](https://pluralkit.me)
- title: ":no_entry: Do not:"
- title: ':no_entry: Do not:'
color: 0xff496e
description: >-
6. Harass, bother, provoke, or insult anyone, including by sending unsolicited DMs or friend requests
@ -46,7 +48,7 @@
9. Report Modrinth content in the Discord (use the Report button on the website)
10. Assume staff member's opinions reflect those of Modrinth
- title: ":pencil2: Nickname policy:"
- title: ':pencil2: Nickname policy:'
color: 0xffa347
description: >-
We want to keep this server clean and therefore require that display names of all members on the server are
@ -60,7 +62,7 @@
from the server. We will also permanently remove any users whose profiles contain inappropriate content.
- type: links
color: 0x4f9cff
title: "**:link: __Links__**"
title: '**:link: __Links__**'
links:
Website: https://modrinth.com
Support: https://support.modrinth.com

View File

@ -1,6 +1,6 @@
import { defineCollection } from 'astro:content';
import { docsSchema } from '@astrojs/starlight/schema';
import { defineCollection } from 'astro:content'
import { docsSchema } from '@astrojs/starlight/schema'
export const collections = {
docs: defineCollection({ schema: docsSchema() }),
};
}

View File

@ -23,7 +23,7 @@
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"glob": "^10.2.7",
"nuxt": "^3.12.3",
"nuxt": "^3.14.1592",
"postcss": "^8.4.39",
"prettier-plugin-tailwindcss": "^0.6.5",
"sass": "^1.58.0",
@ -62,5 +62,6 @@
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.5.2",
"xss": "^1.0.14"
}
},
"web-types": "../../web-types.json"
}

View File

@ -399,22 +399,6 @@
}
}
.v-popper--theme-tooltip {
.v-popper__inner {
background: var(--color-tooltip-bg) !important;
color: var(--color-tooltip-text) !important;
padding: 5px 10px 4px !important;
border-radius: var(--size-rounded-tooltip) !important;
box-shadow: var(--shadow-floating) !important;
font-size: 0.9rem !important;
}
.v-popper__arrow-outer,
.v-popper__arrow-inner {
border-color: var(--color-tooltip-bg) !important;
}
}
.button-base {
@extend .button-animation;
font-weight: 500;
@ -1232,6 +1216,7 @@ svg.inline-svg {
font-size: var(--text-18);
font-weight: var(--weight-extrabold);
color: var(--color-contrast);
line-height: initial;
margin: 0;
}

View File

@ -37,92 +37,8 @@ html {
--icon-20: 1.25rem; // used for icons in normal sized buttons
--icon-24: 1.5rem; // used for icons that are used as a primary label or in large buttons
--icon-32: 2rem;
}
.experimental-styles-within {
// Reset deprecated properties
--color-icon: initial !important;
--color-text: initial !important;
--color-text-inactive: initial !important;
--color-text-dark: initial !important;
--color-heading: initial !important;
--color-divider: initial !important;
--color-divider-dark: initial !important;
--color-text-inverted: initial !important;
--color-bg-inverted: initial !important;
--color-brand: var(--color-green) !important;
--color-brand-inverted: initial !important;
--tab-underline-hovered: initial !important;
--color-button-text: initial !important;
--color-button-bg-hover: initial !important;
--color-button-text-hover: initial !important;
--color-button-bg-active: initial !important;
--color-button-text-active: initial !important;
--color-grey-link: inherit !important;
--color-grey-link-hover: inherit !important; // DEPRECATED, use filters in future
--color-grey-link-active: inherit !important; // DEPRECATED, use filters in future
--color-link: var(--color-blue) !important;
--color-link-hover: var(--color-blue) !important; // DEPRECATED, use filters in future
--color-link-active: var(--color-blue) !important; // DEPRECATED, use filters in future
}
.light-mode,
.light {
.experimental-styles-within,
&.experimental-styles-within {
--color-bg: #ebebeb;
--color-raised-bg: #ffffff;
--color-button-bg: #f5f5f5;
--color-base: #2c2e31;
--color-secondary: #484d54;
--color-accent-contrast: #ffffff;
--color-platform-fabric: #8a7b71;
--color-platform-quilt: #8b61b4;
--color-platform-forge: #5b6197;
--color-platform-neoforge: #dc895c;
--color-platform-liteloader: #4c90de;
--color-platform-bukkit: #e78362;
--color-platform-bungeecord: #c69e39;
--color-platform-folia: #6aa54f;
--color-platform-paper: #e67e7e;
--color-platform-purpur: #7763a3;
--color-platform-spigot: #cd7a21;
--color-platform-velocity: #4b98b0;
--color-platform-waterfall: #5f83cb;
--color-platform-sponge: #c49528;
--color-button-border: rgba(161, 161, 161, 0.35);
}
}
.dark-mode,
.dark {
.experimental-styles-within,
&.experimental-styles-within {
--color-button-bg: #33363d;
--color-platform-fabric: #dbb69b;
--color-platform-quilt: #c796f9;
--color-platform-forge: #959eef;
--color-platform-neoforge: #f99e6b;
--color-platform-liteloader: #7ab0ee;
--color-platform-bukkit: #f6af7b;
--color-platform-bungeecord: #d2c080;
--color-platform-folia: #a5e388;
--color-platform-paper: #eeaaaa;
--color-platform-purpur: #c3abf7;
--color-platform-spigot: #f1cc84;
--color-platform-velocity: #83d5ef;
--color-platform-waterfall: #78a4fb;
--color-platform-sponge: #f9e580;
--color-button-border: rgba(193, 190, 209, 0.12);
}
interpolate-size: allow-keywords;
}
.light-mode {
@ -159,9 +75,6 @@ html {
--color-dropdown-bg: var(--color-button-bg);
--color-dropdown-text: var(--color-button-text);
--color-tooltip-bg: var(--color-text);
--color-tooltip-text: var(--color-bg);
--color-code-bg: var(--color-bg);
--color-code-text: var(--color-text-dark);
@ -179,12 +92,6 @@ html {
--color-link-hover: #1a76e7;
--color-link-active: #146fd7;
--color-red-bg: rgba(203, 34, 69, 0.1);
--color-orange-bg: rgba(224, 131, 37, 0.1);
--color-green-bg: rgba(0, 175, 92, 0.1);
--color-blue-bg: rgba(31, 104, 192, 0.1);
--color-purple-bg: rgba(142, 50, 243, 0.1);
--color-warning-bg: hsl(355, 70%, 88%);
--color-warning-text: hsl(342, 70%, 35%);
@ -275,12 +182,6 @@ html {
--color-text-inverted: var(--color-bg);
--color-bg-inverted: var(--color-text);
--color-red-bg: rgba(255, 73, 110, 0.2);
--color-orange-bg: rgba(255, 163, 71, 0.2);
--color-green-bg: rgba(27, 217, 106, 0.2);
--color-blue-bg: rgba(79, 156, 255, 0.2);
--color-purple-bg: rgba(199, 138, 255, 0.2);
--color-brand: var(--color-green);
--color-brand-highlight: rgba(27, 217, 106, 0.25);
--color-brand-shadow: rgba(27, 217, 106, 0.7);
@ -300,9 +201,6 @@ html {
--color-dropdown-bg: var(--color-button-bg);
--color-dropdown-text: var(--color-button-text);
--color-tooltip-bg: var(--color-button-bg);
--color-tooltip-text: var(--color-text);
--color-code-bg: var(--color-button-bg);
--color-code-text: var(--color-text-dark);

View File

@ -39,7 +39,7 @@
.normal-page {
display: grid;
padding: 0 0.75rem;
padding: 0 1.5rem;
grid-template:
"sidebar"
@ -115,7 +115,7 @@
}
.normal-page__content {
max-width: calc(80rem - 18.75rem - 0.75rem);
max-width: calc(80rem - 18.75rem - 1.5rem);
//overflow-x: hidden;
}
}
@ -125,7 +125,7 @@
margin: 0 auto;
max-width: 80rem;
column-gap: 0.75rem;
padding: 0 0.75rem;
padding: 0 1.5rem;
grid-template:
"header"
@ -162,7 +162,7 @@
.normal-page__content {
grid-area: content;
max-width: calc(80rem - 18.75rem - 0.75rem);
max-width: calc(80rem - 18.75rem - 1.5rem);
//overflow-x: hidden;
}

View File

@ -3,7 +3,7 @@
<ButtonStyled v-if="!!slots.title" :type="type">
<button class="!w-full" @click="() => (isOpen ? close() : open())">
<slot name="title" /><DropdownIcon
class="ml-auto size-5 transition-transform duration-300"
class="ml-auto size-5 text-contrast transition-transform duration-300"
:class="{ 'rotate-180': isOpen }"
/>
</button>
@ -62,6 +62,9 @@ defineOptions({
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
animation: height-animate 500ms ease-in-out both;
content-visibility: auto;
animation-composition: replace;
}
@media (prefers-reduced-motion) {
@ -77,4 +80,13 @@ defineOptions({
.accordion-content > div {
overflow: hidden;
}
@keyframes height-animate {
from {
block-size: initial;
}
to {
block-size: auto;
}
}
</style>

View File

@ -4,7 +4,7 @@
'badge flex items-center gap-1 font-semibold text-secondary ' + color + ' type--' + type
"
>
<template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }}</template>
<template v-if="color"> <span class="circle" /> {{ capitalizeString(type) }}</template>
<!-- User roles -->
<template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team</template>
@ -36,25 +36,28 @@
<template v-else-if="type === 'closed'"> <CloseIcon /> Closed</template>
<!-- Other -->
<template v-else> <span class="circle" /> {{ $capitalizeString(type) }} </template>
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
</span>
</template>
<script setup>
import { GlobeIcon, LinkIcon } from "@modrinth/assets";
import ModrinthIcon from "~/assets/images/logo.svg?component";
import PlusIcon from "~/assets/images/utils/plus.svg?component";
import ModeratorIcon from "~/assets/images/sidebar/admin.svg?component";
import CreatorIcon from "~/assets/images/utils/box.svg?component";
import DraftIcon from "~/assets/images/utils/file-text.svg?component";
import CrossIcon from "~/assets/images/utils/x.svg?component";
import ArchiveIcon from "~/assets/images/utils/archive.svg?component";
import ProcessingIcon from "~/assets/images/utils/updated.svg?component";
import CheckIcon from "~/assets/images/utils/check.svg?component";
import LockIcon from "~/assets/images/utils/lock.svg?component";
import CalendarIcon from "~/assets/images/utils/calendar.svg?component";
import CloseIcon from "~/assets/images/utils/check-circle.svg?component";
import {
GlobeIcon,
LinkIcon,
ModrinthIcon,
PlusIcon,
ScaleIcon as ModeratorIcon,
BoxIcon as CreatorIcon,
FileTextIcon as DraftIcon,
XIcon as CrossIcon,
ArchiveIcon,
UpdatedIcon as ProcessingIcon,
CheckIcon,
LockIcon,
CalendarIcon,
XCircleIcon as CloseIcon,
} from "@modrinth/assets";
import { capitalizeString } from "@modrinth/utils";
defineProps({
type: {

View File

@ -1,7 +1,7 @@
<template>
<nav
ref="scrollContainer"
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<NuxtLink
v-for="(link, index) in filteredLinks"
@ -11,7 +11,7 @@
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="{
'text-brand': activeIndex === index && !subpageSelected,
'text-button-textSelected': activeIndex === index && !subpageSelected,
'text-contrast': activeIndex === index && subpageSelected,
}"
>
@ -20,7 +20,7 @@
</NuxtLink>
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
}`"
:style="{
left: sliderLeftPx,
@ -161,4 +161,8 @@ watch(
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
.card-shadow {
box-shadow: var(--shadow-card);
}
</style>

View File

@ -108,7 +108,7 @@
type="search"
name="search"
autocomplete="off"
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-button-bg bg-transparent py-2 pl-9"
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-divider bg-transparent py-2 pl-9"
placeholder="Search..."
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
/>

View File

@ -25,7 +25,7 @@
</div>
<div
class="border-0 border-b border-solid"
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-button-bg'"
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-divider'"
></div>
<div class="mt-2 h-full w-full overflow-auto px-6">
<slot />

View File

@ -25,7 +25,7 @@
v-if="isOpen"
ref="menuRef"
data-pyro-telepopover-root
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-2 shadow-lg"
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-divider bg-bg-raised p-2 shadow-lg"
:style="menuStyle"
role="menu"
tabindex="-1"
@ -272,7 +272,7 @@ const handleItemClick = (option: Option, index: number) => {
const handleMouseOver = (index: number) => {
selectedIndex.value = index;
menuItemsRef.value[selectedIndex.value].focus();
menuItemsRef.value[selectedIndex.value].focus?.();
};
// Scrolling is disabled for keyboard navigation
@ -295,7 +295,7 @@ const enableBodyScroll = () => {
const focusFirstMenuItem = () => {
if (menuItemsRef.value.length > 0) {
menuItemsRef.value[0].focus();
menuItemsRef.value[0].focus?.();
}
};
@ -312,26 +312,26 @@ const handleKeydown = (event: KeyboardEvent) => {
case "ArrowDown":
event.preventDefault();
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
menuItemsRef.value[selectedIndex.value].focus();
menuItemsRef.value[selectedIndex.value].focus?.();
break;
case "ArrowUp":
event.preventDefault();
selectedIndex.value =
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length;
menuItemsRef.value[selectedIndex.value].focus();
menuItemsRef.value[selectedIndex.value].focus?.();
break;
case "Home":
event.preventDefault();
if (menuItemsRef.value.length > 0) {
selectedIndex.value = 0;
menuItemsRef.value[selectedIndex.value].focus();
menuItemsRef.value[selectedIndex.value].focus?.();
}
break;
case "End":
event.preventDefault();
if (menuItemsRef.value.length > 0) {
selectedIndex.value = filteredOptions.value.length - 1;
menuItemsRef.value[selectedIndex.value].focus();
menuItemsRef.value[selectedIndex.value].focus?.();
}
break;
case "Enter":
@ -344,7 +344,7 @@ const handleKeydown = (event: KeyboardEvent) => {
case "Escape":
event.preventDefault();
closeMenu();
triggerRef.value?.focus();
triggerRef.value?.focus?.();
break;
case "Tab":
event.preventDefault();
@ -355,7 +355,7 @@ const handleKeydown = (event: KeyboardEvent) => {
} else {
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
}
menuItemsRef.value[selectedIndex.value].focus();
menuItemsRef.value[selectedIndex.value].focus?.();
}
break;
default:
@ -366,7 +366,7 @@ const handleKeydown = (event: KeyboardEvent) => {
);
if (matchIndex !== -1) {
selectedIndex.value = matchIndex;
menuItemsRef.value[selectedIndex.value].focus();
menuItemsRef.value[selectedIndex.value].focus?.();
}
if (typeAheadTimeout.value) {
clearTimeout(typeAheadTimeout.value);

View File

@ -8,10 +8,9 @@
}"
>
<template v-if="members[message.author_id]">
<ConditionalNuxtLink
<AutoLink
class="message__icon"
:is-link="!noLinks"
:to="`/user/${members[message.author_id].username}`"
:to="noLinks ? '' : `/user/${members[message.author_id].username}`"
tabindex="-1"
aria-hidden="true"
>
@ -21,19 +20,16 @@
circle
:raised="raised"
/>
</ConditionalNuxtLink>
</AutoLink>
<span :class="`message__author role-${members[message.author_id].role}`">
<LockIcon
v-if="message.body.private"
v-tooltip="'Only visible to moderators'"
class="private-icon"
/>
<ConditionalNuxtLink
:is-link="!noLinks"
:to="`/user/${members[message.author_id].username}`"
>
<AutoLink :to="noLinks ? '' : `/user/${members[message.author_id].username}`">
{{ members[message.author_id].username }}
</ConditionalNuxtLink>
</AutoLink>
<ScaleIcon v-if="members[message.author_id].role === 'moderator'" v-tooltip="'Moderator'" />
<ModrinthIcon
v-else-if="members[message.author_id].role === 'admin'"
@ -107,7 +103,7 @@ import {
ModrinthIcon,
ScaleIcon,
} from "@modrinth/assets";
import { OverflowMenu, ConditionalNuxtLink } from "@modrinth/ui";
import { AutoLink, OverflowMenu } from "@modrinth/ui";
import { renderString } from "@modrinth/utils";
import Avatar from "~/components/ui/Avatar.vue";
import Badge from "~/components/ui/Badge.vue";
@ -287,7 +283,7 @@ a:active + .message__author a,
}
.moderation-color,
role-moderator {
.role-moderator {
color: var(--color-orange);
}

Some files were not shown because too many files have changed in this diff Show More