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

@ -56,4 +56,4 @@ body:
label: Additional context label: Additional context
description: Add any other context about the problem here. description: Add any other context about the problem here.
validations: validations:
required: false required: false

View File

@ -43,4 +43,4 @@ body:
label: Additional context label: Additional context
description: Add any other context or screenshots about the suggested enhancement here. description: Add any other context or screenshots about the suggested enhancement here.
validations: validations:
required: false required: false

View File

@ -11,4 +11,4 @@ contact_links:
url: https://roadmap.modrinth.com url: https://roadmap.modrinth.com
- name: 📚 Documentation - name: 📚 Documentation
about: Useful documentation about Modrinth's API about: Useful documentation about Modrinth's API
url: https://docs.modrinth.com url: https://docs.modrinth.com

View File

@ -8,12 +8,12 @@ on:
- .github/workflows/daedalus-docker.yml - .github/workflows/daedalus-docker.yml
- 'apps/daedalus_client/**' - 'apps/daedalus_client/**'
pull_request: pull_request:
types: [ opened, synchronize ] types: [opened, synchronize]
paths: paths:
- .github/workflows/daedalus-docker.yml - .github/workflows/daedalus-docker.yml
- 'apps/daedalus_client/**' - 'apps/daedalus_client/**'
merge_group: merge_group:
types: [ checks_requested ] types: [checks_requested]
jobs: jobs:
docker: docker:
@ -26,15 +26,13 @@ jobs:
uses: docker/metadata-action@v3 uses: docker/metadata-action@v3
with: with:
images: ghcr.io/modrinth/daedalus images: ghcr.io/modrinth/daedalus
- - name: Login to GitHub Images
name: Login to GitHub Images
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- - name: Build and push
name: Build and push
id: docker_build id: docker_build
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:

View File

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

View File

@ -8,12 +8,12 @@ on:
- .github/workflows/labrinth-docker.yml - .github/workflows/labrinth-docker.yml
- 'apps/labrinth/**' - 'apps/labrinth/**'
pull_request: pull_request:
types: [ opened, synchronize ] types: [opened, synchronize]
paths: paths:
- .github/workflows/labrinth-docker.yml - .github/workflows/labrinth-docker.yml
- 'apps/labrinth/**' - 'apps/labrinth/**'
merge_group: merge_group:
types: [ checks_requested ] types: [checks_requested]
jobs: jobs:
docker: docker:
@ -29,19 +29,17 @@ jobs:
uses: docker/metadata-action@v3 uses: docker/metadata-action@v3
with: with:
images: ghcr.io/modrinth/labrinth images: ghcr.io/modrinth/labrinth
- - name: Login to GitHub Images
name: Login to GitHub Images
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- - name: Build and push
name: Build and push
id: docker_build id: docker_build
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
context: ./apps/labrinth context: ./apps/labrinth
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }} labels: ${{ steps.docker_meta.outputs.labels }}

View File

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

View File

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

View File

@ -8,7 +8,8 @@
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "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": { "dependencies": {
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
@ -28,12 +29,13 @@
"pinia": "^2.1.7", "pinia": "^2.1.7",
"posthog-js": "^1.158.2", "posthog-js": "^1.158.2",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue": "^3.4.21", "vue": "^3.5.13",
"vue-multiselect": "3.0.0", "vue-multiselect": "3.0.0",
"vue-router": "4.3.0", "vue-router": "4.3.0",
"vue-virtual-scroller": "v2.0.0-beta.8" "vue-virtual-scroller": "v2.0.0-beta.8"
}, },
"devDependencies": { "devDependencies": {
"@formatjs/cli": "^6.2.12",
"@eslint/compat": "^1.1.1", "@eslint/compat": "^1.1.1",
"@nuxt/eslint-config": "^0.5.6", "@nuxt/eslint-config": "^0.5.6",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
@ -47,8 +49,9 @@
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"tsconfig": "workspace:*", "tsconfig": "workspace:*",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.2.8", "vite": "^5.4.6",
"vue-tsc": "^2.1.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> <script setup>
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted, watch, onUnmounted } from 'vue'
import { RouterView, RouterLink, useRouter, useRoute } from 'vue-router' import { RouterView, useRouter, useRoute } from 'vue-router'
import { import {
ArrowBigUpDashIcon,
LogInIcon,
HomeIcon, HomeIcon,
SearchIcon,
LibraryIcon, LibraryIcon,
PlusIcon, PlusIcon,
SettingsIcon, SettingsIcon,
XIcon, XIcon,
DownloadIcon, DownloadIcon,
CompassIcon,
MinimizeIcon,
MaximizeIcon,
RestoreIcon,
LogOutIcon,
} from '@modrinth/assets' } 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 { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue' import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue' import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings' import { get } from '@/helpers/settings'
@ -19,10 +26,9 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue' import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue' import SplashScreen from '@/components/ui/SplashScreen.vue'
import ErrorModal from '@/components/ui/ErrorModal.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 { handleError, useNotifications } from '@/store/notifications.js'
import { command_listener, warning_listener } from '@/helpers/events.js' import { command_listener, warning_listener } from '@/helpers/events.js'
import { MinimizeIcon, MaximizeIcon } from '@/assets/icons'
import { type } from '@tauri-apps/plugin-os' import { type } from '@tauri-apps/plugin-os'
import { isDev, getOS, restartApp } from '@/helpers/utils.js' import { isDev, getOS, restartApp } from '@/helpers/utils.js'
import { initAnalytics, debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics' 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 InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import { useInstall } from '@/store/install.js' import { useInstall } from '@/store/install.js'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-shell'
import { get_opening_command, initialize_state } from '@/helpers/state' import { get_opening_command, initialize_state } from '@/helpers/state'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state' import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { renderString } from '@modrinth/utils' import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js' import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater' 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 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 urlModal = ref(null)
const offline = ref(!navigator.onLine) const offline = ref(!navigator.onLine)
@ -65,8 +107,18 @@ const stateInitialized = ref(false)
const criticalErrorMessage = ref() const criticalErrorMessage = ref()
const isMaximized = ref(false)
onMounted(async () => { onMounted(async () => {
await useCheckDisableMouseover() 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() { async function setupApp() {
@ -97,6 +149,12 @@ async function setupApp() {
themeStore.collapsedNavigation = collapsed_navigation themeStore.collapsedNavigation = collapsed_navigation
themeStore.advancedRendering = advanced_rendering themeStore.advancedRendering = advanced_rendering
isMaximized.value = await getCurrentWindow().isMaximized()
await getCurrentWindow().onResized(async () => {
isMaximized.value = await getCurrentWindow().isMaximized()
})
initAnalytics() initAnalytics()
if (!telemetry) { if (!telemetry) {
optOutAnalytics() optOutAnalytics()
@ -160,7 +218,6 @@ router.afterEach((to, from, failure) => {
trackEvent('PageView', { path: to.path, fromPath: from.path, failed: failure }) trackEvent('PageView', { path: to.path, fromPath: from.path, failed: failure })
}) })
const route = useRoute() const route = useRoute()
const isOnBrowse = computed(() => route.path.startsWith('/browse'))
const loading = useLoading() const loading = useLoading()
loading.setEnabled(false) loading.setEnabled(false)
@ -176,6 +233,46 @@ const modInstallModal = ref()
const installConfirmModal = ref() const installConfirmModal = ref()
const incompatibilityWarningModal = 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(() => { onMounted(() => {
invoke('show_window') invoke('show_window')
@ -186,41 +283,8 @@ onMounted(() => {
install.setIncompatibilityWarningModal(incompatibilityWarningModal) install.setIncompatibilityWarningModal(incompatibilityWarningModal)
install.setInstallConfirmModal(installConfirmModal) install.setInstallConfirmModal(installConfirmModal)
install.setModInstallModal(modInstallModal) install.setModInstallModal(modInstallModal)
})
document.querySelector('body').addEventListener('click', function (e) { fetchCredentials()
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)
}
}) })
const accounts = ref(null) const accounts = ref(null)
@ -246,7 +310,6 @@ async function handleCommand(e) {
const updateAvailable = ref(false) const updateAvailable = ref(false)
async function checkUpdates() { async function checkUpdates() {
const update = await check() const update = await check()
console.log(update)
updateAvailable.value = !!update updateAvailable.value = !!update
setTimeout( setTimeout(
@ -256,76 +319,129 @@ async function checkUpdates() {
5 * 1000 * 60, 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> </script>
<template> <template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region /> <SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<div v-if="stateInitialized" class="app-container"> <Suspense>
<div class="nav-container"> <AppSettingsModal ref="settingsModal" />
<div class="nav-section"> </Suspense>
<suspense> <Suspense>
<AccountsCard ref="accounts" mode="small" /> <InstanceCreationModal ref="installationModal" />
</suspense> </Suspense>
<div class="pages-list"> <div v-if="stateInitialized" class="app-grid-layout relative">
<RouterLink v-tooltip="'Home'" to="/" class="btn icon-only collapsed-button"> <div
<HomeIcon /> class="app-grid-navbar bg-bg-raised flex flex-col p-[1rem] pt-0 gap-[0.5rem] z-10 w-[--left-bar-width]"
</RouterLink> >
<RouterLink <NavButton to="/">
v-tooltip="'Browse'" <HomeIcon />
to="/browse/modpack" <template #label>Home</template>
class="btn icon-only collapsed-button" </NavButton>
:class="{ <NavButton
'router-link-active': isOnBrowse, to="/browse/modpack"
}" :is-primary="() => route.path.startsWith('/browse') && !route.query.i"
> :is-subpage="(route) => route.path.startsWith('/project') && !route.query.i"
<SearchIcon /> >
</RouterLink> <CompassIcon />
<RouterLink v-tooltip="'Library'" to="/library" class="btn icon-only collapsed-button"> <template #label>Discover content</template>
<LibraryIcon /> </NavButton>
</RouterLink> <NavButton
<Suspense> to="/library"
<InstanceCreationModal ref="installationModal" /> :is-subpage="
</Suspense> () =>
</div> route.path.startsWith('/instance') ||
</div> ((route.path.startsWith('/browse') || route.path.startsWith('/project')) &&
<div class="settings pages-list"> route.query.i)
<button "
v-if="updateAvailable" >
v-tooltip="'Install update'" <LibraryIcon />
class="btn btn-outline btn-primary icon-only collapsed-button" <template #label>Library</template>
@click="restartApp()" </NavButton>
<div class="h-px w-6 mx-auto my-2 bg-button-bg"></div>
<NavButton :to="() => $refs.installationModal.show()" :disabled="offline">
<PlusIcon />
<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 />
<template #label>Settings</template>
</NavButton>
<ButtonStyled v-if="credentials" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
direction="left"
> >
<DownloadIcon /> <Avatar
</button> :src="credentials.user.avatar_url"
<Button :alt="credentials.user.username"
v-tooltip="'Create profile'" size="32px"
class="sleek-primary collapsed-button" circle
icon-only />
:disabled="offline" <template #sign-out> <LogOutIcon /> Sign out </template>
@click="() => $refs.installationModal.show()" </OverflowMenu>
> </ButtonStyled>
<PlusIcon /> <NavButton v-else :to="() => signIn()">
</Button> <LogInIcon />
<RouterLink v-tooltip="'Settings'" to="/settings" class="btn icon-only collapsed-button"> <template #label>Sign in</template>
<SettingsIcon /> </NavButton>
</RouterLink>
</div>
</div> </div>
<div class="view"> <div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div v-if="criticalErrorMessage" class="critical-error-banner" data-tauri-drag-region> <div data-tauri-drag-region class="flex p-4">
<h1>{{ criticalErrorMessage.header }}</h1> <ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<div class="markdown-body" v-html="renderString(criticalErrorMessage.body ?? '')"></div> <Breadcrumbs />
</div> </div>
<div class="appbar-row"> <section class="flex ml-auto">
<div data-tauri-drag-region class="appbar"> <div class="flex mr-3">
<section class="navigation-controls"> <Suspense>
<Breadcrumbs data-tauri-drag-region /> <RunningAppBar />
</section> </Suspense>
<section class="mod-stats">
<Suspense>
<RunningAppBar />
</Suspense>
</section>
</div> </div>
<section v-if="!nativeDecorations" class="window-controls"> <section v-if="!nativeDecorations" class="window-controls">
<Button class="titlebar-button" icon-only @click="() => getCurrentWindow().minimize()"> <Button class="titlebar-button" icon-only @click="() => getCurrentWindow().minimize()">
@ -336,30 +452,121 @@ async function checkUpdates() {
icon-only icon-only
@click="() => getCurrentWindow().toggleMaximize()" @click="() => getCurrentWindow().toggleMaximize()"
> >
<MaximizeIcon /> <RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</Button> </Button>
<Button class="titlebar-button close" icon-only @click="handleClose"> <Button class="titlebar-button close" icon-only @click="handleClose">
<XIcon /> <XIcon />
</Button> </Button>
</section> </section>
</section>
</div>
</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>
<div class="router-view"> <div
<ModrinthLoadingIndicator v-if="themeStore.featureFlag_pagePath"
offset-height="var(--appbar-height)" 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"
offset-width="var(--sidebar-width)" >
/> {{ route.fullPath }}
<RouterView v-slot="{ Component }"> </div>
<template v-if="Component"> <div
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()"> id="background-teleport-target"
<component :is="Component"></component> class="absolute h-full -z-10 rounded-tl-[--radius-xl] overflow-hidden"
</Suspense> :style="{
</template> width: 'calc(100% - var(--right-bar-width))',
</RouterView> }"
></div>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
<component :is="Component"></component>
</Suspense>
</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> </div>
</div> </div>
<URLConfirmModal ref="urlModal" /> <URLConfirmModal ref="urlModal" />
<Notifications ref="notificationsWrapper" /> <Notifications ref="notificationsWrapper" sidebar />
<ErrorModal ref="errorModal" /> <ErrorModal ref="errorModal" />
<ModInstallModal ref="modInstallModal" /> <ModInstallModal ref="modInstallModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" /> <IncompatibilityWarningModal ref="incompatibilityWarningModal" />
@ -394,31 +601,61 @@ async function checkUpdates() {
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all ease-in-out 0.1s; transition: all ease-in-out 0.1s;
background-color: var(--color-raised-bg); background-color: transparent;
color: var(--color-base); color: var(--color-base);
border-radius: 0; height: 100%;
height: 3.25rem; 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 { &.close {
&:hover, &:hover,
&:active { &:active {
background-color: var(--color-red);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
&::before {
background-color: var(--color-red);
}
} }
} }
&:hover, &:hover,
&:active { &:active {
background-color: var(--color-button-bg);
color: var(--color-contrast); color: var(--color-contrast);
&::before {
background-color: var(--color-button-bg);
scale: 1;
}
} }
} }
} }
.app-container { .app-container {
--appbar-height: 3.25rem;
--sidebar-width: 4.5rem;
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -546,16 +783,139 @@ async function checkUpdates() {
padding: var(--gap-sm) 0; 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>
<style> <style>
.mac { .mac {
.nav-container { .app-grid-statusbar {
padding-top: calc(var(--gap-md) + 1.75rem); padding-left: 5rem;
}
.account-card,
.card-section {
top: calc(var(--gap-md) + 1.75rem);
} }
} }

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 SwapIcon } from './arrow-left-right.svg'
export { default as ToggleIcon } from './toggle.svg' export { default as ToggleIcon } from './toggle.svg'
export { default as PackageIcon } from './package.svg' export { default as PackageIcon } from './package.svg'
export { default as VersionIcon } from './milestone.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 TextInputIcon } from './text-cursor-input.svg'
export { default as AddProjectImage } from './add-project.svg' export { default as AddProjectImage } from './add-project.svg'
export { default as NewInstanceImage } from './new-instance.svg' export { default as NewInstanceImage } from './new-instance.svg'
export { default as MenuIcon } from './menu.svg' export { default as MenuIcon } from './menu.svg'
export { default as BugIcon } from './bug.svg'
export { default as ChatIcon } from './messages-square.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 */ /* Chrome, Edge, and Safari */
*::-webkit-scrollbar { *::-webkit-scrollbar {
width: var(--gap-md); width: 16px;
border: 3px solid var(--color-scrollbar); border: 3px solid transparent;
opacity: 0.5;
transition: opacity 0.2s ease-in-out;
}
*::-webkit-scrollbar:hover {
opacity: 1;
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: var(--color-bg); background: transparent;
border: 3px solid var(--color-bg);
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar); background-color: var(--color-scrollbar);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 3px solid var(--color-bg); border: 5px solid transparent;
background-clip: content-box;
} }
.highlighted { .highlighted {

View File

@ -12,7 +12,7 @@ import {
SearchIcon, SearchIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Button, Card, DropdownSelect } from '@modrinth/ui' import { Button, DropdownSelect } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils' import { formatCategoryHeader } from '@modrinth/utils'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -121,11 +121,10 @@ const handleOptionsClick = async (args) => {
const search = ref('') const search = ref('')
const group = ref('Category') const group = ref('Category')
const filters = ref('All profiles')
const sortBy = ref('Name') const sortBy = ref('Name')
const filteredResults = computed(() => { const filteredResults = computed(() => {
let instances = props.instances.filter((instance) => { const instances = props.instances.filter((instance) => {
return instance.name.toLowerCase().includes(search.value.toLowerCase()) 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() const instanceMap = new Map()
if (group.value === 'Loader') { if (group.value === 'Loader') {
@ -229,53 +218,37 @@ const filteredResults = computed(() => {
}) })
</script> </script>
<template> <template>
<ConfirmModalWrapper <div class="iconified-input">
ref="confirmModal" <SearchIcon />
title="Are you sure you want to delete this instance?" <input v-model="search" type="text" class="h-12" placeholder="Search" />
description="If you proceed, all data for your instance will be removed. You will not be able to recover it." <Button class="r-btn" @click="() => (search = '')">
:has-to-type="false" <XIcon />
proceed-label="Delete" </Button>
@proceed="deleteProfile" </div>
/> <div class="flex gap-2">
<Card class="header"> <DropdownSelect
<div class="iconified-input"> v-slot="{ selected }"
<SearchIcon /> v-model="sortBy"
<input v-model="search" type="text" placeholder="Search" class="search-input" /> name="Sort Dropdown"
<Button class="r-btn" @click="() => (search = '')"> class="max-w-[16rem]"
<XIcon /> :options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
</Button> placeholder="Select..."
</div> >
<div class="labeled_button"> <span class="font-semibold text-primary">Sort by: </span>
<span>Sort by</span> <span class="font-semibold text-secondary">{{ selected }}</span>
<DropdownSelect </DropdownSelect>
v-model="sortBy" <DropdownSelect
class="sort-dropdown" v-slot="{ selected }"
name="Sort Dropdown" v-model="group"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']" class="max-w-[16rem]"
placeholder="Select..." name="Group Dropdown"
/> :options="['Category', 'Loader', 'Game version', 'None']"
</div> placeholder="Select..."
<div class="labeled_button"> >
<span>Filter by</span> <span class="font-semibold text-primary">Group by: </span>
<DropdownSelect <span class="font-semibold text-secondary">{{ selected }}</span>
v-model="filters" </DropdownSelect>
class="filter-dropdown" </div>
name="Filter Dropdown"
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
placeholder="Select..."
/>
</div>
<div class="labeled_button">
<span>Group by</span>
<DropdownSelect
v-model="group"
class="group-dropdown"
name="Group Dropdown"
:options="['Category', 'Loader', 'Game version', 'None']"
placeholder="Select..."
/>
</div>
</Card>
<div <div
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({ v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key, key,
@ -298,6 +271,14 @@ const filteredResults = computed(() => {
/> />
</section> </section>
</div> </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"> <ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template> <template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template> <template #stop> <StopCircleIcon /> Stop </template>
@ -315,7 +296,6 @@ const filteredResults = computed(() => {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
width: 100%; width: 100%;
padding: 1rem;
.divider { .divider {
display: flex; 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; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
padding: 1rem;
gap: 1rem; gap: 1rem;
-ms-overflow-style: none; -ms-overflow-style: none;
@ -294,16 +293,16 @@ onUnmounted(() => {
a { a {
margin: 0; margin: 0;
font-size: var(--font-size-lg); font-size: var(--font-size-md);
font-weight: bolder; font-weight: bolder;
white-space: nowrap; white-space: nowrap;
color: var(--color-contrast); color: var(--color-base);
} }
svg { svg {
height: 1.5rem; height: 1.25rem;
width: 1.5rem; width: 1.25rem;
color: var(--color-contrast); 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 <div
v-if="mode !== 'isolated'" v-if="mode !== 'isolated'"
ref="button" ref="button"
v-tooltip.right="'Minecraft accounts'" class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
class="button-base avatar-button"
:class="{ expanded: mode === 'expanded' }" :class="{ expanded: mode === 'expanded' }"
@click="toggleMenu" @click="toggleMenu"
> >
<Avatar <Avatar
:size="mode === 'expanded' ? 'xs' : 'sm'" size="36px"
:src=" :src="
selectedAccount selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128` ? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://launcher-files.modrinth.com/assets/steve_head.png' : '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> </div>
<transition name="fade"> <transition name="fade">
<Card <Card
@ -59,7 +63,7 @@
</template> </template>
<script setup> <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 { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { import {
@ -73,7 +77,6 @@ import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
defineProps({ defineProps({
mode: { mode: {
@ -151,13 +154,8 @@ const handleClickOutside = (event) => {
function toggleMenu(override = true) { function toggleMenu(override = true) {
if (showCard.value || !override) { if (showCard.value || !override) {
if (showCard.value) {
show_ads_window()
}
showCard.value = false showCard.value = false
} else { } else {
hide_ads_window()
showCard.value = true showCard.value = true
} }
} }
@ -209,11 +207,11 @@ onUnmounted(() => {
} }
.account-card { .account-card {
position: absolute; position: fixed;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
top: 0.5rem; margin-top: 0.5rem;
left: 5.5rem; right: 2rem;
z-index: 11; z-index: 11;
gap: 0.5rem; gap: 0.5rem;
padding: 1rem; padding: 1rem;
@ -288,12 +286,17 @@ onUnmounted(() => {
.fade-enter-active, .fade-enter-active,
.fade-leave-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-enter-from,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
translate: 0 -2rem;
scale: 0.9;
} }
.avatar-button { .avatar-button {
@ -301,9 +304,10 @@ onUnmounted(() => {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: var(--color-base); color: var(--color-base);
background-color: var(--color-raised-bg); background-color: var(--color-button-bg);
border-radius: var(--radius-md); border-radius: var(--radius-md);
width: 100%; width: 100%;
padding: 0.5rem 0.75rem;
text-align: left; text-align: left;
&.expanded { &.expanded {

View File

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

View File

@ -1,13 +1,18 @@
<template> <template>
<div class="breadcrumbs"> <div data-tauri-drag-region class="flex items-center gap-1 pl-3">
<Button class="breadcrumbs__back transparent" icon-only @click="$router.back()"> <Button v-if="false" class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<ChevronLeftIcon /> <ChevronLeftIcon />
</Button> </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 /> <ChevronRightIcon />
</Button> </Button>
{{ breadcrumbData.resetToNames(breadcrumbs) }} {{ 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 <router-link
v-if="breadcrumb.link" v-if="breadcrumb.link"
:to="{ :to="{
@ -20,13 +25,18 @@
: breadcrumb.name : breadcrumb.name
}} }}
</router-link> </router-link>
<span v-else class="selected">{{ <span
breadcrumb.name.charAt(0) === '?' v-else
? breadcrumbData.getName(breadcrumb.name.slice(1)) data-tauri-drag-region
: breadcrumb.name class="text-contrast font-semibold cursor-default select-none"
}}</span> >{{
<ChevronRightIcon v-if="breadcrumb.link" class="chevron" /> breadcrumb.name.charAt(0) === '?'
</div> ? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}</span
>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5" />
</template>
</div> </div>
</template> </template>
@ -50,38 +60,3 @@ const breadcrumbs = computed(() => {
return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb
}) })
</script> </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> <script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
const emit = defineEmits(['menu-closed', 'option-clicked']) const emit = defineEmits(['menu-closed', 'option-clicked'])
@ -38,7 +37,6 @@ const shown = ref(false)
defineExpose({ defineExpose({
showMenu: (event, passedItem, passedOptions) => { showMenu: (event, passedItem, passedOptions) => {
hide_ads_window()
item.value = passedItem item.value = passedItem
options.value = passedOptions options.value = passedOptions
@ -71,9 +69,6 @@ const isLinkedData = (item) => {
} }
const hideContextMenu = () => { const hideContextMenu = () => {
if (shown.value) {
show_ads_window()
}
shown.value = false shown.value = false
emit('menu-closed') emit('menu-closed')
} }

View File

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

View File

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

View File

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

View File

@ -525,8 +525,8 @@ const next = async () => {
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--gap-lg);
gap: var(--gap-md); gap: var(--gap-md);
margin-top: var(--gap-lg);
} }
.input-label { .input-label {
@ -595,7 +595,6 @@ const next = async () => {
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--gap-lg);
padding-bottom: 0; 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.auto-detect-modal { .auto-detect-modal {
padding: 1rem;
.table { .table {
.table-row { .table-row {
grid-template-columns: 1fr 4fr min-content; grid-template-columns: 1fr 4fr min-content;

View File

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

View File

@ -173,7 +173,6 @@ const switchVersion = async (versionId) => {
} }
.modal-body { .modal-body {
padding: var(--gap-xl);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-md); 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> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted } 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 { ChevronRightIcon } from '@modrinth/assets' import { ChevronRightIcon } from '@modrinth/assets'
import { init_ads_window, open_ads_link, record_ads_click } from '@/helpers/ads.js' 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) const adsWrapper = ref(null)
let resizeObserver
let scrollHandler
let intersectionObserver
let mutationObserver
onMounted(() => { onMounted(() => {
if (showAd.value) { updateAdPosition()
updateAdPosition(true)
resizeObserver = new ResizeObserver(() => updateAdPosition()) window.addEventListener('resize', 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 })
}
}) })
function updateAdPosition(overrideShown = false) { function updateAdPosition() {
if (adsWrapper.value) { if (adsWrapper.value) {
const rect = adsWrapper.value.getBoundingClientRect() const rect = adsWrapper.value.getBoundingClientRect()
let y = rect.top + window.scrollY const x = rect.left + window.scrollX
let height = rect.bottom - rect.top const y = rect.top + window.scrollY
// Prevent ad from overlaying the app bar init_ads_window(x, y, 300, 250, true)
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)
} }
} }
@ -77,38 +25,10 @@ async function openPlusLink() {
await record_ads_click() await record_ads_click()
await open_ads_link('https://modrinth.com/plus', 'https://modrinth.com') 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> </script>
<template> <template>
<div <div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
v-if="showAd"
ref="adsWrapper"
class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised cursor-pointer"
>
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6"> <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> <p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
<button <button

View File

@ -1,74 +1,110 @@
<template> <template>
<Card <div
class="card button-base" class="button-base p-4 bg-bg-raised rounded-xl flex gap-3 group"
@click=" @click="
() => { () => {
emits('open') emit('open')
$router.push({ $router.push({
path: `/project/${project.project_id ?? project.id}/`, path: `/project/${project.project_id ?? project.id}`,
query: { i: props.instance ? props.instance.path : undefined }, query: { i: props.instance ? props.instance.path : undefined },
}) })
} }
" "
> >
<div class="icon"> <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>
<div class="content-wrapper"> <div class="flex flex-col gap-2 overflow-hidden">
<div class="title joined-text"> <div class="gap-2 overflow-hidden no-wrap text-ellipsis">
<h2>{{ project.title }}</h2> <span class="text-lg font-extrabold text-contrast m-0 leading-none">{{
<span v-if="project.author">by {{ project.author }}</span> project.title
}}</span>
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
</div> </div>
<div class="description"> <div class="m-0 line-clamp-2">
{{ project.description }} {{ project.description }}
</div> </div>
<div class="tags"> <div class="mt-auto flex items-center gap-1 no-wrap">
<Categories :categories="categories" :type="project.project_type"> <TagsIcon class="h-4 w-4 shrink-0" />
<EnvironmentIndicator <div
:type-only="project.moderation" v-for="tag in categories"
:client-side="project.client_side" :key="tag"
:server-side="project.server_side" class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
:type="project.project_type" >
:search="true" {{ formatCategory(tag.name) }}
/> </div>
</Categories>
</div> </div>
</div> </div>
<div class="stats button-group"> <div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
<div v-if="featured" class="badge"> <div class="flex items-center gap-2">
<StarIcon /> <DownloadIcon class="shrink-0" />
Featured <span>
{{ formatNumber(project.downloads) }}
<span class="text-secondary">downloads</span>
</span>
</div> </div>
<div class="badge"> <div class="flex items-center gap-2">
<DownloadIcon /> <HeartIcon class="shrink-0" />
{{ formatNumber(project.downloads) }} <span>
{{ formatNumber(project.follows ?? project.followers) }}
<span class="text-secondary">followers</span>
</span>
</div> </div>
<div class="badge"> <div class="mt-auto relative">
<HeartIcon /> <div
{{ formatNumber(project.follows ?? project.followers) }} 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"
</div> >
<div class="badge"> <HistoryIcon class="shrink-0" />
<CalendarIcon /> <span>
{{ formatCategory(dayjs(project.date_modified ?? project.updated).fromNow()) }} <span class="text-secondary">Updated</span>
{{ dayjs(project.date_modified ?? project.updated).fromNow() }}
</span>
</div>
<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'
: modpack || instance
? 'Install'
: 'Add to an instance'
}}
</button>
</ButtonStyled>
</div>
</div> </div>
</div> </div>
<div v-if="project.author" class="install"> </div>
<Button color="primary" :disabled="installed || installing" @click.stop="install()">
<DownloadIcon v-if="!installed" />
<CheckIcon v-else />
{{ installing ? 'Installing' : installed ? 'Installed' : 'Install' }}
</Button>
</div>
</Card>
</template> </template>
<script setup> <script setup>
import { DownloadIcon, HeartIcon, CalendarIcon, CheckIcon, StarIcon } from '@modrinth/assets' import {
import { Avatar, Card, Categories, EnvironmentIndicator, Button } from '@modrinth/ui' TagsIcon,
DownloadIcon,
HeartIcon,
PlusIcon,
CheckIcon,
HistoryIcon,
} from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui'
import { formatNumber, formatCategory } from '@modrinth/utils' import { formatNumber, formatCategory } from '@modrinth/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { ref } from 'vue' import { ref, computed } from 'vue'
import { install as installVersion } from '@/store/install.js' import { install as installVersion } from '@/store/install.js'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
@ -99,10 +135,9 @@ const props = defineProps({
}, },
}) })
const emits = defineEmits(['open']) const emit = defineEmits(['open', 'install'])
const installing = ref(false) const installing = ref(false)
const installed = ref(props.installed)
async function install() { async function install() {
installing.value = true installing.value = true
@ -111,87 +146,12 @@ async function install() {
null, null,
props.instance ? props.instance.path : null, props.instance ? props.instance.path : null,
'SearchCard', 'SearchCard',
(version) => { () => {
installing.value = false installing.value = false
emit('install', props.project.project_id)
if (props.instance && version) {
installed.value = true
}
}, },
) )
} }
const modpack = computed(() => props.project.project_type === 'modpack')
</script> </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>
</g> </g>
</svg> </svg>
<ProgressBar class="loading-bar" :progress="loadingProgress" /> <ProgressBar class="loading-bar" :progress="Math.min(loadingProgress, 100)" />
<span v-if="message">{{ message }}</span> <span v-if="message">{{ message }}</span>
</div> </div>
<div class="gradient-bg" data-tauri-drag-region></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 ProgressBar from '@/components/ui/ProgressBar.vue'
import { loading_listener } from '@/helpers/events.js' import { loading_listener } from '@/helpers/events.js'
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { XIcon } from '@modrinth/assets' import { XIcon, MaximizeIcon, MinimizeIcon } from '@modrinth/assets'
import { MaximizeIcon, MinimizeIcon } from '@/assets/icons/index.js'
import { getOS } from '@/helpers/utils.js' import { getOS } from '@/helpers/utils.js'
import { useLoading } from '@/store/loading.js' import { useLoading } from '@/store/loading.js'

View File

@ -71,7 +71,6 @@ async function install() {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--gap-md); gap: var(--gap-md);
padding: var(--gap-lg);
} }
.button-row { .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; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 1rem;
:deep(.animated-dropdown .options) { :deep(.animated-dropdown .options) {
max-height: 13.375rem; max-height: 13.375rem;

View File

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

View File

@ -243,7 +243,7 @@ const createInstance = async () => {
" "
> >
<Button <Button
:disabled="profile.installedMod || profile.installing || profile.linked_data?.locked" :disabled="profile.installedMod || profile.installing"
@click="install(profile)" @click="install(profile)"
> >
<DownloadIcon v-if="!profile.installedMod && !profile.installing" /> <DownloadIcon v-if="!profile.installedMod && !profile.installing" />
@ -253,9 +253,7 @@ const createInstance = async () => {
? 'Installing...' ? 'Installing...'
: profile.installedMod : profile.installedMod
? 'Installed' ? 'Installed'
: profile.linked_data && profile.linked_data.locked : 'Install'
? 'Paired'
: 'Install'
}} }}
</Button> </Button>
</div> </div>
@ -308,7 +306,6 @@ const createInstance = async () => {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
margin: 0; margin: 0;
padding: 1rem;
background-color: var(--color-bg); 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"> <script setup lang="ts">
import { ref } from 'vue' 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 { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.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) { export async function warning_listener(callback) {
return await listen('warning', (event) => callback(event.payload)) 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(router)
app.use(pinia) 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.use(VIntlPlugin)
app.mount('#app') app.mount('#app')

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue' import RowDisplay from '@/components/RowDisplay.vue'
import { list } from '@/helpers/profile.js' import { list } from '@/helpers/profile.js'
@ -8,11 +8,6 @@ import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { get_search_results } from '@/helpers/cache.js' 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 featuredModpacks = ref({})
const featuredMods = ref({}) const featuredMods = ref({})
@ -104,7 +99,8 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 flex flex-col gap-2">
<h1 class="m-0 text-2xl">Welcome back!</h1>
<RowDisplay <RowDisplay
v-if="total > 0" v-if="total > 0"
:instances="[ :instances="[
@ -132,13 +128,3 @@ onUnmounted(() => {
/> />
</div> </div>
</template> </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 Index from './Index.vue'
import Browse from './Browse.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,85 +1,107 @@
<template> <template>
<div class="instance-container"> <div
<div class="side-cards pb-4" @scroll="$refs.promo.scroll()"> class="p-6 pr-2 pb-4"
<Card class="instance-card" @contextmenu.prevent.stop="handleRightClick"> @contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
<Avatar size="md" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : null" /> >
<div class="instance-info"> <ExportModal ref="exportModal" :instance="instance" />
<h2 class="name">{{ instance.name }}</h2> <ContentPageHeader>
<span class="metadata"> {{ instance.loader }} {{ instance.game_version }} </span> <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> </div>
<span class="button-group"> </template>
<Button v-if="instance.install_stage !== 'installed'" disabled class="instance-button"> <template #actions>
Installing... <ButtonStyled v-if="instance.install_stage !== 'installed'" color="brand" size="large">
</Button> <button disabled>Installing...</button>
<Button </ButtonStyled>
v-else-if="playing === true" <template v-else>
color="danger" <div class="flex gap-2">
class="instance-button" <ButtonStyled v-if="playing === true" color="red" size="large">
@click="stopInstance('InstancePage')" <button @click="stopInstance('InstancePage')">
> <StopCircleIcon />
<StopCircleIcon /> Stop
Stop </button>
</Button> </ButtonStyled>
<Button <ButtonStyled
v-else-if="playing === false && loading === false" v-else-if="playing === false && loading === false"
color="primary" color="brand"
class="instance-button" size="large"
@click="startInstance('InstancePage')" >
> <button @click="startInstance('InstancePage')">
<PlayIcon /> <PlayIcon />
Play Play
</Button> </button>
<Button </ButtonStyled>
v-else-if="loading === true && playing === false" <ButtonStyled
disabled v-else-if="loading === true && playing === false"
class="instance-button" color="brand"
> size="large"
Loading... >
</Button> <button disabled>Loading...</button>
<Button </ButtonStyled>
v-tooltip="'Open instance folder'" <ButtonStyled size="large" circular>
class="instance-button" <RouterLink
@click="showProfileInFolder(instance.path)" v-tooltip="'Instance settings'"
> :to="`/instance/${encodeURIComponent(route.params.id)}/options`"
<FolderOpenIcon /> >
Folder <SettingsIcon />
</Button> </RouterLink>
</span> </ButtonStyled>
<hr class="card-divider" /> <ButtonStyled size="large" type="transparent" circular>
<div class="pages-list"> <OverflowMenu
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/`" class="btn"> :options="[
<BoxIcon /> {
Content id: 'open-folder',
</RouterLink> action: () => showProfileInFolder(instance.path),
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/logs`" class="btn"> },
<FileIcon /> {
Logs id: 'export-mrpack',
</RouterLink> action: () => $refs.exportModal.show(),
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/options`" class="btn"> },
<SettingsIcon /> ]"
Options >
</RouterLink> <MoreVerticalIcon />
</div> <template #share-instance> <UserPlusIcon /> Share instance </template>
</Card> <template #host-a-server> <ServerIcon /> Create a server </template>
<PromotionWrapper ref="promo" class="mt-4" /> <template #open-folder> <FolderOpenIcon /> Open folder </template>
</div> <template #export-mrpack> <PackageIcon /> Export modpack </template>
<div class="content"> </OverflowMenu>
<RouterView v-slot="{ Component }"> </ButtonStyled>
<template v-if="Component"> </div>
<Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()">
<component
:is="Component"
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
></component>
</Suspense>
</template> </template>
</RouterView> </template>
</div> </ContentPageHeader>
</div>
<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()">
<component
:is="Component"
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
></component>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
</div> </div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick"> <ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template> <template #play> <PlayIcon /> Play </template>
@ -104,11 +126,18 @@
</ContextMenu> </ContextMenu>
</template> </template>
<script setup> <script setup>
import { Button, Avatar, Card } from '@modrinth/ui'
import { import {
BoxIcon, Avatar,
ContentPageHeader,
ButtonStyled,
OverflowMenu,
LoadingIndicator,
} from '@modrinth/ui'
import {
UserPlusIcon,
ServerIcon,
PackageIcon,
SettingsIcon, SettingsIcon,
FileIcon,
PlayIcon, PlayIcon,
StopCircleIcon, StopCircleIcon,
EditIcon, EditIcon,
@ -122,21 +151,24 @@ import {
XIcon, XIcon,
CheckCircleIcon, CheckCircleIcon,
UpdatedIcon, UpdatedIcon,
MoreVerticalIcon,
GameIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { get, kill, run } from '@/helpers/profile' import { get, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process' import { get_by_profile_path } from '@/helpers/process'
import { process_listener, profile_listener } from '@/helpers/events' import { process_listener, profile_listener } from '@/helpers/events'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ref, onUnmounted } from 'vue' import { ref, onUnmounted, computed } from 'vue'
import { handleError, useBreadcrumbs, useLoading } from '@/store/state' import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { showProfileInFolder } from '@/helpers/utils.js' import { showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { get_project, get_version_many } from '@/helpers/cache.js' import { get_project, get_version_many } from '@/helpers/cache.js'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import ExportModal from '@/components/ui/ExportModal.vue'
const route = useRoute() const route = useRoute()
@ -145,6 +177,17 @@ const breadcrumbs = useBreadcrumbs()
const instance = ref(await get(route.params.id).catch(handleError)) 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( breadcrumbs.setName(
'Instance', 'Instance',
instance.value.name.length > 40 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 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(() => { onUnmounted(() => {
unlistenProcesses() unlistenProcesses()
unlistenProfiles() unlistenProfiles()

View File

@ -1,331 +1,227 @@
<template> <template>
<Card v-if="projects.length > 0" class="mod-card"> <template v-if="projects.length > 0">
<div class="dropdown-input"> <div class="flex items-center gap-2 mb-4">
<DropdownSelect <div class="iconified-input flex-grow">
v-model="selectedProjectType"
:options="Object.keys(selectableProjectTypes)"
default-value="All"
name="project-type-dropdown"
color="primary"
/>
<div class="iconified-input">
<SearchIcon /> <SearchIcon />
<input <input
v-model="searchFilter" v-model="searchFilter"
type="text" type="text"
:placeholder="`Search ${search.length} ${(['All', 'Other'].includes(selectedProjectType) :placeholder="`Search content...`"
? 'projects' class="text-input search-input"
: selectedProjectType.toLowerCase()
).slice(0, search.length === 1 ? -1 : 64)}...`"
class="text-input"
autocomplete="off" autocomplete="off"
/> />
<Button class="r-btn" @click="() => (searchFilter = '')"> <Button class="r-btn" @click="() => (searchFilter = '')">
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
<AddContentButton :instance="instance" />
</div> </div>
<Button <div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
v-tooltip="'Refresh projects'" <FilterIcon class="text-secondary h-5 w-5 mr-1" />
icon-only <button
:disabled="refreshingProjects" v-for="filter in filterOptions"
@click="refreshProjects" :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'}`"
<UpdatedIcon /> @click="toggleArray(selectedFilters, filter.id)"
</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"> {{ filter.formattedName }}
<Checkbox </button>
:model-value="selectionMap.get(mod.path)" </div>
class="select-checkbox" <ContentListPanel
@update:model-value="(newValue) => selectionMap.set(mod.path, newValue)" v-model="selectedFiles"
/> :locked="isPackLocked"
</div> :items="
<div class="table-cell table-text name-cell"> search.map((x) => {
<router-link const item: ContentItem<any> = {
v-if="mod.slug" path: x.path,
:to="{ path: `/project/${mod.slug}/`, query: { i: props.instance.path } }" disabled: x.disabled,
:disabled="offline" filename: x.file_name,
class="mod-content" 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"
> >
<Avatar :src="mod.icon" /> <button @click="updateSelected()"><DownloadIcon /> Update</button>
<div class="mod-text"> </ButtonStyled>
<div class="title">{{ mod.name }}</div> <ButtonStyled>
<span v-if="mod.author" class="no-wrap">by {{ mod.author }}</span> <OverflowMenu
</div> :options="[
</router-link> {
<div v-else class="mod-content"> id: 'share-names',
<Avatar :src="mod.icon" /> action: () => shareNames(),
<span v-tooltip="`${mod.name}`" class="title">{{ mod.name }}</span> },
</div> {
</div> id: 'share-file-names',
<div class="table-cell table-text version"> action: () => shareFileNames(),
<span v-tooltip="`${mod.version}`">{{ mod.version }}</span> },
</div> {
<div class="table-cell table-text manage"> id: 'share-urls',
<div v-tooltip="isPackLocked ? 'Unlock this instance to remove mods.' : 'Remove project'"> action: () => shareUrls(),
<Button :disabled="isPackLocked" icon-only @click="removeMod(mod)"> },
<TrashIcon /> {
</Button> id: 'share-markdown',
</div> action: () => shareMarkdown(),
<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" /> <ShareIcon /> Share <DropdownIcon />
<CheckIcon v-else /> <template #share-names> <TextInputIcon /> Project names </template>
</Button> <template #share-file-names> <FileIcon /> File names </template>
</div> <template #share-urls> <LinkIcon /> Project links </template>
<div v-tooltip="isPackLocked ? 'Unlock this instance to toggle mods.' : ''"> <template #share-markdown> <CodeIcon /> Markdown links </template>
<input </OverflowMenu>
id="switch-1" </ButtonStyled>
:disabled="isPackLocked" <ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
autocomplete="off" <button @click="enableAll()"><CheckCircleIcon /> Enable</button>
type="checkbox" </ButtonStyled>
class="switch stylized-toggle" <ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
:checked="!mod.disabled" <button @click="disableAll()"><SlashIcon /> Disable</button>
@change="toggleDisableMod(mod)" </ButtonStyled>
/> <ButtonStyled color="red">
</div> <button @click="deleteSelected()"><TrashIcon /> Remove</button>
<Button </ButtonStyled>
v-tooltip="`Show ${mod.file_name}`"
icon-only
@click="highlightModInProfile(instance.path, mod.path)"
>
<FolderOpenIcon />
</Button>
</div> </div>
</div> </template>
</div> <template #header-actions>
</Card> <ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
<div v-else class="empty-prompt"> <button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
<div class="empty-icon"> <UpdatedIcon />
<AddProjectImage /> Refresh
</div> </button>
<h3>No projects found</h3> </ButtonStyled>
<p class="empty-subtitle">Add a project to get started</p> <ButtonStyled
<AddContentButton :instance="instance" /> v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
</div> type="transparent"
<Pagination color="brand"
v-if="projects.length > 0" color-fill="text"
:page="currentPage" hover-color-fill="text"
:count="Math.ceil(search.length / 20)" @click="updateAll"
class="pagination-after" >
:link-function="(page) => `?page=${page}`" <button class="w-max"><DownloadIcon /> Update all</button>
@switch-page="switchPage" </ButtonStyled>
/> <ButtonStyled
<ModalWrapper ref="deleteWarning" header="Are you sure?"> v-if="canUpdatePack"
<div class="modal-body"> type="transparent"
<div class="markdown-body"> color="brand"
<p> color-fill="text"
Are you sure you want to remove hover-color-fill="text"
<strong>{{ functionValues.length }} project(s)</strong> from {{ instance.name }}? >
<br /> <button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
This action <strong>cannot</strong> be undone. <DownloadIcon /> Update pack
</p> </button>
</div> </ButtonStyled>
<div class="button-group push-right"> </template>
<Button @click="deleteWarning.hide()"> Cancel </Button> <template #actions="{ item }">
<Button color="danger" @click="deleteSelected"> <ButtonStyled
<TrashIcon /> v-if="!isPackLocked && (item.data as any).outdated"
Remove type="transparent"
</Button> color="brand"
</div> circular
</div> >
</ModalWrapper> <button
<ModalWrapper ref="deleteDisabledWarning" header="Are you sure?"> v-tooltip="`Update`"
<div class="modal-body"> :disabled="(item.data as any).updating"
<div class="markdown-body"> @click="updateProject(item.data)"
<p>
Are you sure you want to remove
<strong
>{{ Array.from(projects.values()).filter((x) => x.disabled).length }} disabled
project(s)</strong
> >
from {{ instance.name }}? <DownloadIcon />
<br /> </button>
This action <strong>cannot</strong> be undone. </ButtonStyled>
</p> <div v-else class="w-[36px]"></div>
</div> <ButtonStyled type="transparent" circular>
<div class="button-group push-right"> <button
<Button @click="deleteDisabledWarning.hide()"> Cancel </Button> v-tooltip="item.disabled ? `Enable` : `Disable`"
<Button color="danger" @click="deleteDisabled"> @click="toggleDisableMod(item.data)"
<TrashIcon /> >
Remove <CheckCircleIcon v-if="item.disabled" />
</Button> <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> </div>
</ModalWrapper> <div class="top-box-divider"></div>
<div class="flex items-center gap-6 py-4">
<AddContentButton :instance="instance" />
</div>
</div>
<ShareModalWrapper <ShareModalWrapper
ref="shareModal" ref="shareModal"
share-title="Sharing modpack content" share-title="Sharing modpack content"
@ -340,34 +236,30 @@
:versions="props.versions" :versions="props.versions"
/> />
</template> </template>
<script setup> <script setup lang="ts">
import { import {
ExternalIcon,
LinkIcon,
ClipboardCopyIcon,
TrashIcon, TrashIcon,
CheckIcon,
SearchIcon, SearchIcon,
UpdatedIcon, UpdatedIcon,
FolderOpenIcon,
XIcon, XIcon,
ShareIcon, ShareIcon,
DropdownIcon, DropdownIcon,
GlobeIcon,
FileIcon, FileIcon,
EyeIcon,
EyeOffIcon,
CodeIcon, CodeIcon,
DownloadIcon, DownloadIcon,
FilterIcon,
MoreVerticalIcon,
CheckCircleIcon,
SlashIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import { Button, ButtonStyled, ContentListPanel, OverflowMenu } from '@modrinth/ui'
Pagination,
DropdownSelect,
Checkbox,
AnimatedLogo,
Avatar,
Button,
Card,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils' import { formatProjectType } from '@modrinth/utils'
import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useVIntl, defineMessages } from '@vintl/vintl'
import { import {
add_project_from_path, add_project_from_path,
get_projects, get_projects,
@ -379,7 +271,7 @@ import {
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { highlightModInProfile } from '@/helpers/utils.js' 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 ExportModal from '@/components/ui/ExportModal.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue' import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import AddContentButton from '@/components/ui/AddContentButton.vue' import AddContentButton from '@/components/ui/AddContentButton.vue'
@ -390,9 +282,9 @@ import {
get_version_many, get_version_many,
} from '@/helpers/cache.js' } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.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 ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { getCurrentWebview } from '@tauri-apps/api/webview' import { getCurrentWebview } from '@tauri-apps/api/webview'
import dayjs from 'dayjs'
const props = defineProps({ const props = defineProps({
instance: { instance: {
@ -433,7 +325,6 @@ onUnmounted(() => {
unlistenProfiles() unlistenProfiles()
}) })
const showingOptions = ref(false)
const isPackLocked = computed(() => { const isPackLocked = computed(() => {
return props.instance.linked_data && props.instance.linked_data.locked return props.instance.linked_data && props.instance.linked_data.locked
}) })
@ -444,9 +335,14 @@ const canUpdatePack = computed(() => {
const exportModal = ref(null) const exportModal = ref(null)
const projects = ref([]) 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 selectionMap = ref(new Map())
const initProjects = async (cacheBehaviour) => { const initProjects = async (cacheBehaviour?) => {
const newProjects = [] const newProjects = []
const profileProjects = await get_projects(props.instance.path, cacheBehaviour) const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
@ -504,6 +400,7 @@ const initProjects = async (cacheBehaviour) => {
icon: project.icon_url, icon: project.icon_url,
disabled: file.file_name.endsWith('.disabled'), disabled: file.file_name.endsWith('.disabled'),
updateVersion: file.update_version_id, updateVersion: file.update_version_id,
updated: dayjs(version.date_published),
outdated: !!file.update_version_id, outdated: !!file.update_version_id,
project_type: project.project_type, project_type: project.project_type,
id: project.id, id: project.id,
@ -545,19 +442,77 @@ await initProjects()
const modpackVersionModal = ref(null) const modpackVersionModal = ref(null)
const installing = computed(() => props.instance.install_stage !== 'installed') 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 searchFilter = ref('')
const selectAll = ref(false) const selectAll = ref(false)
const selectedProjectType = ref('All') const selectedProjectType = ref('All')
const deleteWarning = ref(null)
const deleteDisabledWarning = ref(null)
const hideNonSelected = ref(false) const hideNonSelected = ref(false)
const selectedOption = ref('Share')
const shareModal = ref(null) const shareModal = ref(null)
const ascending = ref(true) const ascending = ref(true)
const sortColumn = ref('Name') const sortColumn = ref('Name')
const currentPage = ref(1)
watch([searchFilter, selectedProjectType], () => (currentPage.value = 1))
const selected = computed(() => const selected = computed(() =>
Array.from(selectionMap.value) Array.from(selectionMap.value)
@ -570,7 +525,7 @@ const selected = computed(() =>
) )
const functionValues = 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(() => { const selectableProjectTypes = computed(() => {
@ -586,7 +541,7 @@ const selectableProjectTypes = computed(() => {
const search = computed(() => { const search = computed(() => {
const projectType = selectableProjectTypes.value[selectedProjectType.value] const projectType = selectableProjectTypes.value[selectedProjectType.value]
const filtered = projects.value const filtered = filteredProjects.value
.filter((mod) => { .filter((mod) => {
return ( return (
mod.name.toLowerCase().includes(searchFilter.value.toLowerCase()) && mod.name.toLowerCase().includes(searchFilter.value.toLowerCase()) &&
@ -600,43 +555,19 @@ const search = computed(() => {
return true return true
}) })
return updateSort(filtered)
})
const updateSort = (projects) => {
switch (sortColumn.value) { switch (sortColumn.value) {
case 'Version': case 'Updated':
return projects.slice().sort((a, b) => { return filtered.slice().sort((a, b) => {
if (a.version < b.version) { if (a.updated < b.updated) {
return ascending.value ? -1 : 1
}
if (a.version > b.version) {
return ascending.value ? 1 : -1 return ascending.value ? 1 : -1
} }
return 0 if (a.updated > b.updated) {
})
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) {
return ascending.value ? -1 : 1 return ascending.value ? -1 : 1
} }
return 0 return 0
}) })
default: default:
return projects.slice().sort((a, b) => { return filtered.slice().sort((a, b) => {
if (a.name < b.name) { if (a.name < b.name) {
return ascending.value ? -1 : 1 return ascending.value ? -1 : 1
} }
@ -646,7 +577,7 @@ const updateSort = (projects) => {
return 0 return 0
}) })
} }
} })
const sortProjects = (filter) => { const sortProjects = (filter) => {
if (sortColumn.value === 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) => { const updateProject = async (mod) => {
mod.updating = true mod.updating = true
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
@ -753,6 +676,7 @@ const toggleDisableMod = async (mod) => {
} }
const removeMod = async (mod) => { const removeMod = async (mod) => {
console.log(mod)
await remove_project(props.instance.path, mod.path).catch(handleError) await remove_project(props.instance.path, mod.path).catch(handleError)
projects.value = projects.value.filter((x) => mod.path !== x.path) 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) 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 () => { const shareNames = async () => {
console.log(functionValues.value)
await shareModal.value.show(functionValues.value.map((x) => x.name).join('\n')) 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 updateSelected = async () => {
const promises = [] const promises = []
for (const project of functionValues.value) { for (const project of functionValues.value) {
@ -829,35 +736,23 @@ const updateSelected = async () => {
} }
const enableAll = async () => { const enableAll = async () => {
const promises = []
for (const project of functionValues.value) { for (const project of functionValues.value) {
if (project.disabled) { if (project.disabled) {
await toggleDisableMod(project, false) promises.push(toggleDisableMod(project))
} }
} }
await Promise.all(promises).catch(handleError)
} }
const disableAll = async () => { const disableAll = async () => {
const promises = []
for (const project of functionValues.value) { for (const project of functionValues.value) {
if (!project.disabled) { if (!project.disabled) {
await toggleDisableMod(project, false) promises.push(toggleDisableMod(project))
} }
} }
} await Promise.all(promises).catch(handleError)
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' }],
)
}
} }
watch(selectAll, () => { watch(selectAll, () => {
@ -868,10 +763,6 @@ watch(selectAll, () => {
} }
}) })
const switchPage = (page) => {
currentPage.value = page
}
const refreshingProjects = ref(false) const refreshingProjects = ref(false)
async function refreshProjects() { async function refreshProjects() {
refreshingProjects.value = true refreshingProjects.value = true
@ -1173,16 +1064,6 @@ onUnmounted(() => {
</style> </style>
<style lang="scss"> <style lang="scss">
.updating-indicator {
height: 2.25rem !important;
width: 2.25rem !important;
svg {
height: 1.25rem !important;
width: 1.25rem !important;
}
}
.select-checkbox { .select-checkbox {
button.checkbox { button.checkbox {
border: none; border: none;
@ -1190,13 +1071,23 @@ onUnmounted(() => {
} }
} }
.dropdown-input { .search-input {
.selected { min-height: 2.25rem;
height: 2.5rem; background-color: var(--color-raised-bg);
}
} }
.pagination-after { .top-box {
margin-bottom: 5rem; 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> </style>

View File

@ -897,7 +897,6 @@ async function saveGvLoaderEdits() {
.change-versions-modal { .change-versions-modal {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 1rem;
gap: 1rem; gap: 1rem;
:deep(.animated-dropdown .options) { :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> <script setup>
import { onMounted, onUnmounted, ref, shallowRef } from 'vue' import { onUnmounted, ref, shallowRef } from 'vue'
import GridDisplay from '@/components/GridDisplay.vue'
import { list } from '@/helpers/profile.js' import { list } from '@/helpers/profile.js'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs' import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { profile_listener } from '@/helpers/events.js' import { profile_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { Button } from '@modrinth/ui' import { Button } from '@modrinth/ui'
import { PlusIcon } from '@modrinth/assets' import { PlusIcon } from '@modrinth/assets'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue' import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { NewInstanceImage } from '@/assets/icons' import { NewInstanceImage } from '@/assets/icons'
import { hide_ads_window } from '@/helpers/ads.js' import NavTabs from '@/components/ui/NavTabs.vue'
onMounted(() => {
hide_ads_window(true)
})
const route = useRoute() const route = useRoute()
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()
@ -40,17 +35,31 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" /> <div class="p-6 flex flex-col gap-3">
<div v-else class="no-instance"> <h1 class="m-0 text-2xl">Library</h1>
<div class="icon"> <NavTabs
<NewInstanceImage /> :links="[
{ label: 'All instances', href: `/library` },
{ label: 'Downloaded', href: `/library/downloaded` },
{ label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<template v-if="instances.length > 0">
<RouterView :instances="instances" />
</template>
<div v-else class="no-instance">
<div class="icon">
<NewInstanceImage />
</div>
<h3>No instances found</h3>
<Button color="primary" :disabled="offline" @click="$refs.installationModal.show()">
<PlusIcon />
Create new instance
</Button>
<InstanceCreationModal ref="installationModal" />
</div> </div>
<h3>No instances found</h3>
<Button color="primary" :disabled="offline" @click="$refs.installationModal.show()">
<PlusIcon />
Create new instance
</Button>
<InstanceCreationModal ref="installationModal" />
</div> </div>
</template> </template>

View File

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

View File

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

View File

@ -1,12 +1,11 @@
<template> <template>
<Card> <Card>
<div class="markdown-body" v-html="renderHighlightedString(project?.body ?? '')" /> <ProjectPageDescription :description="project.body" />
</Card> </Card>
</template> </template>
<script setup> <script setup>
import { renderHighlightedString } from '@modrinth/utils' import { Card, ProjectPageDescription } from '@modrinth/ui'
import { Card } from '@modrinth/ui'
defineProps({ defineProps({
project: { project: {
@ -21,22 +20,3 @@ export default {
name: 'Description', name: 'Description',
} }
</script> </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 { .expanded-image-modal {
position: fixed; position: fixed;
z-index: 10; z-index: 11;
overflow: auto; overflow: auto;
top: 0; top: 0;
left: 0; left: 0;

View File

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

View File

@ -1,165 +1,82 @@
<template> <template>
<Card class="filter-header"> <div>
<div class="manage"> <ProjectPageVersions
<multiselect :loaders="loaders"
v-model="filterLoader" :game-versions="gameVersions"
:options=" :versions="versions"
versions :project="project"
.flatMap((value) => value.loaders) :version-link="(version) => `/project/${project.id}/version/${version.id}`"
.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"
> >
<ClearIcon /> <template #actions="{ version }">
Clear filters <ButtonStyled circular type="transparent">
</Button> <button
</Card> v-tooltip="`Install`"
<Pagination :class="{
:page="currentPage" 'group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted':
:count="Math.ceil(filteredVersions.length / 20)" !installed || version.id !== installedVersion,
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
:disabled="installing || (installed && version.id === installedVersion)" :disabled="installing || (installed && version.id === installedVersion)"
@click.stop="() => install(version.id)" @click.stop="() => install(version.id)"
> >
<DownloadIcon v-if="!installed" /> <DownloadIcon v-if="!installed" />
<SwapIcon v-else-if="installed && version.id !== installedVersion" /> <SwapIcon v-else-if="installed && version.id !== installedVersion" />
<CheckIcon v-else /> <CheckIcon v-else />
</Button> </button>
</div> </ButtonStyled>
<div class="name-cell table-cell table-text"> <ButtonStyled circular type="transparent">
<div class="version-link"> <OverflowMenu
{{ version.name.charAt(0).toUpperCase() + version.name.slice(1) }} v-if="false"
<div class="version-badge"> class="group-hover:!bg-button-bg"
<div class="channel-indicator"> :options="[
<Badge {
:color="releaseColor(version.version_type)" id: 'install-elsewhere',
:type=" action: () => {},
version.version_type.charAt(0).toUpperCase() + version.version_type.slice(1) shown: false && !!instance,
" color: 'primary',
/> hoverFilled: true,
</div> },
<div> {
{{ version.version_number }} id: 'open-in-browser',
</div> link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
</div> },
</div> ]"
</div> aria-label="More options"
<div class="table-cell table-text stacked-text"> >
<span> <MoreVerticalIcon aria-hidden="true" />
{{ <template #install-elsewhere>
version.loaders.map((str) => str.charAt(0).toUpperCase() + str.slice(1)).join(', ') <DownloadIcon aria-hidden="true" />
}} Add to another instance
</span> </template>
<span> <template #open-in-browser> <ExternalIcon /> Open in browser </template>
{{ version.game_versions.join(', ') }} </OverflowMenu>
</span> <a
</div> v-else
<div class="table-cell table-text stacked-text"> v-tooltip="`Open in browser`"
<div> class="group-hover:!bg-button-bg"
<span> Published on </span> :href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
<strong> target="_blank"
{{ >
new Date(version.date_published).toLocaleDateString('en-US', { <ExternalIcon />
year: 'numeric', </a>
month: 'short', </ButtonStyled>
day: 'numeric', </template>
}) </ProjectPageVersions>
}} </div>
</strong>
</div>
<div>
<strong>
{{ formatNumber(version.downloads) }}
</strong>
<span> Downloads </span>
</div>
</div>
</div>
</div>
</Card>
</template> </template>
<script setup> <script setup>
import { Card, Button, Pagination, Badge } from '@modrinth/ui' import { ProjectPageVersions, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { CheckIcon, ClearIcon, DownloadIcon } from '@modrinth/assets' import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import { formatNumber } from '@modrinth/utils' import { ref, watch } from 'vue'
import Multiselect from 'vue-multiselect'
import { releaseColor } from '@/helpers/utils'
import { computed, ref, watch } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js' import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js'
const props = defineProps({ const props = defineProps({
project: {
type: Object,
default: () => {},
},
versions: { versions: {
type: Array, type: Array,
required: true, 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 filterVersions = ref([])
const filterLoader = ref(props.instance ? [props.instance?.loader] : []) const filterLoader = ref(props.instance ? [props.instance?.loader] : [])
const filterGameVersions = ref(props.instance ? [props.instance?.game_version] : []) const filterGameVersions = ref(props.instance ? [props.instance?.game_version] : [])
const currentPage = ref(1) 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 all the filters and if a value changes, reset to page 1
watch([filterVersions, filterLoader, filterGameVersions], () => { watch([filterVersions, filterLoader, filterGameVersions], () => {
currentPage.value = 1 currentPage.value = 1

View File

@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import * as Pages from '@/pages' import * as Pages from '@/pages'
import * as Project from '@/pages/project' import * as Project from '@/pages/project'
import * as Instance from '@/pages/instance' 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. * 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', path: '/browse/:projectType',
name: 'Browse', name: 'Discover content',
component: Pages.Browse, component: Pages.Browse,
meta: { meta: {
breadcrumb: [{ name: 'Browse' }], breadcrumb: [{ name: 'Discover content' }],
}, },
}, },
{ {
path: '/library', path: '/library',
name: 'Library', name: 'Library',
component: Pages.Library, component: Library.Index,
meta: { meta: {
breadcrumb: [{ name: 'Library' }], breadcrumb: [{ name: 'Library' }],
}, },
}, children: [
{ {
path: '/settings', path: '',
name: 'Settings', name: 'Overview',
component: Pages.Settings, component: Library.Overview,
meta: { },
breadcrumb: [{ name: 'Settings' }], {
}, path: 'downloaded',
name: 'Downloaded',
component: Library.Downloaded,
},
{
path: 'custom',
name: 'Custom',
component: Library.Custom,
},
],
}, },
{ {
path: '/project/:id', path: '/project/:id',

View File

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

View File

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

View File

@ -1,3 +1,3 @@
HTML Testing playground for Theseus: 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( .default_permission(
DefaultPermissionRule::AllowAllCommands, DefaultPermissionRule::AllowAllCommands,
), ),
)
.plugin(
"friends",
InlinedPlugin::new()
.commands(&[
"friends",
"friend_statuses",
"add_friend",
"remove_friend",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
), ),
) )
.expect("Failed to run tauri-build"); .expect("Failed to run tauri-build");

View File

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

View File

@ -2,9 +2,7 @@
"identifier": "core", "identifier": "core",
"description": "", "description": "",
"local": true, "local": true,
"windows": [ "windows": ["main"],
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:path:default", "core:path:default",
@ -27,4 +25,4 @@
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:webview:allow-set-webview-zoom" "core:webview:allow-set-webview-zoom"
] ]
} }

View File

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

View File

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

Binary file not shown.

View File

@ -16,4 +16,4 @@
"@modrinth/app-lib": "workspace:*", "@modrinth/app-lib": "workspace:*",
"@modrinth/daedalus": "workspace:*" "@modrinth/daedalus": "workspace:*"
} }
} }

View File

@ -1,8 +1,7 @@
use serde::Serialize;
use std::collections::HashSet; use std::collections::HashSet;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::plugin::TauriPlugin; use tauri::plugin::TauriPlugin;
use tauri::{Emitter, LogicalPosition, LogicalSize, Manager, Runtime}; use tauri::{LogicalPosition, LogicalSize, Manager, Runtime};
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
use theseus::settings; use theseus::settings;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@ -47,7 +46,6 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
init_ads_window, init_ads_window,
hide_ads_window, hide_ads_window,
scroll_ads_window,
show_ads_window, show_ads_window,
record_ads_click, record_ads_click,
open_link, open_link,
@ -154,21 +152,6 @@ pub async fn hide_ads_window<R: Runtime>(
Ok(()) 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] #[tauri::command]
pub async fn record_ads_click<R: Runtime>( pub async fn record_ads_click<R: Runtime>(
app: tauri::AppHandle<R>, 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 ads;
pub mod cache; pub mod cache;
pub mod friends;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>; 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 title_bar_container_view = close.superview().superview();
let close_rect: NSRect = msg_send![close, frame]; 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 title_bar_frame_height = button_height + y;
let mut title_bar_rect = NSView::frame(title_bar_container_view); 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() { for (i, button) in window_buttons.into_iter().enumerate() {
let mut rect: NSRect = NSView::frame(button); 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); button.setFrameOrigin(rect.origin);
} }
} }

View File

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

View File

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

View File

@ -1,9 +1,10 @@
# Daedalus # Daedalus
Daedalus is a powerful tool which queries and generates metadata for the Minecraft (and other games in the future!) game Daedalus is a powerful tool which queries and generates metadata for the Minecraft (and other games in the future!) game
and mod loaders for: and mod loaders for:
- Performance (Serving static files can be easily cached and is extremely quick) - 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) - 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) - Reliability (Provides a versioning system which ensures no breakage with updates)
Daedalus supports the original Minecraft data and reposting for the Forge, Fabric, Quilt, and NeoForge loaders. Daedalus supports the original Minecraft data and reposting for the Forge, Fabric, Quilt, and NeoForge loaders.

View File

@ -6,12 +6,12 @@ services:
volumes: volumes:
- minio-data:/data - minio-data:/data
ports: ports:
- "9000:9000" - '9000:9000'
- "9001:9001" - '9001:9001'
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: miniosecret MINIO_ROOT_PASSWORD: miniosecret
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
volumes: volumes:
minio-data: minio-data:

View File

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

View File

@ -10,4 +10,4 @@
"dependencies": { "dependencies": {
"@modrinth/daedalus": "workspace:*" "@modrinth/daedalus": "workspace:*"
} }
} }

View File

@ -1,6 +1,6 @@
# Modrinth Documentation # Modrinth Documentation
Welcome to the Modrinth documentation! Welcome to the Modrinth documentation!
## Development ## Development

View File

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

View File

@ -6,19 +6,21 @@
- title: __Welcome to Modrinth's Discord server!__ - title: __Welcome to Modrinth's Discord server!__
url: https://modrinth.com url: https://modrinth.com
color: 0x1bd96a color: 0x1bd96a
description: "Modrinth is the place for Minecraft mods, plugins, data packs, shaders, resource packs, and description:
modpacks. Discover, play, and share Minecraft content through our open-source platform built for the community." '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 - type: embed
embeds: embeds:
- title: "**:scroll: __Rules__**" - title: '**:scroll: __Rules__**'
color: 0x4f9cff 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 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 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 messages are in violation of our rules.\n\n
Modrinth's rules are split up into two categories: the **__DOs__** and the **__DO NOTs__**." 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 color: 0x1bd96a
description: >- description: >-
1. Treat every user with respect and consider the opinions and viewpoints of others 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 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) 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 color: 0xff496e
description: >- description: >-
6. Harass, bother, provoke, or insult anyone, including by sending unsolicited DMs or friend requests 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) 9. Report Modrinth content in the Discord (use the Report button on the website)
10. Assume staff member's opinions reflect those of Modrinth 10. Assume staff member's opinions reflect those of Modrinth
- title: ":pencil2: Nickname policy:" - title: ':pencil2: Nickname policy:'
color: 0xffa347 color: 0xffa347
description: >- description: >-
We want to keep this server clean and therefore require that display names of all members on the server are 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. from the server. We will also permanently remove any users whose profiles contain inappropriate content.
- type: links - type: links
color: 0x4f9cff color: 0x4f9cff
title: "**:link: __Links__**" title: '**:link: __Links__**'
links: links:
Website: https://modrinth.com Website: https://modrinth.com
Support: https://support.modrinth.com Support: https://support.modrinth.com

View File

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

View File

@ -7,4 +7,4 @@ description: Guide for contributing to Modrinth's gradle plugin
Minotaur contains two test environments within it - one with ForgeGradle and one with Fabric Loom. You may tweak with these environments to test whatever you may be trying; just make sure that the `modrinth` task within each still functions properly. GitHub Actions will validate this if you're making a pull request, so you may want to use [`act pull_request`](https://github.com/nektos/act) to test them locally. Minotaur contains two test environments within it - one with ForgeGradle and one with Fabric Loom. You may tweak with these environments to test whatever you may be trying; just make sure that the `modrinth` task within each still functions properly. GitHub Actions will validate this if you're making a pull request, so you may want to use [`act pull_request`](https://github.com/nektos/act) to test them locally.
[minotaur]: https://github.com/modrinth/minotaur [minotaur]: https://github.com/modrinth/minotaur

View File

@ -12,4 +12,4 @@ hero:
link: https://support.modrinth.com link: https://support.modrinth.com
icon: external icon: external
variant: minimal variant: minimal
--- ---

View File

@ -1,9 +1,9 @@
:root, :root,
::backdrop, ::backdrop,
:root[data-theme='light'], :root[data-theme='light'],
[data-theme='light'] ::backdrop{ [data-theme='light'] ::backdrop {
--sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, --sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--sl-color-white: var(--color-contrast); /* “white” */ --sl-color-white: var(--color-contrast); /* “white” */
--sl-color-gray-1: var(--color-base); --sl-color-gray-1: var(--color-base);
@ -49,6 +49,6 @@
} }
:root[data-theme='light'], :root[data-theme='light'],
[data-theme='light'] ::backdrop{ [data-theme='light'] ::backdrop {
--sl-color-bg: var(--color-raised-bg); --sl-color-bg: var(--color-raised-bg);
} }

View File

@ -1,3 +1,3 @@
{ {
"extends": "astro/tsconfigs/strict" "extends": "astro/tsconfigs/strict"
} }

View File

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

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