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>
This commit is contained in:
Calum H.
2026-07-28 20:02:58 +00:00
committed by GitHub
co-authored by Prospector
parent cbb31f31c0
commit f89c116bf8
103 changed files with 2166 additions and 1075 deletions
+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',
})
}
}
@@ -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
}
+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 {
@@ -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">
@@ -175,6 +175,7 @@ const props = withDefaults(
onHide?: () => void
onAfterHide?: () => void
onShow?: () => void
beforeHide?: () => boolean
mergeHeader?: boolean
scrollable?: boolean
maxContentHeight?: string
@@ -202,6 +203,7 @@ const props = withDefaults(
onHide: () => {},
onAfterHide: () => {},
onShow: () => {},
beforeHide: undefined,
mergeHeader: false,
// TODO: migrate all modals to use scrollable and remove this prop
scrollable: false,
@@ -279,9 +281,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 +307,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>
@@ -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),
@@ -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[]>(() =>
@@ -644,6 +735,9 @@ const projects = computed<ResolvedProject[]>(() =>
)
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 +836,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 +920,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 +948,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>
@@ -0,0 +1,447 @@
<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-8">
<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.friendRequestsTitle) }}
</h2>
<Chips
v-model="friendRequestSource"
:items="friendRequestSourceOptions"
:format-label="formatInteractionSource"
:disabled-items="friendRequestSourceOptions"
:disabled-tooltip="formatMessage(messages.comingSoon)"
:capitalize="false"
:aria-label="formatMessage(messages.friendRequestsTitle)"
/>
<p class="m-0 text-secondary">
{{ formatMessage(messages.friendRequestsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.sharedInstanceInvitesTitle) }}
</h2>
<Chips
v-model="sharedInstanceInviteSource"
:items="sharedInstanceInviteSourceOptions"
:format-label="formatInteractionSource"
:disabled-items="sharedInstanceInviteSourceOptions"
:disabled-tooltip="formatMessage(messages.comingSoon)"
:capitalize="false"
:aria-label="formatMessage(messages.sharedInstanceInvitesTitle)"
/>
<p class="m-0 text-secondary">
{{ formatMessage(messages.sharedInstanceInvitesDescription) }}
</p>
</div>
</section>
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.blockedUsersTitle) }}
</h2>
<p class="m-0 text-secondary">
{{ formatMessage(messages.blockedUsersDescription) }}
</p>
<ul class="m-0 flex list-disc flex-col gap-1 pl-5 text-secondary">
<li>{{ formatMessage(messages.friendRequestsRestriction) }}</li>
<li>{{ formatMessage(messages.sharedInstancesRestriction) }}</li>
<li>{{ formatMessage(messages.hostingRestriction) }}</li>
</ul>
</div>
<div class="relative overflow-hidden rounded-2xl border border-solid border-surface-4">
<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-3"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-3"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-3 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="blockedUsersTable"
class="max-h-[20.5rem] overflow-y-auto"
@scroll="checkScrollState"
>
<Table
class="!rounded-none !border-0"
:columns="columns"
:data="blockedUsers"
row-key="id"
>
<template #empty-state>
<div class="flex h-40 items-center justify-center px-4 text-center text-secondary">
<div v-if="isLoading" class="flex items-center gap-2">
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
{{ formatMessage(messages.loadingBlockedUsers) }}
</div>
<div v-else-if="loadError" class="flex flex-col items-center gap-3">
<span>{{ formatMessage(messages.loadError) }}</span>
<ButtonStyled type="outlined">
<button type="button" @click="retry">
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
</div>
<span v-else>{{ formatMessage(messages.noBlockedUsers) }}</span>
</div>
</template>
<template #cell-user="{ row }">
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="row.avatar_url"
:alt="formatMessage(messages.userAvatarAlt, { username: row.username })"
:tint-by="row.username"
size="32px"
circle
no-shadow
/>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-contrast">
{{ row.name ?? row.username }}
</span>
<span v-if="row.name" class="truncate text-sm text-secondary">
{{ row.username }}
</span>
</div>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<ButtonStyled type="outlined">
<button
type="button"
:disabled="unblockingUserId !== null"
:aria-label="
formatMessage(messages.unblockUserAriaLabel, {
username: row.username,
})
"
@click="unblock(row)"
>
<SpinnerIcon
v-if="unblockingUserId === row.id"
class="animate-spin"
aria-hidden="true"
/>
{{ formatMessage(messages.unblockButton) }}
</button>
</ButtonStyled>
</div>
</template>
</Table>
</div>
<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-3"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-3"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-3 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</section>
</div>
</template>
<script setup lang="ts">
// TODO this will be moved in with the rest of the xplat settings.
import type { Labrinth } from '@modrinth/api-client'
import { LogInIcon, SpinnerIcon, ThinkingRinthbot } from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Chips from '#ui/components/base/Chips.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import Table, { type TableColumn } from '#ui/components/base/Table.vue'
import { defineMessages, useScrollIndicator, useVIntl } from '#ui/composables'
import { injectAuth, injectNotificationManager } from '#ui/providers'
import { commonMessages } from '#ui/utils'
import { blockedUsersQueryKey } from '../shared/user-profile/providers'
type BlockedUserTableColumn = 'user' | 'actions'
type BlockedUser = Labrinth.Users.v2.User & Record<BlockedUserTableColumn, unknown>
type FriendRequestSource = 'everyone' | 'mutuals' | 'no-one'
type SharedInstanceInviteSource = 'everyone' | 'friends' | 'no-one'
const props = defineProps<{
getBlockedUsers: () => Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]>
getUsers: (userIds: string[]) => Promise<Labrinth.Users.v2.User[]>
unblockUser: (userId: string) => Promise<void>
}>()
const auth = injectAuth()
const notificationManager = injectNotificationManager()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const blockedUsersTable = ref<HTMLElement | null>(null)
const unblockingUserId = ref<string | null>(null)
const friendRequestSource = ref<FriendRequestSource>('everyone')
const sharedInstanceInviteSource = ref<SharedInstanceInviteSource>('everyone')
const friendRequestSourceOptions: FriendRequestSource[] = ['everyone', 'mutuals', 'no-one']
const sharedInstanceInviteSourceOptions: SharedInstanceInviteSource[] = [
'everyone',
'friends',
'no-one',
]
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(blockedUsersTable)
function formatInteractionSource(source: FriendRequestSource | SharedInstanceInviteSource): string {
switch (source) {
case 'everyone':
return formatMessage(messages.everyone)
case 'mutuals':
return formatMessage(messages.friendsOfFriends)
case 'friends':
return formatMessage(messages.friends)
case 'no-one':
return formatMessage(messages.noOne)
}
}
const columns = computed<TableColumn<BlockedUserTableColumn>[]>(() => [
{
key: 'user',
label: formatMessage(messages.userColumn),
},
{
key: 'actions',
label: formatMessage(messages.actionsColumn),
align: 'right',
width: '8rem',
},
])
const blockedUserIdsQuery = useQuery({
queryKey: computed(() => blockedUsersQueryKey(auth.user.value?.id)),
queryFn: props.getBlockedUsers,
enabled: computed(() => Boolean(auth.user.value)),
staleTime: 30_000,
})
const blockedUserIds = computed(() => blockedUserIdsQuery.data.value ?? [])
const blockedUserProfilesQueryKey = computed(
() => ['blocked-user-profiles', auth.user.value?.id ?? null, blockedUserIds.value] as const,
)
const blockedUserProfilesQuery = useQuery({
queryKey: blockedUserProfilesQueryKey,
queryFn: () => props.getUsers(blockedUserIds.value),
enabled: computed(() => Boolean(auth.user.value && blockedUserIds.value.length)),
staleTime: 30_000,
})
const blockedUsers = computed<BlockedUser[]>(() => {
const profilesById = new Map(
(blockedUserProfilesQuery.data.value ?? []).map((user) => [user.id, user]),
)
return blockedUserIds.value
.map((userId) => profilesById.get(userId))
.filter((user): user is Labrinth.Users.v2.User => Boolean(user))
.map((user) => ({
...user,
user: user.username,
actions: null,
}))
})
const isLoading = computed(
() =>
Boolean(auth.user.value) &&
(blockedUserIdsQuery.isPending.value ||
(blockedUserIds.value.length > 0 && blockedUserProfilesQuery.isPending.value)),
)
const loadError = computed(
() => blockedUserIdsQuery.error.value ?? blockedUserProfilesQuery.error.value,
)
async function retry(): Promise<void> {
await blockedUserIdsQuery.refetch()
if (blockedUserIds.value.length > 0) {
await blockedUserProfilesQuery.refetch()
}
}
async function requestSignIn(): Promise<void> {
await auth.requestSignIn('')
}
async function unblock(user: BlockedUser): Promise<void> {
if (unblockingUserId.value) return
unblockingUserId.value = user.id
try {
await props.unblockUser(user.id)
const remainingIds = blockedUserIds.value.filter((userId) => userId !== user.id)
const remainingUsers = blockedUsers.value.filter((blockedUser) => blockedUser.id !== user.id)
queryClient.setQueryData(
['blocked-user-profiles', auth.user.value?.id ?? null, remainingIds],
remainingUsers,
)
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
blockedUsersQueryKey(auth.user.value?.id),
remainingIds,
)
} catch {
notificationManager.addNotification({
type: 'error',
title: formatMessage(messages.unblockError),
text: formatMessage(messages.unblockErrorDescription),
})
} finally {
unblockingUserId.value = null
}
}
const messages = defineMessages({
friendRequestsTitle: {
id: 'settings.social.friend-requests.title',
defaultMessage: 'Friend requests',
},
friendRequestsDescription: {
id: 'settings.social.friend-requests.description',
defaultMessage: 'Control who can send you friend requests on Modrinth.',
},
sharedInstanceInvitesTitle: {
id: 'settings.social.shared-instance-invites.title',
defaultMessage: 'Invitations',
},
sharedInstanceInvitesDescription: {
id: 'settings.social.shared-instance-invites.description',
defaultMessage:
'Control who can send you invites to shared instances and Modrinth Hosting panels.',
},
everyone: {
id: 'settings.social.interaction-source.everyone',
defaultMessage: 'Everyone',
},
friendsOfFriends: {
id: 'settings.social.interaction-source.friends-of-friends',
defaultMessage: 'Friends of friends',
},
friends: {
id: 'settings.social.interaction-source.friends',
defaultMessage: 'Friends',
},
noOne: {
id: 'settings.social.interaction-source.no-one',
defaultMessage: 'No one',
},
comingSoon: {
id: 'settings.social.interaction-source.coming-soon',
defaultMessage: 'Coming soon!',
},
blockedUsersTitle: {
id: 'settings.social.blocked-users.title',
defaultMessage: 'Blocked users',
},
blockedUsersDescription: {
id: 'settings.social.blocked-users.description',
defaultMessage: 'These are the users you have blocked on Modrinth. They cannot:',
},
friendRequestsRestriction: {
id: 'settings.social.blocked-users.restriction.friend-requests',
defaultMessage: 'Send you friend requests',
},
sharedInstancesRestriction: {
id: 'settings.social.blocked-users.restriction.shared-instances',
defaultMessage: 'Invite you to shared instances',
},
hostingRestriction: {
id: 'settings.social.blocked-users.restriction.hosting',
defaultMessage: 'Invite you to manage a Modrinth Hosting server.',
},
userColumn: {
id: 'settings.social.blocked-users.column.user',
defaultMessage: 'User',
},
actionsColumn: {
id: 'settings.social.blocked-users.column.actions',
defaultMessage: 'Actions',
},
unblockButton: {
id: 'settings.social.blocked-users.unblock',
defaultMessage: 'Unblock',
},
unblockUserAriaLabel: {
id: 'settings.social.blocked-users.unblock-user',
defaultMessage: 'Unblock {username}',
},
loadingBlockedUsers: {
id: 'settings.social.blocked-users.loading',
defaultMessage: 'Loading blocked users…',
},
noBlockedUsers: {
id: 'settings.social.blocked-users.empty',
defaultMessage: "You haven't blocked anyone.",
},
signInRequiredTitle: {
id: 'settings.social.sign-in-required.title',
defaultMessage: 'Modrinth account required',
},
signInRequiredDescription: {
id: 'settings.social.sign-in-required.description',
defaultMessage:
'You can control who can interact with you, and manage blocked users with a Modrinth Account',
},
loadError: {
id: 'settings.social.blocked-users.load-error',
defaultMessage: 'Blocked users could not be loaded.',
},
userAvatarAlt: {
id: 'settings.social.blocked-users.user-avatar',
defaultMessage: "{username}'s avatar",
},
unblockError: {
id: 'settings.social.blocked-users.unblock-error',
defaultMessage: 'Failed to unblock user',
},
unblockErrorDescription: {
id: 'settings.social.blocked-users.unblock-error-description',
defaultMessage: 'An error occurred while unblocking this user. Please try again.',
},
})
</script>
+2
View File
@@ -1,3 +1,5 @@
export { default as AccountProfileSettings } from './AccountProfileSettings.vue'
export { default as AccountSocialSettings } from './AccountSocialSettings.vue'
export { default as ServersManageAccessPage } from './hosting/manage/[id]/access/access.vue'
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
-4
View File
@@ -4076,9 +4076,6 @@
"settings.pats.title": {
"defaultMessage": "Osobní přístupové tokeny"
},
"settings.profile.title": {
"defaultMessage": "Veřejný profil"
},
"settings.sessions.title": {
"defaultMessage": "Relace"
},
@@ -4560,4 +4557,3 @@
"defaultMessage": "Typ"
}
}
-4
View File
@@ -2150,9 +2150,6 @@
"settings.pats.title": {
"defaultMessage": "Personlige adgangstokens"
},
"settings.profile.title": {
"defaultMessage": "Offentlig profil"
},
"settings.sessions.title": {
"defaultMessage": "Sessioner"
},
@@ -2241,4 +2238,3 @@
"defaultMessage": "Vanilla Shader"
}
}
-4
View File
@@ -5105,9 +5105,6 @@
"settings.pats.title": {
"defaultMessage": "Persöhnlich Zugangstoken"
},
"settings.profile.title": {
"defaultMessage": "Öffentliches Profil"
},
"settings.sessions.title": {
"defaultMessage": "Sitzungen"
},
@@ -5940,4 +5937,3 @@
"defaultMessage": "Typ"
}
}
-4
View File
@@ -5105,9 +5105,6 @@
"settings.pats.title": {
"defaultMessage": "Persönliche Zugangstoken"
},
"settings.profile.title": {
"defaultMessage": "Öffentliches Profil"
},
"settings.sessions.title": {
"defaultMessage": "Sitzungen"
},
@@ -5940,4 +5937,3 @@
"defaultMessage": "Typ"
}
}
+149 -2
View File
@@ -2861,12 +2861,36 @@
"profile.bio.fallback.user": {
"defaultMessage": "A Modrinth user."
},
"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."
},
"profile.block-user.admonition-title": {
"defaultMessage": "Are you sure you want to block this user?"
},
"profile.block-user.error-description": {
"defaultMessage": "An error occurred while blocking this user. Please try again."
},
"profile.block-user.error-title": {
"defaultMessage": "Failed to block user"
},
"profile.block-user.success-description": {
"defaultMessage": "{username} has been blocked."
},
"profile.block-user.success-title": {
"defaultMessage": "User blocked"
},
"profile.block-user.title": {
"defaultMessage": "Block {username}"
},
"profile.button.analytics": {
"defaultMessage": "View user analytics"
},
"profile.button.billing": {
"defaultMessage": "Manage user billing"
},
"profile.button.block": {
"defaultMessage": "Block"
},
"profile.button.create-collection": {
"defaultMessage": "Create a collection"
},
@@ -2888,6 +2912,9 @@
"profile.button.set-affiliate": {
"defaultMessage": "Set as affiliate"
},
"profile.button.unblock": {
"defaultMessage": "Unblock"
},
"profile.collection.projects-count": {
"defaultMessage": "{count, plural, one {# project} other {# projects}}"
},
@@ -2972,6 +2999,18 @@
"profile.role.update-error-title": {
"defaultMessage": "Failed to update role"
},
"profile.unblock-user.error-description": {
"defaultMessage": "An error occurred while unblocking this user. Please try again."
},
"profile.unblock-user.error-title": {
"defaultMessage": "Failed to unblock user"
},
"profile.unblock-user.success-description": {
"defaultMessage": "{username} has been unblocked."
},
"profile.unblock-user.success-title": {
"defaultMessage": "User unblocked"
},
"project-card.date.published.tooltip": {
"defaultMessage": "Published {date}"
},
@@ -5228,12 +5267,120 @@
"settings.pats.title": {
"defaultMessage": "Personal access tokens"
},
"settings.profile.title": {
"defaultMessage": "Public profile"
"settings.profile.bio.description": {
"defaultMessage": "A short description to tell everyone a little bit about you."
},
"settings.profile.bio.title": {
"defaultMessage": "Bio"
},
"settings.profile.navigation-title": {
"defaultMessage": "Profile"
},
"settings.profile.profile-picture.title": {
"defaultMessage": "Profile picture"
},
"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>."
},
"settings.profile.save-error": {
"defaultMessage": "Failed to update profile"
},
"settings.profile.save-error-description": {
"defaultMessage": "An error occurred while updating your profile. Please try again."
},
"settings.profile.sign-in-required.description": {
"defaultMessage": "Sign in with a Modrinth account to customize your public profile."
},
"settings.profile.sign-in-required.title": {
"defaultMessage": "Modrinth account required"
},
"settings.profile.username.description": {
"defaultMessage": "A unique case-insensitive name to identify your profile."
},
"settings.sessions.title": {
"defaultMessage": "Sessions"
},
"settings.social.blocked-users.column.actions": {
"defaultMessage": "Actions"
},
"settings.social.blocked-users.column.user": {
"defaultMessage": "User"
},
"settings.social.blocked-users.description": {
"defaultMessage": "These are the users you have blocked on Modrinth. They cannot:"
},
"settings.social.blocked-users.empty": {
"defaultMessage": "You haven't blocked anyone."
},
"settings.social.blocked-users.load-error": {
"defaultMessage": "Blocked users could not be loaded."
},
"settings.social.blocked-users.loading": {
"defaultMessage": "Loading blocked users…"
},
"settings.social.blocked-users.restriction.friend-requests": {
"defaultMessage": "Send you friend requests"
},
"settings.social.blocked-users.restriction.hosting": {
"defaultMessage": "Invite you to manage a Modrinth Hosting server."
},
"settings.social.blocked-users.restriction.shared-instances": {
"defaultMessage": "Invite you to shared instances"
},
"settings.social.blocked-users.title": {
"defaultMessage": "Blocked users"
},
"settings.social.blocked-users.unblock": {
"defaultMessage": "Unblock"
},
"settings.social.blocked-users.unblock-error": {
"defaultMessage": "Failed to unblock user"
},
"settings.social.blocked-users.unblock-error-description": {
"defaultMessage": "An error occurred while unblocking this user. Please try again."
},
"settings.social.blocked-users.unblock-user": {
"defaultMessage": "Unblock {username}"
},
"settings.social.blocked-users.user-avatar": {
"defaultMessage": "{username}'s avatar"
},
"settings.social.friend-requests.description": {
"defaultMessage": "Control who can send you friend requests on Modrinth."
},
"settings.social.friend-requests.title": {
"defaultMessage": "Friend requests"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Coming soon!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Everyone"
},
"settings.social.interaction-source.friends": {
"defaultMessage": "Friends"
},
"settings.social.interaction-source.friends-of-friends": {
"defaultMessage": "Friends of friends"
},
"settings.social.interaction-source.no-one": {
"defaultMessage": "No one"
},
"settings.social.shared-instance-invites.description": {
"defaultMessage": "Control who can send you invites to shared instances and Modrinth Hosting panels."
},
"settings.social.shared-instance-invites.title": {
"defaultMessage": "Invitations"
},
"settings.social.sign-in-required.description": {
"defaultMessage": "You can control who can interact with you, and manage blocked users with a Modrinth Account"
},
"settings.social.sign-in-required.title": {
"defaultMessage": "Modrinth account required"
},
"settings.social.title": {
"defaultMessage": "Social"
},
"sharing.invite-players-modal.add": {
"defaultMessage": "Add"
},
@@ -5027,9 +5027,6 @@
"settings.pats.title": {
"defaultMessage": "Tokens de acceso personal"
},
"settings.profile.title": {
"defaultMessage": "Perfil público"
},
"settings.sessions.title": {
"defaultMessage": "Sesiones"
},
@@ -5787,4 +5784,3 @@
"defaultMessage": "Tipo"
}
}
-3
View File
@@ -4931,9 +4931,6 @@
"settings.pats.title": {
"defaultMessage": "Tokens de acceso personal"
},
"settings.profile.title": {
"defaultMessage": "Perfil público"
},
"settings.sessions.title": {
"defaultMessage": "Sesiones"
},
-4
View File
@@ -560,9 +560,6 @@
"settings.language.title": {
"defaultMessage": "Kieli"
},
"settings.profile.title": {
"defaultMessage": "Julkinen profiili"
},
"settings.sessions.title": {
"defaultMessage": "Istunnot"
},
@@ -570,4 +567,3 @@
"defaultMessage": "Sinulla on tallentamattomia muutoksia."
}
}
@@ -2294,9 +2294,6 @@
"settings.pats.title": {
"defaultMessage": "Mga personal na access token"
},
"settings.profile.title": {
"defaultMessage": "Pampublikong profile"
},
"settings.sessions.title": {
"defaultMessage": "Mga sesyon"
},
@@ -2769,4 +2766,3 @@
"defaultMessage": "Uri"
}
}
-3
View File
@@ -5099,9 +5099,6 @@
"settings.pats.title": {
"defaultMessage": "Jetons d'accès personnel"
},
"settings.profile.title": {
"defaultMessage": "Profil public"
},
"settings.sessions.title": {
"defaultMessage": "Sessions"
},
-3
View File
@@ -1127,9 +1127,6 @@
"settings.pats.title": {
"defaultMessage": "מפתחות גישה אישיים"
},
"settings.profile.title": {
"defaultMessage": "פרופיל ציבורי"
},
"settings.sessions.title": {
"defaultMessage": "חיבורים פעילים"
},
-3
View File
@@ -4199,9 +4199,6 @@
"settings.pats.title": {
"defaultMessage": "Személyes hozzáférési tokenek"
},
"settings.profile.title": {
"defaultMessage": "Nyilvános profil"
},
"settings.sessions.title": {
"defaultMessage": "Munkamenetek"
},
-4
View File
@@ -2567,9 +2567,6 @@
"settings.pats.title": {
"defaultMessage": "Token akses pribadi"
},
"settings.profile.title": {
"defaultMessage": "Profil publik"
},
"settings.sessions.title": {
"defaultMessage": "Sesi"
},
@@ -2988,4 +2985,3 @@
"defaultMessage": "Anda memiliki perubahan yang belum tersimpan."
}
}
-3
View File
@@ -5063,9 +5063,6 @@
"settings.pats.title": {
"defaultMessage": "Token di accesso (PAT)"
},
"settings.profile.title": {
"defaultMessage": "Profilo pubblico"
},
"settings.sessions.title": {
"defaultMessage": "Sessioni"
},
-3
View File
@@ -4526,9 +4526,6 @@
"settings.pats.title": {
"defaultMessage": "個人用アクセストークン"
},
"settings.profile.title": {
"defaultMessage": "公開プロフィール"
},
"settings.sessions.title": {
"defaultMessage": "セッション"
},
-3
View File
@@ -5039,9 +5039,6 @@
"settings.pats.title": {
"defaultMessage": "개인 액세스 토큰"
},
"settings.profile.title": {
"defaultMessage": "공개 프로필"
},
"settings.sessions.title": {
"defaultMessage": "세션"
},
-4
View File
@@ -4442,9 +4442,6 @@
"settings.pats.title": {
"defaultMessage": "Token akses peribadi"
},
"settings.profile.title": {
"defaultMessage": "Profil awam"
},
"settings.sessions.title": {
"defaultMessage": "Sesi"
},
@@ -5037,4 +5034,3 @@
"defaultMessage": "Jenis"
}
}
-3
View File
@@ -5096,9 +5096,6 @@
"settings.pats.title": {
"defaultMessage": "Persoonlijke toegangstokens"
},
"settings.profile.title": {
"defaultMessage": "Openbaar profiel"
},
"settings.sessions.title": {
"defaultMessage": "Sessies"
},
-4
View File
@@ -2423,9 +2423,6 @@
"settings.pats.title": {
"defaultMessage": "Personlig tilgang nøkler"
},
"settings.profile.title": {
"defaultMessage": "Offentlig profil"
},
"settings.sessions.title": {
"defaultMessage": "Økter"
},
@@ -2781,4 +2778,3 @@
"defaultMessage": "Du har ulagrede endringer."
}
}
-3
View File
@@ -5087,9 +5087,6 @@
"settings.pats.title": {
"defaultMessage": "Klucze dostępu osobistego"
},
"settings.profile.title": {
"defaultMessage": "Profil publiczny"
},
"settings.sessions.title": {
"defaultMessage": "Sesje"
},
-3
View File
@@ -5102,9 +5102,6 @@
"settings.pats.title": {
"defaultMessage": "Token de acesso pessoal"
},
"settings.profile.title": {
"defaultMessage": "Perfil público"
},
"settings.sessions.title": {
"defaultMessage": "Sessões"
},
-4
View File
@@ -2126,9 +2126,6 @@
"settings.pats.title": {
"defaultMessage": "Tokens de acesso pessoal"
},
"settings.profile.title": {
"defaultMessage": "Perfil público"
},
"settings.sessions.title": {
"defaultMessage": "Sessões"
},
@@ -2529,4 +2526,3 @@
"defaultMessage": "Tens alterações por guardar."
}
}
-3
View File
@@ -3029,9 +3029,6 @@
"settings.pats.title": {
"defaultMessage": "Token-urile personale de acces"
},
"settings.profile.title": {
"defaultMessage": "Profil public"
},
"settings.sessions.title": {
"defaultMessage": "Sesiuni"
},
-3
View File
@@ -5018,9 +5018,6 @@
"settings.pats.title": {
"defaultMessage": "Личные токены доступа"
},
"settings.profile.title": {
"defaultMessage": "Публичный профиль"
},
"settings.sessions.title": {
"defaultMessage": "Сеансы"
},
-4
View File
@@ -5027,9 +5027,6 @@
"settings.pats.title": {
"defaultMessage": "Lični tokeni za pristup"
},
"settings.profile.title": {
"defaultMessage": "Javni profil"
},
"settings.sessions.title": {
"defaultMessage": "Sesije"
},
@@ -5787,4 +5784,3 @@
"defaultMessage": "Tip"
}
}
-4
View File
@@ -4340,9 +4340,6 @@
"settings.pats.title": {
"defaultMessage": "Personliga åtkomstnycklar"
},
"settings.profile.title": {
"defaultMessage": "Offentlig profil"
},
"settings.sessions.title": {
"defaultMessage": "Sessioner"
},
@@ -4938,4 +4935,3 @@
"defaultMessage": "Typ"
}
}
-4
View File
@@ -661,9 +661,5 @@
},
"settings.language.title": {
"defaultMessage": "ภาษา"
},
"settings.profile.title": {
"defaultMessage": "โปรไฟล์สาธารณะ"
}
}
-3
View File
@@ -5015,9 +5015,6 @@
"settings.pats.title": {
"defaultMessage": "Kişisel erişim tokenleri"
},
"settings.profile.title": {
"defaultMessage": "Herkese açık profil"
},
"settings.sessions.title": {
"defaultMessage": "Oturumlar"
},
-3
View File
@@ -4997,9 +4997,6 @@
"settings.pats.title": {
"defaultMessage": "Особистий токен доступу"
},
"settings.profile.title": {
"defaultMessage": "Публічний профіль"
},
"settings.sessions.title": {
"defaultMessage": "Сеанси"
},
-4
View File
@@ -4448,9 +4448,6 @@
"settings.pats.title": {
"defaultMessage": "Khóa truy cập cá nhân"
},
"settings.profile.title": {
"defaultMessage": "Hồ sơ công khai"
},
"settings.sessions.title": {
"defaultMessage": "Phiên hoạt động"
},
@@ -5085,4 +5082,3 @@
"defaultMessage": "Đội ngũ Modrinth"
}
}
-4
View File
@@ -5105,9 +5105,6 @@
"settings.pats.title": {
"defaultMessage": "个人访问令牌"
},
"settings.profile.title": {
"defaultMessage": "公开信息"
},
"settings.sessions.title": {
"defaultMessage": "会话"
},
@@ -5940,4 +5937,3 @@
"defaultMessage": "类型"
}
}
-4
View File
@@ -5105,9 +5105,6 @@
"settings.pats.title": {
"defaultMessage": "個人存取權杖"
},
"settings.profile.title": {
"defaultMessage": "公開個人檔案"
},
"settings.sessions.title": {
"defaultMessage": "工作階段"
},
@@ -5940,4 +5937,3 @@
"defaultMessage": "類型"
}
}
@@ -15,6 +15,7 @@ import type { StoryObj } from '@storybook/vue3-vite'
import { defineComponent, h, ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import UnsavedChangesPopup from '../../components/base/UnsavedChangesPopup.vue'
import TabbedModal from '../../components/modal/TabbedModal.vue'
function makeTabContent(label: string, lines = 3) {
@@ -152,6 +153,47 @@ export const WithFooter: StoryObj = {
}),
}
export const WithFloatingActionBar: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled, UnsavedChangesPopup },
setup() {
const modalRef = ref<InstanceType<typeof TabbedModal> | null>(null)
const dirty = ref(true)
const tabs = [
{
name: { id: 'general', defaultMessage: 'General' },
icon: InfoIcon,
content: makeTabContent('General', 20),
},
]
return { modalRef, dirty, tabs }
},
template: /* html */ `
<div>
<ButtonStyled color="brand">
<button @click="dirty = true; modalRef?.show()">Open with Floating Action Bar</button>
</ButtonStyled>
<TabbedModal
ref="modalRef"
header="Settings"
:tabs="tabs"
:floating-action-bar-shown="dirty"
>
<template #floating-action-bar>
<UnsavedChangesPopup
:original="{ dirty: false }"
:modified="{ dirty }"
inline
@save="dirty = false"
@reset="dirty = false"
/>
</template>
</TabbedModal>
</div>
`,
}),
}
export const WithBadge: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled },
+6 -2
View File
@@ -1072,13 +1072,17 @@ export const commonSettingsMessages = defineMessages({
defaultMessage: 'Personal access tokens',
},
profile: {
id: 'settings.profile.title',
defaultMessage: 'Public profile',
id: 'settings.profile.navigation-title',
defaultMessage: 'Profile',
},
sessions: {
id: 'settings.sessions.title',
defaultMessage: 'Sessions',
},
social: {
id: 'settings.social.title',
defaultMessage: 'Social',
},
})
export const commonProjectSettingsMessages = defineMessages({