Compare commits

..

1 Commits

Author SHA1 Message Date
Jai A
072fa47129 NTEX migration start 2024-12-24 18:51:55 -07:00
696 changed files with 16117 additions and 30432 deletions

View File

@@ -1,6 +1,3 @@
# Windows has stack overflows when calling from Tauri, so we increase compiler size
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220"]
[build]
rustflags = ["--cfg", "tokio_unstable"]

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
* text=auto eol=lf

View File

@@ -16,7 +16,7 @@ body:
id: version
attributes:
label: What version of the Modrinth App are you using?
description: Find this in ⚙️ Settings (bottom right) -> After Modrinth App (bottom left)
description: Find this in ⚙️ Settings (bottom right) -> About -> App version.
validations:
required: true
- type: dropdown

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -7,13 +7,11 @@ on:
paths:
- .github/workflows/daedalus-docker.yml
- 'apps/daedalus_client/**'
- 'packages/daedalus/**'
pull_request:
types: [opened, synchronize]
paths:
- .github/workflows/daedalus-docker.yml
- 'apps/daedalus_client/**'
- 'packages/daedalus/**'
merge_group:
types: [checks_requested]

View File

@@ -38,10 +38,8 @@ jobs:
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
env:
SQLX_OFFLINE: true
with:
file: ./apps/labrinth/Dockerfile
context: ./apps/labrinth
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}

View File

@@ -6,11 +6,9 @@ on:
tags:
- 'v*'
paths:
- .github/workflows/theseus-release.yml
- .github/workflows/app-release.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'apps/labrinth/src/common/**'
- 'apps/labrinth/Cargo.toml'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
@@ -55,11 +53,11 @@ jobs:
!target/release/bundle/*/*.app.tar.gz
!target/release/bundle/*/*.app.tar.gz.sig
!target/release/bundle/appimage/*.AppImage
!target/release/bundle/appimage/*.AppImage.tar.gz
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
!target/release/bundle/deb/*.deb
!target/release/bundle/rpm/*.rpm
!target/release/bundle/*/*.AppImage
!target/release/bundle/*/*.AppImage.tar.gz
!target/release/bundle/*/*.AppImage.tar.gz.sig
!target/release/bundle/*/*.deb
!target/release/bundle/*/*.rpm
!target/release/bundle/msi/*.msi
!target/release/bundle/msi/*.msi.zip

4
.idea/code.iml generated
View File

@@ -10,11 +10,9 @@
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
</module>

2671
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,6 @@ members = [
'./apps/labrinth',
'./apps/daedalus_client',
'./packages/daedalus',
'./packages/ariadne',
]
# Optimize for speed and reduce size on release builds
@@ -22,4 +21,4 @@ strip = true # Remove debug symbols
opt-level = 3
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }
wry = { git = "https://github.com/modrinth/wry", rev ="e88d4a1" }

View File

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

View File

@@ -1,23 +1,21 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { computed, ref, onMounted, watch, onUnmounted } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import {
UserIcon,
ArrowBigUpDashIcon,
CompassIcon,
DownloadIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
MaximizeIcon,
MinimizeIcon,
HomeIcon,
LibraryIcon,
PlusIcon,
RestoreIcon,
RightArrowIcon,
SettingsIcon,
XIcon,
DownloadIcon,
CompassIcon,
MinimizeIcon,
MaximizeIcon,
RestoreIcon,
LogOutIcon,
RightArrowIcon,
} from '@modrinth/assets'
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state'
@@ -33,12 +31,12 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import { handleError, useNotifications } from '@/store/notifications.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { type } from '@tauri-apps/plugin-os'
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { isDev, getOS, restartApp } from '@/helpers/utils.js'
import { initAnalytics, debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { getVersion } from '@tauri-apps/api/app'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { create_profile_and_install_from_file } from './helpers/pack'
import { install_from_file } from './helpers/pack'
import { useError } from '@/store/error.js'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
@@ -52,7 +50,7 @@ import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
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'
@@ -234,9 +232,6 @@ async function fetchCredentials() {
credentials.value = creds
}
const profileMenu = ref()
const isProfileMenuOpen = computed(() => profileMenu.value?.isOpen)
async function signIn() {
await login().catch(handleError)
await fetchCredentials()
@@ -261,19 +256,25 @@ themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const forceSidebar = ref(false)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const showAd = computed(() => !(!sidebarVisible.value || hasPlus.value))
router.afterEach((to) => {
forceSidebar.value = to.path.startsWith('/browse') || to.path.startsWith('/project')
})
const currentTimeout = ref(null)
watch(
showAd,
() => {
if (!showAd.value) {
if (currentTimeout.value) clearTimeout(currentTimeout.value)
hide_ads_window(true)
} else {
init_ads_window(true)
currentTimeout.value = setTimeout(() => {
init_ads_window(true)
}, 400)
}
},
{ immediate: true },
@@ -300,7 +301,7 @@ async function handleCommand(e) {
if (e.event === 'RunMRPack') {
// RunMRPack should directly install a local mrpack given a path
if (e.path.endsWith('.mrpack')) {
await create_profile_and_install_from_file(e.path).catch(handleError)
await install_from_file(e.path).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
@@ -414,34 +415,26 @@ function handleAuxClick(e) {
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
<SettingsIcon />
</NavButton>
<OverflowMenu
v-if="credentials"
ref="profileMenu"
placement="right-end"
class="w-12 h-12 border-none cursor-pointer rounded-full flex items-center justify-center text-2xl transition-all button-animation"
:class="isProfileMenuOpen ? 'bg-button-bg' : 'bg-transparent hover:bg-button-bg'"
:options="[
{
id: 'profile',
action: () => router.push(`/user/${credentials.user.id}`),
},
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
direction="left"
>
<Avatar
:src="credentials.user.avatar_url"
:alt="credentials.user.username"
size="32px"
circle
/>
<template #profile> <UserIcon /> Profile </template>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
<ButtonStyled v-if="credentials" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
direction="left"
>
<Avatar
:src="credentials.user.avatar_url"
:alt="credentials.user.username"
size="32px"
circle
/>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
</ButtonStyled>
<NavButton v-else v-tooltip.right="'Sign in'" :to="() => signIn()">
<LogInIcon />
<template #label>Sign in</template>
@@ -450,20 +443,6 @@ function handleAuxClick(e) {
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex p-3">
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<div class="flex items-center gap-1 ml-3">
<button
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.back()"
>
<LeftArrowIcon />
</button>
<button
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.forward()"
>
<RightArrowIcon />
</button>
</div>
<Breadcrumbs class="pt-[2px]" />
</div>
<section class="flex ml-auto items-center">
@@ -485,7 +464,7 @@ function handleAuxClick(e) {
<RunningAppBar />
</Suspense>
</div>
<section v-if="!nativeDecorations" class="window-controls" data-tauri-drag-region-exclude>
<section v-if="!nativeDecorations" class="window-controls">
<Button class="titlebar-button" icon-only @click="() => getCurrentWindow().minimize()">
<MinimizeIcon />
</Button>
@@ -533,16 +512,6 @@ function handleAuxClick(e) {
width: 'calc(100% - var(--right-bar-width))',
}"
></div>
<div
v-if="criticalErrorMessage"
class="m-6 mb-0 flex flex-col border-red bg-bg-red rounded-2xl border-2 border-solid p-4 gap-1 font-semibold text-contrast"
>
<h1 class="m-0 text-lg font-extrabold">{{ criticalErrorMessage.header }}</h1>
<div
class="markdown-body text-primary"
v-html="renderString(criticalErrorMessage.body ?? '')"
></div>
</div>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
@@ -614,6 +583,12 @@ function handleAuxClick(e) {
<PromotionWrapper />
</template>
</div>
<div class="view">
<div v-if="criticalErrorMessage" class="critical-error-banner" data-tauri-drag-region>
<h1>{{ criticalErrorMessage.header }}</h1>
<div class="markdown-body" v-html="renderString(criticalErrorMessage.body ?? '')"></div>
</div>
</div>
</div>
<URLConfirmModal ref="urlModal" />
<Notifications ref="notificationsWrapper" sidebar />
@@ -710,23 +685,12 @@ function handleAuxClick(e) {
.app-grid-navbar {
grid-area: nav;
// Fixes SVG scaling issues
filter: brightness(1.00001);
}
.app-grid-statusbar {
grid-area: status;
}
[data-tauri-drag-region] {
-webkit-app-region: drag;
}
[data-tauri-drag-region-exclude] {
-webkit-app-region: no-drag;
}
.app-contents {
position: absolute;
z-index: 1;
@@ -740,7 +704,7 @@ function handleAuxClick(e) {
display: grid;
grid-template-columns: 1fr 0px;
// transition: grid-template-columns 0.4s ease-in-out;
transition: grid-template-columns 0.4s ease-in-out;
&.sidebar-enabled {
grid-template-columns: 1fr 300px;
@@ -796,7 +760,6 @@ function handleAuxClick(e) {
height: 100%;
overflow: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.app-contents::before {

View File

@@ -181,26 +181,24 @@ const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
if (rows.value && rows.value[0]) {
// Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem
const containerWidthInRem =
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
// Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem
const containerWidthInRem =
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
if (maxInstancesPerRow.value < 5) {
maxInstancesPerRow.value *= 2
}
if (maxInstancesPerCompactRow.value < 5) {
maxInstancesPerCompactRow.value *= 2
}
if (maxProjectsPerRow.value < 3) {
maxProjectsPerRow.value *= 2
}
if (maxInstancesPerRow.value < 5) {
maxInstancesPerRow.value *= 2
}
if (maxInstancesPerCompactRow.value < 5) {
maxInstancesPerCompactRow.value *= 2
}
if (maxProjectsPerRow.value < 3) {
maxProjectsPerRow.value *= 2
}
}
@@ -209,17 +207,13 @@ const resizeObserver = ref(null)
onMounted(() => {
calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.observe(rowContainer.value)
}
resizeObserver.value.observe(rowContainer.value)
window.addEventListener('resize', calculateCardsPerRow)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.unobserve(rowContainer.value)
}
resizeObserver.value.unobserve(rowContainer.value)
})
</script>

View File

@@ -1,16 +1,7 @@
<script setup>
import {
CheckIcon,
DropdownIcon,
XIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
CopyIcon,
} from '@modrinth/assets'
import { XIcon, HammerIcon, LogInIcon, UpdatedIcon } from '@modrinth/assets'
import { ChatIcon } from '@/assets/icons'
import { ButtonStyled, Collapsible } from '@modrinth/ui'
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js'
@@ -22,7 +13,6 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const errorModal = ref()
const error = ref()
const closable = ref(true)
const errorCollapsed = ref(false)
const title = ref('An error occurred')
const errorType = ref('unknown')
@@ -128,26 +118,6 @@ async function repairInstance() {
}
loadingRepair.value = false
}
const hasDebugInfo = computed(
() =>
errorType.value === 'directory_move' ||
errorType.value === 'minecraft_auth' ||
errorType.value === 'state_init' ||
errorType.value === 'no_loader_version',
)
const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.')
const copied = ref(false)
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>
<template>
@@ -274,9 +244,16 @@ async function copyToClipboard(text) {
</div>
</template>
<template v-else>
{{ debugInfo }}
{{ error.message ?? error }}
</template>
<template v-if="hasDebugInfo">
<template
v-if="
errorType === 'directory_move' ||
errorType === 'minecraft_auth' ||
errorType === 'state_init' ||
errorType === 'no_loader_version'
"
>
<hr />
<p>
If nothing is working and you need help, visit
@@ -284,39 +261,16 @@ async function copyToClipboard(text) {
and start a chat using the widget in the bottom right and we will be more than happy to
assist! Make sure to provide the following debug information to the agent:
</p>
<details>
<summary>Debug information</summary>
{{ error.message ?? error }}
</details>
</template>
</div>
<div class="flex items-center gap-2">
<ButtonStyled>
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
</ButtonStyled>
<ButtonStyled v-if="closable">
<button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled>
<ButtonStyled v-if="hasDebugInfo">
<button :disabled="copied" @click="copyToClipboard(debugInfo)">
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
<template v-else> <CopyIcon /> Copy debug info </template>
</button>
</ButtonStyled>
<div class="input-group push-right">
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a>
<button v-if="closable" class="btn" @click="errorModal.hide()"><XIcon /> Close</button>
</div>
<template v-if="hasDebugInfo">
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
@click="errorCollapsed = !errorCollapsed"
>
<span class="text-contrast font-extrabold m-0">Debug information:</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !errorCollapsed }"
/>
</button>
<Collapsible :collapsed="errorCollapsed">
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
</Collapsible>
</div>
</template>
</div>
</ModalWrapper>
</template>

View File

@@ -151,7 +151,7 @@ const exportPack = async () => {
</div>
</div>
<div v-if="showingFiles" class="table-content">
<div v-for="[path, children] in folders" :key="path.name" class="table-row">
<div v-for="[path, children] of folders" :key="path.name" class="table-row">
<div class="table-cell file-entry">
<div class="file-primary">
<Checkbox

View File

@@ -1,17 +1,10 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onUnmounted, ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
SpinnerIcon,
GameIcon,
TimerIcon,
StopCircleIcon,
PlayIcon,
DownloadIcon,
} from '@modrinth/assets'
import { ButtonStyled, Avatar, SmartClickable } from '@modrinth/ui'
import { SpinnerIcon, GameIcon, TimerIcon, StopCircleIcon, PlayIcon } from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { finish_install, kill, run } from '@/helpers/profile'
import { kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener } from '@/helpers/events'
import { handleError } from '@/store/state.js'
@@ -42,15 +35,12 @@ const props = defineProps({
})
const playing = ref(false)
const loading = ref(false)
const modLoading = computed(
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !playing.value),
currentEvent.value === 'installing' || (currentEvent.value === 'launched' && !playing.value),
)
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
const installing = computed(() => props.instance.install_stage !== 'installed')
const router = useRouter()
@@ -66,7 +56,6 @@ const checkProcess = async () => {
const play = async (e, context) => {
e?.stopPropagation()
loading.value = true
await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => {
@@ -76,7 +65,6 @@ const play = async (e, context) => {
source: context,
})
})
loading.value = false
}
const stop = async (e, context) => {
@@ -92,12 +80,6 @@ const stop = async (e, context) => {
})
}
const repair = async (e) => {
e?.stopPropagation()
await finish_install(props.instance)
}
const openFolder = async () => {
await showProfileInFolder(props.instance.path)
}
@@ -134,26 +116,22 @@ onUnmounted(() => unlisten())
</script>
<template>
<SmartClickable class="card-shadow bg-bg-raised rounded-xl" @mouseenter="checkProcess">
<template #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instance.path)}/`"
/>
</template>
<div v-if="compact" class="grid grid-cols-[auto_1fr_auto] p-3 pl-4 gap-2">
<template v-if="compact">
<div
class="button-base card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer active:scale-[0.98] transition-transform"
@click="seeInstance"
@mouseenter="checkProcess"
>
<Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path"
alt="Mod card"
/>
<div
class="h-full flex items-center font-bold text-contrast leading-normal smart-clickable:underline-on-hover"
>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span>
</div>
<div class="flex items-center smart-clickable:allow-pointer-events">
<div class="flex items-center">
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
<StopCircleIcon />
@@ -180,7 +158,13 @@ onUnmounted(() => unlisten())
<span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </span>
</div>
</div>
<div v-else class="p-4 rounded-xl flex gap-3 group" @mouseenter="checkProcess">
</template>
<div v-else>
<div
class="button-base bg-bg-raised p-4 rounded-xl flex gap-3 group"
@click="seeInstance"
@mouseenter="checkProcess"
>
<div class="relative flex items-center justify-center">
<Avatar
size="48px"
@@ -207,15 +191,6 @@ onUnmounted(() => unlisten())
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
<button
v-tooltip="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular>
<button
v-tooltip="'Play'"
@@ -229,9 +204,7 @@ onUnmounted(() => unlisten())
</div>
</div>
<div class="flex flex-col gap-1">
<p
class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1 smart-clickable:underline-on-hover"
>
<p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1">
{{ instance.name }}
</p>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
@@ -242,5 +215,5 @@ onUnmounted(() => unlisten())
</div>
</div>
</div>
</SmartClickable>
</div>
</template>

View File

@@ -199,16 +199,16 @@
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import {
CodeIcon,
FolderOpenIcon,
FolderSearchIcon,
InfoIcon,
PlusIcon,
UpdatedIcon,
UploadIcon,
XIcon,
CodeIcon,
FolderOpenIcon,
InfoIcon,
FolderSearchIcon,
UpdatedIcon,
} from '@modrinth/assets'
import { Avatar, Button, Checkbox, Chips } from '@modrinth/ui'
import { Avatar, Button, Chips, Checkbox } from '@modrinth/ui'
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import { get_loaders } from '@/helpers/tags'
import { create } from '@/helpers/profile'
@@ -218,7 +218,7 @@ import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
import { handleError } from '@/store/notifications.js'
import Multiselect from 'vue-multiselect'
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
import { install_from_file } from '@/helpers/pack.js'
import {
get_default_launcher_path,
get_importable_instances,
@@ -263,7 +263,7 @@ defineExpose({
hide()
const { paths } = event.payload
if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) {
await create_profile_and_install_from_file(paths[0]).catch(handleError)
await install_from_file(paths[0]).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
@@ -419,7 +419,7 @@ const openFile = async () => {
const newProject = await open({ multiple: false })
if (!newProject) return
hide()
await create_profile_and_install_from_file(newProject.path ?? newProject).catch(handleError)
await install_from_file(newProject.path ?? newProject).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileOpen',

View File

@@ -3,18 +3,23 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { formatCategory } from '@modrinth/utils'
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled } from '@modrinth/ui'
import type { GameInstance } from '@/helpers/types'
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
}
defineProps<{
instance?: GameInstance
instance: Instance
}>()
</script>
<template>
<div
v-if="instance"
class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4"
>
<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"
@@ -44,3 +49,5 @@ defineProps<{
</ButtonStyled>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,13 +1,16 @@
<script setup>
import { Avatar, SmartClickable, TagItem } from '@modrinth/ui'
import { Avatar, TagItem } from '@modrinth/ui'
import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets'
import { formatNumber, formatCategory } from '@modrinth/utils'
import { computed } from 'vue'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useRouter } from 'vue-router'
dayjs.extend(relativeTime)
const router = useRouter()
const props = defineProps({
project: {
type: Object,
@@ -37,15 +40,29 @@ const toColor = computed(() => {
const r = (color >>> 16) & 0xff
return 'rgba(' + [r, g, b, 1].join(',') + ')'
})
const toTransparent = computed(() => {
let color = props.project.color
color >>>= 0
const b = color & 0xff
const g = (color >>> 8) & 0xff
const r = (color >>> 16) & 0xff
return (
'linear-gradient(rgba(' +
[r, g, b, 0.03].join(',') +
'), 65%, rgba(' +
[r, g, b, 0.3].join(',') +
'))'
)
})
</script>
<template>
<SmartClickable
class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all"
<div
class="card-shadow button-base bg-bg-raised rounded-xl overflow-clip cursor-pointer active:scale-[0.98] transition-transform"
@click="router.push(`/project/${project.slug}`)"
>
<template #clickable>
<router-link class="no-click-animation" :to="`/project/${project.slug}`" />
</template>
<div
class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat"
:style="{
@@ -56,13 +73,21 @@ const toColor = computed(() => {
'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`,
}"
></div>
>
<div
class="badges-wrapper"
:class="{
'no-image': !project.featured_gallery && !project.gallery[0],
}"
:style="{
background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null,
}"
></div>
</div>
<div class="flex flex-col justify-center gap-2 px-4 py-3">
<div class="flex gap-2 items-center">
<Avatar size="48px" :src="project.icon_url" />
<div
class="h-full flex items-center font-bold text-contrast leading-normal smart-clickable:underline-on-hover"
>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ project.title }}</span>
</div>
</div>
@@ -90,7 +115,7 @@ const toColor = computed(() => {
</div>
</div>
</div>
</SmartClickable>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,194 +0,0 @@
<script setup lang="ts">
import {
VersionIcon,
ImageIcon,
BookmarkIcon,
DownloadIcon,
HeartIcon,
MoreVerticalIcon,
ExternalIcon,
LinkIcon,
ReportIcon,
SpinnerIcon,
CheckIcon,
} from '@modrinth/assets'
import { ButtonStyled, commonMessages, OverflowMenu } from '@modrinth/ui'
import type { GameInstance } from '@/helpers/types'
import { computed, ref, type Ref } from 'vue'
import { install as installVersion } from '@/store/install'
import { useVIntl, defineMessages } from '@vintl/vintl'
import { isSearchResult, type Project, type SearchResult } from '@modrinth/utils'
import type { InstanceContentMap } from '@/composables/instance-context.ts'
const { formatMessage } = useVIntl()
const props = defineProps<{
project: Project | SearchResult
instance?: GameInstance
instanceContent?: InstanceContentMap
}>()
const installing = ref(false)
const installed: Ref<boolean> = ref(false)
function checkInstallStatus() {
if (props.instanceContent) {
installed.value = Object.values(props.instanceContent).some(
(content) => content.metadata?.project_id === projectId.value,
)
}
}
async function install(toInstance: boolean) {
if (toInstance) {
installing.value = true
}
await installVersion(
projectId.value,
null,
props.instance && toInstance ? props.instance.path : null,
'SearchCard',
() => {
if (toInstance) {
installing.value = false
installed.value = true
}
},
)
}
const modpack = computed(() => props.project.project_type === 'modpack')
const projectWebUrl = computed(
() => `https://modrinth.com/${props.project.project_type}/${props.project.slug}`,
)
const tooltip = defineMessages({
installing: {
id: 'project.card.actions.installing.tooltip',
defaultMessage: 'This project is being installed',
},
installed: {
id: 'project.card.actions.installed.tooltip',
defaultMessage: 'This project is already installed',
},
})
const messages = defineMessages({
viewVersions: {
id: 'project.card.actions.view-versions',
defaultMessage: 'View versions',
},
viewGallery: {
id: 'project.card.actions.view-gallery',
defaultMessage: 'View gallery',
},
})
const projectId = computed(() =>
isSearchResult(props.project) ? props.project.project_id : props.project.id,
)
const copyText = (text: string) => {
navigator.clipboard.writeText(text)
}
checkInstallStatus()
</script>
<template>
<ButtonStyled color="brand">
<button
v-tooltip="
installing
? formatMessage(tooltip.installing)
: installed
? formatMessage(tooltip.installed)
: null
"
:disabled="installing || installed"
@click="() => install(true)"
>
<SpinnerIcon v-if="installing" />
<CheckIcon v-else-if="installed" />
<DownloadIcon v-else />
{{
formatMessage(
installing
? commonMessages.installingButton
: installed
? commonMessages.installedButton
: commonMessages.installButton,
)
}}
</button>
</ButtonStyled>
<!-- TODO: Add in later -->
<ButtonStyled v-if="false" circular>
<button v-tooltip="'Follow'">
<HeartIcon />
</button>
</ButtonStyled>
<ButtonStyled v-if="false" circular>
<button v-tooltip="'Save'">
<BookmarkIcon />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'install-elsewhere',
color: 'primary',
action: () => install(false),
shown: !!instance && !modpack,
},
{
divider: true,
shown: !!instance && !modpack,
},
{
id: 'versions',
link: `/project/${projectId}/versions`,
},
{
id: 'gallery',
link: `/project/${projectId}/gallery`,
shown: (project.gallery?.length ?? 0) > 0,
},
{
id: 'open-link',
link: projectWebUrl,
},
{
id: 'copy-link',
action: () => copyText(projectWebUrl),
},
{
divider: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
action: () => {},
},
]"
>
<MoreVerticalIcon />
<template #install-elsewhere>
<DownloadIcon /> {{ formatMessage(commonMessages.installToButton) }}
</template>
<template #versions> <VersionIcon /> {{ formatMessage(messages.viewVersions) }} </template>
<template #gallery> <ImageIcon /> {{ formatMessage(messages.viewGallery) }} </template>
<template #open-link>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #copy-link>
<LinkIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
</template>
<template #report> <ReportIcon /> {{ formatMessage(commonMessages.reportButton) }} </template>
</OverflowMenu>
</ButtonStyled>
</template>

View File

@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { init_ads_window } from '@/helpers/ads.js'
import { ChevronRightIcon } from '@modrinth/assets'
import { init_ads_window, open_ads_link, record_ads_click } from '@/helpers/ads.js'
const adsWrapper = ref(null)
@@ -28,12 +29,27 @@ function updateAdPosition() {
initDevicePixelRatioWatcher()
}
}
async function openPlusLink() {
await record_ads_click()
await open_ads_link('https://modrinth.com/plus', 'https://modrinth.com')
}
</script>
<template>
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
<button
class="mt-auto items-center gap-1 text-purple hover:underline bg-transparent border-none text-left cursor-pointer outline-none"
@click="openPlusLink"
>
<span>
Support creators and Modrinth ad-free with
<span class="font-bold">Modrinth+</span>
</span>
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
</button>
</div>
</div>
</template>

View File

@@ -1,72 +1,154 @@
<template>
<NewProjectCard
:project="project"
:link="
asLink(
{
path: `/project/${projectId}`,
<div
class="card-shadow button-base p-4 bg-bg-raised rounded-xl flex gap-3 group"
@click="
() => {
emit('open')
$router.push({
path: `/project/${project.project_id ?? project.id}`,
query: { i: props.instance ? props.instance.path : undefined },
},
() => emit('open'),
)
"
:experimental-colors="themeStore.featureFlags.project_card_background"
:creator-link="
creator
? asLink(
{
path: `/user/${creator}`,
query: { i: props.instance ? props.instance.path : undefined },
},
() => emit('open'),
)
: undefined
})
}
"
>
<template #actions>
<ButtonStyled color="brand">
<button
:disabled="installed || installing"
class="shrink-0 no-wrap"
@click.stop="install()"
<div class="icon w-[96px] h-[96px] relative">
<Avatar
:src="project.icon_url"
size="96px"
class="search-icon origin-top transition-all"
:class="{ 'scale-[0.85]': installed, 'brightness-50': installing }"
/>
<div v-if="installing" class="rounded-2xl absolute inset-0 flex items-center justify-center">
<SpinnerIcon class="h-8 w-8 animate-spin" />
</div>
<div
v-if="installed"
class="absolute shadow-sm font-semibold bottom-0 w-full p-1 bg-button-bg rounded-full text-xs justify-center items-center flex gap-1 text-brand border-[1px] border-solid border-[--color-button-border]"
>
<CheckIcon class="shrink-0 stroke-[3px]" /> Installed
</div>
</div>
<div class="flex flex-col gap-2 overflow-hidden">
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
<span class="text-lg font-extrabold text-contrast m-0 leading-none">
{{ project.title }}
</span>
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
</div>
<div class="m-0 line-clamp-2">
{{ project.description }}
</div>
<div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
<TagsIcon class="h-4 w-4 shrink-0" />
<div
v-for="tag in categories"
:key="tag"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
>
<template v-if="!installed">
<DownloadIcon />
</template>
<CheckIcon v-else />
{{ installing ? 'Installing' : installed ? 'Installed' : 'Install' }}
</button>
</ButtonStyled>
</template>
</NewProjectCard>
{{ formatCategory(tag.name) }}
</div>
</div>
</div>
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
<div class="flex items-center gap-2">
<DownloadIcon class="shrink-0" />
<span>
{{ formatNumber(project.downloads) }}
<span class="text-secondary">downloads</span>
</span>
</div>
<div class="flex items-center gap-2">
<HeartIcon class="shrink-0" />
<span>
{{ formatNumber(project.follows ?? project.followers) }}
<span class="text-secondary">followers</span>
</span>
</div>
<div class="mt-auto relative">
<div
class="flex items-center gap-2 group-hover:-translate-y-3 group-hover:opacity-0 group-focus-within:opacity-0 group-hover:scale-95 group-focus-within:scale-95 transition-all"
>
<HistoryIcon class="shrink-0" />
<span>
<span class="text-secondary">Updated</span>
{{ dayjs(project.date_modified ?? project.updated).fromNow() }}
</span>
</div>
<div
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>
</template>
<script setup lang="ts">
import { DownloadIcon, CheckIcon } from '@modrinth/assets'
import { ButtonStyled, NewProjectCard, asLink } from '@modrinth/ui'
import type { Project, SearchResult } from '@modrinth/utils'
import { isSearchResult } from '@modrinth/utils'
<script setup>
import {
SpinnerIcon,
TagsIcon,
DownloadIcon,
HeartIcon,
PlusIcon,
CheckIcon,
HistoryIcon,
} from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui'
import { formatNumber, formatCategory } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { ref, computed } from 'vue'
import { install as installVersion } from '@/store/install.js'
import { useTheming } from '@/store/state.js'
import type { GameInstance } from '@/helpers/types'
dayjs.extend(relativeTime)
const themeStore = useTheming()
const props = withDefaults(
defineProps<{
project: Project | SearchResult
instance?: GameInstance
installed?: boolean
}>(),
{
instance: undefined,
installed: false,
const props = defineProps({
backgroundImage: {
type: String,
default: null,
},
)
project: {
type: Object,
required: true,
},
categories: {
type: Array,
required: true,
},
instance: {
type: Object,
default: null,
},
featured: {
type: Boolean,
default: false,
},
installed: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['open', 'install'])
@@ -75,19 +157,16 @@ const installing = ref(false)
async function install() {
installing.value = true
await installVersion(
projectId.value,
props.project.project_id ?? props.project.id,
null,
props.instance ? props.instance.path : null,
'SearchCard',
() => {
installing.value = false
emit('install', projectId.value)
emit('install', props.project.project_id ?? props.project.id)
},
)
}
const projectId = computed(() =>
isSearchResult(props.project) ? props.project.project_id : props.project.id,
)
const creator = computed(() => (isSearchResult(props.project) ? props.project.author : undefined))
const modpack = computed(() => props.project.project_type === 'modpack')
</script>

View File

@@ -20,7 +20,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const props = defineProps<{
credentials: unknown | null
signIn: () => void
signIn: () => void2
}>()
const userCredentials = computed(() => props.credentials)

View File

@@ -1,7 +1,7 @@
<script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { XIcon, DownloadIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
import { install as pack_install } from '@/helpers/pack'
import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { handleError } from '@/store/state.js'

View File

@@ -5,7 +5,7 @@ import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
import type { InstanceSettingsTabProps, AppSettings } from '../../../helpers/types'
const { formatMessage } = useVIntl()
@@ -114,6 +114,7 @@ const messages = defineMessages({
<Toggle
id="fullscreen"
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
:checked="fullscreenSetting"
:disabled="!overrideWindowSettings"
@update:model-value="
(e) => {

View File

@@ -116,6 +116,10 @@ function devModeCount() {
themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0
if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) {
modal.value.setTab(0)
}
}
}
</script>

View File

@@ -43,7 +43,7 @@ function onModalHide() {
if (props.showAdOnClose) {
show_ads_window()
}
props.onHide?.()
props.onHide()
}
</script>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { Toggle, ThemeSelector, TeleportDropdownMenu } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings'
import { ref, watch } from 'vue'
import { watch, ref } from 'vue'
import { getOS } from '@/helpers/utils'
const themeStore = useTheming()
@@ -46,6 +46,7 @@ watch(
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
:checked="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = e
@@ -60,7 +61,16 @@ watch(
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
<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">
@@ -68,7 +78,16 @@ watch(
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
<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">
@@ -92,6 +111,7 @@ watch(
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
:checked="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = e

View File

@@ -57,7 +57,16 @@ watch(
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
<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">

View File

@@ -1,22 +1,19 @@
<script setup lang="ts">
import { ButtonStyled, Toggle } from '@modrinth/ui'
import { Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { ref, type Ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings'
import { DEFAULT_FEATURE_FLAGS } from '@/store/theme'
type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
const themeStore = useTheming()
const settings = ref(await get())
const options: Ref<FeatureFlag[]> = ref(Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
const options = ref(['project_background', 'page_path'])
function getStoreValue(key: FeatureFlag) {
function getStoreValue(key: string) {
return themeStore.featureFlags[key] ?? false
}
function setStoreValue(key: FeatureFlag, value: boolean) {
function setStoreValue(key: string, value: boolean) {
themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value
}
@@ -30,39 +27,18 @@ watch(
)
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">Feature flags</h2>
<p class="mt-1 mb-0 leading-tight text-secondary">
These are developer tools that are not intended to be used by end users except for debugging
purposes.
</p>
<p class="my-3 font-bold">Do not report bugs or issues if you have any feature flags enabled.</p>
<div
v-for="option in options"
:key="option"
class="mt-2 px-4 py-3 flex items-center justify-between bg-bg rounded-2xl"
>
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-base font-bold text-primary capitalize">
{{ option.replace(new RegExp('_', 'g'), ' ') }}
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ option }}
</h2>
<p class="m-0 text-sm text-secondary">Default: {{ DEFAULT_FEATURE_FLAGS[option] }}</p>
</div>
<div class="flex items-center gap-1">
<ButtonStyled type="transparent">
<button
class="text-sm"
:disabled="getStoreValue(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="() => setStoreValue(option, !themeStore.featureFlags[option])"
>
Reset to default
</button>
</ButtonStyled>
<Toggle
id="advanced-rendering"
:model-value="getStoreValue(option)"
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
/>
</div>
<Toggle
id="advanced-rendering"
:model-value="getStoreValue(option)"
:checked="getStoreValue(option)"
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
/>
</div>
</template>

View File

@@ -30,7 +30,16 @@ watch(
option, you opt out and ads will no longer be shown based on your interests.
</p>
</div>
<Toggle id="personalized-ads" v-model="settings.personalized_ads" />
<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">
@@ -42,7 +51,16 @@ watch(
longer be collected.
</p>
</div>
<Toggle id="opt-out-analytics" v-model="settings.telemetry" />
<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">
@@ -57,6 +75,10 @@ watch(
as those added by mods. (app restart required to take effect)
</p>
</div>
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
<Toggle
id="disable-discord-rpc"
v-model="settings.discord_rpc"
:checked="settings.discord_rpc"
/>
</div>
</template>

View File

@@ -1,41 +0,0 @@
import { useRoute } from 'vue-router'
import { ref, computed, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import type { GameInstance, InstanceContent } from '@/helpers/types'
export type InstanceContentMap = Record<string, InstanceContent>
export async function useInstanceContext() {
const route = useRoute()
const instance: Ref<GameInstance | undefined> = ref()
const instanceContent: Ref<InstanceContentMap | undefined> = ref()
await loadInstance()
watch(route, () => {
loadInstance()
})
async function loadInstance() {
;[instance.value, instanceContent.value] = await Promise.all([
route.query.i ? getInstance(route.query.i).catch(handleError) : Promise.resolve(),
route.query.i ? getInstanceProjects(route.query.i).catch(handleError) : Promise.resolve(),
])
}
const instanceQueryAppendage = computed(() => {
if (instance.value) {
return `?i=${instance.value.path}`
} else {
return ''
}
})
return {
instance,
instanceContent,
instanceQueryAppendage,
}
}

View File

@@ -7,7 +7,7 @@ import { invoke } from '@tauri-apps/api/core'
import { create } from './profile'
// Installs pack from a version ID
export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
export async function install(projectId, versionId, packTitle, iconUrl) {
const location = {
type: 'fromVersionId',
project_id: projectId,
@@ -28,18 +28,8 @@ export async function create_profile_and_install(projectId, versionId, packTitle
return await invoke('plugin:pack|pack_install', { location, profile })
}
export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
const location = {
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title,
}
return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
}
// Installs pack from a path
export async function create_profile_and_install_from_file(path) {
export async function install_from_file(path) {
const location = {
type: 'fromFile',
path: path,

View File

@@ -4,8 +4,6 @@
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import { install_to_existing_profile } from '@/helpers/pack.js'
import { handleError } from '@/store/notifications.js'
/// Add instance
/*
@@ -188,17 +186,3 @@ export async function edit(path, editProfile) {
export async function edit_icon(path, iconPath) {
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
}
export async function finish_install(instance) {
if (instance.install_stage !== 'pack_installed') {
let linkedData = instance.linked_data
await install_to_existing_profile(
linkedData.project_id,
linkedData.version_id,
instance.name,
instance.path,
).catch(handleError)
} else {
await install(instance.path, false).catch(handleError)
}
}

View File

@@ -32,24 +32,7 @@ type GameInstance = {
hooks: Hooks
}
type InstanceContent = {
hash: string
file_name: string
size: number
metadata?: {
project_id: ModrinthId
version_id: ModrinthId
}
update_version_id: string
project_type: 'mod' | 'resourcepack' | 'datapack' | 'shaderpack'
}
type InstallStage =
| 'installed'
| 'minecraft_installing'
| 'pack_installed'
| 'pack_installing'
| 'not_installed'
type InstallStage = 'installed' | 'installing' | 'pack_installing' | 'not_installed'
type LinkedData = {
project_id: ModrinthId

View File

@@ -20,9 +20,6 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"instance.filter.disabled": {
"message": "Disabled projects"
},
"instance.filter.updates-available": {
"message": "Updates available"
},
@@ -308,18 +305,6 @@
"instance.settings.title": {
"message": "Settings"
},
"project.card.actions.installed.tooltip": {
"message": "This project is already installed"
},
"project.card.actions.installing.tooltip": {
"message": "This project is being installed"
},
"project.card.actions.view-gallery": {
"message": "View gallery"
},
"project.card.actions.view-versions": {
"message": "View versions"
},
"search.filter.locked.instance": {
"message": "Provided by the instance"
},

View File

@@ -2,14 +2,7 @@
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import type { Ref } from 'vue'
import { SearchIcon, XIcon, ClipboardCopyIcon, GlobeIcon, ExternalIcon } from '@modrinth/assets'
import type {
CategoryTag,
GameVersionTag,
PlatformTag,
ProjectType,
SortType,
Tags,
} from '@modrinth/ui'
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
import {
SearchFilterControl,
SearchSidebarFilter,
@@ -26,14 +19,14 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
import { get_search_results } from '@/helpers/cache.js'
import NavTabs from '@/components/ui/NavTabs.vue'
import type Instance from '@/components/ui/Instance.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useInstanceContext } from '@/composables/instance-context.ts'
import type { SearchResult } from '@modrinth/utils'
const { formatMessage } = useVIntl()
@@ -45,45 +38,62 @@ const projectTypes = computed(() => {
})
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories()
.catch(handleError)
.then((x: CategoryTag[]) => ref(x)),
get_loaders()
.catch(handleError)
.then((x: PlatformTag[]) => ref(x)),
get_game_versions()
.catch(handleError)
.then((x: GameVersionTag[]) => ref(x)),
get_categories().catch(handleError).then(ref),
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
])
const tags: Ref<Tags> = computed(() => ({
gameVersions: availableGameVersions.value as GameVersionTag[],
loaders: loaders.value as PlatformTag[],
categories: categories.value as CategoryTag[],
gameVersions: availableGameVersions.value as GameVersion[],
loaders: loaders.value as Platform[],
categories: categories.value as Category[],
}))
const instanceHideInstalled = ref(false)
const newlyInstalled: Ref<string[]> = ref([])
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
}
const { instance, instanceContent } = await useInstanceContext()
type InstanceProject = {
metadata: {
project_id: string
}
}
const instance: Ref<Instance | null> = ref(null)
const instanceProjects: Ref<InstanceProject[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref([])
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
await checkHideInstalledQuery()
await updateInstanceContext()
watch(instance, () => {
checkHideInstalledQuery()
watch(route, () => {
updateInstanceContext()
})
async function checkHideInstalledQuery() {
async function updateInstanceContext() {
if (route.query.i) {
;[instance.value, instanceProjects.value] = await Promise.all([
getInstance(route.query.i).catch(handleError),
getInstanceProjects(route.query.i).catch(handleError),
])
newlyInstalled.value = []
}
if (route.query.ai && !(projectTypes.value.length === 1 && projectTypes.value[0] === 'modpack')) {
instanceHideInstalled.value = route.query.ai === 'true'
}
// if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) {
// instance.value = undefined
// instanceHideInstalled.value = false
// }
if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) {
instance.value = null
instanceHideInstalled.value = false
}
}
const instanceFilters = computed(() => {
@@ -109,10 +119,10 @@ const instanceFilters = computed(() => {
})
}
if (instanceHideInstalled.value && instanceContent.value) {
const installedMods: string[] = Object.values(instanceContent.value)
if (instanceHideInstalled.value && instanceProjects.value) {
const installedMods = Object.values(instanceProjects.value)
.filter((x) => x.metadata)
.map((x) => x.metadata!.project_id)
.map((x) => x.metadata.project_id)
installedMods.push(...newlyInstalled.value)
@@ -163,27 +173,23 @@ breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: rout
const loading = ref(true)
const projectType: Ref<ProjectType | undefined> = ref(
typeof route.params.projectType === 'string'
? (route.params.projectType as ProjectType)
: undefined,
)
const projectType = ref(route.params.projectType)
watch(projectType, () => {
loading.value = true
})
type ExtendedSearchResult = SearchResult & {
installed?: boolean
type SearchResult = {
project_id: string
}
type SearchResults = {
total_hits: number
limit: number
hits: ExtendedSearchResult[]
hits: SearchResult[]
}
const results: Ref<SearchResults | undefined> = shallowRef()
const results: Ref<SearchResults | null> = shallowRef(null)
const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
)
@@ -194,7 +200,7 @@ watch(requestParams, () => {
})
async function refreshSearch() {
let rawResults = (await get_search_results(requestParams.value)) as { result: SearchResults }
let rawResults = await get_search_results(requestParams.value)
if (!rawResults) {
rawResults = {
result: {
@@ -205,15 +211,13 @@ async function refreshSearch() {
}
}
if (instance.value) {
rawResults.result.hits.map((x) => ({
...x,
installed:
newlyInstalled.value.includes(x.project_id) ||
(instanceContent.value &&
Object.values(instanceContent.value).some(
(content) => content.metadata && content.metadata.project_id === x.project_id,
)),
}))
for (const val of rawResults.result.hits) {
val.installed =
newlyInstalled.value.includes(val.project_id) ||
Object.values(instanceProjects.value).some(
(x) => x.metadata && x.metadata.project_id === val.project_id,
)
}
}
results.value = rawResults.result
@@ -267,9 +271,9 @@ watch(
() => route.params.projectType,
async (newType) => {
// Check if the newType is not the same as the current value
if (!newType || newType === projectType.value || typeof newType !== 'string') return
if (!newType || newType === projectType.value) return
projectType.value = newType as ProjectType
projectType.value = newType
currentSortType.value = { display: 'Relevance', name: 'relevance' }
query.value = ''
@@ -283,7 +287,7 @@ const selectableProjectTypes = computed(() => {
if (instance.value) {
if (
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13')
) {
dataPacks = true
@@ -349,10 +353,15 @@ const messages = defineMessages({
},
})
const options: Ref<InstanceType<typeof ContextMenu> | null> = ref(null)
const handleRightClick = (event: MouseEvent, result: ExtendedSearchResult) => {
options.value?.showMenu(event, result, [
const options = ref(null)
const handleRightClick = (event, result) => {
options.value.showMenu(event, result, [
{
name: 'install',
},
{
type: 'divider',
},
{
name: 'open_link',
},
@@ -361,7 +370,7 @@ const handleRightClick = (event: MouseEvent, result: ExtendedSearchResult) => {
},
])
}
const handleOptionsClick = (args: { item: ExtendedSearchResult; option: string }) => {
const handleOptionsClick = (args) => {
switch (args.option) {
case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
@@ -474,26 +483,33 @@ await refreshSearch()
<section v-if="loading" class="offline">
<LoadingIndicator />
</section>
<section v-else-if="offline && (!results || results.total_hits === 0)" class="offline">
<section v-else-if="offline && results.total_hits === 0" class="offline">
You are currently offline. Connect to the internet to browse Modrinth!
</section>
<section
v-else-if="results"
class="project-list display-mode--list instance-results"
role="list"
>
<section v-else class="project-list display-mode--list instance-results" role="list">
<SearchCard
v-for="result in results.hits"
:key="result?.project_id"
:project="result"
:instance="instance"
:categories="[
...categories.filter(
(cat) =>
result?.display_categories.includes(cat.name) && cat.project_type === projectType,
),
...loaders.filter(
(loader) =>
result?.display_categories.includes(loader.name) &&
loader.supported_project_types?.includes(projectType),
),
]"
:installed="result.installed || newlyInstalled.includes(result.project_id)"
@install="
(id) => {
newlyInstalled.push(id)
}
"
@contextmenu.prevent.stop="(event: MouseEvent) => handleRightClick(event, result)"
@contextmenu.prevent.stop="(event) => handleRightClick(event, result)"
/>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>

View File

@@ -1,136 +0,0 @@
<template>
<Teleport to="#sidebar-teleport-target">
<CollectionSidebarDescription
v-if="collection"
:collection="collection"
class="project-sidebar-section"
/>
<CollectionSidebarCurator
v-if="curator"
:user="curator"
:link="`/user/${curator.id}`"
class="project-sidebar-section"
/>
<CollectionSidebarDetails
v-if="collection"
:collection="collection"
class="project-sidebar-section"
/>
</Teleport>
<div v-if="collection" class="p-6 flex flex-col gap-4">
<InstanceIndicator :instance="instance" />
<CollectionHeader :collection="collection">
<template #actions>
<ButtonStyled v-if="themeStore.devMode" circular type="transparent" size="large">
<OverflowMenu
:options="[{ id: 'copy-id', action: () => copyId(), shown: themeStore.devMode }]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</CollectionHeader>
<div v-if="projects">
<ProjectsList
:projects="projects"
:project-link="(project) => `/project/${project.id}${instanceQueryAppendage}`"
:experimental-colors="themeStore.featureFlags.project_card_background"
>
<template #project-actions="{ project }">
<ProjectCardActions
:instance="instance"
:instance-content="instanceContent"
:project="project"
/>
</template>
</ProjectsList>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications.js'
import {
ProjectsList,
ButtonStyled,
commonMessages,
OverflowMenu,
CollectionHeader,
CollectionSidebarCurator,
CollectionSidebarDescription,
CollectionSidebarDetails,
} from '@modrinth/ui'
import { ClipboardCopyIcon, MoreVerticalIcon } from '@modrinth/assets'
import { useVIntl } from '@vintl/vintl'
import { useFetch } from '@/helpers/fetch'
import type { User, Project, Collection } from '@modrinth/utils'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/theme'
import { useInstanceContext } from '@/composables/instance-context'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import ProjectCardActions from '@/components/ui/ProjectCardActions.vue'
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const { formatMessage } = useVIntl()
const collection: Ref<Collection | null> = ref(null)
const curator: Ref<User | null> = ref(null)
const projects: Ref<Project[]> = ref([])
async function fetchCollection() {
collection.value = await useFetch(
`https://api.modrinth.com/v3/collection/${route.params.id}`,
).catch(handleError)
if (!collection.value) {
return
}
;[projects.value, curator.value] = await Promise.all([
useFetch(
`https://api.modrinth.com/v2/projects?ids=${encodeURIComponent(JSON.stringify(collection.value.projects))}`,
),
useFetch(`https://api.modrinth.com/v2/user/${collection.value.user}`).catch(handleError),
])
breadcrumbs.setContext({ name: 'Collection', link: `/collection/${collection.value.name}` })
breadcrumbs.setName('Collection', collection.value.name)
}
await fetchCollection()
const { instance, instanceContent, instanceQueryAppendage } = await useInstanceContext()
watch(
() => route.params.id,
async () => {
if (route.params.id && route.path.startsWith('/collection')) {
await fetchCollection()
}
},
)
const themeStore = useTheming()
async function copyId() {
if (collection.value) {
await navigator.clipboard.writeText(String(collection.value.id))
}
}
</script>
<style scoped lang="scss">
.project-sidebar-section {
@apply p-4 flex flex-col gap-2 border-0 border-[--brand-gradient-border] border-solid;
}
.project-sidebar-section:not(:last-child) {
@apply border-b-[1px];
}
</style>

View File

@@ -1,3 +0,0 @@
import Index from './Index.vue'
export { Index }

View File

@@ -17,11 +17,11 @@
<div
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
>
<GameIcon class="h-5 w-5 text-secondary" />
<GameIcon class="h-6 w-6 text-secondary" />
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="flex items-center gap-2 font-semibold">
<TimerIcon class="h-5 w-5 text-secondary" />
<TimerIcon class="h-6 w-6 text-secondary" />
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
@@ -30,23 +30,9 @@
</template>
<template #actions>
<div class="flex gap-2">
<ButtonStyled
v-if="instance.install_stage.includes('installing')"
color="brand"
size="large"
>
<ButtonStyled v-if="instance.install_stage !== 'installed'" color="brand" size="large">
<button disabled>Installing...</button>
</ButtonStyled>
<ButtonStyled
v-else-if="instance.install_stage !== 'installed'"
color="brand"
size="large"
>
<button @click="repairInstance()">
<DownloadIcon />
Repair
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button @click="stopInstance('InstancePage')">
<StopCircleIcon />
@@ -151,39 +137,38 @@
<script setup>
import {
Avatar,
ButtonStyled,
ContentPageHeader,
LoadingIndicator,
ButtonStyled,
OverflowMenu,
LoadingIndicator,
} from '@modrinth/ui'
import {
CheckCircleIcon,
ClipboardCopyIcon,
DownloadIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GameIcon,
GlobeIcon,
HashIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
PlusIcon,
ServerIcon,
SettingsIcon,
StopCircleIcon,
TimerIcon,
UpdatedIcon,
UserPlusIcon,
ServerIcon,
PackageIcon,
SettingsIcon,
PlayIcon,
StopCircleIcon,
EditIcon,
FolderOpenIcon,
ClipboardCopyIcon,
PlusIcon,
ExternalIcon,
HashIcon,
GlobeIcon,
EyeIcon,
XIcon,
CheckCircleIcon,
UpdatedIcon,
MoreVerticalIcon,
GameIcon,
TimerIcon,
} from '@modrinth/assets'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import { get, get_full_path, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener, profile_listener } from '@/helpers/events'
import { useRoute, useRouter } from 'vue-router'
import { computed, onUnmounted, ref, watch } from 'vue'
import { ref, onUnmounted, computed, watch } from 'vue'
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
@@ -309,10 +294,6 @@ const stopInstance = async (context) => {
})
}
const repairInstance = async () => {
await finish_install(instance.value)
}
const handleRightClick = (event) => {
const baseOptions = [
{ name: 'add_content' },

View File

@@ -66,13 +66,10 @@
if (x.author) {
item.creator = {
name: x.author.name,
type: x.author.type,
id: x.author.slug,
link: {
path: `/${x.author.type}/${x.author.slug}`,
query: { i: props.instance.path },
},
name: x.author,
type: 'user',
id: x.author,
link: 'https://modrinth.com/user/' + x.author,
linkProps: { target: '_blank' },
}
}
@@ -179,17 +176,15 @@
</button>
</ButtonStyled>
<div v-else class="w-[36px]"></div>
<Toggle
class="!mx-2"
:model-value="!item.data.disabled"
@update:model-value="toggleDisableMod(item.data)"
/>
<ButtonStyled type="transparent" circular>
<button v-tooltip="'Remove'" @click="removeMod(item)">
<TrashIcon />
<button
v-tooltip="item.disabled ? `Enable` : `Disable`"
@click="toggleDisableMod(item.data)"
>
<CheckCircleIcon v-if="item.disabled" />
<SlashIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<OverflowMenu
:options="[
@@ -202,12 +197,23 @@
shown: item.data !== undefined && item.data.slug !== undefined,
action: () => copyModLink(item),
},
{
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>
@@ -269,14 +275,7 @@ import {
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
ButtonStyled,
ContentListPanel,
OverflowMenu,
Pagination,
Toggle,
} from '@modrinth/ui'
import { Button, ButtonStyled, ContentListPanel, OverflowMenu, Pagination } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
@@ -332,28 +331,6 @@ const props = defineProps({
},
})
type ProjectListEntryAuthor = {
name: string
slug: string
type: 'user' | 'organization'
}
type ProjectListEntry = {
path: string
name: string
slug?: string
author: ProjectListEntryAuthor | null
version: string | null
file_name: string
icon: string | null
disabled: boolean
updateVersion?: string
outdated: boolean
updated: dayjs.Dayjs
project_type: string
id?: string
}
const isPackLocked = computed(() => {
return props.instance.linked_data && props.instance.linked_data.locked
})
@@ -363,7 +340,7 @@ const canUpdatePack = computed(() => {
})
const exportModal = ref(null)
const projects = ref<ProjectListEntry[]>([])
const projects = ref([])
const selectedFiles = ref([])
const selectedProjects = computed(() =>
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
@@ -372,7 +349,7 @@ const selectedProjects = computed(() =>
const selectionMap = ref(new Map())
const initProjects = async (cacheBehaviour?) => {
const newProjects: ProjectListEntry[] = []
const newProjects = []
const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
const fetchProjects = []
@@ -409,29 +386,21 @@ const initProjects = async (cacheBehaviour?) => {
const team = modrinthTeams.find((x) => x[0].team_id === project.team)
let author: ProjectListEntryAuthor | null
let owner
if (org) {
author = {
name: org.name,
slug: org.slug,
type: 'organization',
}
owner = org.name
} else if (team) {
const teamMember = team.find((x) => x.is_owner)
author = {
name: teamMember.user.username,
slug: teamMember.user.username,
type: 'user',
}
owner = team.find((x) => x.is_owner).user.username
} else {
author = null
owner = null
}
newProjects.push({
path,
name: project.title,
slug: project.slug,
author,
author: owner,
version: version.version_number,
file_name: file.file_name,
icon: project.icon_url,
@@ -450,7 +419,7 @@ const initProjects = async (cacheBehaviour?) => {
newProjects.push({
path,
name: file.file_name.replace('.disabled', ''),
author: null,
author: '',
version: null,
file_name: file.file_name,
icon: null,
@@ -493,10 +462,6 @@ const messages = defineMessages({
id: 'instance.filter.updates-available',
defaultMessage: 'Updates available',
},
disabledFilter: {
id: 'instance.filter.disabled',
defaultMessage: 'Disabled projects',
},
})
const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
@@ -523,30 +488,19 @@ const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
})
}
if (projects.value.some((m) => m.disabled)) {
options.push({
id: 'disabled',
formattedName: formatMessage(messages.disabledFilter),
})
}
return options
})
const selectedFilters = ref([])
const filteredProjects = computed(() => {
const updatesFilter = selectedFilters.value.includes('updates')
const disabledFilter = selectedFilters.value.includes('disabled')
const typeFilters = selectedFilters.value.filter(
(filter) => filter !== 'updates' && filter !== 'disabled',
)
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) &&
(!disabledFilter || project.disabled)
(!updatesFilter || project.outdated)
)
})
})

View File

@@ -10,7 +10,7 @@ defineProps({
</script>
<template>
<GridDisplay
v-if="instances && instances.length > 0"
v-if="instances.length > 0"
label="Instances"
:instances="instances.filter((i) => !i.linked_data)"
/>

View File

@@ -10,7 +10,7 @@ defineProps({
</script>
<template>
<GridDisplay
v-if="instances && instances.length > 0"
v-if="instances.length > 0"
label="Instances"
:instances="instances.filter((i) => i.linked_data)"
/>

View File

@@ -9,5 +9,5 @@ defineProps({
})
</script>
<template>
<GridDisplay v-if="instances && instances.length > 0" label="Instances" :instances="instances" />
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
</template>

View File

@@ -1,176 +0,0 @@
<template>
<Teleport to="#sidebar-teleport-target">
<OrganizationSidebarMembers
v-if="organization"
:members="organization.members"
:user-link="(user) => `/user/${user.id}`"
class="project-sidebar-section"
/>
</Teleport>
<div v-if="organization" class="flex flex-col gap-4 p-6">
<InstanceIndicator :instance="instance" />
<OrganizationHeader
:organization="organization"
:download-count="sumDownloads"
:project-count="projects.length"
>
<template #actions>
<ButtonStyled v-if="themeStore.devMode" circular type="transparent" size="large">
<OverflowMenu
:options="[{ id: 'copy-id', action: () => copyId(), shown: themeStore.devMode }]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</OrganizationHeader>
<div v-if="projects">
<ProjectsList
:projects="projects"
:project-link="(project) => `/project/${project.id}${instanceQueryAppendage}`"
:experimental-colors="themeStore.featureFlags.project_card_background"
>
<template #project-actions="{ project }">
<ProjectCardActions
:project="project"
:instance="instance"
:instance-content="instanceContent"
/>
</template>
</ProjectsList>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref, type Ref, watch, computed } from 'vue'
import { handleError } from '@/store/notifications.js'
import {
ProjectsList,
ButtonStyled,
commonMessages,
OverflowMenu,
OrganizationHeader,
OrganizationSidebarMembers,
} from '@modrinth/ui'
import { ClipboardCopyIcon, MoreVerticalIcon } from '@modrinth/assets'
import { useVIntl } from '@vintl/vintl'
import { useFetch } from '@/helpers/fetch'
import type { Project, Organization, ProjectV3, Environment } from '@modrinth/utils'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/theme'
import { useInstanceContext } from '@/composables/instance-context.ts'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import ProjectCardActions from '@/components/ui/ProjectCardActions.vue'
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const { formatMessage } = useVIntl()
const organization: Ref<Organization | null> = ref(null)
const projects: Ref<Project[]> = ref([])
const { instance, instanceContent, instanceQueryAppendage } = await useInstanceContext()
async function fetchOrganization() {
organization.value = await useFetch(
`https://api.modrinth.com/v3/organization/${route.params.id}`,
).catch(handleError)
projects.value = (
await useFetch(`https://api.modrinth.com/v3/organization/${route.params.id}/projects`).catch(
handleError,
)
).map((projectV3: ProjectV3) => {
let type = projectV3.project_types[0]
if (type === 'plugin' || type === 'datapack') {
type = 'mod'
}
let clientSide: Environment = 'unknown'
let serverSide: Environment = 'unknown'
const singleplayer = projectV3.singleplayer && projectV3.singleplayer[0]
const clientAndServer = projectV3.client_and_server && projectV3.client_and_server[0]
const clientOnly = projectV3.client_only && projectV3.client_only[0]
const serverOnly = projectV3.server_only && projectV3.server_only[0]
// quick and dirty hack to show envs as legacy
if (singleplayer && clientAndServer && !clientOnly && !serverOnly) {
clientSide = 'required'
serverSide = 'required'
} else if (singleplayer && clientAndServer && clientOnly && !serverOnly) {
clientSide = 'required'
serverSide = 'unsupported'
} else if (singleplayer && clientAndServer && !clientOnly && serverOnly) {
clientSide = 'unsupported'
serverSide = 'required'
} else if (singleplayer && clientAndServer && clientOnly && serverOnly) {
clientSide = 'optional'
serverSide = 'optional'
}
const projectV2: Project = {
...projectV3,
title: projectV3.name,
description: projectV3.summary,
body: projectV3.description,
project_type: type,
team: projectV3.team_id,
donation_urls: [],
client_side: clientSide,
server_side: serverSide,
}
return projectV2
})
if (!organization.value) {
return
}
breadcrumbs.setName('Organization', organization.value.name)
}
await fetchOrganization()
watch(
() => route.params.id,
async () => {
if (route.params.id && route.path.startsWith('/organization')) {
await fetchOrganization()
}
},
)
const themeStore = useTheming()
async function copyId() {
if (organization.value) {
await navigator.clipboard.writeText(String(organization.value.id))
}
}
const sumDownloads = computed(() => {
let sum = 0
for (const project of projects.value) {
sum += project.downloads
}
return sum
})
</script>
<style scoped lang="scss">
.project-sidebar-section {
@apply p-4 flex flex-col gap-2 border-0 border-[--brand-gradient-border] border-solid;
}
.project-sidebar-section:not(:last-child) {
@apply border-b-[1px];
}
</style>

View File

@@ -1,3 +0,0 @@
import Index from './Index.vue'
export { Index }

View File

@@ -31,10 +31,10 @@
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
@click.stop="() => {}"
@click.stop=""
/>
<div class="floating" @click.stop="() => {}">
<div class="floating" @click.stop="">
<div class="text">
<h2 v-if="expandedGalleryItem.title">
{{ expandedGalleryItem.title }}
@@ -99,7 +99,7 @@ import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
const props = defineProps({
project: {
type: Object,
default: () => ({}),
default: () => {},
},
})

View File

@@ -8,10 +8,11 @@
/>
<ProjectSidebarLinks link-target="_blank" :project="data" class="project-sidebar-section" />
<ProjectSidebarCreators
:organization="organization"
:organization="null"
:members="members"
:org-link="(org) => `/organization/${org.id}${instance ? '?i=' + instance.path : ''}`"
:user-link="(user) => `/user/${user.id}${instance ? '?i=' + instance.path : ''}`"
:org-link="(slug) => `https://modrinth.com/organization/${slug}`"
:user-link="(username) => `https://modrinth.com/user/${username}`"
link-target="_blank"
class="project-sidebar-section"
/>
<ProjectSidebarDetails
@@ -22,7 +23,7 @@
/>
</Teleport>
<div class="flex flex-col gap-4 p-6">
<InstanceIndicator :instance="instance" />
<InstanceIndicator v-if="instance" :instance="instance" />
<template v-if="data">
<Teleport
v-if="themeStore.featureFlags.project_background"
@@ -91,11 +92,6 @@
label: 'Description',
href: `/project/${$route.params.id}`,
},
{
label: 'Gallery',
href: `/project/${$route.params.id}/gallery`,
shown: data.gallery.length > 0,
},
{
label: 'Versions',
href: {
@@ -104,6 +100,11 @@
},
subpages: ['version'],
},
{
label: 'Gallery',
href: `/project/${$route.params.id}/gallery`,
shown: data.gallery.length > 0,
},
]"
/>
<RouterView
@@ -165,7 +166,6 @@ import NavTabs from '@/components/ui/NavTabs.vue'
import { useTheming } from '@/store/state.js'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useFetch } from '@/helpers/fetch.js'
dayjs.extend(relativeTime)
@@ -177,7 +177,6 @@ const installing = ref(false)
const data = shallowRef(null)
const versions = shallowRef([])
const members = shallowRef([])
const organization = shallowRef(null)
const categories = shallowRef([])
const instance = ref(null)
const instanceProjects = ref(null)
@@ -203,12 +202,6 @@ async function fetchProjectData() {
route.query.i ? getInstanceProjects(route.query.i).catch(handleError) : Promise.resolve(),
])
if (project.organization) {
organization.value = await useFetch(
`https://api.modrinth.com/v3/organization/${project.organization}`,
).catch(handleError)
}
versions.value = versions.value.sort((a, b) => dayjs(b.date_published) - dayjs(a.date_published))
if (instanceProjects.value) {
@@ -220,7 +213,6 @@ async function fetchProjectData() {
installedVersion.value = installedFile.metadata.version_id
}
}
breadcrumbs.setName('Project', data.value.title)
}
@@ -445,10 +437,6 @@ const handleOptionsClick = (args) => {
}
.project-sidebar-section {
@apply p-4 flex flex-col gap-2 border-0 border-[--brand-gradient-border] border-solid;
}
.project-sidebar-section:not(:last-child) {
@apply border-b-[1px];
@apply p-4 flex flex-col gap-2 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid;
}
</style>

View File

@@ -1,156 +0,0 @@
<template>
<Teleport to="#sidebar-teleport-target">
<UserSidebarOrganizations
:organizations="organizations"
:link="(org: Organization) => `/organization/${org.id}${instanceQueryAppendage}`"
class="project-sidebar-section"
/>
<UserSidebarBadges
v-if="user"
:user="user"
:download-count="sumDownloads"
class="project-sidebar-section"
/>
<UserSidebarCollections
:collections="collections"
:link="(collection: Collection) => `/collection/${collection.id}${instanceQueryAppendage}`"
class="project-sidebar-section"
/>
</Teleport>
<div v-if="user" class="p-6 flex flex-col gap-4">
<InstanceIndicator :instance="instance" />
<UserHeader :user="user" :project-count="projects.length" :download-count="sumDownloads">
<template #actions>
<ButtonStyled circular type="transparent" size="large">
<OverflowMenu
:options="[
{
id: 'report',
link: `https://modrinth.com/report?item=user&itemID=${user.id}`,
color: 'red',
},
{ id: 'copy-id', action: () => copyId(), shown: themeStore.devMode },
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #report>
<ReportIcon aria-hidden="true" />
{{ formatMessage(commonMessages.reportButton) }}
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</UserHeader>
<div v-if="projects">
<ProjectsList
:projects="projects"
:project-link="(project) => `/project/${project.id}${instanceQueryAppendage}`"
:experimental-colors="themeStore.featureFlags.project_card_background"
>
<template #project-actions="{ project }">
<ProjectCardActions
:instance="instance"
:instance-content="instanceContent"
:project="project"
/>
</template>
</ProjectsList>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref, type Ref, watch, computed } from 'vue'
import { handleError } from '@/store/notifications.js'
import {
ProjectsList,
UserSidebarOrganizations,
ButtonStyled,
commonMessages,
OverflowMenu,
UserHeader,
UserSidebarBadges,
UserSidebarCollections,
} from '@modrinth/ui'
import { ReportIcon, ClipboardCopyIcon, MoreVerticalIcon } from '@modrinth/assets'
import { useVIntl } from '@vintl/vintl'
import { useFetch } from '@/helpers/fetch'
import type { User, Project, Organization, Collection } from '@modrinth/utils'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/theme'
import { useInstanceContext } from '@/composables/instance-context'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import ProjectCardActions from '@/components/ui/ProjectCardActions.vue'
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const { formatMessage } = useVIntl()
const user: Ref<User | null> = ref(null)
const projects: Ref<Project[]> = ref([])
const organizations: Ref<Organization[]> = ref([])
const collections: Ref<Collection[]> = ref([])
async function fetchUser() {
;[user.value, projects.value, organizations.value, collections.value] = await Promise.all([
useFetch(`https://api.modrinth.com/v2/user/${route.params.id}`).catch(handleError),
useFetch(`https://api.modrinth.com/v2/user/${route.params.id}/projects`).catch(handleError),
useFetch(`https://api.modrinth.com/v3/user/${route.params.id}/organizations`).catch(
handleError,
),
useFetch(`https://api.modrinth.com/v3/user/${route.params.id}/collections`).catch(handleError),
])
if (!user.value) {
return
}
breadcrumbs.setContext({ name: 'User', link: `/user/${user.value.username}` })
breadcrumbs.setName('User', user.value.username)
}
await fetchUser()
const { instance, instanceContent, instanceQueryAppendage } = await useInstanceContext()
watch(
() => route.params.id,
async () => {
if (route.params.id && route.path.startsWith('/user')) {
await fetchUser()
}
},
)
const themeStore = useTheming()
async function copyId() {
if (user.value) {
await navigator.clipboard.writeText(String(user.value.id))
}
}
const sumDownloads = computed(() => {
let sum = 0
for (const project of projects.value) {
sum += project.downloads
}
return sum
})
</script>
<style scoped lang="scss">
.project-sidebar-section {
@apply p-4 flex flex-col gap-2 border-0 border-[--brand-gradient-border] border-solid;
}
.project-sidebar-section:not(:last-child) {
@apply border-b-[1px];
}
</style>

View File

@@ -1,3 +0,0 @@
import Index from './Index.vue'
export { Index }

View File

@@ -1,9 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import * as Pages from '@/pages'
import * as Project from '@/pages/project'
import * as User from '@/pages/user'
import * as Organization from '@/pages/organization'
import * as Collection from '@/pages/collection'
import * as Instance from '@/pages/instance'
import * as Library from '@/pages/library'
@@ -103,36 +100,6 @@ export default new createRouter({
},
],
},
{
path: '/user/:id',
name: 'User',
component: User.Index,
props: true,
meta: {
useContext: true,
breadcrumb: [{ name: '?User', link: '/user/{id}' }],
},
},
{
path: '/organization/:id',
name: 'Organization',
component: Organization.Index,
props: true,
meta: {
useContext: true,
breadcrumb: [{ name: '?Organization', link: '/organization/{id}' }],
},
},
{
path: '/collection/:id',
name: 'Collection',
component: Collection.Index,
props: true,
meta: {
useContext: true,
breadcrumb: [{ name: '?Collection', link: '/collection/{id}' }],
},
},
{
path: '/instance/:id',
name: 'Instance',

View File

@@ -2,14 +2,14 @@ import { defineStore } from 'pinia'
import {
add_project_from_version,
check_installed,
list,
get,
get_projects,
list,
remove_project,
} from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import { install as packInstall } from '@/helpers/pack.js'
import { trackEvent } from '@/helpers/analytics.js'
import dayjs from 'dayjs'

View File

@@ -1,11 +1,5 @@
import { defineStore } from 'pinia'
export const DEFAULT_FEATURE_FLAGS = {
project_background: false,
page_path: false,
project_card_background: false,
}
export const useTheming = defineStore('themeStore', {
state: () => ({
themeOptions: ['dark', 'light', 'oled', 'system'],
@@ -14,7 +8,7 @@ export const useTheming = defineStore('themeStore', {
toggleSidebar: false,
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
featureFlags: {},
}),
actions: {
setThemeState(newTheme) {

View File

@@ -1,2 +0,0 @@
[env]
SQLX_OFFLINE = "true"

View File

@@ -7,7 +7,18 @@ edition = "2021"
[dependencies]
theseus = { path = "../../packages/app-lib", features = ["cli"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
thiserror = "1.0"
url = "2.2"
webbrowser = "0.8.13"
dunce = "1.0.3"
futures = "0.3"
uuid = { version = "1.1", features = ["serde", "v4"] }
tracing = "0.1.37"
tracing-subscriber = "0.3.18"
tracing-error = "0.2.0"

View File

@@ -3,9 +3,9 @@
windows_subsystem = "windows"
)]
use std::time::Duration;
use theseus::prelude::*;
use tokio::signal::ctrl_c;
use theseus::profile::create::profile_create;
// A simple Rust implementation of the authentication run
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
@@ -41,21 +41,54 @@ async fn main() -> theseus::Result<()> {
// Initialize state
State::init().await?;
loop {
if State::get().await?.friends_socket.is_connected().await {
break;
if minecraft_auth::users().await?.is_empty() {
println!("No users found, authenticating.");
authenticate_run().await?; // could take credentials from here direct, but also deposited in state users
}
//
// st.settings
// .write()
// .await
// .java_globals
// .insert(JAVA_8_KEY.to_string(), check_jre(path).await?.unwrap());
// Clear profiles
println!("Clearing profiles.");
{
let h = profile::list().await?;
for profile in h.into_iter() {
profile::remove(&profile.path).await?;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
tracing::info!("Starting host");
println!("Creating/adding profile.");
let socket = State::get().await?.friends_socket.open_port(25565).await?;
tracing::info!("Running host on socket {}", socket.socket_id());
let name = "Example".to_string();
let game_version = "1.16.1".to_string();
let modloader = ModLoader::Forge;
let loader_version = "stable".to_string();
ctrl_c().await?;
tracing::info!("Stopping host");
socket.shutdown().await?;
let profile_path = profile_create(
name,
game_version,
modloader,
Some(loader_version),
None,
None,
None,
)
.await?;
println!("running");
// Run a profile, running minecraft and store the RwLock to the process
let process = profile::run(&profile_path).await?;
println!("Minecraft UUID: {}", process.uuid);
println!("All running process UUID {:?}", process::get_all().await?);
// hold the lock to the process until it ends
println!("Waiting for process to end...");
process::wait_for(process.uuid).await?;
Ok(())
}

View File

@@ -1,2 +0,0 @@
[env]
SQLX_OFFLINE = "true"

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_gui"
version = "0.9.3"
version = "0.9.0"
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/"
@@ -28,9 +28,12 @@ tauri-plugin-single-instance = { version = "2.2.0" }
tokio = { version = "1", features = ["full"] }
thiserror = "1.0"
futures = "0.3"
daedalus = { path = "../../packages/daedalus" }
chrono = "0.4.26"
dirs = "5.0.1"
url = "2.2"
uuid = { version = "1.1", features = ["serde", "v4"] }
os_info = "3.7.0"
@@ -38,6 +41,9 @@ os_info = "3.7.0"
tracing = "0.1.37"
tracing-error = "0.2.0"
lazy_static = "1"
once_cell = "1"
dashmap = "6.0.1"
paste = "1.0.15"

View File

@@ -21,86 +21,3 @@ document.addEventListener(
window.open = (url, target, features) => {
window.top.postMessage({ modrinthOpenUrl: url }, 'https://modrinth.com')
}
function muteAudioContext() {
if (window.AudioContext || window.webkitAudioContext) {
const AudioContext = window.AudioContext || window.webkitAudioContext
const originalCreateMediaElementSource = AudioContext.prototype.createMediaElementSource
const originalCreateMediaStreamSource = AudioContext.prototype.createMediaStreamSource
const originalCreateMediaStreamTrackSource = AudioContext.prototype.createMediaStreamTrackSource
const originalCreateBufferSource = AudioContext.prototype.createBufferSource
const originalCreateOscillator = AudioContext.prototype.createOscillator
AudioContext.prototype.createGain = function () {
const gain = originalCreateGain.call(this)
gain.gain.value = 0
return gain
}
AudioContext.prototype.createMediaElementSource = function (mediaElement) {
const source = originalCreateMediaElementSource.call(this, mediaElement)
source.connect(this.createGain())
return source
}
AudioContext.prototype.createMediaStreamSource = function (mediaStream) {
const source = originalCreateMediaStreamSource.call(this, mediaStream)
source.connect(this.createGain())
return source
}
AudioContext.prototype.createMediaStreamTrackSource = function (mediaStreamTrack) {
const source = originalCreateMediaStreamTrackSource.call(this, mediaStreamTrack)
source.connect(this.createGain())
return source
}
AudioContext.prototype.createBufferSource = function () {
const source = originalCreateBufferSource.call(this)
source.connect(this.createGain())
return source
}
AudioContext.prototype.createOscillator = function () {
const oscillator = originalCreateOscillator.call(this)
oscillator.connect(this.createGain())
return oscillator
}
}
}
function muteVideo(mediaElement) {
let count = Number(mediaElement.getAttribute('data-modrinth-muted-count') ?? 0)
if (!mediaElement.muted || mediaElement.volume !== 0) {
mediaElement.muted = true
mediaElement.volume = 0
mediaElement.setAttribute('data-modrinth-muted-count', count + 1)
}
if (count > 5) {
// Video is detected as malicious, so it is removed from the page
mediaElement.remove()
}
}
function muteVideos() {
document.querySelectorAll('video, audio').forEach(function (mediaElement) {
muteVideo(mediaElement)
if (!mediaElement.hasAttribute('data-modrinth-muted')) {
mediaElement.addEventListener('volumechange', () => muteVideo(mediaElement))
mediaElement.setAttribute('data-modrinth-muted', 'true')
}
})
}
document.addEventListener('DOMContentLoaded', () => {
muteVideos()
muteAudioContext()
const observer = new MutationObserver(muteVideos)
observer.observe(document.body, { childList: true, subtree: true })
})

View File

@@ -4,7 +4,6 @@
)]
use native_dialog::{MessageDialog, MessageType};
use std::env;
use tauri::{Listener, Manager};
use theseus::prelude::*;
@@ -30,12 +29,7 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
theseus::EventState::init(app.clone()).await?;
#[cfg(feature = "updater")]
'updater: {
if env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_ok() {
State::init().await?;
break 'updater;
}
{
use tauri_plugin_updater::UpdaterExt;
let updater = app.updater_builder().build()?;

View File

@@ -39,12 +39,12 @@
"fileAssociations": [
{
"ext": ["mrpack"],
"mimeType": "application/x-modrinth-modpack+zip"
"mimeType": "application/zip+mrpack"
}
]
},
"productName": "Modrinth App",
"version": "0.9.3",
"version": "0.9.0",
"mainBinaryName": "Modrinth App",
"identifier": "ModrinthApp",
"plugins": {

View File

@@ -22,6 +22,7 @@ reqwest = { version = "0.12.5", default-features = false, features = [
"rustls-tls-native-roots",
] }
async_zip = { version = "0.0.17", features = ["full"] }
semver = "1.0"
chrono = { version = "0.4", features = ["serde"] }
bytes = "1.6.0"
rust-s3 = { version = "0.33.0", default-features = false, features = [
@@ -38,3 +39,4 @@ tracing-error = "0.2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-futures = { version = "0.2.5", features = ["futures", "tokio"] }

View File

@@ -598,7 +598,7 @@ async fn fetch(
))
})?;
let file_name = value.split('/').next_back()
let file_name = value.split('/').last()
.ok_or_else(|| {
crate::ErrorKind::InvalidInput(format!(
"Unable reading filename for data key {key} at path {value}",

View File

@@ -44,10 +44,6 @@ export default defineConfig({
label: 'Contributing to Modrinth',
autogenerate: { directory: 'contributing' },
},
{
label: 'Guides',
autogenerate: { directory: 'guide' },
},
// Add the generated sidebar group to the sidebar.
...openAPISidebarGroups,
],

View File

@@ -10,12 +10,12 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/starlight": "^0.32.2",
"@astrojs/check": "^0.9.3",
"@astrojs/starlight": "^0.26.3",
"@modrinth/assets": "workspace:*",
"astro": "^5.4.1",
"sharp": "^0.33.5",
"starlight-openapi": "^0.14.0",
"typescript": "^5.8.2"
"astro": "^4.10.2",
"sharp": "^0.32.5",
"starlight-openapi": "^0.7.0",
"typescript": "^5.5.4"
}
}
}

View File

@@ -1,7 +1,7 @@
openapi: '3.0.0'
info:
version: v2.7.0/366f528
version: v2.7.0/15cf3fc
title: Labrinth
termsOfService: https://modrinth.com/legal/terms
contact:
@@ -51,7 +51,35 @@ info:
Please note that certain scopes and requests cannot be completed with a personal access token or using OAuth.
For example, deleting a user account can only be done through Modrinth's frontend.
A detailed guide on OAuth has been published in [Modrinth's technical documentation](https://docs.modrinth.com/guide/oauth).
### OAuth2
Applications interacting with an authenticated API should create an OAuth2 application.
You can do this in [the developer settings](https://modrinth.com/settings/applications).
Make sure to save your application secret, as you will not be able to access it after you leave the page.
Once you have created a client, use the following URL to have a user authorize your client:
```
https://modrinth.com/auth/authorize?client_id=<CLIENT_ID>&redirect_uri=<CALLBACK_URL>&scope=<SCOPE_ONE>+<SCOPE_TWO>+<SCOPE_THREE>
```
> You can get a list of all scope names [here](https://github.com/modrinth/code/tree/main/apps/labrinth/src/models/v3/pats.rs).
Then, send a `POST` request to the following URL to get the token:
```
https://api.modrinth.com/_internal/oauth/token
```
> Note that you will need to provide your application's secret under the Authorization header.
In the body of your request, make sure to include the following:
- `code`: The code generated when authorizing your client
- `client_id`: Your client ID (found in developer settings)
- `redirect_uri`: A valid redirect URI provided in your application's settings
- `grant_type`: This will need to be `authorization_code`.
If your token request fails for any reason, you will need to get another code from the authorization process.
This route will be changed in the future to move the `_internal` part to `v3`.
### Personal access tokens
Personal access tokens (PATs) can be generated in from [the user settings](https://modrinth.com/settings/account).
@@ -1795,7 +1823,7 @@ components:
description: Number of projects on Modrinth
versions:
type: integer
description: Number of versions on Modrinth
description: Number of projects on Modrinth
files:
type: integer
description: Number of version files on Modrinth
@@ -2990,24 +3018,6 @@ paths:
$ref: '#/components/schemas/InvalidInputError'
'404':
description: The requested item(s) were not found or no authorization to access the requested item(s)
delete:
summary: Remove user's avatar
operationId: deleteUserIcon
tags:
- users
security:
- TokenAuth: ['USER_WRITE']
responses:
'204':
description: Expected response to a valid request
'400':
description: Request was invalid, see given error
content:
application/json:
schema:
$ref: '#/components/schemas/InvalidInputError'
'404':
description: The requested item(s) were not found or no authorization to access the requested item(s)
/user/{id|username}/projects:
parameters:
- $ref: '#/components/parameters/UserIdentifier'

View File

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

View File

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

View File

@@ -1,95 +0,0 @@
---
title: The hitchhiker's guide to OAuth
description: Guide for using Modrinth OAuth to interact with the API on users' behalf.
---
Modrinth allows developers to create applications which, once authorized by a Modrinth user, let the developer interact with the API on their behalf. The flow used to get an API token is based on the OAuth 2 protocol. It is recommended that most people use an existing OAuth library to handle the authentication. If you want to implement it from scratch, you will need to look into [RFC 6749]. If the only user of the application is yourself, a personal access token (PAT) may be a better fit.
If you're familiar with OAuth 2, these are the URLs you will need:
| Name | URL |
|--------------------|--------------------------------------------------|
| Authorization page | `https://modrinth.com/auth/authorize` |
| Token exchange | `https://api.modrinth.com/_internal/oauth/token` |
The flow will generally look like this:
1. User is redirected to Modrinth to authorize your application
2. User is redirected back to your site after authorizing, with an authorization code
3. Your backend exchanges this code for an access token
## Register your application
To start off, you need to [register an application] in Modrinth's systems. The settings chosen here can always be changed later. You need to select what permissions you need, called scopes. For security reasons you will want to select only the scopes you need. See the [principle of least privilege].
In addition to name and scopes, you will also need to add one or more redirect URIs. These are the URIs that the user can be redirected to after they authorize your application.
After you've registered your application, it is important that you take note of the client secret somewhere safe. If the client secret is to ever leak, it is important that you regenerate it to ensure the security of your authorized users. If your client secret or access tokens are found exposed in the wild, your application may be disabled without prior notice.
## Getting authorization
Once the user is ready to authorize your application, you need to construct a URL to redirect them to. The authorization URL for Modrinth is `https://api.modrinth.com/_internal/oauth/token`. Supply the following query parameters:
| Query parameter | Description |
|-----------------|-------------------------------------------------------------------------------------------|
| `response_type` | In Modrinth this always needs to be `code`, since only code grants are supported |
| `client_id` | The application identifier found in the settings |
| `scope` | The permissions you need access to |
| `state` | A mechanism to prevent certain attacks. Explained further below. Recommended but optional |
| `redirect_uri` | The URI the user is redirect to after finishing authorization |
You might have noticed the `state` parameter. [CSRF] (Cross-site request forgery), and [clickjacking] are security vulnerabilities that you're recommended to protect against. In OAuth2 this is usually done with the `state` parameter. When the user initiates a request to start authorization, you include a `state` which is unique to this request. This can, for example, be saved in localStorge or a cookie. When the redirect URI is called, you verify that the `state` parameter is the same. Using `state` is optional, but recommended.
The scope identifiers are currently best found in the backend source code located at [`apps/labrinth/src/models/v3/pats.rs`]. The scope parameter is an array of scope identifiers, seperated by a plus sign (`+`).
The redirect URI is the endpoint on your server that will receive the code which can eventually be used to act on the user's behalf. For security reasons the redirect URI used has to be allowlisted in your application settings. The redirect will contain the following query parameters:
| Query parameter | Description |
|-----------------|----------------------------------------------------|
| `code` | The code that can be exchanged for an access token |
| `client_id` | Your client id |
| `redirect_uri` | The redirect URI which was used |
| `grant_type` | Always `authorization_code` in Modrinth |
## Exchanging tokens
If you've followed the previous section on getting authorization, you should now have an authorization code. Before you can access the API, you need to exchange this code for an access token. This is done by sending a POST request to the exchange token endpoint, `https://api.modrinth.com/_internal/oauth/token`. This request has to be of type urlencoded form. Make sure the `Content-Type` header is set to `application/x-www-form-urlencoded`. To authenticate this request you need to place your client secret in the `Authorization` header.
In the body use these fields:
| Field | Description |
|----------------|--------------------------------------------------------------|
| `code` | The authorization code |
| `client_id` | Your client id, the same as in the authorization request |
| `redirect_uri` | The redirect URI which was redirected to after authorization |
| `grant_type` | Always `authorization_code` in Modrinth |
If the request succeeds, you should receive a JSON payload with these fields:
| Field | Description |
|----------------|------------------------------------------------------|
| `access_token` | The access token you can use to access the API |
| `token_type` | Currently only `Bearer` |
| `expires_in` | The amount of seconds until the access token expires |
To use this access token, you attach it to API requests in the `Authorization` header. To get basic information about the authorizer, you can use the [`/user` endpoint], which automatically gets the user from the header.
If you have any questions, you're welcome to ask in #api-development in the [Discord guild], or create a ticket on the [support portal].
[RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
[register an application]: https://modrinth.com/settings/applications
[principle of least privilege]: https://en.wikipedia.org/wiki/Principle_of_least_privilege
[`apps/labrinth/src/models/v3/pats.rs`]: https://github.com/modrinth/code/blob/main/apps/labrinth/src/models/v3/pats.rs
[CSRF]: https://en.wikipedia.org/wiki/Cross-site_request_forgery
[Clickjacking]: https://en.wikipedia.org/wiki/Clickjacking
[`/user` endpoint]: https://docs.modrinth.com/api/operations/getuserfromauth/
[Discord guild]: https://discord.modrinth.com
[support portal]: https://support.modrinth.com/en/

View File

@@ -1,3 +1,2 @@
BASE_URL=https://api.modrinth.com/v2/
BROWSER_BASE_URL=https://api.modrinth.com/v2/
PYRO_BASE_URL=https://archon.modrinth.com/

View File

@@ -126,7 +126,6 @@ export default defineNuxtConfig({
homePageSearch?: any[];
homePageNotifs?: any[];
products?: any[];
errors?: number[];
} = {};
try {
@@ -158,14 +157,6 @@ export default defineNuxtConfig({
},
};
const caughtErrorCodes = new Set<number>();
function handleFetchError(err: any, defaultValue: any) {
console.error("Error generating state: ", err);
caughtErrorCodes.add(err.status);
return defaultValue;
}
const [
categories,
loaders,
@@ -177,25 +168,15 @@ export default defineNuxtConfig({
homePageNotifs,
products,
] = await Promise.all([
$fetch(`${API_URL}tag/category`, headers).catch((err) => handleFetchError(err, [])),
$fetch(`${API_URL}tag/loader`, headers).catch((err) => handleFetchError(err, [])),
$fetch(`${API_URL}tag/game_version`, headers).catch((err) => handleFetchError(err, [])),
$fetch(`${API_URL}tag/donation_platform`, headers).catch((err) =>
handleFetchError(err, []),
),
$fetch(`${API_URL}tag/report_type`, headers).catch((err) => handleFetchError(err, [])),
$fetch(`${API_URL}projects_random?count=60`, headers).catch((err) =>
handleFetchError(err, []),
),
$fetch(`${API_URL}search?limit=3&query=leave&index=relevance`, headers).catch((err) =>
handleFetchError(err, {}),
),
$fetch(`${API_URL}search?limit=3&query=&index=updated`, headers).catch((err) =>
handleFetchError(err, {}),
),
$fetch(`${API_URL.replace("/v2/", "/_internal/")}billing/products`, headers).catch((err) =>
handleFetchError(err, []),
),
$fetch(`${API_URL}tag/category`, headers),
$fetch(`${API_URL}tag/loader`, headers),
$fetch(`${API_URL}tag/game_version`, headers),
$fetch(`${API_URL}tag/donation_platform`, headers),
$fetch(`${API_URL}tag/report_type`, headers),
$fetch(`${API_URL}projects_random?count=60`, headers),
$fetch(`${API_URL}search?limit=3&query=leave&index=relevance`, headers),
$fetch(`${API_URL}search?limit=3&query=&index=updated`, headers),
$fetch(`${API_URL.replace("/v2/", "/_internal/")}billing/products`, headers),
]);
state.categories = categories;
@@ -207,7 +188,6 @@ export default defineNuxtConfig({
state.homePageSearch = homePageSearch;
state.homePageNotifs = homePageNotifs;
state.products = products;
state.errors = [...caughtErrorCodes];
await fs.writeFile("./src/generated/state.json", JSON.stringify(state));

View File

@@ -10,7 +10,7 @@
"postinstall": "nuxi prepare",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{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"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
@@ -57,8 +57,6 @@
"pinia": "^2.1.7",
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"three": "^0.172.0",
"@types/three": "^0.172.0",
"vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="15" y1="12" x2="3" y2="12"></line>
<line x1="17" y1="18" x2="3" y2="18"></line>
</svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="5" rx="2"></rect>
<path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"></path>
<path d="M10 13h4"></path>
</svg>

After

Width:  |  Height:  |  Size: 335 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 6v12"></path>
<path d="M17.196 9 6.804 15"></path>
<path d="m6.804 9 10.392 6"></path>
</svg>

After

Width:  |  Height:  |  Size: 295 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
<path d="M2 8c0-2.2.7-4.3 2-6"></path>
<path d="M22 8a10 10 0 0 0-2-6"></path>
</svg>

After

Width:  |  Height:  |  Size: 383 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>

After

Width:  |  Height:  |  Size: 300 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot-icon lucide-bot"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-text"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M8 7h6"/><path d="M8 11h8"/></svg>

Before

Width:  |  Height:  |  Size: 377 B

After

Width:  |  Height:  |  Size: 330 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.29 7 12 12 20.71 7"></polyline>
<line x1="12" y1="22" x2="12" y2="12"></line>
</svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-clock"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><path d="M17.5 17.5 16 16.25V14"/><path d="M22 16a6 6 0 1 1-12 0 6 6 0 0 1 12 0Z"/></svg>

After

Width:  |  Height:  |  Size: 436 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 7V3M16 7V3M7 11H17M5 21H19C20.1046 21 21 20.1046 21 19V7C21 5.89543 20.1046 5 19 5H5C3.89543 5 3 5.89543 3 7V19C3 20.1046 3.89543 21 5 21Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3v18h18"></path>
<path d="M18 17V9"></path>
<path d="M13 17V5"></path>
<path d="M8 17v-3"></path>
</svg>

After

Width:  |  Height:  |  Size: 307 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-cw-icon lucide-rotate-cw"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-check"><path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/></svg>

Before

Width:  |  Height:  |  Size: 324 B

After

Width:  |  Height:  |  Size: 280 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path>
<path d="m9 12 2 2 4-4"></path>
</svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -0,0 +1,4 @@
<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 6L9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 197 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>

After

Width:  |  Height:  |  Size: 233 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@@ -0,0 +1,4 @@
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="6"></circle>
<path d="M18.09 10.37A6 6 0 1 1 10.34 18"></path>
<path d="M7 6h1v4"></path>
<path d="m16.71 13.88.7.71-2.82 2.82"></path>
</svg>

After

Width:  |  Height:  |  Size: 358 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24" xml:space="preserve"><path d="M9 5v4m0 0H5m4 0L4 4m11 1v4m0 0h4m-4 0 5-5M9 19v-4m0 0H5m4 0-5 5m11-5 5 5m-5-5v4m0-4h4" style="fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M15 9.354a4 4 0 1 0 0 5.292"></path>
</svg>

After

Width:  |  Height:  |  Size: 280 B

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