feat: server access post release QA (#6316)

* fix: clicking users in table in app takes you to blank page instead of website

* fix: wrong loader icon on server panel

* fix: surface var misalignment

* fix: password managers still detecting username field as something to autofill

* feat: show users on backupitem components

* feat: seperators for filter sections

* fix: lint + change remove -> revoke

* fix: copy

* feat: align copy
This commit is contained in:
Calum H.
2026-06-05 15:54:27 +01:00
committed by GitHub
parent c653228fe7
commit dfe12d4ecb
23 changed files with 287 additions and 81 deletions
+9 -3
View File
@@ -20,13 +20,16 @@
ref="searchTriggerRef"
v-model="searchQuery"
:icon="showSearchIcon ? SearchIcon : undefined"
type="text"
:type="searchType"
:name="searchName"
:placeholder="searchPlaceholder || placeholder"
:disabled="disabled"
:autocomplete="searchAutocomplete"
:autocorrect="searchAutocorrect"
:autocapitalize="searchAutocapitalize"
:spellcheck="searchSpellcheck"
:inputmode="searchInputmode"
:input-attrs="searchInputAttrs"
wrapper-class="w-full !bg-transparent"
:input-class="searchableInputClass"
class="relative z-[1]"
@@ -281,8 +284,6 @@ const props = withDefaults(
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
disableSearchFilter?: boolean
dropdownClass?: string
dropdownMinWidth?: string
minSearchLengthToOpen?: number
/** Keep the selected option's label in the input after selection, and show all options on focus */
syncWithSelection?: boolean
@@ -290,10 +291,14 @@ const props = withDefaults(
selectSearchTextOnFocus?: boolean
/** Show a search icon in the searchable input */
showSearchIcon?: boolean
searchType?: 'text' | 'search'
searchName?: string
searchInputmode?: 'text' | 'search'
searchAutocomplete?: string
searchAutocorrect?: 'on' | 'off'
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
searchSpellcheck?: boolean
searchInputAttrs?: Record<string, string | number | boolean | undefined>
}>(),
{
placeholder: 'Select an option',
@@ -309,6 +314,7 @@ const props = withDefaults(
syncWithSelection: true,
selectSearchTextOnFocus: false,
showSearchIcon: false,
searchType: 'text',
outsideClickIgnore: () => [],
},
)
@@ -269,8 +269,13 @@
>
<div
v-if="isDropdownFilterSectionHeader(item)"
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm font-semibold text-secondary"
:class="item.class"
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
@@ -398,6 +403,7 @@ export type DropdownFilterBarSectionHeader = {
key?: string
icon?: Component
class?: string
dividerBefore?: boolean
}
export type DropdownFilterBarItem = DropdownFilterBarOption | DropdownFilterBarSectionHeader
@@ -21,6 +21,7 @@
<textarea
v-if="multiline"
:id="id"
v-bind="inputAttrs"
ref="inputRef"
:value="model"
:placeholder="placeholder"
@@ -50,6 +51,7 @@
<input
v-else
:id="id"
v-bind="inputAttrs"
ref="inputRef"
:type="type"
:value="model"
@@ -149,6 +151,7 @@ const props = withDefaults(
resize?: 'none' | 'vertical' | 'both'
inputClass?: string
wrapperClass?: string
inputAttrs?: Record<string, string | number | boolean | undefined>
}>(),
{
type: 'text',
+9 -9
View File
@@ -1,14 +1,14 @@
<template>
<div class="overflow-hidden rounded-2xl border border-solid border-surface-5">
<div class="overflow-hidden rounded-2xl border border-solid border-surface-4">
<div
v-if="hasHeaderSlot"
class="border-solid border-0 border-b border-surface-5 bg-surface-3 p-4"
class="border-solid border-0 border-b border-surface-4 bg-surface-3 p-4"
>
<slot name="header" />
</div>
<div class="overflow-x-auto overflow-y-hidden">
<table
class="w-full table-fixed border-separate border-spacing-0 border-surface-5"
class="w-full table-fixed border-separate border-spacing-0 border-surface-4"
:style="tableMinWidth ? { minWidth: tableMinWidth } : undefined"
>
<colgroup>
@@ -68,7 +68,7 @@
tag="tbody"
>
<tr v-if="data.length === 0" key="empty" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-4 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
@@ -84,7 +84,7 @@
>
<td
v-if="showSelection"
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
class="w-12 border-solid border-0 border-t border-surface-4 focus:outline-none"
>
<Checkbox
:model-value="isSelected(row)"
@@ -95,7 +95,7 @@
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
:class="`text-${column.align ?? 'left'}`"
>
<slot
@@ -113,7 +113,7 @@
</TransitionGroup>
<tbody v-else :ref="setListContainer">
<tr v-if="data.length === 0" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-4 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
@@ -136,7 +136,7 @@
>
<td
v-if="showSelection"
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
class="w-12 border-solid border-0 border-t border-surface-4 focus:outline-none"
>
<Checkbox
:model-value="isSelected(row)"
@@ -147,7 +147,7 @@
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
:class="`text-${column.align ?? 'left'}`"
>
<slot
@@ -11,9 +11,10 @@
>
<template #cell-user="{ row: member }">
<AutoLink
:to="userProfilePath(member.user.username)"
:to="getUserProfileLink(member.user.username)"
:target="userProfileTarget(member.user.username)"
class="inline-flex max-w-full min-w-0 items-center gap-2"
:class="userProfilePath(member.user.username) ? 'text-primary hover:underline' : ''"
:class="getUserProfileLink(member.user.username) ? 'text-primary hover:underline' : ''"
>
<Avatar
:src="member.user.avatarUrl"
@@ -61,7 +62,7 @@
<template #cell-joined="{ row: member }">
<span
v-if="member.pending"
class="inline-flex h-7 items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
class="inline-flex h-7 items-center rounded-full border border-surface-4 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
>
{{ formatMessage(messages.pendingLabel) }}
</span>
@@ -102,7 +103,7 @@
<div
v-if="members.length > 0"
class="overflow-hidden rounded-2xl border border-solid border-surface-5 sm:hidden"
class="overflow-hidden rounded-2xl border border-solid border-surface-4 sm:hidden"
>
<div
class="grid min-h-14 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] bg-surface-3"
@@ -147,15 +148,16 @@
<div
v-for="(member, index) in sortedMembers"
:key="member.id"
class="grid min-h-16 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] items-center border-0 border-t border-solid border-surface-5"
class="grid min-h-16 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] items-center border-0 border-t border-solid border-surface-4"
:class="index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<div class="flex min-w-0 items-center pl-4">
<AutoLink
v-tooltip="member.user.username"
:to="userProfilePath(member.user.username)"
:to="getUserProfileLink(member.user.username)"
:target="userProfileTarget(member.user.username)"
class="inline-flex min-w-0 items-center gap-2"
:class="userProfilePath(member.user.username) ? 'text-primary hover:underline' : ''"
:class="getUserProfileLink(member.user.username) ? 'text-primary hover:underline' : ''"
>
<Avatar
:src="member.user.avatarUrl"
@@ -207,7 +209,7 @@
<div class="min-w-0 py-3 pr-2 text-right text-secondary">
<span
v-if="member.pending"
class="inline-flex h-7 max-w-full items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
class="inline-flex h-7 max-w-full items-center rounded-full border border-surface-4 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
>
{{ formatMessage(messages.pendingLabel) }}
</span>
@@ -248,7 +250,7 @@
</div>
</div>
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-5">
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-4">
<div
class="grid min-h-14 grid-cols-[3.75rem_7.25rem_minmax(0,1fr)_2.75rem] bg-surface-3 sm:h-14 sm:grid-cols-[32%_28%_28%_12%]"
>
@@ -266,7 +268,7 @@
</div>
</div>
<div
class="border-0 border-t border-solid border-surface-5 bg-surface-2 px-4 py-8 text-center text-secondary"
class="border-0 border-t border-solid border-surface-4 bg-surface-2 px-4 py-8 text-center text-secondary"
>
{{ formatMessage(messages.emptyState) }}
</div>
@@ -293,7 +295,12 @@ import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
import TeleportOverflowMenu from '../../base/TeleportOverflowMenu.vue'
import type { ServerAccessMember, ServerAccessRole, ServerAccessRoleOption } from './types'
import type {
ServerAccessMember,
ServerAccessRole,
ServerAccessRoleOption,
ServerAccessUserProfileLink,
} from './types'
const props = withDefaults(
defineProps<{
@@ -301,6 +308,7 @@ const props = withDefaults(
roles: ServerAccessRoleOption[]
canManageUsers?: boolean
permissionDeniedMessage?: string
userProfileLink?: (username: string) => ServerAccessUserProfileLink
}>(),
{
canManageUsers: true,
@@ -353,11 +361,11 @@ const messages = defineMessages({
},
cancelInvite: {
id: 'servers.access-table.action.cancel-invite',
defaultMessage: 'Cancel invite',
defaultMessage: 'Revoke invite',
},
removeUser: {
id: 'servers.access-table.action.remove-user',
defaultMessage: 'Remove user',
defaultMessage: 'Revoke access',
},
emptyState: {
id: 'servers.access-table.empty',
@@ -533,9 +541,14 @@ function roleTriggerClass(role: ServerAccessRole): string {
return roleClasses(role)
}
function userProfilePath(username: string): string | undefined {
function getUserProfileLink(username: string): ServerAccessUserProfileLink {
if (!username || username.includes('@')) return undefined
return `/user/${encodeURIComponent(username)}`
return props.userProfileLink?.(username) ?? `/user/${encodeURIComponent(username)}`
}
function userProfileTarget(username: string): string | undefined {
const link = getUserProfileLink(username)
return typeof link === 'string' && link.startsWith('http') ? '_blank' : undefined
}
function resendInviteCooldownSeconds(member: ServerAccessMember): number {
@@ -12,7 +12,7 @@
:trigger-class="timeframePickerTriggerClass"
/>
<template v-if="slots.filters">
<div class="hidden h-8 w-[1px] shrink-0 bg-surface-5 @[640px]:ml-1 @[640px]:block"></div>
<div class="hidden h-8 w-[1px] shrink-0 bg-surface-4 @[640px]:ml-1 @[640px]:block"></div>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<slot name="filters"></slot>
</div>
@@ -128,7 +128,7 @@
<div
v-for="entry in filteredEntries"
:key="entry.id"
class="flex min-w-0 flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
class="flex min-w-0 flex-col gap-3 rounded-2xl border border-solid border-surface-4 bg-surface-2 p-4"
>
<AutoLink
v-tooltip="actorName(entry)"
@@ -166,7 +166,7 @@
</div>
</TransitionGroup>
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-5">
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-4">
<div
class="hidden min-h-14 bg-surface-3 @[800px]:grid @[800px]:h-14"
:class="
@@ -224,7 +224,7 @@
</div>
</div>
<div
class="border-0 border-solid border-surface-5 bg-surface-2 px-4 py-8 text-center text-secondary @[800px]:border-t"
class="border-0 border-solid border-surface-4 bg-surface-2 px-4 py-8 text-center text-secondary @[800px]:border-t"
>
{{ formatMessage(emptyStateMessage) }}
</div>
@@ -22,10 +22,14 @@
searchable
show-search-icon
:show-chevron="false"
search-autocomplete="off"
search-type="search"
search-name="modrinth-server-access-member-search"
search-inputmode="search"
search-autocomplete="new-password"
search-autocorrect="off"
search-autocapitalize="none"
:search-spellcheck="false"
:search-input-attrs="passwordManagerIgnoreAttrs"
@open="targetComboboxOpen = true"
@close="targetComboboxOpen = false"
@search-input="handleTargetSearch"
@@ -191,6 +195,13 @@ const targetLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle')
const targetLookupRequestId = ref(0)
const hasSelectedTarget = ref(false)
const targetComboboxOpen = ref(false)
const passwordManagerIgnoreAttrs = {
'data-1p-ignore': 'true',
'data-bwignore': 'true',
'data-form-type': 'other',
'data-lpignore': 'true',
'data-protonpass-ignore': 'true',
}
const messages = defineMessages({
header: {
@@ -135,29 +135,29 @@ const cachedState = ref({
const messages = defineMessages({
header: {
id: 'servers.remove-access-modal.header',
defaultMessage: 'Remove user',
defaultMessage: 'Revoke access',
},
cancelHeader: {
id: 'servers.remove-access-modal.cancel-header',
defaultMessage: 'Cancel invite',
defaultMessage: 'Revoke invite',
},
warningBody: {
id: 'servers.remove-access-modal.warning-body',
defaultMessage:
"If you remove a user from your server, you'll need to re-invite them to restore access.",
"If you revoke a user's access to your server, you'll need to re-invite them to restore access.",
},
cancelWarningBody: {
id: 'servers.remove-access-modal.cancel-warning-body',
defaultMessage:
'If you cancel this invite, {username} will need a new invitation before they can join this server.',
'If you revoke this invite, {username} will need a new invitation before they can join this server.',
},
removeButton: {
id: 'servers.remove-access-modal.remove-button',
defaultMessage: 'Remove user',
defaultMessage: 'Revoke access',
},
cancelButton: {
id: 'servers.remove-access-modal.cancel-button',
defaultMessage: 'Cancel invite',
defaultMessage: 'Revoke invite',
},
userAvatarAlt: {
id: 'servers.remove-access-modal.user-avatar-alt',
@@ -63,7 +63,7 @@ const messages = defineMessages({
},
removed: {
id: 'servers.audit-log.event.user-removed',
defaultMessage: 'Removed <target-user></target-user>',
defaultMessage: 'Revoked access for <target-user></target-user>',
},
ownerRole: {
id: 'servers.access-role.owner',
@@ -1,6 +1,12 @@
import type { RouteLocationRaw } from 'vue-router'
import type { AuditActor, AuditWorld, ParsedAuditEvent } from './events/types'
export type ServerAccessRole = 'owner' | 'editor' | 'viewer'
export type ServerAccessUserProfileLink =
| RouteLocationRaw
| (() => void | Promise<void>)
| undefined
export interface ServerAccessUser extends AuditActor {
id: string
@@ -4,6 +4,7 @@ import {
ClipboardCopyIcon,
DownloadIcon,
EditIcon,
IntercomBubbleIcon,
MoreVerticalIcon,
RotateCounterClockwiseIcon,
ShieldIcon,
@@ -15,6 +16,8 @@ import { computed, ref } from 'vue'
import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages, truncatedTooltip } from '../../../utils'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
@@ -32,6 +35,7 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
backup: Archon.BackupsQueue.v1.BackupQueueBackup
creator?: Archon.BackupsQueue.v1.UserInfo | null
preview?: boolean
kyrosUrl?: string
jwt?: string
@@ -44,6 +48,7 @@ const props = withDefaults(
highlighted?: boolean
}>(),
{
creator: undefined,
preview: false,
kyrosUrl: undefined,
jwt: undefined,
@@ -59,6 +64,22 @@ const props = withDefaults(
const nameRef = ref<HTMLElement | null>(null)
const backupCreator = computed(() => {
if (props.creator !== undefined) return props.creator
return (
props.backup.history.find(
(operation) => operation.operation_type === 'create' && operation.user_info,
)?.user_info ?? null
)
})
const creatorProfileLink = computed(() =>
backupCreator.value && backupCreator.value.id !== 'support'
? `https://modrinth.com/user/${encodeURIComponent(backupCreator.value.username)}`
: undefined,
)
const backupIcon = computed(() => {
if (props.backup.automated) {
return ShieldIcon
@@ -137,7 +158,29 @@ const messages = defineMessages({
id: 'servers.backups.item.manual-backup',
defaultMessage: 'Manual backup',
},
creatorAvatarAlt: {
id: 'servers.backups.item.creator-avatar-alt',
defaultMessage: "{username}'s avatar",
},
supportCreator: {
id: 'servers.backups.item.creator.support',
defaultMessage: 'Support',
},
})
const creatorName = computed(() => {
if (!backupCreator.value) return ''
if (backupCreator.value.id !== 'support') return backupCreator.value.username
return backupCreator.value.username === 'support'
? formatMessage(messages.supportCreator)
: backupCreator.value.username
})
const creatorAvatarSrc = computed(() =>
backupCreator.value?.id === 'support'
? IntercomBubbleIcon
: (backupCreator.value?.avatar_url ?? undefined),
)
</script>
<template>
<div
@@ -174,10 +217,36 @@ const messages = defineMessages({
{{ formatMessage(messages.auto) }}
</span>
</div>
<div class="flex items-center gap-1.5 text-sm font-medium text-secondary">
<div class="flex items-center gap-2 text-sm font-medium text-secondary">
<template v-if="preview">
<span>{{ formatDateTime(backup.created_at) }}</span>
</template>
<template v-else-if="backupCreator">
<AutoLink
:to="creatorProfileLink"
:target="creatorProfileLink ? '_blank' : undefined"
:rel="creatorProfileLink ? 'noopener noreferrer' : undefined"
class="group flex min-w-0 items-center gap-1.5"
:class="creatorProfileLink ? 'text-secondary hover:underline' : 'text-primary'"
>
<Avatar
:src="creatorAvatarSrc"
:alt="formatMessage(messages.creatorAvatarAlt, { username: creatorName })"
:tint-by="creatorName"
size="24px"
circle
no-shadow
class="shrink-0 transition"
:class="creatorProfileLink ? 'group-hover:brightness-125' : ''"
/>
<span
class="min-w-0 truncate font-medium"
:class="backupCreator.id === 'support' ? 'text-blue' : ''"
>
{{ creatorName }}
</span>
</AutoLink>
</template>
<template v-else>
<span>
{{
@@ -1,6 +1,6 @@
<template>
<svg
v-if="loader === 'Fabric'"
v-if="normalizedLoader === 'Fabric'"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
fill-rule="evenodd"
@@ -19,7 +19,7 @@
/>
</svg>
<svg
v-else-if="loader === 'Quilt'"
v-else-if="normalizedLoader === 'Quilt'"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
fill-rule="evenodd"
@@ -59,7 +59,7 @@
></path>
</svg>
<svg
v-else-if="loader === 'Forge'"
v-else-if="normalizedLoader === 'Forge'"
ml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
@@ -77,7 +77,7 @@
></path>
</svg>
<svg
v-else-if="loader === 'NeoForge'"
v-else-if="normalizedLoader === 'NeoForge'"
enable-background="new 0 0 24 24"
version="1.1"
viewBox="0 0 24 24"
@@ -109,7 +109,7 @@
</g>
</svg>
<svg
v-else-if="loader === 'Paper'"
v-else-if="normalizedLoader === 'Paper'"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
@@ -124,7 +124,7 @@
<path fill="currentColor" d="m12 18-4-2 10-9-6 11Z" />
</svg>
<svg
v-else-if="loader === 'Spigot'"
v-else-if="normalizedLoader === 'Spigot'"
viewBox="0 0 332 284"
style="
fill-rule: evenodd;
@@ -141,7 +141,7 @@
/>
</svg>
<svg
v-else-if="loader === 'Bukkit'"
v-else-if="normalizedLoader === 'Bukkit'"
viewBox="0 0 292 319"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linecap: round; stroke-linejoin: round"
stroke="currentColor"
@@ -154,7 +154,7 @@
</g>
</svg>
<svg
v-else-if="loader === 'Purpur'"
v-else-if="normalizedLoader === 'Purpur'"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
@@ -212,7 +212,7 @@
transform="matrix(-1.125 0 0 1.2569 309 -40.78)"
></use>
</svg>
<svg v-else-if="loader === 'Vanilla'" viewBox="0 0 20 20" fill="currentColor">
<svg v-else-if="normalizedLoader === 'Vanilla'" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9.504 1.132a1 1 0 01.992 0l1.75 1a1 1 0 11-.992 1.736L10 3.152l-1.254.716a1 1 0 11-.992-1.736l1.75-1zM5.618 4.504a1 1 0 01-.372 1.364L5.016 6l.23.132a1 1 0 11-.992 1.736L4 7.723V8a1 1 0 01-2 0V6a.996.996 0 01.52-.878l1.734-.99a1 1 0 011.364.372zm8.764 0a1 1 0 011.364-.372l1.733.99A1.002 1.002 0 0118 6v2a1 1 0 11-2 0v-.277l-.254.145a1 1 0 11-.992-1.736l.23-.132-.23-.132a1 1 0 01-.372-1.364zm-7 4a1 1 0 011.364-.372L10 8.848l1.254-.716a1 1 0 11.992 1.736L11 10.58V12a1 1 0 11-2 0v-1.42l-1.246-.712a1 1 0 01-.372-1.364zM3 11a1 1 0 011 1v1.42l1.246.712a1 1 0 11-.992 1.736l-1.75-1A1 1 0 012 14v-2a1 1 0 011-1zm14 0a1 1 0 011 1v2a1 1 0 01-.504.868l-1.75 1a1 1 0 11-.992-1.736L16 13.42V12a1 1 0 011-1zm-9.618 5.504a1 1 0 011.364-.372l.254.145V16a1 1 0 112 0v.277l.254-.145a1 1 0 11.992 1.736l-1.735.992a.995.995 0 01-1.022 0l-1.735-.992a1 1 0 01-.372-1.364z"
@@ -224,10 +224,13 @@
<script setup lang="ts">
import { LoaderIcon } from '@modrinth/assets'
import { computed } from 'vue'
import type { ServerLoader } from '#ui/utils/loaders'
import { formatLoaderLabel, type ServerLoader } from '#ui/utils/loaders'
defineProps<{
const props = defineProps<{
loader: ServerLoader
}>()
const normalizedLoader = computed(() => formatLoaderLabel(props.loader))
</script>
@@ -19,9 +19,9 @@
Configuring server...
</div>
<div v-else class="flex flex-wrap items-center gap-2">
<div v-if="props.server?.loader" class="flex items-center gap-2 font-medium capitalize">
<div v-if="props.server?.loader" class="flex items-center gap-2 font-medium">
<LoaderIcon :loader="props.server.loader" class="flex shrink-0 [&&]:size-5" />
{{ props.server.loader }} {{ props.server.mc_version }}
{{ formatLoaderLabel(props.server.loader) }} {{ props.server.mc_version }}
</div>
<div
@@ -81,7 +81,7 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { NuxtModrinthClient } from '@modrinth/api-client'
import { LinkIcon, LoaderIcon, SettingsIcon, TimerIcon } from '@modrinth/assets'
import { LinkIcon, SettingsIcon, TimerIcon } from '@modrinth/assets'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
@@ -91,6 +91,9 @@ import {
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { formatLoaderLabel } from '#ui/utils/loaders'
import LoaderIcon from '../icons/LoaderIcon.vue'
type ServerProjectSummary = {
id: string