mirror of
https://github.com/modrinth/code.git
synced 2026-09-02 13:05:50 +00:00
Merge remote-tracking branch 'origin/main' into prospector/button-consistency-pass
# Conflicts: # apps/frontend/src/layouts/default.vue
This commit is contained in:
@@ -97,12 +97,7 @@
|
||||
</ButtonFrame>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150"
|
||||
leave-active-class="transition-opacity duration-150"
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="shouldRenderDropdown"
|
||||
ref="dropdownRef"
|
||||
@@ -110,7 +105,6 @@
|
||||
:class="[
|
||||
props.dropdownClass,
|
||||
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
|
||||
props.dropdownClass,
|
||||
]"
|
||||
:style="dropdownStyle"
|
||||
:role="listbox ? 'listbox' : 'menu'"
|
||||
|
||||
@@ -128,12 +128,7 @@
|
||||
</div>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
:enter-active-class="addMenuTransitionEnterActiveClass"
|
||||
leave-active-class="transition-none duration-0"
|
||||
:enter-from-class="addMenuTransitionEnterFromClass"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<Transition name="floating-expand" :css="!isMobileAddMenuLayout">
|
||||
<div
|
||||
v-if="isAddMenuOpen && !isMobileActiveSubmenu"
|
||||
ref="menuContainer"
|
||||
@@ -166,207 +161,214 @@
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<div
|
||||
v-if="isAddMenuOpen && activeCategory && (isMobileAddMenuLayout || hasSubmenuPosition)"
|
||||
ref="submenu"
|
||||
class="fixed z-[10000] flex max-h-[min(70vh,32rem)] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-[14px] border border-solid border-surface-5 bg-surface-4 shadow-2xl"
|
||||
:class="activeCategory.submenuClass ?? DEFAULT_SUBMENU_CLASS"
|
||||
:style="submenuStyle"
|
||||
@mouseenter="handleSubmenuMouseEnter"
|
||||
@mouseleave="handleSubmenuMouseLeave"
|
||||
@keydown="handleAddMenuKeydown"
|
||||
@mousemove="(event) => handleMenuMouseMove(event, 'submenu')"
|
||||
>
|
||||
<Transition name="floating-expand" :css="!isMobileAddMenuLayout">
|
||||
<div
|
||||
v-if="isMobileAddMenuLayout"
|
||||
class="flex items-center border-0 border-b border-solid border-b-surface-5 bg-surface-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-12 w-full items-center gap-2 border-0 bg-transparent px-4 text-left text-base font-semibold text-primary shadow-none transition-all hover:brightness-110 focus-visible:brightness-110"
|
||||
@click="returnToCategoryMenu"
|
||||
>
|
||||
<ChevronLeftIcon class="size-5 shrink-0 text-secondary" />
|
||||
<span class="min-w-0 truncate">{{ activeCategory.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeCategory.searchable"
|
||||
class="flex justify-between border-0 border-b border-solid border-b-surface-5 py-1.5 w-full"
|
||||
>
|
||||
<StyledInput
|
||||
v-model="categorySearchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="activeCategory.searchPlaceholder ?? 'Search...'"
|
||||
wrapper-class="grow bg-surface-4 mx-1"
|
||||
input-class="ps-9 mx-1.5"
|
||||
/>
|
||||
<slot
|
||||
name="search-actions"
|
||||
:category="activeCategory"
|
||||
:selected-values="activeCategorySelectedValues"
|
||||
:set-selected-values="setActiveCategorySelectedValues"
|
||||
:close-menu="closeAddMenu"
|
||||
></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots['category-top']" class="border-0 border-b border-solid border-b-surface-5">
|
||||
<slot
|
||||
name="category-top"
|
||||
:category="activeCategory"
|
||||
:selected-values="activeCategorySelectedValues"
|
||||
:set-selected-values="setActiveCategorySelectedValues"
|
||||
:close-menu="closeAddMenu"
|
||||
></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 border-0 border-b border-solid border-b-surface-5 px-4 py-2.5 text-sm"
|
||||
>
|
||||
<span class="font-semibold text-secondary">{{ activeCategorySelectionLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-colors"
|
||||
:class="
|
||||
hasActiveCategorySelection
|
||||
? 'hover:bg-transparent hover:text-contrast'
|
||||
: 'cursor-not-allowed opacity-50'
|
||||
"
|
||||
:disabled="!hasActiveCategorySelection"
|
||||
@click="clearActiveCategorySelection"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref="activeCategoryOptionsScrollbar"
|
||||
class="dropdown-filter-bar-options-scrollbar min-h-0 flex-1 bg-surface-4"
|
||||
data-overlayscrollbars-initialize
|
||||
v-if="isAddMenuOpen && activeCategory && (isMobileAddMenuLayout || hasSubmenuPosition)"
|
||||
ref="submenu"
|
||||
class="fixed z-[10000] flex max-h-[min(70vh,32rem)] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-[14px] border border-solid border-surface-5 bg-surface-4 shadow-2xl"
|
||||
:class="activeCategory.submenuClass ?? DEFAULT_SUBMENU_CLASS"
|
||||
:style="submenuStyle"
|
||||
@mouseenter="handleSubmenuMouseEnter"
|
||||
@mouseleave="handleSubmenuMouseLeave"
|
||||
@keydown="handleAddMenuKeydown"
|
||||
@mousemove="(event) => handleMenuMouseMove(event, 'submenu')"
|
||||
>
|
||||
<div
|
||||
ref="activeCategoryOptionsContainer"
|
||||
class="h-full min-h-0 overflow-y-auto"
|
||||
data-overlayscrollbars-viewport
|
||||
v-if="isMobileAddMenuLayout"
|
||||
class="flex items-center border-0 border-b border-solid border-b-surface-5 bg-surface-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-12 w-full items-center gap-2 border-0 bg-transparent px-4 text-left text-base font-semibold text-primary shadow-none transition-all hover:brightness-110 focus-visible:brightness-110"
|
||||
@click="returnToCategoryMenu"
|
||||
>
|
||||
<ChevronLeftIcon class="size-5 shrink-0 text-secondary" />
|
||||
<span class="min-w-0 truncate">{{ activeCategory.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeCategory.searchable"
|
||||
class="flex justify-between border-0 border-b border-solid border-b-surface-5 py-1.5 w-full"
|
||||
>
|
||||
<StyledInput
|
||||
v-model="categorySearchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="activeCategory.searchPlaceholder ?? 'Search...'"
|
||||
wrapper-class="grow bg-surface-4 mx-1"
|
||||
input-class="ps-9 mx-1.5"
|
||||
/>
|
||||
<slot
|
||||
name="search-actions"
|
||||
:category="activeCategory"
|
||||
:selected-values="activeCategorySelectedValues"
|
||||
:set-selected-values="setActiveCategorySelectedValues"
|
||||
:close-menu="closeAddMenu"
|
||||
></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots['category-top']"
|
||||
class="border-0 border-b border-solid border-b-surface-5"
|
||||
>
|
||||
<slot
|
||||
name="category-top"
|
||||
:category="activeCategory"
|
||||
:selected-values="activeCategorySelectedValues"
|
||||
:set-selected-values="setActiveCategorySelectedValues"
|
||||
:close-menu="closeAddMenu"
|
||||
></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 border-0 border-b border-solid border-b-surface-5 px-4 py-2.5 text-sm"
|
||||
>
|
||||
<span class="font-semibold text-secondary">{{ activeCategorySelectionLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-colors"
|
||||
:class="
|
||||
hasActiveCategorySelection
|
||||
? 'hover:bg-transparent hover:text-contrast'
|
||||
: 'cursor-not-allowed opacity-50'
|
||||
"
|
||||
:disabled="!hasActiveCategorySelection"
|
||||
@click="clearActiveCategorySelection"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref="activeCategoryOptionsScrollbar"
|
||||
class="dropdown-filter-bar-options-scrollbar min-h-0 flex-1 bg-surface-4"
|
||||
data-overlayscrollbars-initialize
|
||||
>
|
||||
<div
|
||||
v-if="filteredActiveCategoryOptions.length === 0"
|
||||
class="px-4 py-3.5 text-base font-medium text-secondary"
|
||||
>
|
||||
{{ activeCategoryEmptyStateLabel }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="activeCategoryOptionsListContainer"
|
||||
:class="shouldVirtualizeActiveCategoryOptions ? 'relative' : 'flex flex-col'"
|
||||
:style="activeCategoryOptionsListStyle"
|
||||
ref="activeCategoryOptionsContainer"
|
||||
class="h-full min-h-0 overflow-y-auto"
|
||||
data-overlayscrollbars-viewport
|
||||
>
|
||||
<div
|
||||
v-for="{ item, index } in renderedVisibleActiveCategoryOptions"
|
||||
:key="getActiveCategoryItemKey(item, index)"
|
||||
:class="shouldVirtualizeActiveCategoryOptions ? 'absolute left-0 right-0' : undefined"
|
||||
:style="getActiveCategoryOptionWrapperStyle(index)"
|
||||
v-if="filteredActiveCategoryOptions.length === 0"
|
||||
class="px-4 py-3.5 text-base font-medium text-secondary"
|
||||
>
|
||||
{{ activeCategoryEmptyStateLabel }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="activeCategoryOptionsListContainer"
|
||||
:class="shouldVirtualizeActiveCategoryOptions ? 'relative' : 'flex flex-col'"
|
||||
:style="activeCategoryOptionsListStyle"
|
||||
>
|
||||
<div
|
||||
v-if="isDropdownFilterSectionHeader(item)"
|
||||
class="flex items-center justify-between gap-3 border-0 px-4 py-2.5 text-sm font-semibold text-secondary"
|
||||
:class="[
|
||||
item.class,
|
||||
item.dividerBefore && index > 0
|
||||
? 'border-t border-solid border-surface-5'
|
||||
: undefined,
|
||||
]"
|
||||
v-for="{ item, index } in renderedVisibleActiveCategoryOptions"
|
||||
:key="getActiveCategoryItemKey(item, index)"
|
||||
:class="
|
||||
shouldVirtualizeActiveCategoryOptions ? 'absolute left-0 right-0' : undefined
|
||||
"
|
||||
:style="getActiveCategoryOptionWrapperStyle(index)"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<component
|
||||
:is="item.icon"
|
||||
v-if="item.icon"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ item.label }}</span>
|
||||
</span>
|
||||
<div
|
||||
v-if="isDropdownFilterSectionHeader(item)"
|
||||
class="flex items-center justify-between gap-3 border-0 px-4 py-2.5 text-sm font-semibold text-secondary"
|
||||
:class="[
|
||||
item.class,
|
||||
item.dividerBefore && index > 0
|
||||
? 'border-t border-solid border-surface-5'
|
||||
: undefined,
|
||||
]"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<component
|
||||
:is="item.icon"
|
||||
v-if="item.icon"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ item.label }}</span>
|
||||
</span>
|
||||
<button
|
||||
v-if="hasSelectableSectionHeaderOptions(item)"
|
||||
type="button"
|
||||
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-colors hover:text-contrast"
|
||||
@click="toggleSectionHeaderOptions(item)"
|
||||
>
|
||||
{{ areSectionHeaderOptionsSelected(item) ? 'Clear' : 'Select all' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasSelectableSectionHeaderOptions(item)"
|
||||
v-else
|
||||
type="button"
|
||||
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-colors hover:text-contrast"
|
||||
@click="toggleSectionHeaderOptions(item)"
|
||||
class="flex w-full cursor-pointer items-center gap-2.5 border-0 px-4 py-3.5 text-left text-contrast shadow-none transition-all duration-150 bg-surface-4 hover:brightness-[115%] focus-visible:brightness-[115%] focus-visible:outline-none"
|
||||
:class="[
|
||||
shouldVirtualizeActiveCategoryOptions ? 'h-12' : undefined,
|
||||
{
|
||||
'brightness-[115%]': item.selected,
|
||||
'pointer-events-none cursor-not-allowed opacity-50': item.disabled,
|
||||
},
|
||||
]"
|
||||
:aria-disabled="item.disabled || undefined"
|
||||
:aria-checked="item.selected"
|
||||
role="checkbox"
|
||||
@click="toggleFilterOption(activeCategory.key, item)"
|
||||
>
|
||||
{{ areSectionHeaderOptionsSelected(item) ? 'Clear' : 'Select all' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-2.5 border-0 px-4 py-3.5 text-left text-contrast shadow-none transition-all duration-150 bg-surface-4 hover:brightness-[115%] focus-visible:brightness-[115%] focus-visible:outline-none"
|
||||
:class="[
|
||||
shouldVirtualizeActiveCategoryOptions ? 'h-12' : undefined,
|
||||
{
|
||||
'brightness-[115%]': item.selected,
|
||||
'pointer-events-none cursor-not-allowed opacity-50': item.disabled,
|
||||
},
|
||||
]"
|
||||
:aria-disabled="item.disabled || undefined"
|
||||
:aria-checked="item.selected"
|
||||
role="checkbox"
|
||||
@click="toggleFilterOption(activeCategory.key, item)"
|
||||
>
|
||||
<span
|
||||
v-if="checkboxPosition === 'left'"
|
||||
class="checkbox-shadow flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-[1px] border-solid"
|
||||
:class="
|
||||
item.selected
|
||||
? 'border-button-border bg-brand text-brand-inverted'
|
||||
: 'border-surface-5 bg-surface-2'
|
||||
"
|
||||
>
|
||||
<CheckIcon v-if="item.selected" aria-hidden="true" stroke-width="3" />
|
||||
</span>
|
||||
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<slot
|
||||
v-if="$slots.option"
|
||||
name="option"
|
||||
:category="activeCategory"
|
||||
:option="item"
|
||||
:selected="item.selected"
|
||||
:index="index"
|
||||
></slot>
|
||||
<template v-else>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold leading-tight"
|
||||
:class="item.selected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="checkboxPosition === 'left'"
|
||||
class="checkbox-shadow flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-[1px] border-solid"
|
||||
:class="
|
||||
item.selected
|
||||
? 'border-button-border bg-brand text-brand-inverted'
|
||||
: 'border-surface-5 bg-surface-2'
|
||||
"
|
||||
>
|
||||
<CheckIcon v-if="item.selected" aria-hidden="true" stroke-width="3" />
|
||||
</span>
|
||||
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<slot
|
||||
name="option-right"
|
||||
v-if="$slots.option"
|
||||
name="option"
|
||||
:category="activeCategory"
|
||||
:option="item"
|
||||
:selected="item.selected"
|
||||
:index="index"
|
||||
></slot>
|
||||
</template>
|
||||
</div>
|
||||
<span
|
||||
v-if="checkboxPosition === 'right'"
|
||||
class="flex shrink-0 items-center justify-center text-brand"
|
||||
>
|
||||
<CheckIcon v-if="item.selected" aria-hidden="true" class="size-5" />
|
||||
</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold leading-tight"
|
||||
:class="item.selected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<slot
|
||||
name="option-right"
|
||||
:category="activeCategory"
|
||||
:option="item"
|
||||
:selected="item.selected"
|
||||
></slot>
|
||||
</template>
|
||||
</div>
|
||||
<span
|
||||
v-if="checkboxPosition === 'right'"
|
||||
class="flex shrink-0 items-center justify-center text-brand"
|
||||
>
|
||||
<CheckIcon v-if="item.selected" aria-hidden="true" class="size-5" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot
|
||||
name="category-footer"
|
||||
:category="activeCategory"
|
||||
:selected-values="activeCategorySelectedValues"
|
||||
:set-selected-values="setActiveCategorySelectedValues"
|
||||
:close-menu="closeAddMenu"
|
||||
></slot>
|
||||
</div>
|
||||
<slot
|
||||
name="category-footer"
|
||||
:category="activeCategory"
|
||||
:selected-values="activeCategorySelectedValues"
|
||||
:set-selected-values="setActiveCategorySelectedValues"
|
||||
:close-menu="closeAddMenu"
|
||||
></slot>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -726,12 +728,6 @@ const isMobileActiveSubmenu = computed(
|
||||
() =>
|
||||
isMobileAddMenuLayout.value && activeCategory.value !== undefined && hasSubmenuPosition.value,
|
||||
)
|
||||
const addMenuTransitionEnterActiveClass = computed(() =>
|
||||
isMobileAddMenuLayout.value ? 'transition-none duration-0' : 'transition-opacity duration-150',
|
||||
)
|
||||
const addMenuTransitionEnterFromClass = computed(() =>
|
||||
isMobileAddMenuLayout.value ? 'opacity-100' : 'opacity-0',
|
||||
)
|
||||
const addMenuTriggerElement = computed(() => addMenuTrigger.value?.element ?? null)
|
||||
const addMenuOutsideClickTarget = computed(() => menuContainer.value ?? submenu.value)
|
||||
const addMenuOutsideClickIgnore = computed(() => [addMenuTriggerElement, menuContainer, submenu])
|
||||
|
||||
@@ -108,12 +108,7 @@
|
||||
</component>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150"
|
||||
leave-active-class="transition-opacity duration-150"
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="dropdownRef"
|
||||
|
||||
@@ -15,20 +15,25 @@ const emit = defineEmits<{
|
||||
(e: 'reset' | 'save', event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
type SaveDisabledReason = MessageDescriptor | string
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
canReset?: boolean
|
||||
canSave?: boolean
|
||||
original: T
|
||||
modified: Partial<T>
|
||||
saving?: boolean
|
||||
text?: MessageDescriptor | string
|
||||
saveLabel?: MessageDescriptor | string
|
||||
savingLabel?: MessageDescriptor | string
|
||||
saveDisabledReason?: SaveDisabledReason | SaveDisabledReason[]
|
||||
saveIcon?: Component
|
||||
inline?: boolean
|
||||
}>(),
|
||||
{
|
||||
canReset: true,
|
||||
canSave: true,
|
||||
saving: false,
|
||||
text: () =>
|
||||
defineMessage({
|
||||
@@ -37,6 +42,7 @@ const props = withDefaults(
|
||||
}),
|
||||
saveLabel: () => commonMessages.saveButton,
|
||||
savingLabel: () => commonMessages.savingButton,
|
||||
saveDisabledReason: undefined,
|
||||
saveIcon: SaveIcon,
|
||||
inline: false,
|
||||
},
|
||||
@@ -50,6 +56,22 @@ function localizeIfPossible(message: MessageDescriptor | string) {
|
||||
return typeof message === 'string' ? message : formatMessage(message)
|
||||
}
|
||||
|
||||
const saveDisabled = computed(() => props.saving || !props.canSave)
|
||||
|
||||
const saveDisabledTooltip = computed(() => {
|
||||
if (!saveDisabled.value || props.saving || !props.saveDisabledReason) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const reasons = (
|
||||
Array.isArray(props.saveDisabledReason) ? props.saveDisabledReason : [props.saveDisabledReason]
|
||||
).map(localizeIfPossible)
|
||||
|
||||
return reasons.length > 0
|
||||
? { content: reasons.join('\n'), popperClass: 'unsaved-changes-save-tooltip' }
|
||||
: undefined
|
||||
})
|
||||
|
||||
const actionBar = ref<InstanceType<typeof FloatingActionBar> | null>(null)
|
||||
|
||||
function nudge(): void {
|
||||
@@ -66,11 +88,32 @@ defineExpose({ nudge })
|
||||
<Button v-if="canReset" type="quiet" :disabled="saving" @click="(e) => emit('reset', e)">
|
||||
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
||||
</Button>
|
||||
<Button type="colored" color="brand" :disabled="saving" @click="(e) => emit('save', e)">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
<component :is="saveIcon" v-else />
|
||||
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
|
||||
</Button>
|
||||
<span
|
||||
v-tooltip="saveDisabledTooltip"
|
||||
class="flex"
|
||||
:class="{ 'cursor-not-allowed': saveDisabled }"
|
||||
>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="saveDisabled"
|
||||
:class="{ 'pointer-events-none': saveDisabled }"
|
||||
@click="(e) => emit('save', e)"
|
||||
>
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
<component :is="saveIcon" v-else />
|
||||
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
/* unscoped because floating-vue teleports the tooltip outside of scope */
|
||||
.v-popper__popper.v-popper--theme-tooltip.unsaved-changes-save-tooltip .v-popper__inner {
|
||||
max-width: 22rem;
|
||||
white-space: pre-line;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -80,6 +80,19 @@ const { isOpen, panelStyle, anchorStyle, resolvedSide, open, close } = useAnchor
|
||||
resolvedDistance,
|
||||
)
|
||||
|
||||
const menuTransformOrigin = computed(() => {
|
||||
switch (resolvedSide.value) {
|
||||
case 'top':
|
||||
return 'bottom center'
|
||||
case 'left':
|
||||
return 'right center'
|
||||
case 'right':
|
||||
return 'left center'
|
||||
default:
|
||||
return 'top center'
|
||||
}
|
||||
})
|
||||
|
||||
const menuItemClasses =
|
||||
'overflow-menu-item flex min-h-10 w-full items-center gap-2.5 rounded-[14px] border-0 bg-transparent px-3 py-2 text-left text-4 font-[500] leading-5 text-contrast no-underline ' +
|
||||
'cursor-pointer whitespace-nowrap hover:bg-surface-4 focus-visible:bg-surface-4 focus-visible:outline-none ' +
|
||||
@@ -294,20 +307,13 @@ defineExpose({ open: openMenu, close: closeMenu })
|
||||
</component>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-125 ease-out"
|
||||
enter-from-class="scale-95 opacity-0"
|
||||
enter-to-class="scale-100 opacity-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100"
|
||||
leave-to-class="scale-95 opacity-0"
|
||||
>
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
:id="menuId"
|
||||
ref="panelElement"
|
||||
class="fixed isolate z-[9999] flex min-w-48 flex-col gap-1 rounded-[14px] bg-surface-3 p-2 shadow-lg ring-1 ring-surface-5"
|
||||
:style="panelStyle"
|
||||
:style="[panelStyle, { transformOrigin: menuTransformOrigin }]"
|
||||
role="menu"
|
||||
:aria-label="props.label"
|
||||
@keydown="handleMenuKeydown"
|
||||
|
||||
@@ -123,14 +123,7 @@ defineExpose({ open: openMenu, close: closeMenu })
|
||||
</component>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-125 ease-out"
|
||||
enter-from-class="scale-95 opacity-0"
|
||||
enter-to-class="scale-100 opacity-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100"
|
||||
leave-to-class="scale-95 opacity-0"
|
||||
>
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
:id="panelId"
|
||||
|
||||
@@ -2,45 +2,59 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-semibold m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
<div class="flex flex-col gap-3 font-semibold">
|
||||
<template v-if="organization">
|
||||
<template v-if="loading">
|
||||
<div v-for="i in 2" :key="`creator-skeleton-${i}`" class="flex gap-2 items-center">
|
||||
<div class="size-[32px] rounded-full bg-surface-4 animate-pulse"></div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="w-24 h-4 rounded-full bg-surface-4 animate-pulse"></div>
|
||||
<div class="w-16 h-3 rounded-full bg-surface-4 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isEmpty">
|
||||
<span class="text-red"> Error: Project has no members. This shouldn't happen. </span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="organization">
|
||||
<AutoLink
|
||||
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
|
||||
:to="orgLink(organization.slug)"
|
||||
:target="linkTarget ?? null"
|
||||
>
|
||||
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
|
||||
<div class="flex flex-col flex-nowrap justify-center">
|
||||
<span class="group-hover:underline font-medium">
|
||||
{{ organization.name }}
|
||||
</span>
|
||||
<span class="text-sm font-normal text-secondary flex items-center gap-1"
|
||||
><OrganizationIcon /> {{ formatMessage(messages.organization) }}</span
|
||||
>
|
||||
</div>
|
||||
</AutoLink>
|
||||
<hr v-if="sortedMembers.length > 0" class="w-full border-button-border my-0.5" />
|
||||
</template>
|
||||
<AutoLink
|
||||
v-for="member in sortedMembers"
|
||||
:key="`member-${member.id}`"
|
||||
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
|
||||
:to="orgLink(organization.slug)"
|
||||
:target="linkTarget ?? null"
|
||||
:to="userLink(member.user.username)"
|
||||
:target="resolveLinkTarget(userLinkTarget)"
|
||||
>
|
||||
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
|
||||
<div class="flex flex-col flex-nowrap justify-center">
|
||||
<span class="group-hover:underline font-medium">
|
||||
{{ organization.name }}
|
||||
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
|
||||
<div class="flex flex-col">
|
||||
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
|
||||
<span class="min-w-0 overflow-hidden truncate">{{ member.user.username }}</span>
|
||||
<CrownIcon
|
||||
v-if="member.is_owner"
|
||||
v-tooltip="formatMessage(messages.owner)"
|
||||
class="text-brand-orange"
|
||||
/>
|
||||
<ExternalIcon v-if="resolveLinkTarget(userLinkTarget) === '_blank'" />
|
||||
</span>
|
||||
<span class="text-sm font-normal text-secondary flex items-center gap-1"
|
||||
><OrganizationIcon /> {{ formatMessage(messages.organization) }}</span
|
||||
>
|
||||
<span class="text-sm font-normal text-secondary">{{ member.role }}</span>
|
||||
</div>
|
||||
</AutoLink>
|
||||
<hr v-if="sortedMembers.length > 0" class="w-full border-button-border my-0.5" />
|
||||
</template>
|
||||
<AutoLink
|
||||
v-for="member in sortedMembers"
|
||||
:key="`member-${member.id}`"
|
||||
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
|
||||
:to="userLink(member.user.username)"
|
||||
:target="resolveLinkTarget(userLinkTarget)"
|
||||
>
|
||||
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
|
||||
<div class="flex flex-col">
|
||||
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
|
||||
<span class="min-w-0 overflow-hidden truncate">{{ member.user.username }}</span>
|
||||
<CrownIcon
|
||||
v-if="member.is_owner"
|
||||
v-tooltip="formatMessage(messages.owner)"
|
||||
class="text-brand-orange"
|
||||
/>
|
||||
<ExternalIcon v-if="resolveLinkTarget(userLinkTarget) === '_blank'" />
|
||||
</span>
|
||||
<span class="text-sm font-normal text-secondary">{{ member.role }}</span>
|
||||
</div>
|
||||
</AutoLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -80,6 +94,7 @@ const props = defineProps<{
|
||||
userLink: (username: string) => string
|
||||
linkTarget?: string
|
||||
userLinkTarget?: string | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
function resolveLinkTarget(target: string | null | undefined): string | null {
|
||||
@@ -113,6 +128,8 @@ const sortedMembers = computed(() => {
|
||||
return owner ? [owner, ...rest] : rest
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => !props.organization && sortedMembers.value.length === 0)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.about.creators.title',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="hasContent" class="flex flex-col gap-3">
|
||||
<div v-if="loading || hasContent" class="flex flex-col gap-3">
|
||||
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
|
||||
<div
|
||||
@@ -15,6 +15,10 @@
|
||||
<CopyIcon class="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loading && !projectV3"
|
||||
class="h-12 rounded-2xl bg-surface-4 animate-pulse"
|
||||
></div>
|
||||
|
||||
<section v-if="requiredContent" class="flex flex-col gap-2">
|
||||
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.requiredContent) }}</h3>
|
||||
@@ -26,8 +30,13 @@
|
||||
:onclick-version="requiredContent.onclickVersion"
|
||||
:onclick-download="requiredContent.onclickDownload"
|
||||
:show-custom-modpack-tooltip="requiredContent.showCustomModpackTooltip"
|
||||
:loading-version="loading"
|
||||
/>
|
||||
</section>
|
||||
<section v-else-if="loading" class="flex flex-col gap-2">
|
||||
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.requiredContent) }}</h3>
|
||||
<div class="h-[52px] rounded-2xl bg-surface-4 animate-pulse"></div>
|
||||
</section>
|
||||
<section v-if="recommendedVersions.length" class="flex flex-col gap-2">
|
||||
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.minecraftJava) }}</h3>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@@ -57,6 +66,17 @@
|
||||
</TagItem>
|
||||
</div>
|
||||
</section>
|
||||
<section v-else-if="loading" class="flex flex-col gap-2">
|
||||
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.minecraftJava) }}</h3>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<div
|
||||
v-for="width in ['w-16', 'w-20']"
|
||||
:key="`version-skeleton-${width}`"
|
||||
class="h-[26px] rounded-full bg-surface-4 animate-pulse"
|
||||
:class="width"
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="props.ping !== undefined || region" class="flex flex-col gap-2">
|
||||
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.region) }}</h3>
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
@@ -115,6 +135,7 @@ interface Props {
|
||||
loaders?: string[]
|
||||
ping?: number
|
||||
statusOnline?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
>
|
||||
{{ versionNumber }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loadingVersion"
|
||||
class="w-16 h-4 my-0.5 rounded-full bg-surface-4 animate-pulse"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
@@ -58,6 +62,7 @@ defineProps<{
|
||||
onclickVersion?: () => void
|
||||
onclickDownload?: () => void
|
||||
showCustomModpackTooltip?: boolean
|
||||
loadingVersion?: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -136,10 +136,6 @@ const messages = defineMessages({
|
||||
id: 'profile.button.unblock',
|
||||
defaultMessage: 'Unblock',
|
||||
},
|
||||
editRoleButton: {
|
||||
id: 'profile.button.edit-role',
|
||||
defaultMessage: 'Edit role',
|
||||
},
|
||||
infoButton: {
|
||||
id: 'profile.button.info',
|
||||
defaultMessage: 'View user details',
|
||||
@@ -220,7 +216,7 @@ const emit = defineEmits<{
|
||||
toggleAffiliate: []
|
||||
openInfo: []
|
||||
openAnalytics: []
|
||||
editRole: []
|
||||
editUser: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -306,10 +302,10 @@ const moreActions = computed<OverflowMenuOption[]>(() => [
|
||||
shown: props.showStaffActions && props.isAdmin,
|
||||
},
|
||||
{
|
||||
id: 'edit-role',
|
||||
label: formatMessage(messages.editRoleButton),
|
||||
id: 'edit-user',
|
||||
label: 'Edit user',
|
||||
icon: EditIcon,
|
||||
action: () => emit('editRole'),
|
||||
action: () => emit('editUser'),
|
||||
tone: 'orange',
|
||||
shown: props.showStaffActions && props.isAdmin,
|
||||
},
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
<template>
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
enter-active-class="transition duration-125 ease-out"
|
||||
enter-from-class="transform scale-75 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-125 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-75 opacity-0"
|
||||
>
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="fixed isolate z-[9999] flex w-fit min-w-[180px] flex-col gap-2 overflow-hidden rounded-2xl border border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px`, transformOrigin: 'top left' }"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Edit user" :closable="!isSaving">
|
||||
<div class="flex w-[28rem] flex-col gap-4">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">Profile picture</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar
|
||||
:src="displayedAvatarUrl"
|
||||
size="md"
|
||||
circle
|
||||
:alt="form.username || user.username"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<FileButton
|
||||
:max-size="262144"
|
||||
:prompt="formatMessage(commonMessages.uploadImageButton)"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
size="md"
|
||||
:disabled="isSaving"
|
||||
@change="showAvatarPreview"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
</FileButton>
|
||||
<Button
|
||||
v-if="avatarUrl && !pendingAvatarDeletion"
|
||||
native-type="button"
|
||||
size="md"
|
||||
:disabled="isSaving"
|
||||
@click="removeAvatar"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.removeImageButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="avatarFile || pendingAvatarDeletion"
|
||||
native-type="button"
|
||||
size="md"
|
||||
:disabled="isSaving"
|
||||
@click="resetAvatar"
|
||||
>
|
||||
<UndoIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.resetButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label class="text-lg font-semibold text-contrast" for="admin-edit-username">
|
||||
{{ formatMessage(commonMessages.usernameLabel) }}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<StyledInput
|
||||
id="admin-edit-username"
|
||||
v-model="form.username"
|
||||
class="w-full"
|
||||
:error="form.username.length > 39"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
<span
|
||||
v-if="form.username.length >= 30"
|
||||
class="shrink-0 text-secondary"
|
||||
:class="{ 'text-red': form.username.length > 39 }"
|
||||
>
|
||||
{{ form.username.length }}/39
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label class="text-lg font-semibold text-contrast" for="admin-edit-bio">Bio</label>
|
||||
<StyledInput
|
||||
id="admin-edit-bio"
|
||||
v-model="form.bio"
|
||||
multiline
|
||||
:error="form.bio.length > 160"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
<div class="text-secondary" :class="{ 'text-red': form.bio.length > 160 }">
|
||||
{{ form.bio.length }}/160
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">Role</span>
|
||||
<Combobox
|
||||
v-model="form.role"
|
||||
:options="roleOptions"
|
||||
placeholder="Select a role"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button native-type="button" :disabled="isSaving" @click="cancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
native-type="button"
|
||||
:disabled="!canSave || isSaving"
|
||||
@click="save"
|
||||
>
|
||||
<template v-if="isSaving">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(commonMessages.savingButton) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { SaveIcon, SpinnerIcon, TrashIcon, UndoIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import { Button, FileButton } from '#ui/components/base/buttons'
|
||||
import Combobox from '#ui/components/base/Combobox.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { useVIntl } from '#ui/composables'
|
||||
import { injectNotificationManager } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils'
|
||||
|
||||
import { injectUserProfile } from '../providers'
|
||||
|
||||
const props = defineProps<{
|
||||
user: Labrinth.Users.v3.User
|
||||
userId: string
|
||||
}>()
|
||||
|
||||
const userProfile = injectUserProfile()
|
||||
const notificationManager = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const isSaving = ref(false)
|
||||
const form = ref<{
|
||||
username: string
|
||||
bio: string
|
||||
role: Labrinth.Users.v3.Role | null
|
||||
}>({
|
||||
username: '',
|
||||
bio: '',
|
||||
role: null,
|
||||
})
|
||||
const avatarUrl = ref<string | null>(null)
|
||||
const avatarFile = shallowRef<File | null>(null)
|
||||
const avatarPreviewUrl = ref<string | null>(null)
|
||||
const pendingAvatarDeletion = ref(false)
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'developer', label: 'None' },
|
||||
{ value: 'moderator', label: 'Content Moderator' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
] satisfies { value: Labrinth.Users.v3.Role; label: string }[]
|
||||
|
||||
const displayedAvatarUrl = computed(() => {
|
||||
if (avatarPreviewUrl.value) {
|
||||
return avatarPreviewUrl.value
|
||||
}
|
||||
if (pendingAvatarDeletion.value) {
|
||||
return null
|
||||
}
|
||||
return avatarUrl.value
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return (
|
||||
form.value.username !== props.user.username ||
|
||||
form.value.bio !== (props.user.bio ?? '') ||
|
||||
form.value.role !== props.user.role ||
|
||||
Boolean(avatarFile.value || pendingAvatarDeletion.value)
|
||||
)
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
return (
|
||||
hasChanges.value &&
|
||||
form.value.username.length > 0 &&
|
||||
form.value.username.length <= 39 &&
|
||||
form.value.bio.length <= 160 &&
|
||||
form.value.role !== null
|
||||
)
|
||||
})
|
||||
|
||||
function revokeAvatarPreview(): void {
|
||||
if (avatarPreviewUrl.value) {
|
||||
URL.revokeObjectURL(avatarPreviewUrl.value)
|
||||
avatarPreviewUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function syncFromUser(): void {
|
||||
revokeAvatarPreview()
|
||||
form.value = {
|
||||
username: props.user.username,
|
||||
bio: props.user.bio ?? '',
|
||||
role: props.user.role,
|
||||
}
|
||||
avatarUrl.value = props.user.avatar_url ?? null
|
||||
avatarFile.value = null
|
||||
pendingAvatarDeletion.value = false
|
||||
}
|
||||
|
||||
function showAvatarPreview(files: File[]): void {
|
||||
const file = files[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
revokeAvatarPreview()
|
||||
avatarFile.value = file
|
||||
avatarPreviewUrl.value = URL.createObjectURL(file)
|
||||
pendingAvatarDeletion.value = false
|
||||
}
|
||||
|
||||
function removeAvatar(): void {
|
||||
revokeAvatarPreview()
|
||||
avatarFile.value = null
|
||||
pendingAvatarDeletion.value = true
|
||||
}
|
||||
|
||||
function resetAvatar(): void {
|
||||
revokeAvatarPreview()
|
||||
avatarFile.value = null
|
||||
pendingAvatarDeletion.value = false
|
||||
}
|
||||
|
||||
function show(): void {
|
||||
syncFromUser()
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
syncFromUser()
|
||||
hide()
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!form.value.role || !canSave.value || isSaving.value) {
|
||||
return
|
||||
}
|
||||
const nextUsername = form.value.username
|
||||
const usernameChanged = nextUsername !== props.user.username
|
||||
isSaving.value = true
|
||||
try {
|
||||
const patch: Partial<Pick<Labrinth.Users.v3.User, 'bio' | 'role' | 'username'>> = {}
|
||||
if (usernameChanged) {
|
||||
patch.username = nextUsername
|
||||
}
|
||||
if (form.value.bio !== (props.user.bio ?? '')) {
|
||||
patch.bio = form.value.bio
|
||||
}
|
||||
if (form.value.role !== props.user.role) {
|
||||
patch.role = form.value.role
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await userProfile.patchUser(props.user.id, patch)
|
||||
}
|
||||
|
||||
if (pendingAvatarDeletion.value) {
|
||||
await userProfile.deleteAvatar(props.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 userProfile.changeAvatar(props.user.id, avatarFile.value, extension)
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['user', props.userId] })
|
||||
hide()
|
||||
|
||||
if (usernameChanged) {
|
||||
await router.replace(`/user/${encodeURIComponent(nextUsername)}`)
|
||||
}
|
||||
} catch {
|
||||
notificationManager.addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update user',
|
||||
text: 'An error occurred while updating this user. Please try again.',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(revokeAvatarPreview)
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
@@ -37,41 +37,7 @@
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
v-if="variant === 'web'"
|
||||
ref="editRoleModal"
|
||||
:header="formatMessage(messages.editRoleButton)"
|
||||
>
|
||||
<div class="flex w-80 flex-col gap-4">
|
||||
<Combobox
|
||||
v-model="selectedRole"
|
||||
:options="roleOptions"
|
||||
:placeholder="formatMessage(messages.selectRolePlaceholder)"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button native-type="button" @click="cancelRoleEdit">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
native-type="button"
|
||||
:disabled="!selectedRole || selectedRole === user.role || isSavingRole"
|
||||
@click="saveRoleEdit"
|
||||
>
|
||||
<template v-if="isSavingRole">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.savingLabel) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<EditUserModal v-if="variant === 'web'" ref="editUserModal" :user="user" :user-id="userId" />
|
||||
|
||||
<NewModal
|
||||
v-if="variant === 'web' && isStaffViewing"
|
||||
@@ -226,7 +192,7 @@
|
||||
@open-analytics="
|
||||
openPath(`/dashboard/analytics?user=${encodeURIComponent(user.username)}`)
|
||||
"
|
||||
@edit-role="openRoleEditModal"
|
||||
@edit-user="editUserModal?.show()"
|
||||
>
|
||||
<template v-if="isModrinthUser" #summary>
|
||||
<IntlFormatted :message-id="messages.officialAccountBio">
|
||||
@@ -460,7 +426,6 @@ import {
|
||||
LibraryIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -477,7 +442,6 @@ 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 { Button } from '#ui/components/base/buttons'
|
||||
import Combobox from '#ui/components/base/Combobox.vue'
|
||||
import EmptyState from '#ui/components/base/EmptyState.vue'
|
||||
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
|
||||
import NavTabs from '#ui/components/base/NavTabs.vue'
|
||||
@@ -496,8 +460,9 @@ import {
|
||||
injectPageContext,
|
||||
injectTags,
|
||||
} from '#ui/providers'
|
||||
import { commonMessages, getProjectTypeTitleMessage } from '#ui/utils'
|
||||
import { commonMessages, getProjectTypeTitleMessage, sortProjectTypes } from '#ui/utils'
|
||||
|
||||
import EditUserModal from './components/edit-user-modal.vue'
|
||||
import { blockedUsersQueryKey, injectUserProfile } from './providers'
|
||||
import {
|
||||
hasActivePride26Midas,
|
||||
@@ -571,18 +536,6 @@ const messages = defineMessages({
|
||||
id: 'profile.collection.projects-count',
|
||||
defaultMessage: '{count, plural, one {# project} other {# projects}}',
|
||||
},
|
||||
savingLabel: {
|
||||
id: 'profile.label.saving',
|
||||
defaultMessage: 'Saving...',
|
||||
},
|
||||
editRoleButton: {
|
||||
id: 'profile.button.edit-role',
|
||||
defaultMessage: 'Edit role',
|
||||
},
|
||||
selectRolePlaceholder: {
|
||||
id: 'profile.role.select-placeholder',
|
||||
defaultMessage: 'Select a role',
|
||||
},
|
||||
userDetailsTitle: {
|
||||
id: 'profile.details.title',
|
||||
defaultMessage: 'User details',
|
||||
@@ -692,14 +645,6 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'The official user account of Modrinth. Get support at <support-link></support-link> or via email at <email></email>',
|
||||
},
|
||||
roleUpdateErrorTitle: {
|
||||
id: 'profile.role.update-error-title',
|
||||
defaultMessage: 'Failed to update role',
|
||||
},
|
||||
roleUpdateErrorDescription: {
|
||||
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',
|
||||
@@ -840,7 +785,7 @@ const projectTypes = computed(() => {
|
||||
const types = new Set(projects.value.map((project) => project.resolvedProjectType))
|
||||
if (collections.value.length > 0) types.add('collection')
|
||||
types.delete('project')
|
||||
return [...types]
|
||||
return sortProjectTypes(types)
|
||||
})
|
||||
|
||||
const navLinks = computed(() => {
|
||||
@@ -851,15 +796,13 @@ const navLinks = computed(() => {
|
||||
label: formatMessage(commonMessages.allProjectType),
|
||||
href: profilePath,
|
||||
},
|
||||
...projectTypes.value
|
||||
.map((projectType) => ({
|
||||
label:
|
||||
projectType === 'collection'
|
||||
? formatMessage(messages.collectionsLabel)
|
||||
: formatMessage(getProjectTypeTitleMessage(projectType), { count: 2 }),
|
||||
href: `${profilePath}/${projectType}s`,
|
||||
}))
|
||||
.sort((first, second) => first.label.localeCompare(second.label)),
|
||||
...projectTypes.value.map((projectType) => ({
|
||||
label:
|
||||
projectType === 'collection'
|
||||
? formatMessage(messages.collectionsLabel)
|
||||
: formatMessage(getProjectTypeTitleMessage(projectType), { count: 2 }),
|
||||
href: `${profilePath}/${projectType}s`,
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1045,35 +988,15 @@ async function retryQueries(): Promise<void> {
|
||||
}
|
||||
|
||||
const userDetailsModal = ref<ModalRef | null>(null)
|
||||
const editRoleModal = ref<ModalRef | null>(null)
|
||||
const editUserModal = ref<InstanceType<typeof EditUserModal> | 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' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
] satisfies { value: Labrinth.Users.v3.Role; label: string }[]
|
||||
|
||||
watch(
|
||||
user,
|
||||
(currentUser) => {
|
||||
selectedRole.value = currentUser?.role ?? null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function openUserDetails(): void {
|
||||
userDetailsModal.value?.show()
|
||||
}
|
||||
|
||||
function openRoleEditModal(): void {
|
||||
selectedRole.value = user.value?.role ?? null
|
||||
editRoleModal.value?.show()
|
||||
}
|
||||
|
||||
async function handleBlockAction(): Promise<void> {
|
||||
if (!auth.user.value) {
|
||||
await auth.requestSignIn(route.fullPath)
|
||||
@@ -1150,11 +1073,6 @@ async function unblockCurrentUser(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRoleEdit(): void {
|
||||
selectedRole.value = user.value?.role ?? null
|
||||
editRoleModal.value?.hide()
|
||||
}
|
||||
|
||||
async function toggleAffiliate(): Promise<void> {
|
||||
if (!user.value) return
|
||||
await userProfile.patchUser(user.value.id, {
|
||||
@@ -1162,23 +1080,4 @@ async function toggleAffiliate(): Promise<void> {
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['user', props.userId] })
|
||||
}
|
||||
|
||||
async function saveRoleEdit(): Promise<void> {
|
||||
if (!user.value || !selectedRole.value || selectedRole.value === user.value.role) return
|
||||
|
||||
isSavingRole.value = true
|
||||
try {
|
||||
await userProfile.patchUser(user.value.id, { role: selectedRole.value })
|
||||
await queryClient.invalidateQueries({ queryKey: ['user', props.userId] })
|
||||
editRoleModal.value?.hide()
|
||||
} catch {
|
||||
notificationManager.addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.roleUpdateErrorTitle),
|
||||
text: formatMessage(messages.roleUpdateErrorDescription),
|
||||
})
|
||||
} finally {
|
||||
isSavingRole.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,8 +9,10 @@ export interface UserProfileContext {
|
||||
getCollections: (userId: string) => Promise<Labrinth.Collections.Collection[]>
|
||||
patchUser: (
|
||||
userId: string,
|
||||
patch: Partial<Pick<Labrinth.Users.v3.User, 'badges' | 'role'>>,
|
||||
patch: Partial<Pick<Labrinth.Users.v3.User, 'badges' | 'bio' | 'role' | 'username'>>,
|
||||
) => Promise<void>
|
||||
changeAvatar: (userId: string, file: Blob, extension: string) => Promise<void>
|
||||
deleteAvatar: (userId: string) => Promise<void>
|
||||
getBlockedUsers: () => Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]>
|
||||
blockUser: (userId: string) => Promise<void>
|
||||
unblockUser: (userId: string) => Promise<void>
|
||||
|
||||
@@ -2897,9 +2897,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Projekt erstellen"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Rolle bearbeiten"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Nutzerdetails anzeigen"
|
||||
},
|
||||
@@ -2981,24 +2978,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, one {Projekt} other {Projekte}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Speichert..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Offizielles Modrinth-Konto"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "Das offizielle Benutzerkonto von Modrinth. Erhalte Hilfe unter <support-link></support-link> oder per E-Mail unter <email></email>"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Eine Rolle auswählen"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Beim Aktualisieren dieser Nutzerrolle ist ein Fehler aufgetreten. Bitte versuche es erneut."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Rolle konnte nicht aktualisiert werden"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Beim Freigeben dieses Nutzers ist ein Fehler aufgetreten. Bitte versuche es erneut."
|
||||
},
|
||||
|
||||
@@ -2897,9 +2897,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Projekt erstellen"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Rolle bearbeiten"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Nutzerdetails anzeigen"
|
||||
},
|
||||
@@ -2981,24 +2978,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, one {Projekt} other {Projekte}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Speichert..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Offizielles Modrinth-Konto"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "Das offizielle Benutzerkonto von Modrinth. Erhalte Hilfe unter <support-link></support-link> oder per E-Mail unter <email></email>"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Eine Rolle auswählen"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Beim Aktualisieren dieser Nutzerrolle ist ein Fehler aufgetreten. Bitte versuche es erneut."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Rolle konnte nicht aktualisiert werden"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Beim Freigeben dieses Nutzers ist ein Fehler aufgetreten. Bitte versuche es erneut."
|
||||
},
|
||||
|
||||
@@ -2897,9 +2897,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Create a project"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Edit role"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "View user details"
|
||||
},
|
||||
@@ -2996,24 +2993,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, one {project} other {projects}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Saving..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Official Modrinth account"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "The official user account of Modrinth. Get support at <support-link></support-link> or via email at <email></email>"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Select a role"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "An error occurred while updating the user role. Please try again."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -2897,9 +2897,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Crear un proyecto"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Editar rol"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Ver detalles del usuario"
|
||||
},
|
||||
@@ -2996,24 +2993,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, one {proyecto} other {proyectos}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Guardando..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Cuenta oficial de Modrinth"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "La cuenta oficial de Modrinth. Consigue ayuda en <support-link></support-link> o escríbenos a través de email: <email></email>"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Selecciona un rol"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Ocurrió un error al actualizar el rol del usuario. Por favor inténtalo otra vez."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Error al actualizar el rol"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Ocurrió un error al desbloquear a este usuario. Por favor inténtalo otra vez."
|
||||
},
|
||||
|
||||
@@ -2891,9 +2891,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Créer un projet"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Modifier le rôle"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Voir les détails de l'utilisateur"
|
||||
},
|
||||
@@ -2975,24 +2972,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural,one {projet}other {projets}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Enregistrement..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Compte Modrinth officiel"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "Le compte utilisateur officiel de Modrinth. Obtenez de l’aide via <support-link></support-link> ou par e-mail à <email></email>"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Sélectionner un rôle"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Une erreur s'est produite lors de la mise à jour du rôle utilisateur. Veuillez réessayer."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Impossible de mettre à jour le rôle"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Une erreur s'est produite pendant le déblocage de cet utilisateur. Veuillez réessayer."
|
||||
},
|
||||
|
||||
@@ -2534,9 +2534,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Projekt létrehozása"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Szerep szerkesztése"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Felhasználói adatok megtekintése"
|
||||
},
|
||||
@@ -2609,24 +2606,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "projekt"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Mentés..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Hivatalos Modrinth-fiók"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "A Modrinth hivatalos felhasználói fiókja. Támogatás a <support-link></support-link> oldalon kaphatsz vagy e-mailben a <email></email> címen."
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Válassz egy rangot"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Hiba történt a felhasználói szerepkör frissítése közben. Kérlek próbáld meg újra."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "A szerepkör frissítése nem sikerült"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Hiba történt a felhasználó letiltásának feloldása közben. Kérlek próbáld meg újra."
|
||||
},
|
||||
|
||||
@@ -2855,9 +2855,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Crea un progetto"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Modifica ruolo"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Mostra info utente"
|
||||
},
|
||||
@@ -2954,24 +2951,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, one {progetto} other {progetti}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Salvataggio..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Account Modrinth ufficiale"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "L'account ufficiale di Modrinth. Ricevi assistenza presso <support-link></support-link> o tramite mail presso <email></email>"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Seleziona il ruolo"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Sì è verificato un errore nel salvataggio del ruolo utente. Riprova più tardi."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Impossibile salvare il ruolo"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Sì è verificato un errore nello sbloccare l'utente. Riprova più tardi."
|
||||
},
|
||||
|
||||
@@ -2897,9 +2897,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "프로젝트 만들기"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "역할 수정"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "사용자 상세 정보 보기"
|
||||
},
|
||||
@@ -2981,24 +2978,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, other {프로젝트}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "저장 중..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "공식 Modrinth 계정"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "Modrinth의 공식 사용자 계정입니다. <support-link></support-link> 또는 <email></email> 을 통해 문의해 주세요"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "역할 선택"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "사용자 역할을 업데이트하는 동안 오류가 발생했습니다. 다시 시도해 주세요."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "역할 업데이트 실패"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "이 사용자의 차단을 해제하는 동안 오류가 발생했습니다. 다시 시도해 주세요."
|
||||
},
|
||||
|
||||
@@ -2858,9 +2858,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Een project aanmaken"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Rol bewerken"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Gebruikersgegevens bekijken"
|
||||
},
|
||||
@@ -2939,9 +2936,6 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, one {project} other {projecten}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Opslaan..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Officieel Modrinth-account"
|
||||
},
|
||||
|
||||
@@ -2885,9 +2885,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Utwórz projekt"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Edytuj rolę"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Otwórz szczegóły użytkownika"
|
||||
},
|
||||
@@ -2960,24 +2957,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, one {projekt} few {projekty} other {projektów}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Zapisywanie..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Oficjalne konto Modrinth"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "Oficjalne konto Modrinth. Otrzymaj wsparcie na stronie <support-link></support-link> lub poprzez e-mail <email></email>"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Wybierz rolę"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Wystąpił błąd podczas ustawiania roli tego użytkownika. Spróbuj ponownie później."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Nie udało się zaktualizować roli"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Wystąpił błąd podczas odblokowywania tego użytkownika. Spróbuj ponownie później."
|
||||
},
|
||||
|
||||
@@ -2897,9 +2897,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Criar projeto"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Editar cargo"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Ver detalhes do usuário"
|
||||
},
|
||||
@@ -2996,24 +2993,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, one {projeto} other {projetos}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Salvando..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Conta oficial do Modrinth"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "Conta oficial do Modrinth. Obtenha suporte em<support-link></support-link> ou por e-mail via <email></email>"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Selecione um cargo"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Ocorreu um erro ao atualizar o cargo do usuário. Tente novamente."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Falha ao atualizar cargo"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Ocorreu um erro ao desbloquear este usuário. Tente novamente."
|
||||
},
|
||||
|
||||
@@ -2810,9 +2810,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Создать проект"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Настройка роли"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Посмотреть подробности"
|
||||
},
|
||||
@@ -2909,24 +2906,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, one {проект} few {проекта} other {проектов}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Сохранение..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Официальный аккаунт Modrinth"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "Официальный аккаунт Modrinth. Связь с поддержкой: <support-link></support-link> или по почте <email></email>"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Выберите роль"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Произошла ошибка при обновлении роли. Попробуйте снова."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Не удалось обновить роль"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Произошла ошибка при разблокировке пользователя. Попробуйте снова."
|
||||
},
|
||||
|
||||
@@ -2558,9 +2558,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Skapa ett projekt"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Ändra roll"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Visa användardetaljer"
|
||||
},
|
||||
@@ -2627,21 +2624,9 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "Projekt"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Sparar..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Officiellt Modrinth-konto"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Välj en roll"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Ett fel inträffade medan användarrollen uppdaterades. Vänligen försök igen."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Misslyckades att uppdatera rollen"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Ett fel inträffade under avblockeringen av den här användaren. Vänligen försök igen."
|
||||
},
|
||||
|
||||
@@ -2852,9 +2852,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "Створити проєкт"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "Зміна ролі"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "Деталі про користувача"
|
||||
},
|
||||
@@ -2945,24 +2942,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, one {проєкт} few {проєкти} many {проєктів} other {проєктів}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "Збереження..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Офіційний обліковий запис Modrinth"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "Офіційний обліковий запис Modrinth. Зв'язатися з підтримкою можна за <support-link></support-link> або через електронну пошту за <email></email>"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "Виберіть роль"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "Виникла помилка під час оновлення ролі користувача. Спробуйте знову."
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Не вдалося оновити роль"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "Виникла помилка під час розблокування користувача. Спробуйте знову."
|
||||
},
|
||||
|
||||
@@ -2897,9 +2897,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "创建项目"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "编辑角色"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "查看用户详情"
|
||||
},
|
||||
@@ -2996,24 +2993,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, other {项目}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "正在保存……"
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "官方 Modrinth 账户"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "Modrinth 的官方用户账号。可通过 <support-link></support-link> 或电子邮件 <email></email> 获取支持"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "选择角色"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "更新用户角色时发生了错误。请重试。"
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "更新角色失败"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "解除屏蔽该用户时发生了错误。请重试。"
|
||||
},
|
||||
|
||||
@@ -2897,9 +2897,6 @@
|
||||
"profile.button.create-project": {
|
||||
"defaultMessage": "建立專案"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"defaultMessage": "編輯身分"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"defaultMessage": "檢視使用者詳細資訊"
|
||||
},
|
||||
@@ -2996,24 +2993,12 @@
|
||||
"profile.label.project-count": {
|
||||
"defaultMessage": "{count, plural, other {個專案}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"defaultMessage": "正在儲存..."
|
||||
},
|
||||
"profile.official-account": {
|
||||
"defaultMessage": "Modrinth 官方帳號"
|
||||
},
|
||||
"profile.official-account.bio": {
|
||||
"defaultMessage": "Modrinth 的官方使用者帳號。請至 <support-link></support-link> 取得支援,或透過電子郵件 <email></email> 聯絡客服團隊"
|
||||
},
|
||||
"profile.role.select-placeholder": {
|
||||
"defaultMessage": "選擇身分"
|
||||
},
|
||||
"profile.role.update-error-description": {
|
||||
"defaultMessage": "更新使用者身分時發生錯誤,請再試一次。"
|
||||
},
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "無法更新身分"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "解除封鎖使用者時發生錯誤,請再試一次。"
|
||||
},
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
:root {
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.floating-expand-enter-from,
|
||||
.floating-expand-leave-to {
|
||||
opacity: 0;
|
||||
scale: 0.9 0.3;
|
||||
}
|
||||
|
||||
.floating-expand-enter-to,
|
||||
.floating-expand-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.floating-expand-enter-active,
|
||||
.floating-expand-leave-active {
|
||||
transition:
|
||||
opacity 0.125s var(--ease-out-expo),
|
||||
scale 0.125s var(--ease-out-expo);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.heading-xl {
|
||||
@apply m-0 text-xl font-semibold leading-none text-contrast;
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './file-extensions'
|
||||
export * from './game-modes'
|
||||
export * from './loaders'
|
||||
export * from './notices'
|
||||
export * from './project-types'
|
||||
export * from './savable'
|
||||
export * from './search'
|
||||
export * from './server-content-installing'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export const PROJECT_TYPE_ORDER = [
|
||||
'mod',
|
||||
'resourcepack',
|
||||
'datapack',
|
||||
'shader',
|
||||
'modpack',
|
||||
'plugin',
|
||||
'server',
|
||||
'collection',
|
||||
] as const
|
||||
|
||||
export type OrderedProjectType = (typeof PROJECT_TYPE_ORDER)[number]
|
||||
|
||||
const PROJECT_TYPE_SORT_ALIASES: Record<string, string> = {
|
||||
minecraft_java_server: 'server',
|
||||
shaderpack: 'shader',
|
||||
}
|
||||
|
||||
export function getProjectTypeSortIndex(type: string): number {
|
||||
const normalized = PROJECT_TYPE_SORT_ALIASES[type] ?? type
|
||||
const index = (PROJECT_TYPE_ORDER as readonly string[]).indexOf(normalized)
|
||||
return index === -1 ? PROJECT_TYPE_ORDER.length : index
|
||||
}
|
||||
|
||||
export function compareProjectTypes(a: string, b: string): number {
|
||||
return getProjectTypeSortIndex(a) - getProjectTypeSortIndex(b)
|
||||
}
|
||||
|
||||
export function sortProjectTypes<T extends string>(types: Iterable<T>): T[] {
|
||||
return [...types].sort(compareProjectTypes)
|
||||
}
|
||||
@@ -93,10 +93,10 @@ export type ProjectType =
|
||||
|
||||
const ALL_PROJECT_TYPES: ProjectType[] = [
|
||||
'mod',
|
||||
'modpack',
|
||||
'resourcepack',
|
||||
'shader',
|
||||
'datapack',
|
||||
'shader',
|
||||
'modpack',
|
||||
'plugin',
|
||||
'server',
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user