Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) c3ff8702c3 fix: fmt 2026-07-29 14:02:04 +01:00
Calum H. (IMB11) 6160717f9b fix: cleanup impl 2026-07-29 14:01:19 +01:00
Calum H. (IMB11) dd8360febd fix: icon handling 2026-07-29 13:56:22 +01:00
Truman GaoandGitHub a8dcfa8a5d fix: download modal suggested dep can use wrong mc version (#6925) 2026-07-29 12:44:34 +00:00
Truman GaoandGitHub 0f6a74b87f fix: pats page code invalidating in same session (#6924) 2026-07-29 12:44:30 +00:00
ProspectorandGitHub d5b763c8e3 Fix tauri overlay in modal closing on drag (#6920)
* Fix tauri overlay in modal closing on drag

* prepr
2026-07-29 12:44:28 +00:00
ProspectorandGitHub 7a98f45c3d add installation settings tooltip (#6916) 2026-07-29 12:44:16 +00:00
ProspectorandGitHub 0ea47ad7ef add new instance button (#6918) 2026-07-29 12:43:17 +00:00
ProspectorandGitHub cc804625ec Replace final usage of legacy modal (#6921)
* Replace final usage of legacy modal

* prepr
2026-07-29 12:42:59 +00:00
Prospector 20235b7d32 changelog 2026-07-28 16:36:16 -07:00
ProspectorandGitHub 920dc8dcbb Fix oauth appliction icon upload (#6913) 2026-07-28 16:30:21 -07:00
Calum H.andGitHub 30f64c0020 fix: remove dummy data (#6915) 2026-07-28 16:30:14 -07:00
ProspectorandGitHub cfafa25ed4 fix moderation messages + add legacy message (#6914) 2026-07-28 16:30:05 -07:00
Prospector 7495b99ddd fix changelog 2026-07-28 15:06:12 -07:00
Prospector 800271a606 0.17.1 2026-07-28 14:01:28 -07:00
Michael H. 36791a33d8 fix: app build ci 2026-07-28 22:59:26 +02:00
Prospector 20ec70b917 comment out 0.17.0 changelog 2026-07-28 13:58:21 -07:00
Prospector ab6edbee48 changelog 2026-07-28 13:47:48 -07:00
b17523e7fd refactor: breadcrumbs system (#6912)
* refactor: breadcrumbs system

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: loading flashes

* fix: qa

* fix: prepr

* fix: qa

* fix: change icon

* use modrinth logo in header for compactness

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-28 20:38:16 +00:00
f89c116bf8 feat: blocking frontend (#6911)
* feat: blocking api interfaces

* feat: block action on user pages + shared instance flows

* feat: safety settings subpage

* feat: interaction source settings

* feat: finish social settings

* feat: profile settings xplat

* fix: prepr

* feat: click on friends

* default instance -> game options & fix feature flag width

* fix: scroll indicators

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-28 20:02:58 +00:00
137 changed files with 3303 additions and 2154 deletions
+10 -10
View File
@@ -74,10 +74,10 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
rustflags: ''
target: ${{ contains(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
target: ${{ contains(matrix.artifact-target-name, 'darwin') && 'x86_64-apple-darwin' || '' }}
- name: Setup mold
if: contains(matrix.platform, 'ubuntu')
if: contains(matrix.artifact-target-name, 'linux')
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Setup sccache
@@ -92,7 +92,7 @@ jobs:
run: corepack enable
- name: Set up caches
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'macos')
if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
@@ -100,7 +100,7 @@ jobs:
pnpm
- name: Configure sccache
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'macos')
if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
- name: Generate tauri-dev.conf.json
@@ -119,7 +119,7 @@ jobs:
EOF
- name: Install Linux build dependencies
if: contains(matrix.platform, 'ubuntu')
if: contains(matrix.artifact-target-name, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
@@ -136,7 +136,7 @@ jobs:
repo: TomWright/dasel
tag: v2.8.1
extension-matching: disable
rename-to: ${{ contains(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
rename-to: ${{ contains(matrix.artifact-target-name, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: Set application version and environment
@@ -159,7 +159,7 @@ jobs:
run: pnpm install
- name: Set up Windows code signing
if: contains(matrix.platform, 'windows')
if: contains(matrix.artifact-target-name, 'windows')
shell: bash
run: |
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
@@ -170,7 +170,7 @@ jobs:
- name: Build macOS app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
if: contains(matrix.platform, 'macos')
if: contains(matrix.artifact-target-name, 'darwin')
env:
TAURI_BUNDLER_DMG_IGNORE_CI: 'true'
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
@@ -185,7 +185,7 @@ jobs:
- name: Build Linux app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
if: contains(matrix.platform, 'ubuntu')
if: contains(matrix.artifact-target-name, 'linux')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -197,7 +197,7 @@ jobs:
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
if: contains(matrix.platform, 'windows')
if: contains(matrix.artifact-target-name, 'windows')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+53 -20
View File
@@ -10,9 +10,10 @@ import {
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
@@ -46,6 +47,7 @@ import {
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
TextLogo,
useDebugLogger,
useFormatBytes,
useHostingIntercom,
@@ -63,7 +65,6 @@ import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
@@ -129,6 +130,7 @@ import {
openAppUpdateChangelog,
setAppUpdateActions,
} from '@/providers/app-update.ts'
import { createBreadcrumbManager, provideBreadcrumbManager } from '@/providers/breadcrumbs'
import { createContentInstall, provideContentInstall } from '@/providers/content-install'
import {
provideAppUpdateDownloadProgress,
@@ -146,10 +148,24 @@ import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const breadcrumbManager = createBreadcrumbManager()
provideBreadcrumbManager(breadcrumbManager)
const canNavigateBack = ref(false)
const canNavigateForward = ref(false)
function updateHistoryNavigationState() {
const historyState = window.history.state
canNavigateBack.value = historyState?.back != null
canNavigateForward.value = historyState?.forward != null
}
updateHistoryNavigationState()
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
@@ -654,6 +670,7 @@ router.beforeEach(() => {
routerToken = loading.begin()
})
router.afterEach((to, from, failure) => {
updateHistoryNavigationState()
trackEvent('PageView', {
path: to.path,
fromPath: from.path,
@@ -795,6 +812,8 @@ const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginModal = ref()
const appSettingsModal = ref()
provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile())
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
@@ -1500,7 +1519,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
</Transition>
<Suspense>
<AppSettingsModal ref="settingsModal" />
<AppSettingsModal ref="appSettingsModal" />
</Suspense>
<Suspense>
<ModrinthAccountRequiredModal ref="modrinthLoginModal" :request-auth="requestModrinthAuth" />
@@ -1568,7 +1587,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div class="flex flex-grow"></div>
<NavButton
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
:to="() => $refs.settingsModal.show()"
:to="() => appSettingsModal?.show()"
>
<SettingsIcon />
</NavButton>
@@ -1620,23 +1639,37 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</NavButton>
</div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex min-w-0 flex-1 overflow-hidden p-3">
<ModrinthAppLogo class="h-full w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="flex shrink-0 items-center gap-1 ml-3">
<button
class="cursor-pointer p-0 m-0 text-contrast 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 text-contrast 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 data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
<TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
<ButtonStyled type="outlined" circular>
<button
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateBack"
aria-label="Go back"
@click="router.back()"
>
<ChevronLeftIcon
class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateBack }"
/>
</button>
</ButtonStyled>
<ButtonStyled type="outlined" circular>
<button
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateForward"
aria-label="Go forward"
@click="router.forward()"
>
<ChevronRightIcon
class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateForward }"
/>
</button>
</ButtonStyled>
</div>
<Breadcrumbs class="pt-[2px]" />
<Breadcrumbs />
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
<ButtonStyled
@@ -2,7 +2,7 @@
<div
ref="outerRef"
data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3"
class="min-w-0 overflow-hidden pl-4"
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
@mouseenter="onMouseEnter"
@@ -11,30 +11,48 @@
<div
ref="innerRef"
data-tauri-drag-region
class="flex w-fit items-center gap-1"
class="flex w-fit items-center gap-2 pr-4"
:class="{ 'breadcrumbs-scroll': isAnimating }"
@animationiteration="onAnimationIteration"
>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
class="shrink-0 whitespace-nowrap text-primary"
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.slot">
<component
:is="index < breadcrumbs.length - 1 && breadcrumb.to ? RouterLink : 'span'"
v-bind="index < breadcrumbs.length - 1 && breadcrumb.to ? { to: breadcrumb.to } : {}"
:data-tauri-drag-region="index === breadcrumbs.length - 1 ? '' : undefined"
class="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-base font-medium leading-6"
:class="
index === breadcrumbs.length - 1
? 'cursor-default select-none text-contrast'
: 'text-primary hover:text-contrast'
"
:aria-current="index === breadcrumbs.length - 1 ? 'page' : undefined"
>
{{ resolveLabel(breadcrumb.name) }}
</router-link>
<span
v-else
<Avatar
v-if="breadcrumb.visual?.type === 'image'"
:src="breadcrumb.visual.src"
:alt="breadcrumb.visual.alt ?? breadcrumb.label"
:circle="breadcrumb.visual.circle"
:tint-by="breadcrumb.visual.tintBy ?? breadcrumb.id"
size="20px"
no-shadow
raised
class="inline-block shrink-0 align-middle"
:class="{ '!rounded-md': !breadcrumb.visual.circle }"
/>
<component
:is="breadcrumb.visual.component"
v-else-if="breadcrumb.visual?.type === 'icon'"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
<span>{{ breadcrumb.label }}</span>
</component>
<ChevronRightIcon
v-if="index < breadcrumbs.length - 1"
data-tauri-drag-region
class="shrink-0 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
{{ resolveLabel(breadcrumb.name) }}
</span>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5 shrink-0" />
class="size-5 shrink-0 text-primary"
/>
</template>
</div>
</div>
@@ -42,34 +60,13 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Avatar } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { injectBreadcrumbManager } from '@/providers/breadcrumbs'
interface Breadcrumb {
name: string
link?: string
query?: Record<string, string>
}
const route = useRoute()
const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed<Breadcrumb[]>(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
})
function resolveLabel(name: string): string {
return name.charAt(0) === '?' ? breadcrumbData.getName(name.slice(1)) : name
}
const { entries: breadcrumbs } = injectBreadcrumbManager()
// Overflow detection
const outerRef = ref<HTMLDivElement | null>(null)
@@ -83,9 +80,13 @@ let stopping = false
function checkOverflow() {
if (!outerRef.value || !innerRef.value) return
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
const outerStyles = window.getComputedStyle(outerRef.value)
const horizontalPadding =
Number.parseFloat(outerStyles.paddingLeft) + Number.parseFloat(outerStyles.paddingRight)
const availableWidth = outerRef.value.clientWidth - horizontalPadding
const overflow = innerRef.value.scrollWidth - availableWidth
isOverflowing.value = overflow > 0
overflowAmount.value = overflow + 12
overflowAmount.value = overflow
}
function onMouseEnter() {
@@ -1,6 +1,7 @@
<script setup>
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
@@ -14,6 +15,7 @@ const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just clos
const STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
@@ -120,6 +122,10 @@ const onDividerPointerUp = (event) => {
const getInstances = async () => {
const instances = await list().catch(handleError)
for (const instance of instances) {
queryClient.setQueryData(['instances', 'summary', instance.id], instance)
}
allInstances.value = instances.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
@@ -123,45 +123,46 @@ const messages = defineMessages({
<div
v-for="friend in friends"
:key="friend.username"
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
@contextmenu.prevent.stop="
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
<RouterLink
:to="`/user/${encodeURIComponent(friend.username)}`"
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
</RouterLink>
<ButtonStyled v-if="friend.accepted" circular type="transparent">
<OverflowMenu
class="opacity-0 group-hover:opacity-100 transition-opacity"
:options="[
{
id: 'view-profile',
action: () => openProfile(friend.username),
},
{
id: 'remove-friend',
action: () => removeFriend(friend),
@@ -170,10 +171,6 @@ const messages = defineMessages({
]"
>
<MoreVerticalIcon />
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend>
<TrashIcon />
{{ formatMessage(messages.removeFriend) }}
@@ -13,7 +13,14 @@
<Table :columns="inviteColumns" :data="activeInvites" row-key="id" table-min-width="36rem">
<template #empty-state>
<div class="flex h-40 items-center justify-center text-secondary">
{{ formatMessage(messages.noActiveInvites) }}
<SpinnerIcon
v-if="activeInvitesQuery.isLoading.value"
class="animate-spin"
aria-hidden="true"
/>
<template v-else>
{{ formatMessage(messages.noActiveInvites) }}
</template>
</div>
</template>
<template #cell-id="{ row }">
@@ -41,10 +48,19 @@
code: row.id,
})
"
:disabled="revokeInviteMutation.isPending.value || isBusy"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
@click="revokeInviteModal?.show(row.id)"
>
<XIcon aria-hidden="true" />
<SpinnerIcon
v-if="
revokeInviteMutation.isPending.value &&
revokeInviteMutation.variables.value?.inviteId === row.id
"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
</button>
</ButtonStyled>
</div>
@@ -64,7 +80,7 @@
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { SpinnerIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CopyCode,
@@ -75,13 +91,18 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import ConfirmRevokeSharedInstanceInviteModal from '@/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { config } from '@/config'
import { type SharedInstanceInvite, unpublish_shared_instance } from '@/helpers/instance'
import {
get_shared_instance_invites,
revoke_shared_instance_invite,
type SharedInstanceInvite,
unpublish_shared_instance,
} from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { injectInstanceSettings } from '@/providers/instance-settings'
@@ -93,11 +114,51 @@ const unpublishing = ref(false)
const revokeInviteModal = ref<InstanceType<typeof ConfirmRevokeSharedInstanceInviteModal>>()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const isBusy = computed(
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
)
type InviteTableColumn = 'id' | 'uses' | 'expiration' | 'actions'
type ActiveInvitesQueryKey = readonly ['sharedInstanceInvites', string]
const activeInvitesQueryKey = computed(
() => ['sharedInstanceInvites', instance.value.id] as const satisfies ActiveInvitesQueryKey,
)
const activeInvitesQuery = useQuery({
queryKey: activeInvitesQueryKey,
queryFn: async ({ queryKey }) => {
try {
return await get_shared_instance_invites(queryKey[1])
} catch (error) {
notifySharedInstanceError(error)
throw error
}
},
enabled: () => !!instance.value.id && !offline,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnReconnect: false,
refetchOnWindowFocus: false,
})
const activeInvites = computed(() => activeInvitesQuery.data.value ?? [])
const revokeInviteMutation = useMutation({
mutationFn: ({ instanceId, inviteId }: { instanceId: string; inviteId: string }) =>
revoke_shared_instance_invite(instanceId, inviteId),
onSuccess: (_data, { instanceId, inviteId }) => {
queryClient.setQueryData<SharedInstanceInvite[]>(
['sharedInstanceInvites', instanceId],
(invites = []) => invites.filter((invite) => invite.id !== inviteId),
)
},
onError: notifySharedInstanceError,
})
const isBusy = computed(
() =>
instance.value.install_stage !== 'installed' ||
unpublishing.value ||
revokeInviteMutation.isPending.value ||
!!offline,
)
const inviteColumns = computed<TableColumn<InviteTableColumn>[]>(() => [
{
@@ -123,32 +184,9 @@ const inviteColumns = computed<TableColumn<InviteTableColumn>[]>(() => [
},
])
const now = Date.now()
// TODO: Use actual endpoint
const activeInvites = ref<SharedInstanceInvite[]>([
{
id: 'wqHPxNagZr',
expiration: new Date(now + 6 * 24 * 60 * 60 * 1000).toISOString(),
maxUses: 10,
uses: 0,
},
{
id: 'GbRGfY7hbs',
expiration: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(),
maxUses: 10,
uses: 2,
},
{
id: 'k9mvD2QxLc',
expiration: new Date(now + 90 * 60 * 1000).toISOString(),
maxUses: 5,
uses: 4,
},
])
function revokeInvite(inviteId: string) {
activeInvites.value = activeInvites.value.filter((invite) => invite.id !== inviteId)
if (revokeInviteMutation.isPending.value) return
revokeInviteMutation.mutate({ instanceId: instance.value.id, inviteId })
}
async function unpublishSharedInstance() {
@@ -3,12 +3,14 @@ import {
CoffeeIcon,
GameIcon,
GaugeIcon,
HeartHandshakeIcon,
LanguagesIcon,
ModrinthIcon,
PaintbrushIcon,
Settings2Icon,
ShieldIcon,
ToggleRightIcon,
UserIcon,
} from '@modrinth/assets'
import {
commonMessages,
@@ -17,13 +19,16 @@ import {
defineMessages,
ProgressBar,
TabbedModal,
UnsavedChangesPopup,
useVIntl,
} from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
import { computed, ref, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import PrivacySettings from '@/components/ui/settings/account/PrivacySettings.vue'
import ProfileSettings from '@/components/ui/settings/account/ProfileSettings.vue'
import SocialSettings from '@/components/ui/settings/account/SocialSettings.vue'
import AppearanceSettings from '@/components/ui/settings/display/AppearanceSettings.vue'
import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue'
@@ -32,6 +37,10 @@ import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultI
import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/instances/ResourceManagementSettings.vue'
import { get, set } from '@/helpers/settings.ts'
import {
appSettingsModalContextKey,
type UnsavedChangesController,
} from '@/providers/app-settings-modal'
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
import { useTheming } from '@/store/state'
@@ -98,6 +107,18 @@ const tabs = [
content: FeatureFlagSettings,
developerOnly: true,
},
{
name: commonSettingsMessages.profile,
category: tabCategories.account,
icon: UserIcon,
content: ProfileSettings,
},
{
name: commonSettingsMessages.social,
category: tabCategories.account,
icon: HeartHandshakeIcon,
content: SocialSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.privacy',
@@ -110,7 +131,7 @@ const tabs = [
{
name: defineMessage({
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default instance options',
defaultMessage: 'Default game options',
}),
category: tabCategories.instances,
icon: GameIcon,
@@ -139,12 +160,58 @@ const tabs = [
const availableTabs = computed(() => tabs.filter((tab) => !tab.developerOnly || themeStore.devMode))
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const unsavedChangesPopup = ref<{ nudge: () => void } | null>(null)
const unsavedChangesController = ref<UnsavedChangesController | null>(null)
const emptyUnsavedChangesState: Record<string, unknown> = {}
const originalUnsavedChangesState = computed(
() => unsavedChangesController.value?.getOriginal() ?? emptyUnsavedChangesState,
)
const modifiedUnsavedChangesState = computed(
() => unsavedChangesController.value?.getModified() ?? emptyUnsavedChangesState,
)
const savingUnsavedChanges = computed(() => unsavedChangesController.value?.isSaving() ?? false)
const hasUnsavedChanges = computed(() => unsavedChangesController.value?.hasChanges() ?? false)
function canLeaveCurrentTab(): boolean {
if (!unsavedChangesController.value?.hasChanges()) return true
unsavedChangesPopup.value?.nudge()
return false
}
function close(): boolean {
return modal.value?.hide() ?? false
}
function registerUnsavedChangesController(controller: UnsavedChangesController | null): void {
unsavedChangesController.value = controller
}
provide(appSettingsModalContextKey, {
close,
registerUnsavedChangesController,
})
function resetUnsavedChanges(): void {
unsavedChangesController.value?.reset()
}
function saveUnsavedChanges(): void {
void unsavedChangesController.value?.save()
}
function show() {
modal.value?.show()
}
defineExpose({ show })
function showProfile(): void {
const profileTabIndex = availableTabs.value.findIndex((tab) => tab.content === ProfileSettings)
if (profileTabIndex >= 0) {
modal.value?.setTab(profileTabIndex)
}
modal.value?.show()
}
defineExpose({ show, showProfile })
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
@@ -197,12 +264,30 @@ const messages = defineMessages({
})
</script>
<template>
<TabbedModal ref="modal" :tabs="availableTabs" :width="'min(928px, calc(95vw - 10rem))'">
<TabbedModal
ref="modal"
:tabs="availableTabs"
:width="'min(928px, calc(95vw - 10rem))'"
:before-hide="canLeaveCurrentTab"
:before-tab-change="canLeaveCurrentTab"
:floating-action-bar-shown="hasUnsavedChanges"
>
<template #title>
<span class="text-2xl font-semibold text-contrast">
{{ formatMessage(commonMessages.settingsLabel) }}
</span>
</template>
<template #floating-action-bar>
<UnsavedChangesPopup
ref="unsavedChangesPopup"
:original="originalUnsavedChangesState"
:modified="modifiedUnsavedChangesState"
:saving="savingUnsavedChanges"
inline
@reset="resetUnsavedChanges"
@save="saveUnsavedChanges"
/>
</template>
<template #footer>
<div class="mt-auto text-secondary text-sm">
<div class="mb-3">
@@ -0,0 +1,70 @@
<template>
<AccountProfileSettings
ref="profileSettings"
:patch-user="patchUser"
:change-avatar="changeAvatar"
:delete-avatar="deleteAvatar"
:get-authenticated-user="getAuthenticatedUser"
@profile-link-click="handleProfileLinkClick"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { AccountProfileSettings, injectAuth } from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import {
change_user_avatar,
delete_user_avatar,
get_user_profile,
patch_user,
} from '@/helpers/users'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const settingsModal = inject(appSettingsModalContextKey, null)
const auth = injectAuth()
const profileSettings = ref<InstanceType<typeof AccountProfileSettings> | null>(null)
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => profileSettings.value?.hasChanges ?? false,
getOriginal: () => profileSettings.value?.originalState ?? {},
getModified: () => profileSettings.value?.modifiedState ?? {},
isSaving: () => profileSettings.value?.saving ?? false,
reset: () => profileSettings.value?.reset(),
save: () => profileSettings.value?.save(),
})
})
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
function handleProfileLinkClick(event: MouseEvent): void {
if (settingsModal && !settingsModal.close()) {
event.preventDefault()
}
}
function patchUser(
userId: string,
patch: Partial<Pick<Labrinth.Users.v2.User, 'bio' | 'username'>>,
): Promise<void> {
return patch_user(userId, patch)
}
async function changeAvatar(userId: string, file: Blob, extension: string): Promise<void> {
await change_user_avatar(userId, new Uint8Array(await file.arrayBuffer()), extension)
}
function deleteAvatar(userId: string): Promise<void> {
return delete_user_avatar(userId)
}
function getAuthenticatedUser(): Promise<Labrinth.Users.v3.User> {
const userId = auth.user.value?.id
if (!userId) throw new Error('Cannot refresh a signed-out user.')
return get_user_profile(userId)
}
</script>
@@ -0,0 +1,20 @@
<template>
<AccountSocialSettings
:get-blocked-users="get_blocked_users"
:get-users="getUsers"
:unblock-user="unblock_user"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { AccountSocialSettings, injectModrinthClient } from '@modrinth/ui'
import { get_blocked_users, unblock_user } from '@/helpers/users'
const client = injectModrinthClient()
function getUsers(userIds: string[]): Promise<Labrinth.Users.v2.User[]> {
return client.labrinth.users_v2.getMultiple(userIds)
}
</script>
@@ -25,7 +25,7 @@ watch(
)
</script>
<template>
<div class="flex flex-col gap-2.5 min-w-[600px]">
<div class="flex flex-col gap-2.5">
<div v-for="option in options" :key="option" class="flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
@@ -120,8 +120,17 @@
:max-height="240"
/>
</div>
<div v-if="reportOnly" class="flex flex-col gap-2">
<Checkbox v-model="deleteInstance" :label="formatMessage(messages.deleteInstance)" />
<div v-if="reportOnly || blockTargetUserId" class="flex flex-col gap-2">
<Checkbox
v-if="reportOnly"
v-model="deleteInstance"
:label="formatMessage(messages.deleteInstance)"
/>
<Checkbox
v-if="blockTargetUserId"
v-model="blockUser"
:label="formatMessage(messages.blockUser)"
/>
</div>
</div>
</Transition>
@@ -249,12 +258,14 @@ import {
Admonition,
AutoLink,
Avatar,
blockedUsersQueryKey,
ButtonStyled,
Checkbox,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectAuth,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
@@ -266,6 +277,7 @@ import {
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref } from 'vue'
@@ -274,6 +286,7 @@ import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { toError } from '@/helpers/errors'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
import { create_report } from '@/helpers/reports'
import { block_user } from '@/helpers/users'
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
@@ -284,6 +297,7 @@ type ExternalFileRow = {
name: string
}
type SharedInstanceCreator = {
id: string | null
username: string
avatarUrl: string | null
}
@@ -300,13 +314,17 @@ type ReportReason = 'malicious' | 'inappropriate' | 'spam'
const reportReason = ref<ReportReason>('malicious')
const additionalContext = ref('')
const deleteInstance = ref(true)
const blockUser = ref(true)
const blockTargetUserId = ref<string | null>(null)
const submitLoading = ref(false)
const uploadedImageIDs = ref<string[]>([])
const emit = defineEmits<{
reported: [deleteInstance: boolean]
}>()
const { formatMessage } = useVIntl()
const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification, handleError } = injectNotificationManager()
const { load } = useSharedInstancePreviewContent()
const {
@@ -365,13 +383,39 @@ async function submitReport() {
submitLoading.value = true
try {
const uploadedImages = uploadedImageIDs.value.slice(-10)
await create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
})
const blockTarget = blockUser.value ? blockTargetUserId.value : null
const [reportResult, blockResult] = await Promise.allSettled([
create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
}),
blockTarget ? block_user(blockTarget) : Promise.resolve(),
])
if (blockTarget) {
if (blockResult.status === 'fulfilled') {
blockUser.value = false
const authUserId = auth.user.value?.id
if (authUserId) {
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
blockedUsersQueryKey(authUserId),
(blockedUsers = []) =>
blockedUsers.includes(blockTarget) ? blockedUsers : [...blockedUsers, blockTarget],
)
}
addNotification({
type: 'success',
title: formatMessage(messages.userBlocked),
})
} else {
handleError(toError(blockResult.reason))
}
}
if (reportResult.status === 'rejected') throw reportResult.reason
const shouldDeleteInstance = reportOnly.value && deleteInstance.value
hide()
@@ -426,6 +470,8 @@ function resetReportState() {
reportReason.value = 'malicious'
additionalContext.value = ''
deleteInstance.value = true
blockUser.value = true
blockTargetUserId.value = null
submitLoading.value = false
uploadedImageIDs.value = []
}
@@ -437,12 +483,18 @@ function show(
) {
resetReportState()
creator.value = creatorValue ?? null
blockTargetUserId.value = creatorValue?.id ?? null
install.value = installValue
showPreview(previewValue, event)
}
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
function showReport(
previewValue: SharedInstanceInstallPreview,
blockTargetUserIdValue?: string | null,
event?: MouseEvent,
) {
resetReportState()
creator.value = null
blockTargetUserId.value = blockTargetUserIdValue ?? null
reportMode.value = true
reportOnly.value = true
install.value = () => {}
@@ -537,6 +589,14 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.delete-instance',
defaultMessage: 'Delete instance',
},
blockUser: {
id: 'app.modal.install-to-play.block-user',
defaultMessage: 'Block user',
},
userBlocked: {
id: 'app.modal.install-to-play.user-blocked',
defaultMessage: 'User blocked',
},
unknownFilesWarning: {
id: 'app.modal.install-to-play.unknown-files-warning',
defaultMessage: 'Unknown files warning',
@@ -32,6 +32,7 @@ type InstallModal = {
}
type SharedInstanceCreator = {
id: string | null
username: string
avatarUrl: string | null
}
@@ -190,6 +191,7 @@ export function useSharedInstanceInviteHandler(
},
invite.invitedByUsername
? {
id: invite.invitedById,
username: invite.invitedByUsername,
avatarUrl: invite.invitedByAvatarUrl,
}
@@ -294,6 +296,7 @@ export function useSharedInstanceInviteHandler(
},
manager
? {
id: manager.id,
username: manager.username,
avatarUrl: manager.avatar_url ?? null,
}
+25 -1
View File
@@ -50,7 +50,31 @@ export async function get_user_collections(
export async function patch_user(
userId: string,
patch: Partial<Pick<Labrinth.Users.v3.User, 'badges' | 'role'>>,
patch: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'bio' | 'role' | 'username'>>,
): Promise<void> {
await invoke('plugin:users|patch_user', { userId, patch })
}
export async function change_user_avatar(
userId: string,
image: Uint8Array,
extension: string,
): Promise<void> {
await invoke('plugin:users|change_user_avatar', { userId, image, extension })
}
export async function delete_user_avatar(userId: string): Promise<void> {
await invoke('plugin:users|delete_user_avatar', { userId })
}
export async function block_user(userId: string): Promise<void> {
await invoke('plugin:users|block_user', { userId })
}
export async function unblock_user(userId: string): Promise<void> {
await invoke('plugin:users|unblock_user', { userId })
}
export async function get_blocked_users(): Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]> {
return await invoke<Labrinth.BlockedUsers.v3.BlockedUserId[]>('plugin:users|get_blocked_users')
}
+13 -1
View File
@@ -251,6 +251,9 @@
"app.browse.back-to-instance": {
"message": "Back to instance"
},
"app.browse.discover-project-type": {
"message": "Discover {projectType}"
},
"app.browse.discover-servers": {
"message": "Discover servers"
},
@@ -650,6 +653,9 @@
"app.modal.install-to-play.additional-context-placeholder": {
"message": "Include links and images if possible and relevant"
},
"app.modal.install-to-play.block-user": {
"message": "Block user"
},
"app.modal.install-to-play.content-you-are-reporting": {
"message": "Instance youre reporting"
},
@@ -731,6 +737,9 @@
"app.modal.install-to-play.unrecognized-files": {
"message": "Unrecognized files"
},
"app.modal.install-to-play.user-blocked": {
"message": "User blocked"
},
"app.modal.install-to-play.view-contents": {
"message": "View contents"
},
@@ -797,6 +806,9 @@
"app.project.install-context.back-to-browse": {
"message": "Back to discover"
},
"app.project.install-context.back-to-instance": {
"message": "Back to instance"
},
"app.project.version.all-versions": {
"message": "All versions"
},
@@ -990,7 +1002,7 @@
"message": "Behavior"
},
"app.settings.tabs.default-instance-options": {
"message": "Default instance options"
"message": "Default game options"
},
"app.settings.tabs.java-installations": {
"message": "Java installations"
+133 -56
View File
@@ -3,9 +3,11 @@ import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ClipboardCopyIcon,
CompassIcon,
ExternalIcon,
GlobeIcon,
PlusIcon,
ServerStackIcon,
SpinnerIcon,
} from '@modrinth/assets'
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
@@ -15,6 +17,7 @@ import {
commonMessages,
CreationFlowModal,
defineMessages,
formatProjectTypeSentence,
getLatestMatchingInstallVersion,
getSelectedInstallPreferences,
getTargetInstallPreferences,
@@ -31,9 +34,9 @@ import {
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
@@ -52,13 +55,17 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_instance_worlds } from '@/helpers/worlds'
import {
type BreadcrumbDefinition,
useBreadcrumb,
useRootBreadcrumb,
} from '@/providers/breadcrumbs'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import {
createServerInstallContent,
provideServerInstallContent,
} from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
@@ -71,6 +78,40 @@ const debugLog = useDebugLogger('Browse')
const router = useRouter()
const route = useRoute()
const displayedBrowseRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/browse/')) {
displayedBrowseRoute.value = nextRoute
}
},
{ immediate: true },
)
const breadcrumbMessages = defineMessages({
discoverProjectType: {
id: 'app.browse.discover-project-type',
defaultMessage: 'Discover {projectType}',
},
discoverServers: {
id: 'app.browse.discover-servers',
defaultMessage: 'Discover servers',
},
})
const breadcrumbLabel = computed(() => {
const browseRoute = displayedBrowseRoute.value
if (browseRoute.query.from === 'worlds' || browseRoute.params.projectType === 'server') {
return formatMessage(breadcrumbMessages.discoverServers)
}
return formatMessage(breadcrumbMessages.discoverProjectType, {
projectType: formatProjectTypeSentence(
formatMessage,
String(browseRoute.params.projectType ?? ''),
2,
),
})
})
const themeStore = useTheming()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
@@ -111,6 +152,86 @@ const {
markServerProjectInstalled,
} = serverInstallContent
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
link?: {
type: string
project_id: string
version_id: string
}
}
const initialInstanceId = String(route.query.i ?? '')
const instance: Ref<Instance | null> = ref(
queryClient.getQueryData<Instance>(['instances', 'summary', initialInstanceId]) ?? null,
)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
const instanceBreadcrumb = route.query.i
? useBreadcrumb({
slot: 'instance',
id: () => `instance:${String(displayedBrowseRoute.value.query.i ?? '')}`,
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: () => ({
type: 'image',
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
alt: instance.value?.name,
tintBy: String(displayedBrowseRoute.value.query.i ?? ''),
}),
to: () => {
const instancePath = `/instance/${encodeURIComponent(
String(displayedBrowseRoute.value.query.i ?? ''),
)}`
return displayedBrowseRoute.value.query.from === 'worlds'
? `${instancePath}/worlds`
: instancePath
},
})
: undefined
const serverBreadcrumbTo = ref(serverBackUrl.value)
watch(serverBackUrl, (value) => {
if (route.path.startsWith('/browse/')) {
serverBreadcrumbTo.value = value
}
})
const serverBreadcrumb =
!instanceBreadcrumb && serverIdQuery.value
? useBreadcrumb({
slot: 'server',
id: () => `server:${String(displayedBrowseRoute.value.query.sid ?? '')}`,
label: () =>
serverContextServerData.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: { type: 'icon', component: ServerStackIcon },
to: serverBreadcrumbTo,
})
: undefined
const breadcrumbParent = instanceBreadcrumb ?? serverBreadcrumb
const breadcrumbDefinition = {
slot: 'browse',
id: () =>
`browse:${String(displayedBrowseRoute.value.params.projectType ?? '')}:${String(
displayedBrowseRoute.value.query.i ?? '',
)}:${String(displayedBrowseRoute.value.query.sid ?? '')}:${String(
displayedBrowseRoute.value.query.from ?? '',
)}`,
label: breadcrumbLabel,
to: () => displayedBrowseRoute.value.fullPath,
visual: { type: 'icon', component: CompassIcon },
} satisfies BreadcrumbDefinition
const browseBreadcrumb = breadcrumbParent
? useBreadcrumb(breadcrumbDefinition, { parent: breadcrumbParent })
: useRootBreadcrumb(breadcrumbDefinition)
debugLog('fetching tags (categories, loaders, gameVersions)')
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories()
@@ -130,28 +251,6 @@ const tags: Ref<Tags> = computed(() => ({
categories: categories.value ?? [],
}))
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
link?: {
type: string
project_id: string
version_id: string
}
}
const instance: Ref<Instance | null> = ref(null)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
if (isFromWorlds.value && route.params.projectType !== 'server') {
router.replace({
path: '/browse/server',
@@ -385,10 +484,6 @@ const messages = defineMessages({
id: 'app.browse.add-to-an-instance',
defaultMessage: 'Add to an instance',
},
discoverServers: {
id: 'app.browse.discover-servers',
defaultMessage: 'Discover servers',
},
environmentProvidedByServer: {
id: 'search.filter.locked.server-environment.title',
defaultMessage: 'Only client-side mods can be added to the server instance',
@@ -444,31 +539,6 @@ const messages = defineMessages({
},
})
const breadcrumbs = useBreadcrumbs()
const browseTitle = computed(() =>
formatMessage(
isFromWorlds.value ? messages.discoverServers : commonMessages.discoverContentLabel,
),
)
breadcrumbs.setName('BrowseTitle', browseTitle.value)
if (instance.value) {
const instanceLink = `/instance/${encodeURIComponent(instance.value.id)}`
breadcrumbs.setContext({
name: instance.value.name,
link: isFromWorlds.value ? `${instanceLink}/worlds` : instanceLink,
})
} else {
breadcrumbs.setContext(null)
}
onBeforeRouteLeave(() => {
breadcrumbs.setContext({
name: browseTitle.value,
link: `/browse/${projectType.value}`,
query: route.query,
})
})
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
function resetInstanceContext() {
@@ -482,8 +552,7 @@ function resetInstanceContext() {
hiddenInstanceProjectIds.value = new Set()
hiddenInstanceProjectIdsInitialized.value = false
isServerInstance.value = false
breadcrumbs.setName('BrowseTitle', formatMessage(commonMessages.discoverContentLabel))
breadcrumbs.setContext(null)
browseBreadcrumb.reset()
}
watch(
@@ -901,6 +970,14 @@ async function search(requestParams: string) {
}
}
for (const hit of rawResults.result.hits) {
for (const identifier of [hit.project_id, hit.slug]) {
if (identifier) {
queryClient.setQueryData(['projects', 'summary', identifier], hit)
}
}
}
if (isServer) {
const hits = rawResults.result.hits ?? []
updateServerHits(hits)
+9 -5
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { HomeIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import type { SearchResult } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
@@ -11,13 +11,17 @@ import { get_search_results } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events'
import { list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
const { handleError } = injectNotificationManager()
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
useRootBreadcrumb({
slot: 'root',
id: 'home',
label: 'Home',
to: '/',
visual: { type: 'icon', component: HomeIcon },
})
const instances = ref<GameInstance[]>([])
+11
View File
@@ -1,15 +1,26 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ServerStackIcon } from '@modrinth/assets'
import { injectModrinthClient, ServersManagePageIndex } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { config } from '../config'
const stripePublishableKey = (config.stripePublishableKey as string) || ''
const client = injectModrinthClient()
useRootBreadcrumb({
slot: 'root',
id: 'servers',
label: 'Servers',
to: '/hosting/manage/',
visual: { type: 'icon', component: ServerStackIcon },
})
const { data: products } = useQuery({
queryKey: ['billing', 'products'],
queryFn: () => client.labrinth.billing_internal.getProducts(),
+10
View File
@@ -6,6 +6,7 @@ import {
EyeIcon,
LogInIcon,
RotateCounterClockwiseIcon,
ShirtIcon,
SpinnerIcon,
} from '@modrinth/assets'
import {
@@ -56,10 +57,19 @@ import {
set_custom_skin_order,
} from '@/helpers/skins.ts'
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/state'
import { appMessages } from '@/utils/app-messages'
useRootBreadcrumb({
slot: 'root',
id: 'skins',
label: 'Skin selector',
to: '/skins',
visual: { type: 'icon', component: ShirtIcon },
})
type UnlistenFn = () => void
type VirtualSkinSectionListExpose = {
getAddSkinButtonElement: () => HTMLElement | null | undefined
+53 -25
View File
@@ -1,40 +1,49 @@
<template>
<div class="w-full pt-2">
<div class="w-full px-2 pt-2">
<UserProfilePageLayout
:user-id="userId"
:project-type="projectType"
variant="app"
site-url="https://modrinth.com"
project-link-mode="app"
:edit-profile-link="openProfileSettings"
external-navigation
/>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { provideUserProfile, UserProfilePageLayout } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
import {
block_user,
get_blocked_users,
get_user_collections,
get_user_organizations,
get_user_profile,
get_user_projects,
patch_user,
unblock_user,
} from '@/helpers/users'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { appSettingsModalOpenProfileKey } from '@/providers/app-settings-modal'
import { useBreadcrumb } from '@/providers/breadcrumbs'
const route = useRoute()
const openProfileSettings = inject(appSettingsModalOpenProfileKey, () => {})
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const userProfile = provideUserProfile({
getUser: get_user_profile,
getProjects: get_user_projects,
getOrganizations: get_user_organizations,
getCollections: get_user_collections,
patchUser: patch_user,
getBlockedUsers: get_blocked_users,
blockUser: block_user,
unblockUser: unblock_user,
})
const userId = computed(() => {
@@ -46,17 +55,54 @@ const projectType = computed(() => {
return Array.isArray(value) ? value[0] : value
})
function getCachedUserSummary(id: string) {
return queryClient.getQueryData<Labrinth.Users.v3.User>(['users', 'summary', id])
}
const { data: user } = useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: () => userProfile.getUser(userId.value),
enabled: false,
staleTime: 30_000,
})
const breadcrumbUserId = ref(userId.value)
const breadcrumbLabel = ref(getCachedUserSummary(userId.value)?.username ?? userId.value)
const breadcrumbTo = ref(route.fullPath)
watch(
[userId, user, () => route.fullPath],
([currentUserId, currentUser, currentPath]) => {
if (route.name !== 'User') return
breadcrumbUserId.value = currentUserId
breadcrumbLabel.value = currentUser?.username ?? currentUserId
breadcrumbTo.value = currentPath
},
{ immediate: true, flush: 'sync' },
)
useBreadcrumb({
slot: 'user',
id: () => `user:${breadcrumbUserId.value}`,
label: breadcrumbLabel,
to: breadcrumbTo,
visual: () => ({
type: 'image',
src: user.value?.avatar_url ?? getCachedUserSummary(breadcrumbUserId.value)?.avatar_url,
alt: breadcrumbLabel.value,
circle: true,
tintBy: breadcrumbUserId.value,
}),
})
async function ensureUserProfileData(id: string): Promise<void> {
if (!id) return
let breadcrumbName = id
try {
const user = await queryClient.ensureQueryData({
await queryClient.ensureQueryData({
queryKey: ['user', id],
queryFn: () => userProfile.getUser(id),
staleTime: 30_000,
})
breadcrumbName = user.username
} catch {
// Let the mounted layout's useQuery surface errors; do not fail route setup.
}
@@ -78,8 +124,6 @@ async function ensureUserProfileData(id: string): Promise<void> {
staleTime: 30_000,
}),
])
breadcrumbs.setName('User', breadcrumbName)
}
onBeforeRouteUpdate(async (to) => {
@@ -88,21 +132,5 @@ onBeforeRouteUpdate(async (to) => {
await ensureUserProfileData(id)
})
breadcrumbs.setName('User', userId.value)
await ensureUserProfileData(userId.value)
const { data: user } = useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: () => userProfile.getUser(userId.value),
enabled: false,
staleTime: 30_000,
})
watch(
[userId, user],
([currentUserId, value]) => {
breadcrumbs.setName('User', value?.username ?? currentUserId)
},
{ immediate: true },
)
</script>
@@ -48,15 +48,22 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
import { ServerStackIcon } from '@modrinth/assets'
import {
commonMessages,
injectAuth,
injectModrinthClient,
ServersManageRootLayout,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { provideBreadcrumbParent, useBreadcrumb } from '@/providers/breadcrumbs'
import { useTheming } from '@/store/theme'
const route = useRoute()
@@ -65,47 +72,63 @@ const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const themeStore = useTheming()
const breadcrumbs = useBreadcrumbs()
const { formatMessage } = useVIntl()
const isContainedServerRoute = computed(() => route.name === 'ServerManageOverview')
const serverId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
return Array.isArray(rawId) ? (rawId[0] ?? '') : (rawId ?? '')
})
if (serverId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['servers', 'detail', serverId.value],
queryFn: () => client.archon.servers_v0.get(serverId.value)!,
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
function getCachedServerName(id: string): string | undefined {
return queryClient
.getQueryData<Archon.Servers.v0.ServerGetResponse>(['servers'])
?.servers.find((server) => server.server_id === id)?.name
}
const { data: serverData } = useQuery({
queryKey: computed(() => ['servers', 'detail', serverId.value]),
queryFn: () => null as unknown as Archon.Servers.v0.Server,
enabled: false,
queryFn: () => client.archon.servers_v0.get(serverId.value),
enabled: computed(() => Boolean(serverId.value)),
placeholderData: () =>
queryClient
.getQueryData<Archon.Servers.v0.ServerGetResponse>(['servers'])
?.servers.find((server) => server.server_id === serverId.value),
staleTime: 30_000,
})
const breadcrumbServerId = ref(serverId.value)
const breadcrumbLabel = ref(
getCachedServerName(serverId.value) ?? formatMessage(commonMessages.loadingLabel),
)
watch(
serverId,
(value) => {
if (!route.path.startsWith('/hosting/manage/') || route.name === 'Servers') return
breadcrumbServerId.value = value
breadcrumbLabel.value = getCachedServerName(value) ?? formatMessage(commonMessages.loadingLabel)
},
{ flush: 'sync' },
)
watch(
serverData,
(server) => {
if (server?.name) {
breadcrumbs.setName('Server', server.name)
breadcrumbs.setContext({
name: server.name,
link: `/hosting/manage/${serverId.value}/content`,
})
}
if (!route.path.startsWith('/hosting/manage/') || !server?.name) return
breadcrumbLabel.value = server.name
},
{ immediate: true },
)
const serverBreadcrumb = useBreadcrumb({
slot: 'server',
id: () => `server:${breadcrumbServerId.value}`,
label: breadcrumbLabel,
visual: { type: 'icon', component: ServerStackIcon },
to: () => `/hosting/manage/${encodeURIComponent(breadcrumbServerId.value)}`,
})
provideBreadcrumbParent(serverBreadcrumb)
watch(
() => auth.user.value,
(user, previousUser) => {
+32 -19
View File
@@ -138,7 +138,14 @@ import {
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { injectAuth, injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import {
commonMessages,
injectAuth,
injectNotificationManager,
NavTabs,
useLoadingBarToken,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
@@ -185,9 +192,10 @@ import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
import { useTheming } from '@/store/state'
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
@@ -198,10 +206,10 @@ const { playServerProject } = injectServerInstall()
const auth = injectAuth()
const queryClient = useQueryClient()
const route = useRoute()
const { formatMessage } = useVIntl()
const router = useRouter()
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
@@ -214,7 +222,23 @@ window.addEventListener('online', () => {
offline.value = false
})
const instance = ref<GameInstance>()
const initialInstanceId = String(displayedInstanceRoute.value.params.id ?? '')
const instance = ref<GameInstance | undefined>(
queryClient.getQueryData<GameInstance>(['instances', 'summary', initialInstanceId]),
)
useRootBreadcrumb({
slot: 'instance',
id: () => `instance:${String(displayedInstanceRoute.value.params.id ?? '')}`,
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: () => ({
type: 'image',
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
alt: instance.value?.name,
tintBy: instance.value?.id ?? String(displayedInstanceRoute.value.params.id ?? ''),
}),
to: () => `/instance/${encodeURIComponent(String(displayedInstanceRoute.value.params.id ?? ''))}`,
})
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
@@ -339,6 +363,9 @@ async function fetchInstance() {
}
instance.value = nextInstance ?? undefined
if (nextInstance) {
queryClient.setQueryData(['instances', 'summary', nextInstance.id], nextInstance)
}
displayedInstanceRoute.value = nextRoute
sharedInstanceState.reset()
sharedInstanceState.refreshAvailability()
@@ -503,20 +530,6 @@ watch(
{ immediate: true },
)
if (instance.value) {
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
? instance.value.name.substring(0, 40) + '...'
: instance.value.name,
)
breadcrumbs.setContext({
name: instance.value.name,
link: displayedInstanceRoute.value.path,
query: displayedInstanceRoute.value.query,
})
}
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const launchInstance = async (context: string) => {
@@ -701,7 +714,7 @@ async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false
)
if (instance.value?.id !== reportInstance.id) return
if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide()
sharedInstanceReportModal.value?.showReport(preview, event)
sharedInstanceReportModal.value?.showReport(preview, sharedInstance.manager_id, event)
} catch (error) {
notifySharedInstanceError(error)
}
+27 -14
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { PlusIcon } from '@modrinth/assets'
import { LibraryIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NavTabs } from '@modrinth/ui'
import { inject, onUnmounted, ref, shallowRef } from 'vue'
import { useRoute } from 'vue-router'
@@ -7,14 +7,19 @@ import { useRoute } from 'vue-router'
import { NewInstanceImage } from '@/assets/icons'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
const { handleError } = injectNotificationManager()
const showCreationModal = inject('showCreationModal')
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Library', link: route.path })
useRootBreadcrumb({
slot: 'root',
id: 'library',
label: 'Library',
to: '/library',
visual: { type: 'icon', component: LibraryIcon },
})
const instances = shallowRef(await list().catch(handleError))
@@ -37,16 +42,24 @@ onUnmounted(() => {
<template>
<div class="p-6 flex flex-col gap-3">
<h1 class="m-0 text-2xl hidden">Library</h1>
<NavTabs
:links="[
{ label: 'All instances', href: `/library` },
{ label: 'Modpacks', href: `/library/modpacks` },
{ label: 'Servers', href: `/library/servers` },
{ label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<div class="flex items-center justify-between gap-3">
<NavTabs
:links="[
{ label: 'All instances', href: `/library` },
{ label: 'Modpacks', href: `/library/modpacks` },
{ label: 'Servers', href: `/library/servers` },
{ label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<ButtonStyled color="brand">
<button :disabled="offline" @click="showCreationModal?.()">
<PlusIcon />
New instance
</button>
</ButtonStyled>
</div>
<template v-if="instances && instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
+82 -6
View File
@@ -295,10 +295,10 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_instance_id } from '@/helpers/process'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerAddress } from '@/helpers/worlds'
import { provideBreadcrumbParent, useBreadcrumb } from '@/providers/breadcrumbs'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { createServerInstallContent } from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state.js'
dayjs.extend(relativeTime)
@@ -307,8 +307,29 @@ const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const route = useRoute()
const router = useRouter()
const displayedProjectRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/project/')) {
displayedProjectRoute.value = nextRoute
}
},
{ immediate: true },
)
const projectBreadcrumbTo = computed(() => {
const currentRoute = displayedProjectRoute.value
if (currentRoute.name === 'Version') {
return {
name: 'Versions',
params: { id: currentRoute.params.id },
query: currentRoute.query,
}
}
return currentRoute.fullPath
})
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const { formatMessage } = useVIntl()
@@ -317,6 +338,10 @@ const messages = defineMessages({
id: 'app.project.install-context.back-to-browse',
defaultMessage: 'Back to discover',
},
backToInstance: {
id: 'app.project.install-context.back-to-instance',
defaultMessage: 'Back to instance',
},
alreadyInstalled: {
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
@@ -331,6 +356,40 @@ const { installingServerProjects, playServerProject, showAddServerToInstanceModa
injectServerInstall()
const installing = ref(false)
const data = shallowRef(null)
function getProjectBreadcrumbSummary(projectId) {
const identifier = Array.isArray(projectId) ? projectId[0] : projectId
if (typeof identifier !== 'string' || !identifier) return undefined
return queryClient.getQueryData(['projects', 'summary', identifier])
}
function getProjectBreadcrumbLabel(projectId) {
const summary = getProjectBreadcrumbSummary(projectId)
return summary?.name ?? summary?.title ?? formatMessage(commonMessages.loadingLabel)
}
const projectBreadcrumbLabel = ref(getProjectBreadcrumbLabel(route.params.id))
const projectBreadcrumb = useBreadcrumb({
slot: 'project',
id: () => `project:${String(displayedProjectRoute.value.params.id ?? '')}`,
label: projectBreadcrumbLabel,
visual: () => {
const identifier = String(displayedProjectRoute.value.params.id ?? '')
const loadedProject =
data.value?.id === identifier || data.value?.slug === identifier ? data.value : undefined
const project = loadedProject ?? getProjectBreadcrumbSummary(identifier)
return {
type: 'image',
src: project?.icon_url,
alt: projectBreadcrumbLabel.value,
tintBy: identifier,
}
},
to: projectBreadcrumbTo,
})
provideBreadcrumbParent(projectBreadcrumb)
const versions = shallowRef([])
const members = shallowRef([])
const categories = shallowRef([])
@@ -410,9 +469,18 @@ const projectGalleryHref = computed(() => buildProjectHref(`/project/${route.par
const projectBrowseBackUrl = computed(() => {
const browsePath = route.query.b
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
const instanceId = route.query.i
if (typeof instanceId === 'string' && instanceId) {
return `/instance/${encodeURIComponent(instanceId)}`
}
const type = data.value?.project_type ? `${data.value.project_type}` : 'mod'
return buildBrowseHref(`/browse/${type}`)
})
const projectBackLabel = computed(() =>
typeof route.query.i === 'string' && typeof route.query.b !== 'string'
? formatMessage(messages.backToInstance)
: formatMessage(messages.backToBrowse),
)
const projectInstallContext = computed(() => {
const serverData = serverInstallContent.serverContextServerData.value
@@ -426,7 +494,7 @@ const projectInstallContext = computed(() => {
iconSrc: null,
isMedal: serverData.is_medal,
backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse),
backLabel: projectBackLabel.value,
heading: serverInstallContent.serverBrowseHeading.value,
queuedCount: serverInstallContent.queuedServerInstallCount.value,
selectedProjects: serverInstallContent.selectedServerInstallProjects.value,
@@ -446,7 +514,7 @@ const projectInstallContext = computed(() => {
gameVersion: instance.value.game_version,
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse),
backLabel: projectBackLabel.value,
heading: formatMessage(commonMessages.installingContentLabel),
}
}
@@ -607,6 +675,7 @@ function reportProject() {
}
async function fetchProjectData() {
projectBreadcrumbLabel.value = getProjectBreadcrumbLabel(route.params.id)
const [project, projectV3Result] = await Promise.all([
get_project(route.params.id, 'must_revalidate').catch(handleError),
get_project_v3(route.params.id, 'must_revalidate').catch(handleError),
@@ -619,6 +688,7 @@ async function fetchProjectData() {
}
data.value = project
projectBreadcrumbLabel.value = project.title
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
await Promise.all([
get_version_many(project.versions, 'must_revalidate').catch(handleError),
@@ -628,6 +698,14 @@ async function fetchProjectData() {
route.query.i ? getInstanceProjects(route.query.i).catch(handleError) : Promise.resolve(),
])
for (const member of members.value ?? []) {
for (const identifier of [member.user.id, member.user.username]) {
if (identifier) {
queryClient.setQueryData(['users', 'summary', identifier], member.user)
}
}
}
versions.value = versions.value.sort((a, b) => dayjs(b.date_published) - dayjs(a.date_published))
if (instanceProjects.value) {
@@ -647,8 +725,6 @@ async function fetchProjectData() {
isServerProject.value = projectV3.value?.minecraft_server != null
serverStatusOnline.value = !!projectV3.value?.minecraft_java_server?.ping?.data
breadcrumbs.setName('Project', data.value.title)
fetchDeferredServerData(project)
}
+34 -19
View File
@@ -85,6 +85,7 @@ import {
ExternalIcon,
MoreVerticalIcon,
ReportIcon,
VersionIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
@@ -95,12 +96,12 @@ import {
useVIntl,
VersionPage,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { computed, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons'
import { get_project_many, get_version_many } from '@/helpers/cache.js'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useBreadcrumb } from '@/providers/breadcrumbs'
const { formatMessage } = useVIntl()
@@ -119,8 +120,18 @@ const messages = defineMessages({
},
})
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const router = useRouter()
const displayedVersionRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.name === 'Version') {
displayedVersionRoute.value = nextRoute
}
},
{ immediate: true },
)
const props = defineProps<{
project: Labrinth.Projects.v2.Project
@@ -133,9 +144,19 @@ const props = defineProps<{
}>()
const version = ref(props.versions.find((version) => version.id === route.params.version))
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
const versionBreadcrumbLabel = computed(() => {
const versionNumber = version.value?.version_number
const versionLabel = formatMessage(commonMessages.versionLabel)
return versionNumber ? `${versionLabel} ${versionNumber}` : versionLabel
})
useBreadcrumb({
slot: 'project-version',
id: () =>
`version:${props.project.id}:${String(displayedVersionRoute.value.params.version ?? '')}`,
label: versionBreadcrumbLabel,
visual: { type: 'icon', component: VersionIcon },
to: () => displayedVersionRoute.value.fullPath,
})
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
const enrichmentLoading = ref(false)
@@ -202,18 +223,12 @@ async function refreshEnrichment() {
}
}
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
await refreshEnrichment()
}
},
)
watch([() => props.versions, () => route.params.version], async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
await refreshEnrichment()
}
})
await refreshEnrichment()
</script>
@@ -0,0 +1,21 @@
import type { InjectionKey } from 'vue'
export type UnsavedChangesController = {
hasChanges: () => boolean
getOriginal: () => Record<string, unknown>
getModified: () => Record<string, unknown>
isSaving: () => boolean
reset: () => void
save: () => void | Promise<void>
}
export type AppSettingsModalContext = {
close: () => boolean
registerUnsavedChangesController: (controller: UnsavedChangesController | null) => void
}
export const appSettingsModalContextKey: InjectionKey<AppSettingsModalContext> =
Symbol('appSettingsModalContext')
export const appSettingsModalOpenProfileKey: InjectionKey<() => void> = Symbol(
'appSettingsModalOpenProfile',
)
@@ -0,0 +1,209 @@
import { createContext } from '@modrinth/ui'
import {
type Component,
computed,
type ComputedRef,
type MaybeRefOrGetter,
shallowRef,
toValue,
watch,
} from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export type BreadcrumbVisual =
| {
type: 'icon'
component: Component
}
| {
type: 'image'
src?: string | null
alt?: string
circle?: boolean
tintBy?: string | null
}
export interface BreadcrumbDefinition {
slot: string
id: MaybeRefOrGetter<string>
label: MaybeRefOrGetter<string>
to?: MaybeRefOrGetter<RouteLocationRaw | undefined>
visual?: MaybeRefOrGetter<BreadcrumbVisual | undefined>
}
export interface ResolvedBreadcrumb {
slot: string
id: string
label: string
to?: RouteLocationRaw
visual?: BreadcrumbVisual
}
export interface BreadcrumbHandle {
readonly slot: string
activate: () => void
reset: () => void
pop: () => void
}
export interface BreadcrumbManager {
readonly entries: ComputedRef<ResolvedBreadcrumb[]>
reset: (definition: BreadcrumbDefinition) => BreadcrumbHandle
push: (
definition: BreadcrumbDefinition,
options?: { parent?: BreadcrumbHandle },
) => BreadcrumbHandle
find: (slot: string) => BreadcrumbHandle | undefined
}
interface InternalBreadcrumb {
token: symbol
definition: BreadcrumbDefinition
parentToken?: symbol
parentSlot?: string
root: boolean
handle: BreadcrumbHandle
}
const [injectBreadcrumbManager, provideBreadcrumbManager] = createContext<BreadcrumbManager>(
'root',
'breadcrumbManager',
)
const [injectBreadcrumbParent, provideBreadcrumbParent] = createContext<BreadcrumbHandle>(
'BreadcrumbParent',
'breadcrumbParent',
)
export { injectBreadcrumbManager, provideBreadcrumbManager, provideBreadcrumbParent }
export function createBreadcrumbManager(): BreadcrumbManager {
const stack = shallowRef<InternalBreadcrumb[]>([])
function findIndex(entry: InternalBreadcrumb): number {
const tokenIndex = stack.value.findIndex((candidate) => candidate.token === entry.token)
if (tokenIndex !== -1) return tokenIndex
return stack.value.findIndex((candidate) => candidate.definition.slot === entry.definition.slot)
}
function findParentIndex(entry: InternalBreadcrumb): number {
if (entry.parentToken) {
const tokenIndex = stack.value.findIndex((candidate) => candidate.token === entry.parentToken)
if (tokenIndex !== -1) return tokenIndex
}
if (entry.parentSlot) {
return stack.value.findIndex((candidate) => candidate.definition.slot === entry.parentSlot)
}
return stack.value.length - 1
}
function activate(entry: InternalBreadcrumb) {
if (entry.root) {
stack.value = [entry]
return
}
const existingIndex = findIndex(entry)
if (existingIndex !== -1) {
stack.value = [...stack.value.slice(0, existingIndex), entry]
return
}
const parentIndex = findParentIndex(entry)
stack.value = [...stack.value.slice(0, parentIndex + 1), entry]
}
function resetTo(entry: InternalBreadcrumb) {
stack.value = [entry]
}
function pop(entry: InternalBreadcrumb) {
const index = stack.value.findIndex((candidate) => candidate.token === entry.token)
if (index !== -1) {
stack.value = stack.value.slice(0, index)
}
}
function createHandle(
definition: BreadcrumbDefinition,
options: { parent?: BreadcrumbHandle; root: boolean },
): BreadcrumbHandle {
const entry = {} as InternalBreadcrumb
const handle: BreadcrumbHandle = {
slot: definition.slot,
activate: () => activate(entry),
reset: () => resetTo(entry),
pop: () => pop(entry),
}
Object.assign(entry, {
token: Symbol(definition.slot),
definition,
parentToken: options.parent
? stack.value.find((candidate) => candidate.handle === options.parent)?.token
: undefined,
parentSlot: options.parent?.slot,
root: options.root,
handle,
})
return handle
}
function reset(definition: BreadcrumbDefinition): BreadcrumbHandle {
const handle = createHandle(definition, { root: true })
handle.reset()
return handle
}
function push(
definition: BreadcrumbDefinition,
options: { parent?: BreadcrumbHandle } = {},
): BreadcrumbHandle {
const handle = createHandle(definition, { ...options, root: false })
handle.activate()
return handle
}
const entries = computed<ResolvedBreadcrumb[]>(() =>
stack.value.map(({ definition }) => ({
slot: definition.slot,
id: toValue(definition.id),
label: toValue(definition.label),
to: definition.to === undefined ? undefined : toValue(definition.to),
visual: definition.visual === undefined ? undefined : toValue(definition.visual),
})),
)
return {
entries,
reset,
push,
find: (slot) => stack.value.find((entry) => entry.definition.slot === slot)?.handle,
}
}
function watchBreadcrumbIdentity(definition: BreadcrumbDefinition, handle: BreadcrumbHandle) {
watch(
() => toValue(definition.id),
() => handle.activate(),
{ flush: 'sync' },
)
}
export function useRootBreadcrumb(definition: BreadcrumbDefinition): BreadcrumbHandle {
const manager = injectBreadcrumbManager()
const handle = manager.reset(definition)
watchBreadcrumbIdentity(definition, handle)
return handle
}
export function useBreadcrumb(
definition: BreadcrumbDefinition,
options: { parent?: BreadcrumbHandle } = {},
): BreadcrumbHandle {
const manager = injectBreadcrumbManager()
const parent = options.parent ?? injectBreadcrumbParent(null) ?? undefined
const handle = manager.push(definition, { parent })
watchBreadcrumbIdentity(definition, handle)
return handle
}
@@ -6,7 +6,7 @@ import {
type AuthUser,
provideAuth,
} from '@modrinth/ui'
import { computed, type Ref, ref, watchEffect } from 'vue'
import { computed, type Ref, ref, watch, watchEffect } from 'vue'
type AppCredentials = {
session?: string | null
@@ -37,5 +37,13 @@ export function setupAuthProvider(
user.value = credentials.value?.user ?? null
})
watch(user, (updatedUser) => {
if (!credentials.value || !updatedUser || credentials.value.user === updatedUser) return
credentials.value = {
...credentials.value,
user: updatedUser,
}
})
provideAuth(authProvider)
}
-85
View File
@@ -16,17 +16,11 @@ export default new createRouter({
path: '/',
name: 'Home',
component: Pages.Index,
meta: {
breadcrumb: [{ name: 'Home' }],
},
},
{
path: '/hosting/manage/',
name: 'Servers',
component: Pages.Servers,
meta: {
breadcrumb: [{ name: 'Servers' }],
},
},
{
path: '/hosting/manage/:id',
@@ -37,41 +31,26 @@ export default new createRouter({
path: '',
name: 'ServerManageOverview',
component: Hosting.Overview,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'content',
name: 'ServerManageContent',
component: Hosting.Content,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'files',
name: 'ServerManageFiles',
component: Hosting.Files,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'backups',
name: 'ServerManageBackups',
component: Hosting.Backups,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'access',
name: 'ServerManageAccess',
component: Hosting.Access,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
],
},
@@ -79,34 +58,21 @@ export default new createRouter({
path: '/browse/:projectType',
name: 'Discover content',
component: Pages.Browse,
meta: {
useContext: true,
breadcrumb: [{ name: '?BrowseTitle' }],
},
},
{
path: '/skins',
name: 'Skin selector',
component: Pages.Skins,
meta: {
breadcrumb: [{ name: 'Skin selector' }],
},
},
{
path: '/user/:user/:projectType?',
name: 'User',
component: Pages.User,
meta: {
breadcrumb: [{ name: '?User' }],
},
},
{
path: '/library',
name: 'Library',
component: Library.Index,
meta: {
breadcrumb: [{ name: 'Library' }],
},
children: [
{
path: '',
@@ -152,42 +118,22 @@ export default new createRouter({
path: '',
name: 'Description',
component: Project.Description,
meta: {
useContext: true,
breadcrumb: [{ name: '?Project' }],
},
},
{
path: 'versions',
name: 'Versions',
component: Project.Versions,
meta: {
useContext: true,
breadcrumb: [{ name: '?Project', link: '/project/{id}/' }, { name: 'Versions' }],
},
},
{
path: 'version/:version',
name: 'Version',
component: Project.Version,
props: true,
meta: {
useContext: true,
breadcrumb: [
{ name: '?Project', link: '/project/{id}/' },
{ name: 'Versions', link: '/project/{id}/versions' },
{ name: '?Version' },
],
},
},
{
path: 'gallery',
name: 'Gallery',
component: Project.Gallery,
meta: {
useContext: true,
breadcrumb: [{ name: '?Project', link: '/project/{id}/' }, { name: 'Gallery' }],
},
},
],
},
@@ -197,59 +143,30 @@ export default new createRouter({
component: Instance.Index,
props: true,
children: [
// {
// path: '',
// name: 'Overview',
// component: Instance.Overview,
// meta: {
// useRootContext: true,
// breadcrumb: [{ name: '?Instance' }],
// },
// },
{
path: 'worlds',
name: 'InstanceWorlds',
component: Instance.Worlds,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
},
},
{
path: 'share',
name: 'InstanceShare',
component: Instance.Share,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Share' }],
},
},
{
path: '',
name: 'Mods',
component: Instance.Mods,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{
path: 'projects/:type',
name: 'ModsFilter',
component: Instance.Mods,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{
path: 'files',
name: 'Files',
component: Instance.Files,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Files' }],
},
},
{
path: 'logs',
@@ -257,8 +174,6 @@ export default new createRouter({
component: Instance.Logs,
meta: {
renderMode: 'fixed',
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
},
},
],
@@ -1,37 +0,0 @@
import { defineStore } from 'pinia'
export const useBreadcrumbs = defineStore('breadcrumbsStore', {
state: () => ({
names: new Map(),
context: null,
rootContext: null,
}),
actions: {
getName(route) {
return this.names.get(route) ?? ''
},
setName(route, title) {
this.names.set(route, title)
},
// resets breadcrumbs to only included ones as to not have stale breadcrumbs
resetToNames(breadcrumbs) {
if (!breadcrumbs) return
// names is an array of every breadcrumb.name that starts with a ?
const names = breadcrumbs
.filter((breadcrumb) => breadcrumb.name.charAt(0) === '?')
.map((breadcrumb) => breadcrumb.name.slice(1))
// remove all names that are not in the names array
for (const [route] of this.names) {
if (!names.includes(route)) {
this.names.delete(route)
}
}
},
setContext(context) {
this.context = context
},
setRootContext(context) {
this.rootContext = context
},
},
})
+1 -2
View File
@@ -1,4 +1,3 @@
import { useBreadcrumbs } from './breadcrumbs'
import { useTheming } from './theme.ts'
export { useBreadcrumbs, useTheming }
export { useTheming }
+3
View File
@@ -285,6 +285,9 @@ fn main() {
"get_user_organizations",
"get_user_collections",
"patch_user",
"block_user",
"unblock_user",
"get_blocked_users",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
+37
View File
@@ -32,6 +32,38 @@ pub async fn patch_user(user_id: &str, patch: Value) -> Result<()> {
Ok(theseus::users::patch_user(user_id, patch).await?)
}
#[tauri::command]
pub async fn change_user_avatar(
user_id: &str,
image: Vec<u8>,
extension: &str,
) -> Result<()> {
Ok(
theseus::users::change_user_avatar(user_id, image.into(), extension)
.await?,
)
}
#[tauri::command]
pub async fn delete_user_avatar(user_id: &str) -> Result<()> {
Ok(theseus::users::delete_user_avatar(user_id).await?)
}
#[tauri::command]
pub async fn block_user(user_id: &str) -> Result<()> {
Ok(theseus::users::block_user(user_id).await?)
}
#[tauri::command]
pub async fn unblock_user(user_id: &str) -> Result<()> {
Ok(theseus::users::unblock_user(user_id).await?)
}
#[tauri::command]
pub async fn get_blocked_users() -> Result<Vec<String>> {
Ok(theseus::users::get_blocked_users().await?)
}
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("users")
.invoke_handler(tauri::generate_handler![
@@ -41,6 +73,11 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
get_user_organizations,
get_user_collections,
patch_user,
change_user_avatar,
delete_user_avatar,
block_user,
unblock_user,
get_blocked_users,
])
.build()
}
+1 -1
View File
@@ -16,7 +16,7 @@
"decorations": true,
"trafficLightPosition": {
"x": 15.0,
"y": 22.0
"y": 26.0
}
}
]
-156
View File
@@ -1,156 +0,0 @@
<template>
<div v-if="shown">
<div
:class="{
shown: actuallyShown,
noblur: !$orElse(cosmetics.advancedRendering, true),
}"
class="modal-overlay"
@click="hide"
/>
<div class="modal-container" :class="{ shown: actuallyShown }">
<div class="modal-body">
<div v-if="header" class="header">
<strong>{{ header }}</strong>
<ButtonStyled circular type="transparent">
<button @click="hide">
<XIcon />
</button>
</ButtonStyled>
</div>
<div class="content">
<slot />
</div>
</div>
</div>
</div>
<div v-else />
</template>
<script>
import { XIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
export default {
components: {
ButtonStyled,
XIcon,
},
props: {
header: {
type: String,
default: null,
},
},
setup() {
const cosmetics = useCosmetics()
return { cosmetics }
},
data() {
return {
shown: false,
actuallyShown: false,
}
},
methods: {
show() {
this.shown = true
setTimeout(() => {
this.actuallyShown = true
}, 50)
},
hide() {
this.actuallyShown = false
setTimeout(() => {
this.shown = false
}, 300)
},
},
}
</script>
<style lang="scss" scoped>
.modal-overlay {
visibility: hidden;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
transition: all 0.3s ease-in-out;
&.shown {
opacity: 1;
visibility: visible;
background: hsla(0, 0%, 0%, 0.5);
backdrop-filter: blur(3px);
}
&.noblur {
backdrop-filter: none;
}
}
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 21;
visibility: hidden;
pointer-events: none;
&.shown {
visibility: visible;
.modal-body {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
.modal-body {
position: fixed;
box-shadow: var(--shadow-raised), var(--shadow-inset);
border-radius: var(--size-rounded-lg);
max-height: calc(100% - 2 * var(--spacing-card-bg));
overflow-y: auto;
width: 600px;
pointer-events: auto;
outline: 3px solid transparent;
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg);
padding: var(--spacing-card-md) var(--spacing-card-lg);
strong {
font-size: 1.25rem;
margin: 0.67em 0;
}
}
.content {
background-color: var(--color-raised-bg);
}
transform: translateY(50vh);
visibility: hidden;
opacity: 0;
transition: all 0.25s ease-in-out;
@media screen and (max-width: 650px) {
width: calc(100% - 2 * var(--spacing-card-bg));
}
}
}
</style>
@@ -86,10 +86,17 @@ export function provideDownloadModalProvider(
const tags = useGeneratedState()
const shouldResolveDependencies = computed(
() => !!options.project.value && !!options.selectedVersion.value,
() =>
!!options.project.value &&
!!options.selectedVersion.value &&
!!options.currentGameVersion.value,
)
const dependencyResolutionPreferences = computed(() =>
createResolutionPreferences(options.selectedVersion.value, options.currentPlatform.value),
createResolutionPreferences(
options.selectedVersion.value,
options.currentGameVersion.value,
options.currentPlatform.value,
),
)
const { data: dependencyResolution, isFetching: dependencyResolutionFetching } = useQuery({
@@ -309,10 +316,12 @@ export function provideDownloadModalProvider(
})
async function preloadDependenciesForSelection(selection: ProjectDownloadSelection) {
if (!options.project.value || !selection.selectedVersion) return
if (!options.project.value || !selection.selectedVersion || !selection.currentGameVersion)
return
const preferences = createResolutionPreferences(
selection.selectedVersion,
selection.currentGameVersion,
selection.currentPlatform,
)
@@ -548,10 +557,11 @@ function dependencyProjectsQueryOptions(
function createResolutionPreferences(
version: Labrinth.Versions.v3.Version | null,
currentGameVersion: string | null,
currentPlatform: string | null,
): Labrinth.Content.v3.ResolutionPreferences {
return {
game_versions: version?.game_versions || [],
game_versions: currentGameVersion ? [currentGameVersion] : [],
loaders: currentPlatform ? [currentPlatform] : version?.loaders || [],
}
}
@@ -6,6 +6,7 @@
'no-actions': noLinks,
private: isPrivateMessage,
'show-private-bg': settings.get(moderationSettings.General.PrivateMessageHighlight),
'show-info-bg mb-2 !flex-nowrap py-6': message.body.type === 'legacy_project_message',
}"
>
<template v-if="members[message.author_id]">
@@ -51,23 +52,28 @@
</template>
<template v-else>
<div
class="message__icon backed-svg circle moderation-color"
class="message__icon backed-svg circle moderation-color shrink-0"
:class="{
raised: raised,
'system-message-icon': [
'legacy_project_message',
'tech_review_entered',
'tech_review_exited',
'tech_review_exit_file_deleted',
].includes(message.body.type),
}"
>
<ScaleIcon />
<InfoIcon v-if="message.body.type === 'legacy_project_message'" class="text-blue" />
<ScaleIcon v-else />
</div>
<span
v-if="
!['tech_review_entered', 'tech_review_exited', 'tech_review_exit_file_deleted'].includes(
message.body.type,
)
![
'legacy_project_message',
'tech_review_entered',
'tech_review_exited',
'tech_review_exit_file_deleted',
].includes(message.body.type)
"
class="message__author moderation-color"
>
@@ -81,6 +87,10 @@
v-html="formattedMessage"
/>
<div v-else class="message__body status-message">
<span v-if="message.body.type === 'legacy_project_message'">
This project was published on Modrinth before moderation threads existed and may be missing
moderation history.
</span>
<span v-if="message.body.type === 'deleted'"> posted a message that has been deleted. </span>
<template v-else-if="message.body.type === 'status_change'">
<span v-if="message.body.new_status === 'processing'">
@@ -114,7 +124,7 @@
the user.
</span>
</div>
<span class="message__date">
<span class="message__date shrink-0">
<span v-tooltip="formatDateTime(message.created)">
{{ timeSincePosted }}
</span>
@@ -143,6 +153,7 @@
<script setup>
import {
EyeOffIcon,
InfoIcon,
MicrophoneIcon,
ModrinthIcon,
MoreHorizontalIcon,
@@ -293,6 +304,15 @@ async function deleteMessage() {
pointer-events: none;
}
&.show-info-bg::before {
content: '';
inset: 0;
position: absolute;
background-color: var(--color-blue);
opacity: 0.05;
pointer-events: none;
}
&.no-actions {
padding: 0;
+7 -3
View File
@@ -27,7 +27,10 @@ const useModerationCookies = () => {
moderationKeybindsId,
getCookieOptions(),
)
const optionsCookie = useCookie<Partial<StoredOptions>>(moderationSettingsId, getCookieOptions())
const optionsCookie = useCookie<Partial<StoredOptions> | null>(
moderationSettingsId,
getCookieOptions(),
)
if (keybindCookie.value && !optionsCookie.value) {
optionsCookie.value = {
@@ -45,16 +48,17 @@ const useModerationCookies = () => {
const useModerationOptions = () =>
useState<{ keybinds: StoredKeybinds; settings: StoredSettings }>(moderationKeybindsId, () => {
const cookie = useModerationCookies()
const stored = cookie.value ?? {}
const keybindOutput: StoredKeybinds = {}
for (const [id, definition] of Object.entries(cookie.value.keybinds || {})) {
for (const [id, definition] of Object.entries(stored.keybinds || {})) {
if (!definition) continue
keybindOutput[id] = definition
}
const settingsOutput: StoredSettings = {}
for (const [id, setting] of Object.entries(cookie.value.settings || {})) {
for (const [id, setting] of Object.entries(stored.settings || {})) {
settingsOutput[id] = setting
}
@@ -2744,24 +2744,15 @@
"settings.applications.about": {
"message": "O"
},
"settings.applications.button.cancel": {
"message": "Zrušit"
},
"settings.applications.button.create": {
"message": "Vytvořit aplikaci"
},
"settings.applications.button.delete": {
"message": "Smazat"
},
"settings.applications.button.edit": {
"message": "Upravit"
},
"settings.applications.button.new": {
"message": "Nová aplikace"
},
"settings.applications.button.save-changes": {
"message": "Uložit změny"
},
"settings.applications.button.upload-icon": {
"message": "Nahrát ikonu"
},
@@ -2972,12 +2963,6 @@
"settings.pats.modal.delete.description": {
"message": "Toto vymaže tento token navždy (jako opravdu navždy)."
},
"settings.profile.bio.title": {
"message": "Bio"
},
"settings.profile.profile-picture.title": {
"message": "Profilový obrázek"
},
"settings.sidebar.label.developer": {
"message": "Vývojář"
},
@@ -2945,24 +2945,15 @@
"settings.applications.button.add-more": {
"message": "Tilføj mere"
},
"settings.applications.button.cancel": {
"message": "Annuller"
},
"settings.applications.button.create": {
"message": "Opret app"
},
"settings.applications.button.delete": {
"message": "Slet"
},
"settings.applications.button.edit": {
"message": "Rediger"
},
"settings.applications.button.new": {
"message": "Ny applikation"
},
"settings.applications.button.save-changes": {
"message": "Gem ændringer"
},
"settings.applications.button.upload-icon": {
"message": "Upload ikon"
},
@@ -3200,27 +3191,6 @@
"settings.pats.token.never-used": {
"message": "Aldrig brugt"
},
"settings.profile.bio.description": {
"message": "En kort beskrivelse, der fortæller alle lidt om dig selv."
},
"settings.profile.bio.title": {
"message": "Om dig"
},
"settings.profile.description": {
"message": "Dine profiloplysninger kan ses offentligt på Modrinth og via <docs-link>Modrinth API</docs-link>."
},
"settings.profile.head-title": {
"message": "Profil indstillinger"
},
"settings.profile.profile-info": {
"message": "Profiloplysninger"
},
"settings.profile.profile-picture.title": {
"message": "Profilbillede"
},
"settings.profile.username.description": {
"message": "Et unikt, ikke-versalfølsomt navn til at identificere din profil."
},
"settings.sessions.action.revoke-session": {
"message": "Inddrag adgang"
},
@@ -4679,24 +4679,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Weiterleitungs-URI hinzufügen"
},
"settings.applications.button.cancel": {
"message": "Abbrechen"
},
"settings.applications.button.create": {
"message": "App erstellen"
},
"settings.applications.button.delete": {
"message": "Löschen"
},
"settings.applications.button.edit": {
"message": "Bearbeiten"
},
"settings.applications.button.new": {
"message": "Neue Anwendung"
},
"settings.applications.button.save-changes": {
"message": "Änderungen speichern"
},
"settings.applications.button.upload-icon": {
"message": "Icon hochladen"
},
@@ -5129,27 +5120,6 @@
"settings.pats.token.never-used": {
"message": "Nie verwendet"
},
"settings.profile.bio.description": {
"message": "Eine kurze Beschreibung um allen etwas über dich zu erzählen."
},
"settings.profile.bio.title": {
"message": "Bio"
},
"settings.profile.description": {
"message": "Deine Profilinformationen sind auf Modrinth und über die <docs-link>Modrinth API</docs-link> öffentlich einsehbar."
},
"settings.profile.head-title": {
"message": "Profileinstellungen"
},
"settings.profile.profile-info": {
"message": "Profilinformationen"
},
"settings.profile.profile-picture.title": {
"message": "Profilbild"
},
"settings.profile.username.description": {
"message": "Ein eindeutiger Name (ohne berücksichtigung der Gross-/Kleinschreibung) zur identifikation deines Profils."
},
"settings.sessions.action.revoke-session": {
"message": "Sitzung widerrufen"
},
@@ -4679,24 +4679,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Weiterleitungs-URI hinzufügen"
},
"settings.applications.button.cancel": {
"message": "Abbrechen"
},
"settings.applications.button.create": {
"message": "Anwendung erstellen"
},
"settings.applications.button.delete": {
"message": "Löschen"
},
"settings.applications.button.edit": {
"message": "Bearbeiten"
},
"settings.applications.button.new": {
"message": "Neue Anwendung"
},
"settings.applications.button.save-changes": {
"message": "Änderungen speichern"
},
"settings.applications.button.upload-icon": {
"message": "Icon hochladen"
},
@@ -5129,27 +5120,6 @@
"settings.pats.token.never-used": {
"message": "Nie verwendet"
},
"settings.profile.bio.description": {
"message": "Eine kurze Beschreibung, um allen ein wenig über dich zu erzählen."
},
"settings.profile.bio.title": {
"message": "Bio"
},
"settings.profile.description": {
"message": "Deine Profilinformationen sind auf Modrinth und über die <docs-link>Modrinth-API</docs-link> öffentlich einsehbar."
},
"settings.profile.head-title": {
"message": "Profileinstellungen"
},
"settings.profile.profile-info": {
"message": "Profilinformationen"
},
"settings.profile.profile-picture.title": {
"message": "Profilbild"
},
"settings.profile.username.description": {
"message": "Ein eindeutiger Name ohne Berücksichtigung der Groß-/Kleinschreibung zur Identifizierung deines Profils."
},
"settings.sessions.action.revoke-session": {
"message": "Sitzung widerrufen"
},
@@ -4709,24 +4709,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Add a redirect URI"
},
"settings.applications.button.cancel": {
"message": "Cancel"
},
"settings.applications.button.create": {
"message": "Create app"
},
"settings.applications.button.delete": {
"message": "Delete"
},
"settings.applications.button.edit": {
"message": "Edit"
},
"settings.applications.button.new": {
"message": "New application"
},
"settings.applications.button.save-changes": {
"message": "Save changes"
},
"settings.applications.button.upload-icon": {
"message": "Upload icon"
},
@@ -5174,27 +5165,6 @@
"settings.pats.token.never-used": {
"message": "Never used"
},
"settings.profile.bio.description": {
"message": "A short description to tell everyone a little bit about you."
},
"settings.profile.bio.title": {
"message": "Bio"
},
"settings.profile.description": {
"message": "Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>."
},
"settings.profile.head-title": {
"message": "Profile settings"
},
"settings.profile.profile-info": {
"message": "Profile information"
},
"settings.profile.profile-picture.title": {
"message": "Profile picture"
},
"settings.profile.username.description": {
"message": "A unique case-insensitive name to identify your profile."
},
"settings.sessions.action.revoke-session": {
"message": "Revoke session"
},
@@ -4187,24 +4187,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Agregar un URI de redirección"
},
"settings.applications.button.cancel": {
"message": "Cancelar"
},
"settings.applications.button.create": {
"message": "Crear aplicación"
},
"settings.applications.button.delete": {
"message": "Borrar"
},
"settings.applications.button.edit": {
"message": "Editar"
},
"settings.applications.button.new": {
"message": "Nueva aplicación"
},
"settings.applications.button.save-changes": {
"message": "Guardar cambios"
},
"settings.applications.button.upload-icon": {
"message": "Cargar icono"
},
@@ -4637,27 +4628,6 @@
"settings.pats.token.never-used": {
"message": "Nunca usado"
},
"settings.profile.bio.description": {
"message": "Una breve descripción para que todos sepan un poco sobre ti."
},
"settings.profile.bio.title": {
"message": "Biografía"
},
"settings.profile.description": {
"message": "La información de tu perfil es visible públicamente en Modrinth y mediante la <docs-link>API de Modrinth</docs-link>."
},
"settings.profile.head-title": {
"message": "Configuración del perfil"
},
"settings.profile.profile-info": {
"message": "Información del perfil"
},
"settings.profile.profile-picture.title": {
"message": "Imagen de perfil"
},
"settings.profile.username.description": {
"message": "Un nombre único, sin distinción entre mayúsculas y minúsculas, para identificar tu perfil."
},
"settings.sessions.action.revoke-session": {
"message": "Revocar sesión"
},
@@ -3860,24 +3860,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Añadir una URI de redirección"
},
"settings.applications.button.cancel": {
"message": "Cancelar"
},
"settings.applications.button.create": {
"message": "Crear aplicación"
},
"settings.applications.button.delete": {
"message": "Eliminar"
},
"settings.applications.button.edit": {
"message": "Editar"
},
"settings.applications.button.new": {
"message": "Nueva aplicación"
},
"settings.applications.button.save-changes": {
"message": "Guardar cambios"
},
"settings.applications.button.upload-icon": {
"message": "Subir icono"
},
@@ -4310,27 +4301,6 @@
"settings.pats.token.never-used": {
"message": "Nunca utilizado"
},
"settings.profile.bio.description": {
"message": "Una breve descripción para que todos sepan un poco sobre ti."
},
"settings.profile.bio.title": {
"message": "Biografía"
},
"settings.profile.description": {
"message": "La información de tu perfil es visible públicamente en Modrinth y a través de la <docs-link>API de Modrinth</docs-link>."
},
"settings.profile.head-title": {
"message": "Configuración del perfil"
},
"settings.profile.profile-info": {
"message": "Información perfil"
},
"settings.profile.profile-picture.title": {
"message": "Foto de perfil"
},
"settings.profile.username.description": {
"message": "Un nombre único que no distingue entre mayúsculas y minúsculas para identificar tu perfil."
},
"settings.sessions.action.revoke-session": {
"message": "Revocar sesión"
},
@@ -1055,9 +1055,6 @@
"settings.pats.token.never-used": {
"message": "Ei koskaan käytetty"
},
"settings.profile.bio.title": {
"message": "Kuvaus"
},
"settings.sessions.unknown-os": {
"message": "Tuntematon käyttöjärjestelmä"
}
@@ -2840,24 +2840,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Magdagdag ng redirect URI"
},
"settings.applications.button.cancel": {
"message": "Kanselahin"
},
"settings.applications.button.create": {
"message": "Lumikha ng app"
},
"settings.applications.button.delete": {
"message": "Tanggalin"
},
"settings.applications.button.edit": {
"message": "Baguhin"
},
"settings.applications.button.new": {
"message": "Bagong aplikasyon"
},
"settings.applications.button.save-changes": {
"message": "I-save ang mga pagbabago"
},
"settings.applications.button.upload-icon": {
"message": "I-upload ang ikono"
},
@@ -3167,27 +3158,6 @@
"settings.pats.token.never-used": {
"message": "Hindi ginamit"
},
"settings.profile.bio.description": {
"message": "Isang maikling paglalarawan upang maipaalam sa lahat ang maliit na sipi ukol sa iyo."
},
"settings.profile.bio.title": {
"message": "Biyograpiya"
},
"settings.profile.description": {
"message": "Makikita ng publiko ang impormasyon ng iyong profile at sa <docs-link>API ng Modrinth</docs-link>."
},
"settings.profile.head-title": {
"message": "Settings ng profile"
},
"settings.profile.profile-info": {
"message": "Impormasyon ng profile"
},
"settings.profile.profile-picture.title": {
"message": "Profile picture"
},
"settings.profile.username.description": {
"message": "Isang natatanging case-insensitive na pangalan upang matukoy ang iyong profile."
},
"settings.sessions.action.revoke-session": {
"message": "Bawiin ang sesyon"
},
@@ -4667,24 +4667,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Ajouter une URI de redirection"
},
"settings.applications.button.cancel": {
"message": "Annuler"
},
"settings.applications.button.create": {
"message": "Créer une appli"
},
"settings.applications.button.delete": {
"message": "Supprimer"
},
"settings.applications.button.edit": {
"message": "Modifier"
},
"settings.applications.button.new": {
"message": "Nouvelle application"
},
"settings.applications.button.save-changes": {
"message": "Sauvegarder les changements"
},
"settings.applications.button.upload-icon": {
"message": "Charger une icône"
},
@@ -5117,27 +5108,6 @@
"settings.pats.token.never-used": {
"message": "Jamais utilisé"
},
"settings.profile.bio.description": {
"message": "Une courte description pour indiquer à tout le monde un peu à propos de vous."
},
"settings.profile.bio.title": {
"message": "À propos"
},
"settings.profile.description": {
"message": "Votre information de profil est visible publiquement sur Modrinth et sur la <docs-link>Modrinth API</docs-link>."
},
"settings.profile.head-title": {
"message": "Paramètres du profil"
},
"settings.profile.profile-info": {
"message": "Information du profil"
},
"settings.profile.profile-picture.title": {
"message": "Photo de profil"
},
"settings.profile.username.description": {
"message": "Un nom unique insensible à la casse pour identifier votre profil."
},
"settings.sessions.action.revoke-session": {
"message": "Révoquer la session"
},
@@ -2282,9 +2282,6 @@
"settings.account.data-export.description": {
"message": "תבקש עותק של כל הדאטה האישי שהעלית ל-Modrinth. זה יכול לקחת כמה דקות."
},
"settings.applications.button.edit": {
"message": "ערוך"
},
"settings.applications.field.name": {
"message": "שם"
},
@@ -2305,11 +2302,5 @@
},
"settings.pats.modal.create.name.label": {
"message": "שם"
},
"settings.profile.bio.title": {
"message": "תיאור"
},
"settings.profile.profile-picture.title": {
"message": "תמונת פרופיל"
}
}
@@ -4130,24 +4130,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Átírányítás URI hozzáadása"
},
"settings.applications.button.cancel": {
"message": "Mégse"
},
"settings.applications.button.create": {
"message": "Alkalmazás létrehozása"
},
"settings.applications.button.delete": {
"message": "Törlés"
},
"settings.applications.button.edit": {
"message": "Szerkesztés"
},
"settings.applications.button.new": {
"message": "Új alkalmazás"
},
"settings.applications.button.save-changes": {
"message": "Módosítások mentése"
},
"settings.applications.button.upload-icon": {
"message": "Ikon feltöltése"
},
@@ -4571,27 +4562,6 @@
"settings.pats.token.never-used": {
"message": "Sohasem volt használva"
},
"settings.profile.bio.description": {
"message": "Egy rövid leírás, hogy mindenki megismerjen téged."
},
"settings.profile.bio.title": {
"message": "Bemutatkozás"
},
"settings.profile.description": {
"message": "A profiladataid nyilvánosan megtekinthetők a Modrinthon és a <docs-link>Modrinth API</docs-link>-n keresztül."
},
"settings.profile.head-title": {
"message": "Profilbeállítások"
},
"settings.profile.profile-info": {
"message": "Profilinformációk"
},
"settings.profile.profile-picture.title": {
"message": "Profilkép"
},
"settings.profile.username.description": {
"message": "Egyedi, kis- és nagybetűktől független név a profilod azonosításához."
},
"settings.sessions.action.revoke-session": {
"message": "Munkamenet visszavonása"
},
@@ -2918,24 +2918,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Tambah URI pengarahan ulang"
},
"settings.applications.button.cancel": {
"message": "Batal"
},
"settings.applications.button.create": {
"message": "Buat aplikasi"
},
"settings.applications.button.delete": {
"message": "Hapus"
},
"settings.applications.button.edit": {
"message": "Sunting"
},
"settings.applications.button.new": {
"message": "Aplikasi baru"
},
"settings.applications.button.save-changes": {
"message": "Simpan perubahan"
},
"settings.applications.button.upload-icon": {
"message": "Unggah ikon"
},
@@ -3185,24 +3176,6 @@
"settings.pats.token.never-used": {
"message": "Tidak pernah digunakan"
},
"settings.profile.bio.description": {
"message": "Keterangan singkat untuk mengenalkan sedikit tentang diri Anda."
},
"settings.profile.bio.title": {
"message": "Bio"
},
"settings.profile.description": {
"message": "Informasi profil Anda dapat dilihat secara publik di Modrinth dan melalui <docs-link>Modrinth API</docs-link>."
},
"settings.profile.profile-info": {
"message": "Informasi profil"
},
"settings.profile.profile-picture.title": {
"message": "Foto profil"
},
"settings.profile.username.description": {
"message": "Nama unik yang tidak peka huruf besar-kecil untuk mengidentifikasi profil Anda."
},
"settings.sessions.action.revoke-session": {
"message": "Cabut sesi"
},
@@ -4667,24 +4667,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Aggiungi reindirizzamento"
},
"settings.applications.button.cancel": {
"message": "Annulla"
},
"settings.applications.button.create": {
"message": "Crea app"
},
"settings.applications.button.delete": {
"message": "Elimina"
},
"settings.applications.button.edit": {
"message": "Modifica"
},
"settings.applications.button.new": {
"message": "Nuova applicazione"
},
"settings.applications.button.save-changes": {
"message": "Salva modifiche"
},
"settings.applications.button.upload-icon": {
"message": "Carica icona"
},
@@ -5117,27 +5108,6 @@
"settings.pats.token.never-used": {
"message": "Mai usato"
},
"settings.profile.bio.description": {
"message": "Una breve descrizione per raccontare a tutti qualcosa di te."
},
"settings.profile.bio.title": {
"message": "Bio"
},
"settings.profile.description": {
"message": "Le informazioni del tuo profilo sono visibili pubblicamente su Modrinth e tramite l''<docs-link>API di Modrinth</docs-link>."
},
"settings.profile.head-title": {
"message": "Impostazioni del profilo"
},
"settings.profile.profile-info": {
"message": "Informazioni del profilo"
},
"settings.profile.profile-picture.title": {
"message": "Foto profilo"
},
"settings.profile.username.description": {
"message": "Un nome univoco (maiuscole irrilevanti) per identificare il tuo profilo."
},
"settings.sessions.action.revoke-session": {
"message": "Revoca sessione"
},
@@ -3620,24 +3620,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "リダイレクトURIを追加"
},
"settings.applications.button.cancel": {
"message": "キャンセル"
},
"settings.applications.button.create": {
"message": "アプリを作成する"
},
"settings.applications.button.delete": {
"message": "削除"
},
"settings.applications.button.edit": {
"message": "編集"
},
"settings.applications.button.new": {
"message": "新規アプリケーション"
},
"settings.applications.button.save-changes": {
"message": "変更を保存する"
},
"settings.applications.button.upload-icon": {
"message": "アイコンをアップロード"
},
@@ -4061,27 +4052,6 @@
"settings.pats.token.never-used": {
"message": "未使用"
},
"settings.profile.bio.description": {
"message": "あなたのことをみんなに知ってもらうための簡単な自己紹介。"
},
"settings.profile.bio.title": {
"message": "自己紹介"
},
"settings.profile.description": {
"message": "あなたのプロフィール情報は、Modrinth上および<docs-link>Modrinth API</docs-link>を通じて一般公開されます。"
},
"settings.profile.head-title": {
"message": "プロフィール設定"
},
"settings.profile.profile-info": {
"message": "プロフィール情報"
},
"settings.profile.profile-picture.title": {
"message": "プロフィール写真"
},
"settings.profile.username.description": {
"message": "大文字・小文字を区別しない、プロフィールを識別するための固有の名前。"
},
"settings.sessions.action.revoke-session": {
"message": "セッションを取り消す"
},
@@ -4631,24 +4631,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "리디렉션 URI 를 추가하세요"
},
"settings.applications.button.cancel": {
"message": "취소"
},
"settings.applications.button.create": {
"message": "앱 만들기"
},
"settings.applications.button.delete": {
"message": "지우기"
},
"settings.applications.button.edit": {
"message": "편집"
},
"settings.applications.button.new": {
"message": "새 애플리케이션"
},
"settings.applications.button.save-changes": {
"message": "변경사항 저장"
},
"settings.applications.button.upload-icon": {
"message": "아이콘 업로드"
},
@@ -5081,27 +5072,6 @@
"settings.pats.token.never-used": {
"message": "사용된 적 없음"
},
"settings.profile.bio.description": {
"message": "모든 사람에게 당신을 소개하는 간단한 설명입니다."
},
"settings.profile.bio.title": {
"message": "자기소개"
},
"settings.profile.description": {
"message": "당신의 프로필 정보는 Modrinth 나 <docs-link>Modrinth API</docs-link>를 통해 공개될 수 있습니다."
},
"settings.profile.head-title": {
"message": "프로필 설정"
},
"settings.profile.profile-info": {
"message": "프로필 정보"
},
"settings.profile.profile-picture.title": {
"message": "프로필 사진"
},
"settings.profile.username.description": {
"message": "대소문자 구분 없이 프로필을 식별할 수 있는 이름"
},
"settings.sessions.action.revoke-session": {
"message": "세션 제거"
},
@@ -3653,24 +3653,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Tambah URI pengubah hala"
},
"settings.applications.button.cancel": {
"message": "Batal"
},
"settings.applications.button.create": {
"message": "Cipta aplikasi"
},
"settings.applications.button.delete": {
"message": "Padam"
},
"settings.applications.button.edit": {
"message": "Sunting"
},
"settings.applications.button.new": {
"message": "Aplikasi baharu"
},
"settings.applications.button.save-changes": {
"message": "Simpan perubahan"
},
"settings.applications.button.upload-icon": {
"message": "Muat naik ikon"
},
@@ -4103,27 +4094,6 @@
"settings.pats.token.never-used": {
"message": "Tidak pernah digunakan"
},
"settings.profile.bio.description": {
"message": "Keterangan ringkas untuk memberitahu semua orang serba sedikit tentang anda."
},
"settings.profile.bio.title": {
"message": "Bio"
},
"settings.profile.description": {
"message": "Maklumat profil anda boleh dilihat secara terbuka pada Modrinth dan melalui <docs-link>API Modrinth</docs-link>."
},
"settings.profile.head-title": {
"message": "Tetapan profil"
},
"settings.profile.profile-info": {
"message": "Maklumat profil"
},
"settings.profile.profile-picture.title": {
"message": "Gambar profil"
},
"settings.profile.username.description": {
"message": "Nama unik (tidak sensitif huruf besar dan kecil) untuk mengenal pasti profil anda."
},
"settings.sessions.action.revoke-session": {
"message": "Batalkan sesi"
},
@@ -4706,24 +4706,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Voeg een URI toe"
},
"settings.applications.button.cancel": {
"message": "Annuleren"
},
"settings.applications.button.create": {
"message": "Maak nieuwe"
},
"settings.applications.button.delete": {
"message": "Verwijder"
},
"settings.applications.button.edit": {
"message": "Bewerk"
},
"settings.applications.button.new": {
"message": "Nieuw project"
},
"settings.applications.button.save-changes": {
"message": "Wijzigingen opslaan"
},
"settings.applications.button.upload-icon": {
"message": "Pictogram uploaden"
},
@@ -5141,27 +5132,6 @@
"settings.pats.token.never-used": {
"message": "Nooit gebruikt"
},
"settings.profile.bio.description": {
"message": "Een korte beschrijving om iedereen wat over jou te vertellen."
},
"settings.profile.bio.title": {
"message": "Bio"
},
"settings.profile.description": {
"message": "Je profielgegevens zijn openbaar op Modrinth en zichtbaar via de <docs-link>Modrinth API</docs-link>."
},
"settings.profile.head-title": {
"message": "Profielinstellingen"
},
"settings.profile.profile-info": {
"message": "Profielgegevens"
},
"settings.profile.profile-picture.title": {
"message": "Profielfoto"
},
"settings.profile.username.description": {
"message": "Een unieke hoofdletterongevoelige naam om je profiel mee te identificeren."
},
"settings.sessions.action.revoke-session": {
"message": "Sessie beëindigen"
},
@@ -3539,24 +3539,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Legg til en omdirings-URI"
},
"settings.applications.button.cancel": {
"message": "Avbryt"
},
"settings.applications.button.create": {
"message": "Lag app"
},
"settings.applications.button.delete": {
"message": "Slett"
},
"settings.applications.button.edit": {
"message": "Regiser"
},
"settings.applications.button.new": {
"message": "Ny søknad"
},
"settings.applications.button.save-changes": {
"message": "Lagre endringer"
},
"settings.applications.button.upload-icon": {
"message": "Last opp ikon"
},
@@ -3974,27 +3965,6 @@
"settings.pats.token.never-used": {
"message": "Aldri brukt"
},
"settings.profile.bio.description": {
"message": "En kort beskrivelse sånn at alle veit litt om deg."
},
"settings.profile.bio.title": {
"message": "Bio"
},
"settings.profile.description": {
"message": "Profilinformasjonen din er offentlig lesbar på Modrinth og gjennom <docs-link>Modrinth API</docs-link>-en."
},
"settings.profile.head-title": {
"message": "Profilinnstillinger"
},
"settings.profile.profile-info": {
"message": "Profilinformasjon"
},
"settings.profile.profile-picture.title": {
"message": "Profilbilde"
},
"settings.profile.username.description": {
"message": "Et unikt navn (som ikke skiller mellom store og små bokstaver) for å identifisere profilen din."
},
"settings.sessions.action.revoke-session": {
"message": "Tilbakekall økta"
},
@@ -4622,24 +4622,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Dodaj URI przekierowywania"
},
"settings.applications.button.cancel": {
"message": "Anuluj"
},
"settings.applications.button.create": {
"message": "Stwórz aplikację"
},
"settings.applications.button.delete": {
"message": "Usuń"
},
"settings.applications.button.edit": {
"message": "Edytuj"
},
"settings.applications.button.new": {
"message": "Nowa aplikacja"
},
"settings.applications.button.save-changes": {
"message": "Zapisz zmiany"
},
"settings.applications.button.upload-icon": {
"message": "Prześlij ikonę"
},
@@ -5072,27 +5063,6 @@
"settings.pats.token.never-used": {
"message": "Nigdy nie użyty"
},
"settings.profile.bio.description": {
"message": "Krótki opis, aby powiedzieć wszystkim trochę o Tobie."
},
"settings.profile.bio.title": {
"message": "O mnie"
},
"settings.profile.description": {
"message": "Twoje informacje profilowe są publicznie dostępnie na Modrinth i poprzez <docs-link>API Modrinth</docs-link>."
},
"settings.profile.head-title": {
"message": "Ustawienia konta"
},
"settings.profile.profile-info": {
"message": "Informacje profilowe"
},
"settings.profile.profile-picture.title": {
"message": "Zdjęcie profilowe"
},
"settings.profile.username.description": {
"message": "Unikatowa bez uwzględniania wielkości liter nazwa identyfikująca Twój profil."
},
"settings.sessions.action.revoke-session": {
"message": "Unieważnij sesję"
},
@@ -4709,24 +4709,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Adicionar URI de redirecionamento"
},
"settings.applications.button.cancel": {
"message": "Cancelar"
},
"settings.applications.button.create": {
"message": "Criar app"
},
"settings.applications.button.delete": {
"message": "Excluir"
},
"settings.applications.button.edit": {
"message": "Editar"
},
"settings.applications.button.new": {
"message": "Novo aplicativo"
},
"settings.applications.button.save-changes": {
"message": "Salvar alterações"
},
"settings.applications.button.upload-icon": {
"message": "Enviar ícone"
},
@@ -5159,27 +5150,6 @@
"settings.pats.token.never-used": {
"message": "Nunca usado"
},
"settings.profile.bio.description": {
"message": "Uma breve descrição para falar um pouco sobre você para todos."
},
"settings.profile.bio.title": {
"message": "Sobre"
},
"settings.profile.description": {
"message": "As informações do seu perfil são visíveis publicamente no Modrinth e por meio da <docs-link>API do Modrinth</docs-link>."
},
"settings.profile.head-title": {
"message": "Configurações de perfil"
},
"settings.profile.profile-info": {
"message": "Informação do perfil"
},
"settings.profile.profile-picture.title": {
"message": "Foto de perfil"
},
"settings.profile.username.description": {
"message": "Um nome único sem distinção de maiúsculas e minúsculas para identificar o seu perfil."
},
"settings.sessions.action.revoke-session": {
"message": "Encerrar sessão"
},
@@ -3350,24 +3350,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Adicionar um endereço de redirecionamento"
},
"settings.applications.button.cancel": {
"message": "Cancelar"
},
"settings.applications.button.create": {
"message": "Criar app"
},
"settings.applications.button.delete": {
"message": "Apagar"
},
"settings.applications.button.edit": {
"message": "Editar"
},
"settings.applications.button.new": {
"message": "Nova aplicação"
},
"settings.applications.button.save-changes": {
"message": "Guardar alterações"
},
"settings.applications.button.upload-icon": {
"message": "Carregar ícone"
},
@@ -3776,27 +3767,6 @@
"settings.pats.token.never-used": {
"message": "Nunca usado"
},
"settings.profile.bio.description": {
"message": "Uma breve descrição para que todos possam saber mais sobre ti."
},
"settings.profile.bio.title": {
"message": "Biografia"
},
"settings.profile.description": {
"message": "As informações do teu perfil são visíveis publicamente no Modrinth e por meio da <docs-link>Modrinth API</docs-link>."
},
"settings.profile.head-title": {
"message": "Definições do perfil"
},
"settings.profile.profile-info": {
"message": "Informação do perfil"
},
"settings.profile.profile-picture.title": {
"message": "Imagem do perfil"
},
"settings.profile.username.description": {
"message": "Um nome único sem distinção de maiúsculas de minúsculas para identificar o teu perfil."
},
"settings.sessions.action.revoke-session": {
"message": "Revogar sessão"
},
@@ -1814,21 +1814,12 @@
"settings.applications.about": {
"message": "Despre"
},
"settings.applications.button.cancel": {
"message": "Anuleaza"
},
"settings.applications.button.create": {
"message": "Creaza aplicația"
},
"settings.applications.button.delete": {
"message": "Sterge"
},
"settings.applications.button.edit": {
"message": "Editeaza"
},
"settings.applications.button.save-changes": {
"message": "Salvează schimbările"
},
"settings.applications.client-id": {
"message": "ID Client"
},
@@ -2039,24 +2030,6 @@
"settings.pats.token.never-used": {
"message": "N-a fost folosit niciodată"
},
"settings.profile.bio.description": {
"message": "O scurtă descriere pentru a le spune tuturor câte ceva despre tine."
},
"settings.profile.bio.title": {
"message": "Biografie"
},
"settings.profile.description": {
"message": "Informațiile profilului tău sunt vizibile public pe Modrinth și prin intermediul <docs-link>API-ului Modrinth</docs-link>."
},
"settings.profile.profile-info": {
"message": "Informațiile profilului"
},
"settings.profile.profile-picture.title": {
"message": "Imagine de profil"
},
"settings.profile.username.description": {
"message": "Un nume unic care nu ține cont de majuscule, pentru a-ți identifica profilul."
},
"settings.sessions.action.revoke-session": {
"message": "Anulează sesiunea"
},
@@ -4655,24 +4655,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Добавить адрес перенаправления"
},
"settings.applications.button.cancel": {
"message": "Отмена"
},
"settings.applications.button.create": {
"message": "Создать приложение"
},
"settings.applications.button.delete": {
"message": "Удалить"
},
"settings.applications.button.edit": {
"message": "Изменить"
},
"settings.applications.button.new": {
"message": "Новое приложение"
},
"settings.applications.button.save-changes": {
"message": "Сохранить изменения"
},
"settings.applications.button.upload-icon": {
"message": "Загрузить иконку"
},
@@ -5099,27 +5090,6 @@
"settings.pats.token.never-used": {
"message": "Не использовался"
},
"settings.profile.bio.description": {
"message": "Возможность кратко рассказать всем о себе."
},
"settings.profile.bio.title": {
"message": "О себе"
},
"settings.profile.description": {
"message": "Информация в вашем профиле доступна публично на Modrinth и через <docs-link>API Modrinth</docs-link>."
},
"settings.profile.head-title": {
"message": "Настройки профиля"
},
"settings.profile.profile-info": {
"message": "Информация в профиле"
},
"settings.profile.profile-picture.title": {
"message": "Изображение профиля"
},
"settings.profile.username.description": {
"message": "Уникальное имя для идентификации профиля. Регистр не имеет значения."
},
"settings.sessions.action.revoke-session": {
"message": "Завершить сеанс"
},
@@ -1973,27 +1973,6 @@
"settings.pats.token.never-used": {
"message": "Nikada korišćen"
},
"settings.profile.bio.description": {
"message": "Kratak opis kojim ćete drugima reći nešto o sebi."
},
"settings.profile.bio.title": {
"message": "Opis"
},
"settings.profile.description": {
"message": "Informacije na vašem profilu su javno vidljive na Modrinthu i putem <docs-link>Modrinth API-ja</docs-link>."
},
"settings.profile.head-title": {
"message": "Podešavanja profila"
},
"settings.profile.profile-info": {
"message": "Informacije o profilu"
},
"settings.profile.profile-picture.title": {
"message": "Profilna slika"
},
"settings.profile.username.description": {
"message": "Jedinstveno ime, bez razlikovanja velikih i malih slova, koje identifikuje vaš profil."
},
"settings.sessions.action.revoke-session": {
"message": "Opozovi sesiju"
},
@@ -3713,24 +3713,15 @@
"settings.applications.button.add-more": {
"message": "Lägg till mer"
},
"settings.applications.button.cancel": {
"message": "Avbryt"
},
"settings.applications.button.create": {
"message": "Skapa app"
},
"settings.applications.button.delete": {
"message": "Ta bort"
},
"settings.applications.button.edit": {
"message": "Redigera"
},
"settings.applications.button.new": {
"message": "Ny applikation"
},
"settings.applications.button.save-changes": {
"message": "Spara ändringar"
},
"settings.applications.button.upload-icon": {
"message": "Ladda upp ikon"
},
@@ -4121,27 +4112,6 @@
"settings.pats.token.never-used": {
"message": "Aldrig använd"
},
"settings.profile.bio.description": {
"message": "En kort beskrivning för att berätta lite om dig själv."
},
"settings.profile.bio.title": {
"message": "Om mig"
},
"settings.profile.description": {
"message": "Din profilinformation visas offentligt på Modrinth och genom <docs-link>Modrinth API:t</docs-link>."
},
"settings.profile.head-title": {
"message": "Profilinställningar"
},
"settings.profile.profile-info": {
"message": "Profilinformation"
},
"settings.profile.profile-picture.title": {
"message": "Profilbild"
},
"settings.profile.username.description": {
"message": "Ett unikt skift-känsligt namn för att identifiera din profil."
},
"settings.sessions.action.revoke-session": {
"message": "Återkalla session"
},
@@ -4118,24 +4118,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Yönlendirme URI'ları ekle"
},
"settings.applications.button.cancel": {
"message": "İptal"
},
"settings.applications.button.create": {
"message": "Uygulama oluştur"
},
"settings.applications.button.delete": {
"message": "Sil"
},
"settings.applications.button.edit": {
"message": "Düzenle"
},
"settings.applications.button.new": {
"message": "Yeni uygulama"
},
"settings.applications.button.save-changes": {
"message": "Değişiklikleri kaydet"
},
"settings.applications.button.upload-icon": {
"message": "Simge yükle"
},
@@ -4568,27 +4559,6 @@
"settings.pats.token.never-used": {
"message": "Hiç kullanılmadı"
},
"settings.profile.bio.description": {
"message": "Herkese kendinden biraz bahsedebileceğin kısa tanım."
},
"settings.profile.bio.title": {
"message": "Hakkında"
},
"settings.profile.description": {
"message": "Profil bilgin Modrinth'te ve <docs-link>Modrinth API</docs-link> herkese açık görüntülenebilir."
},
"settings.profile.head-title": {
"message": "Profil Ayarları"
},
"settings.profile.profile-info": {
"message": "Profil bilgisi"
},
"settings.profile.profile-picture.title": {
"message": "Profil resmi"
},
"settings.profile.username.description": {
"message": "Profilini tanımlamak için büyük-küçük harf hassasiyetli özel bir ad."
},
"settings.sessions.action.revoke-session": {
"message": "Oturumu iptal et"
},
@@ -4631,24 +4631,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Додати переадресацію URI"
},
"settings.applications.button.cancel": {
"message": "Скасувати"
},
"settings.applications.button.create": {
"message": "Створити застосунок"
},
"settings.applications.button.delete": {
"message": "Видалити"
},
"settings.applications.button.edit": {
"message": "Редагувати"
},
"settings.applications.button.new": {
"message": "Новий застосунок"
},
"settings.applications.button.save-changes": {
"message": "Зберегти зміни"
},
"settings.applications.button.upload-icon": {
"message": "Завантажити значок"
},
@@ -5081,27 +5072,6 @@
"settings.pats.token.never-used": {
"message": "Не використовувався"
},
"settings.profile.bio.description": {
"message": "Короткий опис, щоб розповісти всім трохи про себе."
},
"settings.profile.bio.title": {
"message": "Про себе"
},
"settings.profile.description": {
"message": "Інформація з вашого профілю є загальнодоступною на Modrinth та через <docs-link>Modrinth API</docs-link>."
},
"settings.profile.head-title": {
"message": "Налаштування профілю"
},
"settings.profile.profile-info": {
"message": "Інформація про профіль"
},
"settings.profile.profile-picture.title": {
"message": "Зображення профілю"
},
"settings.profile.username.description": {
"message": "Унікальне ім’я без урахування регістру, для ідентифікації вашого профілю."
},
"settings.sessions.action.revoke-session": {
"message": "Завершити сеанс"
},
@@ -3818,24 +3818,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "Thêm URI chuyển hướng"
},
"settings.applications.button.cancel": {
"message": "Huỷ"
},
"settings.applications.button.create": {
"message": "Tạo ứng dụng"
},
"settings.applications.button.delete": {
"message": "Xoá"
},
"settings.applications.button.edit": {
"message": "Chỉnh sửa"
},
"settings.applications.button.new": {
"message": "Ứng dụng mới"
},
"settings.applications.button.save-changes": {
"message": "Lưu thay đổi"
},
"settings.applications.button.upload-icon": {
"message": "Tải lên biểu tượng"
},
@@ -4268,27 +4259,6 @@
"settings.pats.token.never-used": {
"message": "Chưa từng sử dụng"
},
"settings.profile.bio.description": {
"message": "Mô tả ngắn để giới thiệu đôi chút về bạn."
},
"settings.profile.bio.title": {
"message": "Tiểu sử"
},
"settings.profile.description": {
"message": "Thông tin hồ sơ của bạn được hiển thị công khai trên Modrinth và thông qua <docs-link>API Modrinth</docs-link>."
},
"settings.profile.head-title": {
"message": "Cài đặt hồ sơ"
},
"settings.profile.profile-info": {
"message": "Thông tin hồ sơ"
},
"settings.profile.profile-picture.title": {
"message": "Ảnh hồ sơ"
},
"settings.profile.username.description": {
"message": "Tên duy nhất (không phân biệt chữ hoa/thường) dùng để nhận diện hồ sơ của bạn."
},
"settings.sessions.action.revoke-session": {
"message": "Thu hồi phiên"
},
@@ -4709,24 +4709,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "添加重定向 URI"
},
"settings.applications.button.cancel": {
"message": "取消"
},
"settings.applications.button.create": {
"message": "新建 App"
},
"settings.applications.button.delete": {
"message": "删除"
},
"settings.applications.button.edit": {
"message": "编辑"
},
"settings.applications.button.new": {
"message": "新建应用程序"
},
"settings.applications.button.save-changes": {
"message": "保存更改"
},
"settings.applications.button.upload-icon": {
"message": "上传图标"
},
@@ -5159,27 +5150,6 @@
"settings.pats.token.never-used": {
"message": "从未使用"
},
"settings.profile.bio.description": {
"message": "做个简短的自我介绍吧。"
},
"settings.profile.bio.title": {
"message": "个人简介"
},
"settings.profile.description": {
"message": "你的个人资料信息在 Modrinth 网站及<docs-link>其 API </docs-link>上均公开可见。"
},
"settings.profile.head-title": {
"message": "个人资料设置"
},
"settings.profile.profile-info": {
"message": "个人资料信息"
},
"settings.profile.profile-picture.title": {
"message": "头像"
},
"settings.profile.username.description": {
"message": "不区分大小写的唯一名称,用于识别你的个人资料。"
},
"settings.sessions.action.revoke-session": {
"message": "登出设备"
},
@@ -4709,24 +4709,15 @@
"settings.applications.button.add-redirect-uri": {
"message": "新增重新導向 URI"
},
"settings.applications.button.cancel": {
"message": "取消"
},
"settings.applications.button.create": {
"message": "建立應用程式"
},
"settings.applications.button.delete": {
"message": "刪除"
},
"settings.applications.button.edit": {
"message": "編輯"
},
"settings.applications.button.new": {
"message": "新的應用程式"
},
"settings.applications.button.save-changes": {
"message": "儲存變更"
},
"settings.applications.button.upload-icon": {
"message": "上傳圖示"
},
@@ -5159,27 +5150,6 @@
"settings.pats.token.never-used": {
"message": "從未使用"
},
"settings.profile.bio.description": {
"message": "簡單介紹一下你自己。"
},
"settings.profile.bio.title": {
"message": "關於我"
},
"settings.profile.description": {
"message": "你的個人檔案資訊會公開顯示在 Modrinth 上,並可透過 <docs-link>Modrinth API</docs-link> 查詢。"
},
"settings.profile.head-title": {
"message": "個人設定"
},
"settings.profile.profile-info": {
"message": "個人檔案資訊"
},
"settings.profile.profile-picture.title": {
"message": "頭貼"
},
"settings.profile.username.description": {
"message": "獨特且不區分大小寫的名稱,用於識別你的個人檔案。"
},
"settings.sessions.action.revoke-session": {
"message": "撤銷工作階段"
},
@@ -109,8 +109,8 @@
</template>
</div>
<ConversationThread
v-if="thread"
:thread="thread"
v-if="prefixedThread"
:thread="prefixedThread"
:project="project"
:set-status="setStatus"
:current-member="currentMember ?? undefined"
@@ -151,6 +151,7 @@ import {
} from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { computed, watch } from 'vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
@@ -217,6 +218,26 @@ const {
thread,
} = injectProjectPageContext()
const THREADS_RELEASE_DATE = '2023-08-05T12:00:00-07:00'
const prefixedThread = computed(() => {
const projectDate = project.value?.queued ?? project.value?.approved ?? project.value?.published
if (thread.value && projectDate && dayjs(projectDate).isBefore(dayjs(THREADS_RELEASE_DATE))) {
const newThread = JSON.parse(JSON.stringify(thread.value))
newThread.messages.unshift({
id: '69',
author_id: null,
body: {
type: 'legacy_project_message',
},
created: THREADS_RELEASE_DATE,
hide_identity: false,
})
return newThread
}
return thread.value
})
const canAccess = computed(() => !!currentMember.value)
const staff = computed(() => isStaff(currentMember.value?.user))
const userFacingUiVisible = computed(
+8
View File
@@ -36,6 +36,13 @@
icon: UserIcon,
}
: null,
auth.user
? {
link: '/settings/social',
label: formatMessage(commonSettingsMessages.social),
icon: HeartHandshakeIcon,
}
: null,
auth.user
? {
link: '/settings/account',
@@ -99,6 +106,7 @@
import {
CardIcon,
GridIcon,
HeartHandshakeIcon,
KeyIcon,
LanguagesIcon,
MonitorSmartphoneIcon,
@@ -7,10 +7,10 @@
:proceed-label="formatMessage(messages.deleteConfirmButton)"
@proceed="removeApp(editingId)"
/>
<Modal ref="appModal" :header="formatMessage(messages.modalHeader)">
<div class="universal-modal">
<label for="app-name"
><span class="label__title">{{ formatMessage(messages.nameLabel) }}</span>
<NewModal ref="appModal" :header="formatMessage(messages.modalHeader)" width="40rem" scrollable>
<div class="flex flex-col">
<label for="app-name" class="m-0 mb-2 text-lg font-semibold text-contrast">
{{ formatMessage(messages.nameLabel) }}
</label>
<StyledInput
id="app-name"
@@ -19,8 +19,12 @@
autocomplete="off"
:placeholder="formatMessage(messages.namePlaceholder)"
/>
<label v-if="editingId" for="app-icon"
><span class="label__title">{{ formatMessage(messages.iconLabel) }}</span>
<label
v-if="editingId"
for="app-icon"
class="mb-2 mt-4 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.iconLabel) }}
</label>
<div v-if="editingId" class="icon-submission">
<Avatar size="md" :src="icon" />
@@ -36,8 +40,8 @@
</FileInput>
</ButtonStyled>
</div>
<label v-if="editingId" for="app-url">
<span class="label__title">{{ formatMessage(messages.urlLabel) }}</span>
<label v-if="editingId" for="app-url" class="mb-2 mt-4 text-lg font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
</label>
<StyledInput
v-if="editingId"
@@ -48,8 +52,12 @@
autocomplete="off"
:placeholder="formatMessage(messages.urlPlaceholder)"
/>
<label v-if="editingId" for="app-description">
<span class="label__title">{{ formatMessage(messages.descriptionLabel) }}</span>
<label
v-if="editingId"
for="app-description"
class="mb-2 mt-4 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.descriptionLabel) }}
</label>
<StyledInput
v-if="editingId"
@@ -61,15 +69,12 @@
:placeholder="formatMessage(messages.descriptionPlaceholder)"
input-class="h-24 resize-y"
/>
<label for="app-scopes"
><span class="label__title">{{ formatMessage(messages.scopesLabel) }}</span>
<label for="app-scopes" class="mb-2 mt-4 text-lg font-semibold text-contrast">
{{ formatMessage(messages.scopesLabel) }}
</label>
<div
id="app-scopes"
class="scope-items mt-2 grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
>
<div id="app-scopes" class="scope-items grid grid-cols-1 gap-6 min-[600px]:grid-cols-2">
<div v-for="category in scopeCategories" :key="category.name" class="flex flex-col gap-2">
<h4 class="m-0 border-b border-divider pb-1 text-base font-bold text-contrast">
<h4 class="m-0 text-base font-medium text-primary">
{{ category.name }}
</h4>
<div class="flex flex-col gap-2">
@@ -83,8 +88,8 @@
</div>
</div>
</div>
<label for="app-redirect-uris" class="mt-4"
><span class="label__title">{{ formatMessage(messages.redirectUrisLabel) }}</span>
<label for="app-redirect-uris" class="mb-2 mt-4 text-lg font-semibold text-contrast">
{{ formatMessage(messages.redirectUrisLabel) }}
</label>
<div class="uri-input-list">
<div v-for="(_, index) in redirectUris" :key="index">
@@ -93,6 +98,7 @@
v-model="redirectUris[index]"
:maxlength="2048"
type="url"
class="w-80"
autocomplete="off"
:placeholder="formatMessage(messages.redirectUriPlaceholder)"
/>
@@ -116,18 +122,20 @@
</ButtonStyled>
</div>
</div>
</div>
<div class="submit-row input-group push-right">
<template #actions>
<div class="flex justify-end gap-2 p-2">
<ButtonStyled>
<button @click="$refs.appModal.hide()">
<XIcon />
{{ formatMessage(messages.cancel) }}
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="editingId" color="brand">
<button :disabled="!canSubmit" @click="editApp">
<SaveIcon />
{{ formatMessage(messages.saveChanges) }}
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand">
@@ -137,8 +145,8 @@
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</template>
</NewModal>
<div class="header__row">
<div class="header__title">
@@ -220,7 +228,7 @@
"
>
<EditIcon />
{{ formatMessage(messages.edit) }}
{{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
@@ -255,6 +263,7 @@ import {
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
NewModal,
normalizeChildren,
StyledInput,
useFormatDateTime,
@@ -262,7 +271,6 @@ import {
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import Modal from '~/components/ui/Modal.vue'
import {
getScopeValue,
hasScope,
@@ -355,14 +363,6 @@ const messages = defineMessages({
id: 'settings.applications.button.add-redirect-uri',
defaultMessage: 'Add a redirect URI',
},
cancel: {
id: 'settings.applications.button.cancel',
defaultMessage: 'Cancel',
},
saveChanges: {
id: 'settings.applications.button.save-changes',
defaultMessage: 'Save changes',
},
createApp: {
id: 'settings.applications.button.create',
defaultMessage: 'Create app',
@@ -396,10 +396,6 @@ const messages = defineMessages({
id: 'settings.applications.created-on',
defaultMessage: 'Created on {date}',
},
edit: {
id: 'settings.applications.button.edit',
defaultMessage: 'Edit',
},
delete: {
id: 'settings.applications.button.delete',
defaultMessage: 'Delete',
@@ -549,7 +545,7 @@ async function onImageSelection(files) {
const file = files[0]
const extFromType = file.type.split('/')[1]
await client.labrinth.oauth_internal.uploadAppIcon(editingId.value, file, extFromType).promise
await client.labrinth.oauth_internal.uploadAppIcon(editingId.value, file, extFromType)
await refresh()
+19 -5
View File
@@ -62,7 +62,13 @@
<label for="pat-expires">
<span class="font-semibold">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
</label>
<DatePicker id="pat-expires" v-model="expires" show-today wrapper-class="w-full" />
<DatePicker
id="pat-expires"
v-model="expires"
:min-date="minimumPatExpiry"
show-today
wrapper-class="w-full"
/>
</div>
<div class="ml-auto mt-4 flex gap-2">
@@ -73,13 +79,13 @@
</button>
</ButtonStyled>
<ButtonStyled v-if="editPatId !== null" color="brand">
<button :disabled="loading || !name || !expires" @click="editPat">
<button :disabled="loading || !name || !isExpiryInFuture" @click="editPat">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand">
<button :disabled="loading || !name || !expires" @click="createPat">
<button :disabled="loading || !name || !isExpiryInFuture" @click="createPat">
<PlusIcon />
{{ formatMessage(createModalMessages.action) }}
</button>
@@ -123,8 +129,8 @@
<strong>{{ pat.name }}</strong>
</div>
<div>
<template v-if="pat.access_token">
<CopyCode :text="pat.access_token" />
<template v-if="createdPatTokens[pat.id] || pat.access_token">
<CopyCode :text="createdPatTokens[pat.id] || pat.access_token" />
</template>
<template v-else>
<span v-tooltip="pat.last_used ? formatDateTime(pat.last_used) : null">
@@ -331,12 +337,17 @@ useHead({
const data = useNuxtApp()
const { scopesToLabels } = useScopes()
const patModal = ref()
const minimumPatExpiry = data.$dayjs().add(1, 'day').format('YYYY-MM-DD')
const editPatId = ref(null)
const name = ref(null)
const scopesVal = ref(BigInt(0))
const expires = ref(null)
const createdPatTokens = ref({})
const isExpiryInFuture = computed(
() => expires.value && data.$dayjs(expires.value).isAfter(data.$dayjs(), 'day'),
)
const deletePatIndex = ref(null)
@@ -415,6 +426,9 @@ async function createPat() {
scopes: Number(scopesVal.value),
expires: data.$dayjs(expires.value).toISOString(),
})
if (res.access_token) {
createdPatTokens.value[res.id] = res.access_token
}
queryClient.setQueryData(['pat'], (old) => [...(old || []), res])
patModal.value.hide()
} catch (err) {
+60 -259
View File
@@ -1,276 +1,77 @@
<template>
<div>
<section class="card">
<h2 class="text-2xl">{{ formatMessage(messages.title) }}</h2>
<p class="mb-4">
<IntlFormatted :message-id="messages.description">
<template #docs-link="{ children }">
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<label>
<span class="label__title">{{ formatMessage(messages.profilePicture) }}</span>
</label>
<div class="avatar-changer">
<Avatar
:src="previewImage ? previewImage : avatarUrl"
size="md"
circle
:alt="auth.user.username"
/>
<div class="flex flex-col gap-2">
<ButtonStyled>
<FileInput
:max-size="262144"
:show-icon="true"
class="button-like"
:prompt="formatMessage(commonMessages.uploadImageButton)"
accept="image/png,image/jpeg,image/gif,image/webp"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
</ButtonStyled>
<ButtonStyled v-if="avatarUrl !== null">
<button @click="removePreviewImage">
<TrashIcon />
{{ formatMessage(commonMessages.removeImageButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="previewImage">
<button
@click="
() => {
icon = null
previewImage = null
}
"
>
<UndoIcon />
{{ formatMessage(commonMessages.resetButton) }}
</button>
</ButtonStyled>
</div>
</div>
<label for="username-field">
<span class="label__title">{{ formatMessage(commonMessages.usernameLabel) }}</span>
<span class="label__description">
{{ formatMessage(messages.usernameDescription) }}
</span>
</label>
<StyledInput id="username-field" v-model="current.username" />
<div
v-if="current.username.length >= 30"
id="bio-character-limit"
class="inline-block pl-2"
:class="{ 'text-red': current.username.length > 39 }"
>
{{ current.username.length }}/{{ 39 }}
</div>
<label for="bio-field">
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
<span class="label__description">
{{ formatMessage(messages.bioDescription) }}
</span>
</label>
<StyledInput id="bio-field" v-model="current.bio" multiline />
<div id="bio-character-limit" class="pt-2" :class="{ 'text-red': current.bio.length > 160 }">
{{ current.bio.length }}/{{ 160 }}
</div>
<div class="input-group mt-4">
<ButtonStyled>
<NuxtLink :to="`/user/${auth.user.username}`">
<UserIcon /> {{ formatMessage(commonMessages.visitYourProfile) }}
</NuxtLink>
</ButtonStyled>
</div>
</section>
<UnsavedChangesPopup
:original="originalState"
:modified="modifiedState"
:saving="saving"
@reset="reset"
@save="save"
<section v-if="auth.user" class="universal-card">
<AccountProfileSettings
ref="profileSettings"
:patch-user="patchUser"
:change-avatar="changeAvatar"
:delete-avatar="deleteAvatar"
:get-authenticated-user="getAuthenticatedUser"
disclaimer-position="bottom"
/>
</div>
<UnsavedChangesPopup
:original="profileSettings?.originalState ?? emptyProfileState"
:modified="profileSettings?.modifiedState ?? emptyProfileState"
:saving="profileSettings?.saving ?? false"
@reset="resetProfileSettings"
@save="saveProfileSettings"
/>
</section>
</template>
<script setup>
import { TrashIcon, UndoIcon, UploadIcon, UserIcon } from '@modrinth/assets'
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
FileInput,
injectNotificationManager,
IntlFormatted,
StyledInput,
AccountProfileSettings,
commonSettingsMessages,
injectModrinthClient,
UnsavedChangesPopup,
useSavable,
useVIntl,
} from '@modrinth/ui'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
definePageMeta({
middleware: 'auth',
})
const messages = defineMessages({
headTitle: {
id: 'settings.profile.head-title',
defaultMessage: 'Profile settings',
},
title: {
id: 'settings.profile.profile-info',
defaultMessage: 'Profile information',
},
description: {
id: 'settings.profile.description',
defaultMessage:
'Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>.',
},
profilePicture: {
id: 'settings.profile.profile-picture.title',
defaultMessage: 'Profile picture',
},
usernameDescription: {
id: 'settings.profile.username.description',
defaultMessage: 'A unique case-insensitive name to identify your profile.',
},
bioTitle: {
id: 'settings.profile.bio.title',
defaultMessage: 'Bio',
},
bioDescription: {
id: 'settings.profile.bio.description',
defaultMessage: 'A short description to tell everyone a little bit about you.',
},
})
const auth = await useAuth()
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
const profileSettings = ref<InstanceType<typeof AccountProfileSettings> | null>(null)
const emptyProfileState = {
username: '',
bio: '',
avatarChanged: false,
}
function patchUser(
userId: string,
patch: Partial<Pick<Labrinth.Users.v2.User, 'bio' | 'username'>>,
): Promise<void> {
return client.labrinth.users_v2.patch(userId, patch)
}
function changeAvatar(userId: string, file: Blob, extension: string): Promise<void> {
return client.labrinth.users_v2.changeIcon(userId, file, extension)
}
function deleteAvatar(userId: string): Promise<void> {
return client.labrinth.users_v2.deleteIcon(userId)
}
async function getAuthenticatedUser(): Promise<Labrinth.Users.v3.User> {
const user = await client.labrinth.users_v3.getAuthenticated()
auth.value.user = user
return user
}
function resetProfileSettings(): void {
profileSettings.value?.reset()
}
function saveProfileSettings(): void {
void profileSettings.value?.save()
}
useHead({
title: () => `${formatMessage(messages.headTitle)} - Modrinth`,
title: () => `${formatMessage(commonSettingsMessages.profile)} - Modrinth`,
})
const auth = await useAuth()
// Avatar state (separate from useSavable)
const avatarUrl = ref(auth.value.user.avatar_url)
const icon = shallowRef(null)
const previewImage = shallowRef(null)
const pendingAvatarDeletion = ref(false)
const saving = ref(false)
const {
saved,
current,
reset: resetFields,
} = useSavable(
() => ({
username: auth.value.user.username,
bio: auth.value.user.bio ?? '',
}),
async () => {}, // Save is handled manually due to complex icon logic
)
// Combined state for UnsavedChangesPopup
const originalState = computed(() => ({
...saved.value,
avatarChanged: false,
}))
const modifiedState = computed(() => ({
...current.value,
avatarChanged: !!(previewImage.value || pendingAvatarDeletion.value),
}))
const reset = () => {
resetFields()
icon.value = null
previewImage.value = null
pendingAvatarDeletion.value = false
}
function showPreviewImage(files) {
const reader = new FileReader()
icon.value = files[0]
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
previewImage.value = event.target.result
}
}
function removePreviewImage() {
pendingAvatarDeletion.value = true
previewImage.value = 'https://cdn.modrinth.com/placeholder.png'
}
async function save() {
saving.value = true
try {
if (pendingAvatarDeletion.value) {
await useBaseFetch(`user/${auth.value.user.id}/icon`, {
method: 'DELETE',
})
pendingAvatarDeletion.value = false
previewImage.value = null
}
if (icon.value) {
await useBaseFetch(
`user/${auth.value.user.id}/icon?ext=${
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
}`,
{
method: 'PATCH',
body: icon.value,
},
)
icon.value = null
previewImage.value = null
}
const body = {}
if (auth.value.user.username !== current.value.username) {
body.username = current.value.username
}
if (auth.value.user.bio !== current.value.bio) {
body.bio = current.value.bio
}
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'PATCH',
body,
})
await useAuth(auth.value.token)
avatarUrl.value = auth.value.user.avatar_url
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err
? err.data
? err.data.description
? err.data.description
: err.data
: err
: 'aaaaahhh',
type: 'error',
})
}
saving.value = false
}
</script>
<style lang="scss" scoped>
.avatar-changer {
display: flex;
gap: var(--gap-lg);
margin-top: var(--gap-md);
}
</style>
@@ -0,0 +1,43 @@
<template>
<section v-if="auth.user" class="universal-card">
<AccountSocialSettings
:get-blocked-users="getBlockedUsers"
:get-users="getUsers"
:unblock-user="unblockUser"
/>
</section>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
AccountSocialSettings,
commonSettingsMessages,
injectModrinthClient,
useVIntl,
} from '@modrinth/ui'
definePageMeta({
middleware: 'auth',
})
const auth = await useAuth()
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
function getBlockedUsers(): Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]> {
return client.labrinth.blocked_users_v3.list()
}
function getUsers(userIds: string[]): Promise<Labrinth.Users.v2.User[]> {
return client.labrinth.users_v2.getMultiple(userIds)
}
function unblockUser(userId: string): Promise<void> {
return client.labrinth.blocked_users_v3.unblock(userId)
}
useHead({
title: () => `${formatMessage(commonSettingsMessages.social)} - Modrinth`,
})
</script>
+3
View File
@@ -34,6 +34,9 @@ const userProfile = provideUserProfile({
getOrganizations: (userId) => client.labrinth.users_v2.getOrganizations(userId),
getCollections: (userId) => client.labrinth.users_v2.getCollections(userId),
patchUser: (userId, patch) => client.labrinth.users_v2.patch(userId, patch),
getBlockedUsers: () => client.labrinth.blocked_users_v3.list(),
blockUser: (userId) => client.labrinth.blocked_users_v3.block(userId),
unblockUser: (userId) => client.labrinth.blocked_users_v3.unblock(userId),
})
const auth = await useAuth()
const cosmetics = useCosmetics()
+4
View File
@@ -24,6 +24,8 @@ import { LabrinthAttributionInternalModule } from './labrinth/attribution/intern
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthBlockedUsersInternalModule } from './labrinth/blocked-users/internal'
import { LabrinthBlockedUsersV3Module } from './labrinth/blocked-users/v3'
import { LabrinthCampaignInternalModule } from './labrinth/campaign/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthContentV3Module } from './labrinth/content/v3'
@@ -100,6 +102,8 @@ export const MODULE_REGISTRY = {
labrinth_auth_v2: LabrinthAuthV2Module,
labrinth_attribution_internal: LabrinthAttributionInternalModule,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_blocked_users_internal: LabrinthBlockedUsersInternalModule,
labrinth_blocked_users_v3: LabrinthBlockedUsersV3Module,
labrinth_campaign_internal: LabrinthCampaignInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_content_v3: LabrinthContentV3Module,
@@ -0,0 +1,25 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthBlockedUsersInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_blocked_users_internal'
}
/**
* Check whether one user has blocked another.
*/
public async getStatus(
userId: string,
targetId: string,
): Promise<Labrinth.BlockedUsers.Internal.BlockStatus> {
return this.client.request<Labrinth.BlockedUsers.Internal.BlockStatus>(
`/block/${encodeURIComponent(userId)}/${encodeURIComponent(targetId)}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
}
@@ -0,0 +1,45 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthBlockedUsersV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_blocked_users_v3'
}
/**
* List the users blocked by the authenticated user.
*/
public async list(): Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]> {
return this.client.request<Labrinth.BlockedUsers.v3.BlockedUserId[]>('/blocks', {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Block a user.
*
* @param idOrUsername - The target user's ID or username
*/
public async block(idOrUsername: string): Promise<void> {
return this.client.request(`/block/${encodeURIComponent(idOrUsername)}`, {
api: 'labrinth',
version: 3,
method: 'POST',
})
}
/**
* Unblock a user.
*
* @param idOrUsername - The target user's ID or username
*/
public async unblock(idOrUsername: string): Promise<void> {
return this.client.request(`/block/${encodeURIComponent(idOrUsername)}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}
@@ -1,5 +1,4 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle } from '../../../types/upload'
import type { Labrinth } from '../types'
export class LabrinthOAuthInternalModule extends AbstractModule {
@@ -110,14 +109,14 @@ export class LabrinthOAuthInternalModule extends AbstractModule {
* @param id - The OAuth client ID
* @param file - The icon file
* @param ext - The file extension (e.g. 'png', 'jpeg')
* @returns UploadHandle for progress tracking and cancellation
*/
public uploadAppIcon(id: string, file: File | Blob, ext: string): UploadHandle<void> {
return this.client.upload<void>(`/oauth/app/${id}/icon`, {
public async uploadAppIcon(id: string, file: File | Blob, ext: string): Promise<void> {
return this.client.request(`/oauth/app/${id}/icon`, {
api: 'labrinth',
version: 'internal',
file,
method: 'PATCH',
params: { ext },
body: file,
})
}
@@ -1691,6 +1691,18 @@ export namespace Labrinth {
}
}
export namespace BlockedUsers {
export namespace Internal {
export type BlockStatus = {
blocked: boolean
}
}
export namespace v3 {
export type BlockedUserId = string
}
}
export namespace ServerPing {
export namespace Internal {
export type MinecraftJavaPingRequest = {
@@ -168,7 +168,7 @@ export class LabrinthUsersV2Module extends AbstractModule {
*/
public async patch(
idOrUsername: string,
data: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'role'>>,
data: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'bio' | 'role' | 'username'>>,
): Promise<void> {
return this.client.request(`/user/${idOrUsername}`, {
api: 'labrinth',
@@ -177,4 +177,34 @@ export class LabrinthUsersV2Module extends AbstractModule {
body: data,
})
}
/**
* Change a user's avatar.
*
* @param idOrUsername - The user's ID or username
* @param file - Image file to upload
* @param ext - File extension (e.g., 'png', 'jpeg', 'gif', 'webp')
*/
public async changeIcon(idOrUsername: string, file: Blob, ext: string): Promise<void> {
return this.client.request(`/user/${idOrUsername}/icon`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
params: { ext },
body: file,
})
}
/**
* Delete a user's avatar.
*
* @param idOrUsername - The user's ID or username
*/
public async deleteIcon(idOrUsername: string): Promise<void> {
return this.client.request(`/user/${idOrUsername}/icon`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
}
+113 -1
View File
@@ -1,5 +1,6 @@
use crate::State;
use crate::util::fetch::{fetch_advanced, fetch_json};
use crate::util::fetch::{fetch_advanced, fetch_advanced_bytes, fetch_json};
use bytes::Bytes;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -129,3 +130,114 @@ pub async fn patch_user(user_id: &str, patch: Value) -> crate::Result<()> {
Ok(())
}
#[tracing::instrument(skip(image))]
pub async fn change_user_avatar(
user_id: &str,
image: Bytes,
extension: &str,
) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
let extension = urlencoding::encode(extension);
fetch_advanced_bytes(
Method::PATCH,
&format!(
"{}user/{}/icon?ext={}",
env!("MODRINTH_API_URL"),
user_id,
extension
),
image,
Some(("Content-Type", "application/octet-stream")),
Some("/v2/user/:id/icon"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
#[tracing::instrument]
pub async fn delete_user_avatar(user_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_advanced(
Method::DELETE,
&format!("{}user/{}/icon", env!("MODRINTH_API_URL"), user_id),
None,
None,
None,
None,
None,
Some("/v2/user/:id/icon"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
#[tracing::instrument]
pub async fn block_user(user_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_advanced(
Method::POST,
&format!("{}block/{}", env!("MODRINTH_API_URL_V3"), user_id),
None,
None,
None,
None,
None,
Some("/v3/block/:id"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
#[tracing::instrument]
pub async fn unblock_user(user_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_advanced(
Method::DELETE,
&format!("{}block/{}", env!("MODRINTH_API_URL_V3"), user_id),
None,
None,
None,
None,
None,
Some("/v3/block/:id"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
#[tracing::instrument]
pub async fn get_blocked_users() -> crate::Result<Vec<String>> {
let state = State::get().await?;
fetch_json(
Method::GET,
&format!("{}blocks", env!("MODRINTH_API_URL_V3")),
None,
None,
Some("/v3/blocks"),
&state.api_semaphore,
&state.pool,
)
.await
}
@@ -55,8 +55,12 @@ pub(crate) async fn create_instance(
None
};
let icon_path =
resolve_icon_path(input.icon_path.as_deref(), state).await?;
let icon_path = resolve_icon_path(
input.icon_path.as_deref(),
matches!(&input.link, InstanceLink::SharedInstance { .. }),
state,
)
.await?;
let now = Utc::now();
let instance_id = format!("local:{}", Uuid::new_v4());
let content_set_id = format!("content-set:{}", Uuid::new_v4());
@@ -168,6 +172,7 @@ async fn path_available(
async fn resolve_icon_path(
icon_path: Option<&str>,
ignore_missing_remote_icon: bool,
state: &State,
) -> crate::Result<Option<String>> {
let Some(icon) = icon_path else {
@@ -175,7 +180,7 @@ async fn resolve_icon_path(
};
let file = if icon.starts_with("https://") || icon.starts_with("http://") {
let bytes = fetch::fetch(
let bytes = match fetch::fetch(
icon,
None,
None,
@@ -183,7 +188,16 @@ async fn resolve_icon_path(
&state.fetch_semaphore,
&state.pool,
)
.await?;
.await
{
Ok(bytes) => bytes,
Err(error)
if ignore_missing_remote_icon && is_not_found_error(&error) =>
{
return Ok(None);
}
Err(error) => return Err(error),
};
crate::api::instance::cache_icon(bytes, state).await?
} else {
crate::api::instance::cache_icon_from_path(
@@ -196,6 +210,18 @@ async fn resolve_icon_path(
Ok(Some(file.to_string_lossy().to_string()))
}
fn is_not_found_error(error: &crate::Error) -> bool {
match error.raw.as_ref() {
crate::ErrorKind::FetchError(error) => {
error.status() == Some(reqwest::StatusCode::NOT_FOUND)
}
crate::ErrorKind::LabrinthError(error) => {
error.status == Some(reqwest::StatusCode::NOT_FOUND.as_u16())
}
_ => false,
}
}
fn content_source_kind(link: &InstanceLink) -> ContentSourceKind {
match link {
InstanceLink::Unmanaged => ContentSourceKind::Local,
+38 -1
View File
@@ -428,6 +428,7 @@ pub async fn fetch_with_client_progress(
sha1,
None,
None,
None,
download_meta,
None,
uri_path,
@@ -493,6 +494,35 @@ pub async fn fetch_advanced(
.await
}
#[tracing::instrument(skip(body, semaphore))]
#[allow(clippy::too_many_arguments)]
pub async fn fetch_advanced_bytes(
method: Method,
url: &str,
body: Bytes,
header: Option<(&str, &str)>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Bytes> {
fetch_advanced_with_client_and_progress(
method,
url,
None,
None,
Some(body),
header,
None,
None,
uri_path,
semaphore,
exec,
&INSECURE_REQWEST_CLIENT,
None,
)
.await
}
#[tracing::instrument(skip(json_body, semaphore, progress))]
#[allow(clippy::too_many_arguments)]
pub async fn fetch_advanced_with_progress(
@@ -513,6 +543,7 @@ pub async fn fetch_advanced_with_progress(
url,
sha1,
json_body,
None,
header,
download_meta,
loading_bar,
@@ -546,6 +577,7 @@ pub async fn fetch_advanced_with_client(
url,
sha1,
json_body,
None,
header,
download_meta,
loading_bar,
@@ -558,13 +590,16 @@ pub async fn fetch_advanced_with_client(
.await
}
#[tracing::instrument(skip(json_body, semaphore, client, progress))]
#[tracing::instrument(skip(
json_body, bytes_body, semaphore, client, progress
))]
#[allow(clippy::too_many_arguments)]
async fn fetch_advanced_with_client_and_progress(
method: Method,
url: &str,
sha1: Option<&str>,
json_body: Option<serde_json::Value>,
bytes_body: Option<Bytes>,
header: Option<(&str, &str)>,
download_meta: Option<&DownloadMeta>,
loading_bar: Option<(&LoadingBarId, f64)>,
@@ -611,6 +646,8 @@ async fn fetch_advanced_with_client_and_progress(
if let Some(body) = json_body.clone() {
req = req.json(&body);
} else if let Some(body) = bytes_body.clone() {
req = req.body(body);
}
if let Some(header) = header {
-2
View File
@@ -66,7 +66,6 @@ import _BugIcon from './icons/bug.svg?component'
import _CalendarIcon from './icons/calendar.svg?component'
import _CalendarArrowDownIcon from './icons/calendar-arrow-down.svg?component'
import _CardIcon from './icons/card.svg?component'
import _ChangeSkinIcon from './icons/change-skin.svg?component'
import _ChartIcon from './icons/chart.svg?component'
import _ChartAreaIcon from './icons/chart-area.svg?component'
import _ChartColumnBigIcon from './icons/chart-column-big.svg?component'
@@ -499,7 +498,6 @@ export const BugIcon = _BugIcon
export const CalendarIcon = _CalendarIcon
export const CalendarArrowDownIcon = _CalendarArrowDownIcon
export const CardIcon = _CardIcon
export const ChangeSkinIcon = _ChangeSkinIcon
export const ChartIcon = _ChartIcon
export const ChartAreaIcon = _ChartAreaIcon
export const ChartColumnBigIcon = _ChartColumnBigIcon
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" transform="scale(-1 1)" viewBox="0 0 49.915 52.72">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.331" d="M15.71 31.484v19.07h18.63v-19.07l6.538 6.539 6.871-6.872-11.203-11.733H14.122L2.166 31.375l6.827 6.827z"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.993" d="M24.872 19.548v-6.44"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.331" d="M24.704 13.202a5.518 5.518 0 0 1-5.518-5.518 5.518 5.518 0 0 1 5.518-5.518 5.518 5.518 0 0 1 5.518 5.518"/>
</svg>

Before

Width:  |  Height:  |  Size: 695 B

+86
View File
@@ -10,6 +10,92 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-28T23:36:10+00:00`,
product: 'web',
body: `## Changed
- Added message to legacy moderation threads showing that there may be undocumented moderation history.
## Fixed
- Fixed OAuth application icon upload not working.
- Fixed moderation messages not showing.`,
},
{
date: `2026-07-28T23:36:10+00:00`,
product: 'app',
version: '0.17.2',
body: `## Fixed
- Fixed broken shared instances invite management table.`,
},
{
date: `2026-07-28T20:40:11+00:00`,
product: 'app',
version: '0.17.1',
body: `## Added
- Added user pages.
- Added a banner for users of a shared instance which let's them know that they need to review an update to play the instance - alongside the existing checks when you click Play.
- Added a way to see and manage the invite links you have created for a shared instance in the Sharing tab of the Instance Settings modal.
- Added a way to see where a shared instance content is coming from when in the Install to play modal, either the linked modpack or if it was added on top. Content which is a part of the linked modpack will show the modpack information underneath it's name.
- Added user blocking
- You can block users on their profile page.
- You can block users when reporting a shared instance. This prevents the user from sending you invites to shared instances and Modrinth Hosting server panels.
- You can manage who you've blocked in the new Social settings in the app's Settings menu, or on the Modrinth website.
- Added the ability to edit your Modrinth profile in the app's Settings menu.
- Added the ability to adjust the amount of quick instances shown in the sidebar by dragging the divider up and down.
- Added an option to always show "Copy details" on the installation job notifications, rather than just on failed and interrupted instance install jobs.
- Clicking on friends in the friends list will take you to their profile page.
## Changed
- More than three instances now show up in the left sidebar's quick instance selection area for larger window sizes.
- Updated Modrinth App logo to just use the standard Modrinth logo to save space.
- Updated the design of the back/forward buttons.
- Limited shared instances to 50 users.
- Changed the expiry date picker in the shared instance invite edit modal to be a dropdown of common dates, rather than a complicated date picker. You can still use the fine-grained date picker by choosing "Custom"
- Instance icons must now be smaller than 4MB - any existing instances will have their icons compressed to 512x512px size to conform to the new limit. This will break any instances which have .GIF icons.
- Split up the App Settings modal into categories.
- Moved out behavioural settings into it's own subpage, rather than being in Appearance settings.
- Updated "Advanced" toggle filter design to be the same as the other filters, just with only an exclude button as the primary action.
- Re-aligned the traffic light buttons on macOS with the top bar.
- Updated translations. Want to help translate the Modrinth App? [Click here](https://translate.modrinth.com)
- **Modrinth Hosting:** Updated translations. Want to help translate Modrinth Hosting? [Click here](https://translate.modrinth.com)
## Fixed
- **Modrinth Hosting:** Fixed issue where when browsing content for your server panel, when visiting a project page and going back to search, it would reset the page back to one.
- Fixed issue where when browsing content for an instance, when visiting a project page and going back to search, it would reset the page back to one.
- Refactored how breadcrumbs work in the app, this should solve many issues you might have encountered using the back and forward navigation buttons in the app header.
- Fixed error spam when the shared instances API is not accessible, it will cleanly provide feedback that the app cannot connect.`,
},
{
date: `2026-07-28T20:40:11+00:00`,
product: 'web',
body: `## Added
- You can now block users on their profile page. This prevents the user from sending you invites to shared instances and Modrinth Hosting server panels.
- Added new social settings page, where you can manage users you have blocked and in the future set who can send you friend requests and invitations to shared instances and Modrinth Hosting server panels.
## Changed
- Cleaned up the layout of and renamed the Public profile settings page to Profile settings.
- Updated "Advanced" toggle filter design to be the same as the other filters, just with only an exclude button as the primary action.
- Updated translations. Want to help translate Modrinth's website? [Click here](https://translate.modrinth.com)
## Fixed
- Fixed profile pictures still being deleted after resetting a pending removal in Profile settings.
- Fixed shared instance reports not showing up in the dashboard's Reports page.
- Fixed shared instance report emails saying "Unknown" rather than the shared instance's name.
- Fixed the settings page for Authorized apps being broken.
- Broken Retro theme colors
- Improved vertical alignment of status indicators in notifications.`,
},
{
date: `2026-07-28T20:40:11+00:00`,
product: 'hosting',
body: `## Changed
- Updated translations. Want to help translate Modrinth Hosting? [Click here](https://translate.modrinth.com)
## Fixed
- Fixed issue where when browsing content for your server panel, when visiting a project page and going back to search, it would reset the page back to one.`,
},
{
date: `2026-07-26T19:06:47+00:00`,
product: 'web',
@@ -1,6 +1,8 @@
<template>
<div class="mx-auto flex flex-col items-center p-6 text-center">
<component :is="illustration" v-if="illustration" class="h-[200px] w-auto" />
<slot name="illustration">
<component :is="illustration" v-if="illustration" class="h-[200px] w-auto" />
</slot>
<div class="flex flex-col items-center gap-1.5">
<span class="text-2xl font-semibold text-contrast">
<slot name="heading">{{ heading }}</slot>
@@ -17,6 +17,7 @@ const props = defineProps<{
ariaLabel?: string
belowModal?: boolean
hideWhenModalOpen?: boolean
inline?: boolean
}>()
const INTERCOM_BUBBLE_GAP = 8
@@ -24,6 +25,7 @@ const INTERCOM_BUBBLE_GAP = 8
const barEl = ref<HTMLElement | null>(null)
const toolbarEl = ref<HTMLElement | null>(null)
const compact = ref(false)
const attentionRequested = ref(false)
const { stackCount } = useModalStack()
const pageContext = injectPageContext(null)
@@ -75,6 +77,7 @@ function updateIntercomBubbleClearance() {
if (
typeof window === 'undefined' ||
props.inline ||
!shown.value ||
stackCount.value > 0 ||
!barEl.value ||
@@ -105,7 +108,7 @@ function updateIntercomBubbleClearance() {
function updateBodyState(isShown = shown.value) {
if (typeof document === 'undefined') return
if (isShown) {
if (isShown && !props.inline) {
visibleFloatingActionBars.add(floatingActionBarId)
} else {
visibleFloatingActionBars.delete(floatingActionBarId)
@@ -149,10 +152,10 @@ watch(
)
watch(
shown,
[shown, () => props.inline],
async (isShown) => {
await nextTick()
updateBodyState(isShown)
updateBodyState(isShown[0])
scheduleIntercomBubbleClearanceUpdate()
},
{ immediate: true },
@@ -187,24 +190,40 @@ onUnmounted(() => {
if (typeof document === 'undefined') return
updateFloatingActionBarBodyClass()
})
async function nudge(): Promise<void> {
attentionRequested.value = false
await nextTick()
attentionRequested.value = true
}
defineExpose({ nudge })
</script>
<template>
<Teleport to="body">
<Teleport to="body" :disabled="inline">
<Transition name="floating-action-bar" appear>
<div
v-if="shown"
ref="barEl"
class="floating-action-bar drop-shadow-2xl fixed p-4 bottom-0"
:style="barStyle"
class="floating-action-bar drop-shadow-2xl"
:class="inline ? 'floating-action-bar--inline z-10' : 'fixed bottom-0 p-4'"
:style="inline ? undefined : barStyle"
aria-live="polite"
>
<div
ref="toolbarEl"
role="toolbar"
:aria-label="ariaLabel"
class="relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto md:max-w-[60vw] px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
:class="{ 'bar-compact': compact }"
class="relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
:class="[
{
'bar-compact': compact,
'floating-action-bar-attention': attentionRequested,
},
inline ? 'w-full' : 'mx-auto md:max-w-[60vw]',
]"
@animationend="attentionRequested = false"
>
<slot />
</div>
@@ -220,6 +239,31 @@ onUnmounted(() => {
transition: bottom 0.25s ease-in-out;
}
.floating-action-bar--inline {
left: auto;
right: auto;
}
.floating-action-bar-attention {
animation: floating-action-bar-attention 300ms ease-in-out;
}
@keyframes floating-action-bar-attention {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-0.4rem);
}
50% {
transform: translateX(0.4rem);
}
75% {
transform: translateX(-0.2rem);
}
}
.floating-action-bar-enter-active {
transition:
transform 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96),
@@ -243,14 +287,20 @@ onUnmounted(() => {
}
@media (any-hover: none) and (max-width: 640px) {
.floating-action-bar {
.floating-action-bar:not(.floating-action-bar--inline) {
bottom: var(--size-mobile-navbar-height);
}
.expanded-mobile-nav .floating-action-bar {
.expanded-mobile-nav .floating-action-bar:not(.floating-action-bar--inline) {
bottom: var(--size-mobile-navbar-height-expanded);
}
}
@media (prefers-reduced-motion: reduce) {
.floating-action-bar-attention {
animation: none;
}
}
</style>
<style>
@@ -1,7 +1,7 @@
<script setup lang="ts" generic="T">
import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
import { isEqual } from 'es-toolkit'
import { type Component, computed } from 'vue'
import { type Component, computed, ref } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils'
@@ -24,6 +24,7 @@ const props = withDefaults(
saveLabel?: MessageDescriptor | string
savingLabel?: MessageDescriptor | string
saveIcon?: Component
inline?: boolean
}>(),
{
canReset: true,
@@ -36,6 +37,7 @@ const props = withDefaults(
saveLabel: () => commonMessages.saveButton,
savingLabel: () => commonMessages.savingButton,
saveIcon: SaveIcon,
inline: false,
},
)
@@ -46,10 +48,18 @@ const shown = computed(() =>
function localizeIfPossible(message: MessageDescriptor | string) {
return typeof message === 'string' ? message : formatMessage(message)
}
const actionBar = ref<InstanceType<typeof FloatingActionBar> | null>(null)
function nudge(): void {
void actionBar.value?.nudge()
}
defineExpose({ nudge })
</script>
<template>
<FloatingActionBar :shown="shown">
<FloatingActionBar ref="actionBar" :shown="shown" :inline="inline">
<p class="m-0 font-semibold text-sm md:text-base">{{ localizeIfPossible(text) }}</p>
<div class="ml-auto flex gap-2">
<ButtonStyled v-if="canReset" type="transparent">
-184
View File
@@ -1,184 +0,0 @@
<template>
<div v-if="shown">
<div
:class="{ shown: actuallyShown }"
class="tauri-overlay"
data-tauri-drag-region
@click="() => (closable ? hide() : {})"
/>
<div
:class="{
shown: actuallyShown,
noblur: props.noblur,
}"
class="modal-overlay"
@click="() => (closable ? hide() : {})"
/>
<div class="modal-container" :class="{ shown: actuallyShown }">
<div class="modal-body">
<div v-if="props.header" class="header">
<h1>{{ props.header }}</h1>
<button v-if="closable" class="btn icon-only transparent" @click="hide">
<XIcon />
</button>
</div>
<div class="content">
<slot />
</div>
</div>
</div>
</div>
<div v-else></div>
</template>
<script setup>
import { XIcon } from '@modrinth/assets'
import { ref } from 'vue'
const props = defineProps({
header: {
type: String,
default: null,
},
noblur: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: true,
},
onHide: {
type: Function,
default() {
return () => {}
},
},
})
const shown = ref(false)
const actuallyShown = ref(false)
function show() {
shown.value = true
setTimeout(() => {
actuallyShown.value = true
}, 50)
}
function hide() {
props.onHide?.()
actuallyShown.value = false
setTimeout(() => {
shown.value = false
}, 300)
}
defineExpose({
show,
hide,
})
</script>
<style lang="scss" scoped>
.tauri-overlay {
position: fixed;
visibility: hidden;
top: 0;
left: 0;
width: 100%;
height: 100px;
z-index: 20;
&.shown {
opacity: 1;
visibility: visible;
}
}
.modal-overlay {
visibility: hidden;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 19;
transition: all 0.3s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
&.shown {
opacity: 1;
visibility: visible;
background: hsla(0, 0%, 0%, 0.5);
backdrop-filter: blur(3px);
}
&.noblur {
backdrop-filter: none;
}
}
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 21;
visibility: hidden;
pointer-events: none;
&.shown {
visibility: visible;
.modal-body {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
.modal-body {
position: fixed;
box-shadow: var(--shadow-raised), var(--shadow-inset);
border-radius: var(--radius-lg);
background-color: var(--color-raised-bg);
max-height: calc(100% - 2 * var(--gap-lg));
overflow-y: visible;
width: 600px;
pointer-events: auto;
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg);
padding: var(--gap-md) var(--gap-lg);
h1 {
font-weight: bold;
font-size: 1.25rem;
}
}
transform: translateY(50vh);
visibility: hidden;
opacity: 0;
transition: all 0.25s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
@media screen and (max-width: 650px) {
width: calc(100% - 2 * var(--gap-lg));
}
}
}
</style>
+34 -3
View File
@@ -5,7 +5,8 @@
:class="{ shown: visible }"
class="tauri-overlay"
data-tauri-drag-region
@click="() => (closeOnClickOutside && closable ? hide() : {})"
@pointerdown="onTauriOverlayPointerDown"
@click="onTauriOverlayClick"
/>
<div
:class="[
@@ -175,6 +176,7 @@ const props = withDefaults(
onHide?: () => void
onAfterHide?: () => void
onShow?: () => void
beforeHide?: () => boolean
mergeHeader?: boolean
scrollable?: boolean
maxContentHeight?: string
@@ -202,6 +204,7 @@ const props = withDefaults(
onHide: () => {},
onAfterHide: () => {},
onShow: () => {},
beforeHide: undefined,
mergeHeader: false,
// TODO: migrate all modals to use scrollable and remove this prop
scrollable: false,
@@ -216,6 +219,30 @@ const props = withDefaults(
const effectiveNoblur = computed(() => props.noblur ?? modalBehavior?.noblur.value ?? false)
const TAURI_DRAG_THRESHOLD_PX = 4
let tauriPointerScreen: { x: number; y: number } | null = null
function onTauriOverlayPointerDown(event: PointerEvent) {
if (event.button !== 0) {
return
}
tauriPointerScreen = { x: event.screenX, y: event.screenY }
}
function onTauriOverlayClick(event: MouseEvent) {
const start = tauriPointerScreen
tauriPointerScreen = null
if (
start &&
Math.hypot(event.screenX - start.x, event.screenY - start.y) >= TAURI_DRAG_THRESHOLD_PX
) {
return
}
if (props.closeOnClickOutside && props.closable && !props.disableClose) {
hide()
}
}
const computedFade = computed(() => {
if (props.fade) return props.fade
if (props.danger) return 'danger'
@@ -279,9 +306,12 @@ function show(event?: MouseEvent) {
}, 50)
}
function hide() {
function hide(): boolean {
if (props.disableClose) {
return
return false
}
if (props.beforeHide?.() === false) {
return false
}
props.onHide?.()
resetMousePosition()
@@ -302,6 +332,7 @@ function hide() {
hideTimeout = null
nextTick(() => props.onAfterHide?.())
}, 300)
return true
}
async function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
@@ -28,6 +28,9 @@ const props = withDefaults(
closable?: boolean
onHide?: () => void
onShow?: () => void
beforeHide?: () => boolean
beforeTabChange?: (fromIndex: number, toIndex: number) => boolean
floatingActionBarShown?: boolean
}>(),
{
header: undefined,
@@ -36,6 +39,9 @@ const props = withDefaults(
closable: true,
onHide: undefined,
onShow: undefined,
beforeHide: undefined,
beforeTabChange: undefined,
floatingActionBarShown: false,
},
)
@@ -47,9 +53,18 @@ const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
useScrollIndicator(scrollContainer)
const sidebarScrollContainer = ref<HTMLElement | null>(null)
const {
showTopFade: showSidebarTopFade,
showBottomFade: showSidebarBottomFade,
checkScrollState: checkSidebarScrollState,
} = useScrollIndicator(sidebarScrollContainer)
const modal = ref<InstanceType<typeof NewModal> | null>(null)
function setTab(index: number) {
if (index === selectedTab.value) return
if (props.beforeTabChange?.(selectedTab.value, index) === false) return
selectedTab.value = index
nextTick(() => forceCheck())
}
@@ -58,8 +73,8 @@ function show(event?: MouseEvent) {
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
function hide(): boolean {
return modal.value?.hide() ?? false
}
function startsCategory(index: number) {
@@ -78,6 +93,7 @@ defineExpose({ show, hide, selectedTab, setTab })
:closable="closable"
:on-hide="onHide"
:on-show="onShow"
:before-hide="beforeHide"
no-padding
>
<template v-if="$slots.title" #title>
@@ -85,38 +101,74 @@ defineExpose({ show, hide, selectedTab, setTab })
</template>
<div class="grid grid-cols-[auto_1fr] p-6 pb-3 pr-0">
<div
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
class="flex min-w-[200px] max-h-[min(65vh,600px)] flex-col border-0 border-r-[1px] border-solid border-divider pr-4"
>
<template v-for="(tab, index) in visibleTabs" :key="index">
<div class="relative min-h-0 flex-1">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-4"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-4"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showSidebarTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-4 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
v-if="startsCategory(index) && tab.category"
class="px-4 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary"
ref="sidebarScrollContainer"
class="flex h-full flex-col gap-1 overflow-y-auto"
@scroll="checkSidebarScrollState"
>
{{ formatMessage(tab.category) }}
<template v-for="(tab, index) in visibleTabs" :key="index">
<div
v-if="startsCategory(index) && tab.category"
class="px-4 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary"
>
{{ formatMessage(tab.category) }}
</div>
<component
:is="tab.href ? 'a' : 'button'"
:href="tab.href ?? undefined"
:target="tab.href ? '_blank' : undefined"
:rel="tab.href ? 'noopener noreferrer' : undefined"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all no-underline ${!tab.href && selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="!tab.href && setTab(index)"
>
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ formatMessage(tab.name) }}</span>
<span
v-if="tab.badge"
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
>
{{ formatMessage(tab.badge) }}
</span>
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
</component>
</template>
</div>
<component
:is="tab.href ? 'a' : 'button'"
:href="tab.href ?? undefined"
:target="tab.href ? '_blank' : undefined"
:rel="tab.href ? 'noopener noreferrer' : undefined"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all no-underline ${!tab.href && selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="!tab.href && setTab(index)"
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-16"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-16"
leave-to-class="opacity-0 max-h-0"
>
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ formatMessage(tab.name) }}</span>
<span
v-if="tab.badge"
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
>
{{ formatMessage(tab.badge) }}
</span>
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
</component>
</template>
<div
v-if="showSidebarBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-16 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<slot name="footer" />
</div>
<div class="relative">
<div class="relative min-h-[min(65vh,600px)]">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
@@ -133,7 +185,8 @@ defineExpose({ show, hide, selectedTab, setTab })
<div
ref="scrollContainer"
class="overflow-y-auto px-6 pb-6 h-screen max-h-[min(65vh,600px)]"
class="absolute inset-0 overflow-y-auto px-6"
:class="floatingActionBarShown ? 'pb-24' : 'pb-6'"
@scroll="checkScrollState"
>
<Suspense>
@@ -157,6 +210,12 @@ defineExpose({ show, hide, selectedTab, setTab })
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-16 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
<div class="pointer-events-none absolute bottom-3 left-6 right-6 z-20">
<div class="pointer-events-auto">
<slot name="floating-action-bar" />
</div>
</div>
</div>
</div>
</NewModal>
@@ -1,6 +1,5 @@
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as Modal } from './Modal.vue'
export { default as NewModal } from './NewModal.vue'
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
export { default as OpenInAppModal } from './OpenInAppModal.vue'
@@ -82,6 +82,7 @@ import type { Labrinth } from '@modrinth/api-client'
import {
AffiliateIcon,
BadgeCheckIcon,
BanIcon,
BoxIcon,
CalendarIcon,
ChartIcon,
@@ -123,6 +124,14 @@ const messages = defineMessages({
id: 'profile.button.billing',
defaultMessage: 'Manage user billing',
},
blockButton: {
id: 'profile.button.block',
defaultMessage: 'Block',
},
unblockButton: {
id: 'profile.button.unblock',
defaultMessage: 'Unblock',
},
editRoleButton: {
id: 'profile.button.edit-role',
defaultMessage: 'Edit role',
@@ -175,6 +184,7 @@ const props = withDefaults(
isAdmin?: boolean
isStaff?: boolean
showStaffActions?: boolean
isBlocked?: boolean
projectsCount?: number
downloads?: number
}>(),
@@ -190,6 +200,7 @@ const props = withDefaults(
isAdmin: false,
isStaff: false,
showStaffActions: false,
isBlocked: false,
projectsCount: 0,
downloads: 0,
},
@@ -198,6 +209,7 @@ const props = withDefaults(
const emit = defineEmits<{
manageProjects: []
report: []
block: []
copyId: []
copyPermalink: []
openBilling: []
@@ -236,6 +248,14 @@ const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
color: 'red',
shown: props.authUser?.id !== props.user.id,
},
{
id: 'block',
label: formatMessage(props.isBlocked ? messages.unblockButton : messages.blockButton),
icon: BanIcon,
action: () => emit('block'),
color: 'red',
shown: props.authUser?.id !== props.user.id,
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
@@ -107,7 +107,11 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
</template>
<SelectedProjectsFloatingBar v-if="ctx.installContext?.value && ctx.variant !== 'web'" />
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
<NavTabs
v-if="ctx.showProjectTypeTabs.value"
:links="ctx.selectableProjectTypes.value"
:replace="ctx.variant === 'app'"
/>
<StyledInput
v-model="ctx.query.value"
@@ -21,7 +21,7 @@ import OverflowMenu, {
import TagTagItem from '#ui/components/base/TagTagItem.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { useRelativeTime } from '#ui/composables/how-ago'
import { useVIntl } from '#ui/composables/i18n'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import type {
@@ -33,6 +33,13 @@ import type {
const { formatMessage } = useVIntl()
const messages = defineMessages({
installationSettingsTooltip: {
id: 'content.modpack-card.installation-settings',
defaultMessage: 'Installation settings',
},
})
interface Props {
project: ContentModpackCardProject
projectLink?: string | RouteLocationRaw
@@ -218,7 +225,10 @@ onUnmounted(() => {
</ButtonStyled>
<ButtonStyled v-if="hasSettingsListener" type="outlined" circular>
<button @click="emit('settings')">
<button
v-tooltip="formatMessage(messages.installationSettingsTooltip)"
@click="emit('settings')"
>
<Settings2Icon />
</button>
</ButtonStyled>
@@ -244,7 +254,7 @@ onUnmounted(() => {
</template>
<template #settings>
<Settings2Icon class="size-5" />
{{ formatMessage(commonMessages.settingsLabel) }}
{{ formatMessage(messages.installationSettingsTooltip) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
@@ -1,5 +1,35 @@
<template>
<template v-if="user">
<NewModal
ref="blockUserModal"
:header="formatMessage(messages.blockUserTitle, { username: user.username })"
:closable="!isBlockingUser"
fade="danger"
max-width="500px"
>
<Admonition type="critical" :header="formatMessage(messages.blockUserAdmonitionTitle)">
{{ formatMessage(messages.blockUserAdmonitionBody, { username: user.username }) }}
</Admonition>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" :disabled="isBlockingUser" @click="blockUserModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button type="button" :disabled="isBlockingUser" @click="confirmBlockUser">
<SpinnerIcon v-if="isBlockingUser" class="animate-spin" />
<BanIcon v-else />
{{ formatMessage(messages.blockButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<NewModal
v-if="variant === 'web'"
ref="editRoleModal"
@@ -151,10 +181,12 @@
:is-admin="isAdminViewing"
:is-staff="isStaffViewing"
:show-staff-actions="variant === 'web'"
:is-blocked="isBlocked"
:projects-count="projects.length"
:downloads="sumDownloads"
@manage-projects="openPath('/dashboard/projects')"
@report="reportProfile"
@block="handleBlockAction"
@copy-id="copyId"
@copy-permalink="copyPermalink"
@open-billing="openPath(`/admin/billing/${user.id}`)"
@@ -392,6 +424,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BanIcon,
BoxIcon,
CheckIcon,
GlobeIcon,
@@ -411,6 +444,7 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -429,7 +463,7 @@ import { defineMessages, useVIntl } from '#ui/composables'
import { injectAuth, injectNotificationManager, injectPageContext, injectTags } from '#ui/providers'
import { commonMessages, getProjectTypeTitleMessage } from '#ui/utils'
import { injectUserProfile } from './providers'
import { blockedUsersQueryKey, injectUserProfile } from './providers'
import {
hasActivePride26Midas,
hasPride26Badge,
@@ -463,6 +497,7 @@ const props = withDefaults(
siteUrl?: string
externalNavigation?: boolean
projectLinkMode?: 'website' | 'app'
editProfileLink?: string | (() => void)
onCreateProject?: (event?: MouseEvent) => void
onCreateCollection?: (event?: MouseEvent) => void
}>(),
@@ -474,6 +509,7 @@ const props = withDefaults(
siteUrl: 'https://modrinth.com',
externalNavigation: false,
projectLinkMode: 'website',
editProfileLink: undefined,
onCreateProject: undefined,
onCreateCollection: undefined,
},
@@ -608,6 +644,55 @@ const messages = defineMessages({
id: 'profile.role.update-error-description',
defaultMessage: 'An error occurred while updating the user role. Please try again.',
},
blockButton: {
id: 'profile.button.block',
defaultMessage: 'Block',
},
unblockUserSuccessTitle: {
id: 'profile.unblock-user.success-title',
defaultMessage: 'User unblocked',
},
unblockUserSuccessDescription: {
id: 'profile.unblock-user.success-description',
defaultMessage: '{username} has been unblocked.',
},
unblockUserErrorTitle: {
id: 'profile.unblock-user.error-title',
defaultMessage: 'Failed to unblock user',
},
unblockUserErrorDescription: {
id: 'profile.unblock-user.error-description',
defaultMessage: 'An error occurred while unblocking this user. Please try again.',
},
blockUserTitle: {
id: 'profile.block-user.title',
defaultMessage: 'Block {username}',
},
blockUserAdmonitionTitle: {
id: 'profile.block-user.admonition-title',
defaultMessage: 'Are you sure you want to block this user?',
},
blockUserAdmonitionBody: {
id: 'profile.block-user.admonition-body',
defaultMessage:
'{username} will not be able to send you friend requests, invite you to shared instances or invite you to Modrinth Hosting servers.',
},
blockUserSuccessTitle: {
id: 'profile.block-user.success-title',
defaultMessage: 'User blocked',
},
blockUserSuccessDescription: {
id: 'profile.block-user.success-description',
defaultMessage: '{username} has been blocked.',
},
blockUserErrorTitle: {
id: 'profile.block-user.error-title',
defaultMessage: 'Failed to block user',
},
blockUserErrorDescription: {
id: 'profile.block-user.error-description',
defaultMessage: 'An error occurred while blocking this user. Please try again.',
},
})
const userQuery = useQuery({
@@ -634,6 +719,12 @@ const collectionsQuery = useQuery({
enabled: computed(() => Boolean(props.userId)),
staleTime: 30_000,
})
const blockedUsersQuery = useQuery({
queryKey: computed(() => blockedUsersQueryKey(auth.user.value?.id)),
queryFn: userProfile.getBlockedUsers,
enabled: computed(() => Boolean(auth.user.value)),
staleTime: 30_000,
})
const user = computed(() => userQuery.data.value)
const projects = computed<ResolvedProject[]>(() =>
@@ -642,8 +733,26 @@ const projects = computed<ResolvedProject[]>(() =>
resolvedProjectType: resolveProjectType(project, tags?.loaders.value ?? []),
})),
)
watch(
() => projectsQuery.data.value,
(projects) => {
if (props.projectLinkMode !== 'app') return
for (const project of projects ?? []) {
for (const identifier of [project.id, project.slug]) {
if (identifier) {
queryClient.setQueryData(['projects', 'summary', identifier], project)
}
}
}
},
{ immediate: true },
)
const organizations = computed(() => organizationsQuery.data.value ?? [])
const collections = computed(() => collectionsQuery.data.value ?? [])
const isBlocked = computed(() =>
user.value ? (blockedUsersQuery.data.value ?? []).includes(user.value.id) : false,
)
const selectedProjectType = computed(() => {
const projectType = props.projectType
@@ -742,7 +851,7 @@ const showCollectionsEmptyState = computed(
)
const normalizedSiteUrl = computed(() => props.siteUrl.replace(/\/$/, ''))
const editProfileLink = computed(() => linkTarget('/settings/profile'))
const editProfileLink = computed(() => props.editProfileLink ?? linkTarget('/settings/profile'))
function externalUrl(path: string): string {
return `${normalizedSiteUrl.value}${path.startsWith('/') ? path : `/${path}`}`
@@ -826,8 +935,11 @@ async function retryQueries(): Promise<void> {
const userDetailsModal = ref<ModalRef | null>(null)
const editRoleModal = ref<ModalRef | null>(null)
const blockUserModal = ref<ModalRef | null>(null)
const selectedRole = ref<Labrinth.Users.v3.Role | null>(null)
const isSavingRole = ref(false)
const isBlockingUser = ref(false)
const isUnblockingUser = ref(false)
const roleOptions = [
{ value: 'developer', label: 'Developer' },
{ value: 'moderator', label: 'Moderator' },
@@ -851,6 +963,82 @@ function openRoleEditModal(): void {
editRoleModal.value?.show()
}
async function handleBlockAction(): Promise<void> {
if (!auth.user.value) {
await auth.requestSignIn(route.fullPath)
return
}
if (isBlocked.value) {
await unblockCurrentUser()
return
}
blockUserModal.value?.show()
}
async function confirmBlockUser(): Promise<void> {
if (!user.value || isBlockingUser.value) return
const blockedUser = user.value
const authUserId = auth.user.value?.id
isBlockingUser.value = true
try {
await userProfile.blockUser(blockedUser.id)
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
blockedUsersQueryKey(authUserId),
(blockedUsers = []) =>
blockedUsers.includes(blockedUser.id) ? blockedUsers : [...blockedUsers, blockedUser.id],
)
blockUserModal.value?.hide()
notificationManager.addNotification({
type: 'success',
title: formatMessage(messages.blockUserSuccessTitle),
text: formatMessage(messages.blockUserSuccessDescription, {
username: blockedUser.username,
}),
})
} catch {
notificationManager.addNotification({
type: 'error',
title: formatMessage(messages.blockUserErrorTitle),
text: formatMessage(messages.blockUserErrorDescription),
})
} finally {
isBlockingUser.value = false
}
}
async function unblockCurrentUser(): Promise<void> {
if (!user.value || isUnblockingUser.value) return
const blockedUser = user.value
const authUserId = auth.user.value?.id
isUnblockingUser.value = true
try {
await userProfile.unblockUser(blockedUser.id)
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
blockedUsersQueryKey(authUserId),
(blockedUsers = []) => blockedUsers.filter((userId) => userId !== blockedUser.id),
)
notificationManager.addNotification({
type: 'success',
title: formatMessage(messages.unblockUserSuccessTitle),
text: formatMessage(messages.unblockUserSuccessDescription, {
username: blockedUser.username,
}),
})
} catch {
notificationManager.addNotification({
type: 'error',
title: formatMessage(messages.unblockUserErrorTitle),
text: formatMessage(messages.unblockUserErrorDescription),
})
} finally {
isUnblockingUser.value = false
}
}
function cancelRoleEdit(): void {
selectedRole.value = user.value?.role ?? null
editRoleModal.value?.hide()
@@ -11,8 +11,14 @@ export interface UserProfileContext {
userId: string,
patch: Partial<Pick<Labrinth.Users.v3.User, 'badges' | 'role'>>,
) => Promise<void>
getBlockedUsers: () => Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]>
blockUser: (userId: string) => Promise<void>
unblockUser: (userId: string) => Promise<void>
}
export const blockedUsersQueryKey = (userId?: string | null) =>
['blocked-users', userId ?? null] as const
export const [injectUserProfile, provideUserProfile] = createContext<UserProfileContext>(
'UserProfilePageLayout',
'userProfileContext',
@@ -0,0 +1,364 @@
<template>
<EmptyState
v-if="!auth.user.value"
type="empty"
class="[&>div:last-child]:!mt-6"
:heading="formatMessage(messages.signInRequiredTitle)"
:description="formatMessage(messages.signInRequiredDescription)"
>
<template #illustration>
<div class="relative mb-4 h-[200px]">
<img :src="ThinkingRinthbot" alt="" class="h-full w-auto object-contain" />
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-bg-raised to-transparent"
/>
</div>
</template>
<template #actions>
<ButtonStyled color="brand" size="large">
<button type="button" @click="requestSignIn">
<LogInIcon aria-hidden="true" />
{{ formatMessage(commonMessages.signInButton) }}
</button>
</ButtonStyled>
</template>
</EmptyState>
<div v-else class="flex flex-col gap-4">
<p class="m-0 text-secondary" :class="{ 'order-last': disclaimerPosition === 'bottom' }">
<IntlFormatted :message-id="messages.description">
<template #profile-link="{ children }">
<RouterLink v-slot="{ href, navigate }" :to="profilePath" custom>
<a :href="href" class="text-link" @click="handleProfileLinkClick($event, navigate)">
<component :is="() => children" />
</a>
</RouterLink>
</template>
<template #docs-link="{ children }">
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<hr
v-if="disclaimerPosition === 'top'"
class="m-0 h-px w-full border-none bg-divider"
aria-hidden="true"
/>
<section class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.profilePicture) }}
</h2>
<div class="flex items-center gap-4">
<Avatar :src="displayedAvatarUrl" size="md" circle :alt="auth.user.value.username" />
<div class="flex flex-col gap-2">
<ButtonStyled>
<FileInput
:max-size="262144"
:show-icon="true"
class="button-like !shadow-none"
:prompt="formatMessage(commonMessages.uploadImageButton)"
accept="image/png,image/jpeg,image/gif,image/webp"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
</ButtonStyled>
<ButtonStyled v-if="avatarUrl && !pendingAvatarDeletion">
<button type="button" class="!shadow-none" @click="removePreviewImage">
<TrashIcon aria-hidden="true" />
{{ formatMessage(commonMessages.removeImageButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="avatarFile || pendingAvatarDeletion">
<button type="button" class="!shadow-none" @click="resetAvatar">
<UndoIcon aria-hidden="true" />
{{ formatMessage(commonMessages.resetButton) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.usernameLabel) }}
</h2>
<div class="flex items-center gap-2">
<StyledInput
id="username-field"
v-model="current.username"
class="w-full max-w-md"
:error="current.username.length > 39"
/>
<span
v-if="current.username.length >= 30"
class="shrink-0 text-secondary"
:class="{ 'text-red': current.username.length > 39 }"
>
{{ current.username.length }}/39
</span>
</div>
<p class="m-0 text-secondary">
{{ formatMessage(messages.usernameDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.bioTitle) }}
</h2>
<StyledInput
id="bio-field"
v-model="current.bio"
multiline
:error="current.bio.length > 160"
/>
<div class="text-secondary" :class="{ 'text-red': current.bio.length > 160 }">
{{ current.bio.length }}/160
</div>
<p class="m-0 text-secondary">
{{ formatMessage(messages.bioDescription) }}
</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { LogInIcon, ThinkingRinthbot, TrashIcon, UndoIcon, UploadIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import FileInput from '#ui/components/base/FileInput.vue'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import { defineMessages, useVIntl } from '#ui/composables'
import { type AuthUser, injectAuth, injectNotificationManager } from '#ui/providers'
import { commonMessages } from '#ui/utils'
type ProfileFields = {
username: string
bio: string
}
const props = withDefaults(
defineProps<{
patchUser: (userId: string, patch: Partial<ProfileFields>) => Promise<void>
changeAvatar: (userId: string, file: Blob, extension: string) => Promise<void>
deleteAvatar: (userId: string) => Promise<void>
getAuthenticatedUser: () => Promise<AuthUser>
disclaimerPosition?: 'top' | 'bottom'
}>(),
{
disclaimerPosition: 'top',
},
)
const emit = defineEmits<{
profileLinkClick: [event: MouseEvent]
}>()
const auth = injectAuth()
const notificationManager = injectNotificationManager()
const { formatMessage } = useVIntl()
const activeUserId = ref<string | null>(null)
const original = ref<ProfileFields>({ username: '', bio: '' })
const current = ref<ProfileFields>({ username: '', bio: '' })
const avatarUrl = ref<string | null>(null)
const avatarFile = shallowRef<File | null>(null)
const previewImageUrl = ref<string | null>(null)
const pendingAvatarDeletion = ref(false)
const saving = ref(false)
const displayedAvatarUrl = computed(() => {
if (previewImageUrl.value) return previewImageUrl.value
if (pendingAvatarDeletion.value) return null
return avatarUrl.value
})
const profilePath = computed(
() => `/user/${encodeURIComponent(auth.user.value?.username ?? current.value.username)}`,
)
const originalState = computed(() => ({
...original.value,
avatarChanged: false,
}))
const modifiedState = computed(() => ({
...current.value,
avatarChanged: Boolean(avatarFile.value || pendingAvatarDeletion.value),
}))
const hasChanges = computed(
() =>
current.value.username !== original.value.username ||
current.value.bio !== original.value.bio ||
Boolean(avatarFile.value || pendingAvatarDeletion.value),
)
watch(
() => auth.user.value,
(user) => {
if (!user || user.id !== activeUserId.value) {
syncFromUser(user)
}
},
{ immediate: true },
)
function syncFromUser(user: AuthUser | null): void {
revokePreviewImage()
activeUserId.value = user?.id ?? null
original.value = {
username: user?.username ?? '',
bio: user?.bio ?? '',
}
current.value = { ...original.value }
avatarUrl.value = user?.avatar_url ?? null
avatarFile.value = null
pendingAvatarDeletion.value = false
}
function revokePreviewImage(): void {
if (previewImageUrl.value) {
URL.revokeObjectURL(previewImageUrl.value)
previewImageUrl.value = null
}
}
function showPreviewImage(files: File[]): void {
const file = files[0]
if (!file) return
revokePreviewImage()
avatarFile.value = file
previewImageUrl.value = URL.createObjectURL(file)
pendingAvatarDeletion.value = false
}
function removePreviewImage(): void {
revokePreviewImage()
avatarFile.value = null
pendingAvatarDeletion.value = true
}
function resetAvatar(): void {
revokePreviewImage()
avatarFile.value = null
pendingAvatarDeletion.value = false
}
function reset(): void {
current.value = { ...original.value }
resetAvatar()
}
async function requestSignIn(): Promise<void> {
await auth.requestSignIn('')
}
function handleProfileLinkClick(
event: MouseEvent,
navigate: (event?: MouseEvent) => unknown,
): void {
emit('profileLinkClick', event)
if (!event.defaultPrevented) {
navigate(event)
}
}
async function save(): Promise<void> {
const user = auth.user.value
if (!user || saving.value) return
saving.value = true
try {
const patch: Partial<ProfileFields> = {}
if (current.value.username !== original.value.username) {
patch.username = current.value.username
}
if (current.value.bio !== original.value.bio) {
patch.bio = current.value.bio
}
if (Object.keys(patch).length > 0) {
await props.patchUser(user.id, patch)
}
if (pendingAvatarDeletion.value) {
await props.deleteAvatar(user.id)
} else if (avatarFile.value) {
const extension = avatarFile.value.type.split('/').at(-1)
if (!extension) throw new Error('The selected image does not have a valid file type.')
await props.changeAvatar(user.id, avatarFile.value, extension)
}
const refreshedUser = await props.getAuthenticatedUser()
auth.user.value = refreshedUser
syncFromUser(refreshedUser)
} catch {
notificationManager.addNotification({
type: 'error',
title: formatMessage(messages.saveError),
text: formatMessage(messages.saveErrorDescription),
})
} finally {
saving.value = false
}
}
onBeforeUnmount(revokePreviewImage)
defineExpose({
originalState,
modifiedState,
saving,
hasChanges,
reset,
save,
})
const messages = defineMessages({
description: {
id: 'settings.profile.public-information.description',
defaultMessage:
'Your profile information is publicly <profile-link>viewable on Modrinth</profile-link> and through the <docs-link>Modrinth API</docs-link>.',
},
profilePicture: {
id: 'settings.profile.profile-picture.title',
defaultMessage: 'Profile picture',
},
usernameDescription: {
id: 'settings.profile.username.description',
defaultMessage: 'A unique case-insensitive name to identify your profile.',
},
bioTitle: {
id: 'settings.profile.bio.title',
defaultMessage: 'Bio',
},
bioDescription: {
id: 'settings.profile.bio.description',
defaultMessage: 'A short description to tell everyone a little bit about you.',
},
signInRequiredTitle: {
id: 'settings.profile.sign-in-required.title',
defaultMessage: 'Modrinth account required',
},
signInRequiredDescription: {
id: 'settings.profile.sign-in-required.description',
defaultMessage: 'Sign in with a Modrinth account to customize your public profile.',
},
saveError: {
id: 'settings.profile.save-error',
defaultMessage: 'Failed to update profile',
},
saveErrorDescription: {
id: 'settings.profile.save-error-description',
defaultMessage: 'An error occurred while updating your profile. Please try again.',
},
})
</script>

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