mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 09:04:55 +00:00
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:
@@ -5,6 +5,7 @@ import {
|
|||||||
ServersManageAccessPage,
|
ServersManageAccessPage,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
|
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const { serverId } = injectModrinthServerContext()
|
const { serverId } = injectModrinthServerContext()
|
||||||
@@ -26,8 +27,12 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userProfileLink(username: string) {
|
||||||
|
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ServersManageAccessPage />
|
<ServersManageAccessPage :user-profile-link="userProfileLink" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -858,6 +858,12 @@ export namespace Archon {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserInfo = {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type DeleteManyBackupRequest = {
|
export type DeleteManyBackupRequest = {
|
||||||
backup_ids: string[]
|
backup_ids: string[]
|
||||||
}
|
}
|
||||||
@@ -865,22 +871,26 @@ export namespace Archon {
|
|||||||
export type ActiveOperation = {
|
export type ActiveOperation = {
|
||||||
backup_id: string
|
backup_id: string
|
||||||
operation_type: BackupQueueOperationType
|
operation_type: BackupQueueOperationType
|
||||||
operation_id?: number | null
|
operation_id: number | null
|
||||||
has_parent: boolean
|
has_parent: boolean
|
||||||
scheduled_for: string
|
scheduled_for: string
|
||||||
|
started_at: string | null
|
||||||
synthetic_legacy: boolean
|
synthetic_legacy: boolean
|
||||||
|
user_info: UserInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackupQueueOperation = {
|
export type BackupQueueOperation = {
|
||||||
operation_type: BackupQueueOperationType
|
operation_type: BackupQueueOperationType
|
||||||
operation_id?: number | null
|
operation_id: number | null
|
||||||
state: BackupQueueState
|
state: BackupQueueState
|
||||||
scheduled_for: string
|
scheduled_for: string
|
||||||
completed_at?: string | null
|
started_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
has_parent: boolean
|
has_parent: boolean
|
||||||
error?: string | null
|
error: string | null
|
||||||
should_prompt: boolean
|
should_prompt: boolean
|
||||||
synthetic_legacy: boolean
|
synthetic_legacy: boolean
|
||||||
|
user_info: UserInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackupQueueBackup = {
|
export type BackupQueueBackup = {
|
||||||
|
|||||||
@@ -20,13 +20,16 @@
|
|||||||
ref="searchTriggerRef"
|
ref="searchTriggerRef"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
:icon="showSearchIcon ? SearchIcon : undefined"
|
:icon="showSearchIcon ? SearchIcon : undefined"
|
||||||
type="text"
|
:type="searchType"
|
||||||
|
:name="searchName"
|
||||||
:placeholder="searchPlaceholder || placeholder"
|
:placeholder="searchPlaceholder || placeholder"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:autocomplete="searchAutocomplete"
|
:autocomplete="searchAutocomplete"
|
||||||
:autocorrect="searchAutocorrect"
|
:autocorrect="searchAutocorrect"
|
||||||
:autocapitalize="searchAutocapitalize"
|
:autocapitalize="searchAutocapitalize"
|
||||||
:spellcheck="searchSpellcheck"
|
:spellcheck="searchSpellcheck"
|
||||||
|
:inputmode="searchInputmode"
|
||||||
|
:input-attrs="searchInputAttrs"
|
||||||
wrapper-class="w-full !bg-transparent"
|
wrapper-class="w-full !bg-transparent"
|
||||||
:input-class="searchableInputClass"
|
:input-class="searchableInputClass"
|
||||||
class="relative z-[1]"
|
class="relative z-[1]"
|
||||||
@@ -281,8 +284,6 @@ const props = withDefaults(
|
|||||||
forceDirection?: 'up' | 'down'
|
forceDirection?: 'up' | 'down'
|
||||||
noOptionsMessage?: string
|
noOptionsMessage?: string
|
||||||
disableSearchFilter?: boolean
|
disableSearchFilter?: boolean
|
||||||
dropdownClass?: string
|
|
||||||
dropdownMinWidth?: string
|
|
||||||
minSearchLengthToOpen?: number
|
minSearchLengthToOpen?: number
|
||||||
/** Keep the selected option's label in the input after selection, and show all options on focus */
|
/** Keep the selected option's label in the input after selection, and show all options on focus */
|
||||||
syncWithSelection?: boolean
|
syncWithSelection?: boolean
|
||||||
@@ -290,10 +291,14 @@ const props = withDefaults(
|
|||||||
selectSearchTextOnFocus?: boolean
|
selectSearchTextOnFocus?: boolean
|
||||||
/** Show a search icon in the searchable input */
|
/** Show a search icon in the searchable input */
|
||||||
showSearchIcon?: boolean
|
showSearchIcon?: boolean
|
||||||
|
searchType?: 'text' | 'search'
|
||||||
|
searchName?: string
|
||||||
|
searchInputmode?: 'text' | 'search'
|
||||||
searchAutocomplete?: string
|
searchAutocomplete?: string
|
||||||
searchAutocorrect?: 'on' | 'off'
|
searchAutocorrect?: 'on' | 'off'
|
||||||
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
|
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
|
||||||
searchSpellcheck?: boolean
|
searchSpellcheck?: boolean
|
||||||
|
searchInputAttrs?: Record<string, string | number | boolean | undefined>
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
placeholder: 'Select an option',
|
placeholder: 'Select an option',
|
||||||
@@ -309,6 +314,7 @@ const props = withDefaults(
|
|||||||
syncWithSelection: true,
|
syncWithSelection: true,
|
||||||
selectSearchTextOnFocus: false,
|
selectSearchTextOnFocus: false,
|
||||||
showSearchIcon: false,
|
showSearchIcon: false,
|
||||||
|
searchType: 'text',
|
||||||
outsideClickIgnore: () => [],
|
outsideClickIgnore: () => [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -269,8 +269,13 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isDropdownFilterSectionHeader(item)"
|
v-if="isDropdownFilterSectionHeader(item)"
|
||||||
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm font-semibold text-secondary"
|
class="flex items-center justify-between gap-3 border-0 px-4 py-2.5 text-sm font-semibold text-secondary"
|
||||||
:class="item.class"
|
: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">
|
<span class="flex min-w-0 items-center gap-2">
|
||||||
<component
|
<component
|
||||||
@@ -398,6 +403,7 @@ export type DropdownFilterBarSectionHeader = {
|
|||||||
key?: string
|
key?: string
|
||||||
icon?: Component
|
icon?: Component
|
||||||
class?: string
|
class?: string
|
||||||
|
dividerBefore?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DropdownFilterBarItem = DropdownFilterBarOption | DropdownFilterBarSectionHeader
|
export type DropdownFilterBarItem = DropdownFilterBarOption | DropdownFilterBarSectionHeader
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
v-if="multiline"
|
v-if="multiline"
|
||||||
:id="id"
|
:id="id"
|
||||||
|
v-bind="inputAttrs"
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
:value="model"
|
:value="model"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
:id="id"
|
:id="id"
|
||||||
|
v-bind="inputAttrs"
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
:type="type"
|
:type="type"
|
||||||
:value="model"
|
:value="model"
|
||||||
@@ -149,6 +151,7 @@ const props = withDefaults(
|
|||||||
resize?: 'none' | 'vertical' | 'both'
|
resize?: 'none' | 'vertical' | 'both'
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
wrapperClass?: string
|
wrapperClass?: string
|
||||||
|
inputAttrs?: Record<string, string | number | boolean | undefined>
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<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
|
<div
|
||||||
v-if="hasHeaderSlot"
|
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" />
|
<slot name="header" />
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto overflow-y-hidden">
|
<div class="overflow-x-auto overflow-y-hidden">
|
||||||
<table
|
<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"
|
:style="tableMinWidth ? { minWidth: tableMinWidth } : undefined"
|
||||||
>
|
>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
tag="tbody"
|
tag="tbody"
|
||||||
>
|
>
|
||||||
<tr v-if="data.length === 0" key="empty" class="bg-surface-2">
|
<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">
|
<slot name="empty-state">
|
||||||
<div class="text-secondary flex h-64 items-center justify-center">
|
<div class="text-secondary flex h-64 items-center justify-center">
|
||||||
No data available.
|
No data available.
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
v-if="showSelection"
|
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
|
<Checkbox
|
||||||
:model-value="isSelected(row)"
|
:model-value="isSelected(row)"
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
<td
|
<td
|
||||||
v-for="column in columns"
|
v-for="column in columns"
|
||||||
:key="column.key"
|
: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'}`"
|
:class="`text-${column.align ?? 'left'}`"
|
||||||
>
|
>
|
||||||
<slot
|
<slot
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
<tbody v-else :ref="setListContainer">
|
<tbody v-else :ref="setListContainer">
|
||||||
<tr v-if="data.length === 0" class="bg-surface-2">
|
<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">
|
<slot name="empty-state">
|
||||||
<div class="text-secondary flex h-64 items-center justify-center">
|
<div class="text-secondary flex h-64 items-center justify-center">
|
||||||
No data available.
|
No data available.
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
v-if="showSelection"
|
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
|
<Checkbox
|
||||||
:model-value="isSelected(row)"
|
:model-value="isSelected(row)"
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
<td
|
<td
|
||||||
v-for="column in columns"
|
v-for="column in columns"
|
||||||
:key="column.key"
|
: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'}`"
|
:class="`text-${column.align ?? 'left'}`"
|
||||||
>
|
>
|
||||||
<slot
|
<slot
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
>
|
>
|
||||||
<template #cell-user="{ row: member }">
|
<template #cell-user="{ row: member }">
|
||||||
<AutoLink
|
<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="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
|
<Avatar
|
||||||
:src="member.user.avatarUrl"
|
:src="member.user.avatarUrl"
|
||||||
@@ -61,7 +62,7 @@
|
|||||||
<template #cell-joined="{ row: member }">
|
<template #cell-joined="{ row: member }">
|
||||||
<span
|
<span
|
||||||
v-if="member.pending"
|
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) }}
|
{{ formatMessage(messages.pendingLabel) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -102,7 +103,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="members.length > 0"
|
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
|
<div
|
||||||
class="grid min-h-14 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] bg-surface-3"
|
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
|
<div
|
||||||
v-for="(member, index) in sortedMembers"
|
v-for="(member, index) in sortedMembers"
|
||||||
:key="member.id"
|
: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'"
|
:class="index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center pl-4">
|
<div class="flex min-w-0 items-center pl-4">
|
||||||
<AutoLink
|
<AutoLink
|
||||||
v-tooltip="member.user.username"
|
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="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
|
<Avatar
|
||||||
:src="member.user.avatarUrl"
|
:src="member.user.avatarUrl"
|
||||||
@@ -207,7 +209,7 @@
|
|||||||
<div class="min-w-0 py-3 pr-2 text-right text-secondary">
|
<div class="min-w-0 py-3 pr-2 text-right text-secondary">
|
||||||
<span
|
<span
|
||||||
v-if="member.pending"
|
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) }}
|
{{ formatMessage(messages.pendingLabel) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -248,7 +250,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<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%]"
|
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>
|
</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) }}
|
{{ formatMessage(messages.emptyState) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -293,7 +295,12 @@ import ButtonStyled from '../../base/ButtonStyled.vue'
|
|||||||
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
|
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
|
||||||
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
|
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
|
||||||
import TeleportOverflowMenu from '../../base/TeleportOverflowMenu.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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -301,6 +308,7 @@ const props = withDefaults(
|
|||||||
roles: ServerAccessRoleOption[]
|
roles: ServerAccessRoleOption[]
|
||||||
canManageUsers?: boolean
|
canManageUsers?: boolean
|
||||||
permissionDeniedMessage?: string
|
permissionDeniedMessage?: string
|
||||||
|
userProfileLink?: (username: string) => ServerAccessUserProfileLink
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
canManageUsers: true,
|
canManageUsers: true,
|
||||||
@@ -353,11 +361,11 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
cancelInvite: {
|
cancelInvite: {
|
||||||
id: 'servers.access-table.action.cancel-invite',
|
id: 'servers.access-table.action.cancel-invite',
|
||||||
defaultMessage: 'Cancel invite',
|
defaultMessage: 'Revoke invite',
|
||||||
},
|
},
|
||||||
removeUser: {
|
removeUser: {
|
||||||
id: 'servers.access-table.action.remove-user',
|
id: 'servers.access-table.action.remove-user',
|
||||||
defaultMessage: 'Remove user',
|
defaultMessage: 'Revoke access',
|
||||||
},
|
},
|
||||||
emptyState: {
|
emptyState: {
|
||||||
id: 'servers.access-table.empty',
|
id: 'servers.access-table.empty',
|
||||||
@@ -533,9 +541,14 @@ function roleTriggerClass(role: ServerAccessRole): string {
|
|||||||
return roleClasses(role)
|
return roleClasses(role)
|
||||||
}
|
}
|
||||||
|
|
||||||
function userProfilePath(username: string): string | undefined {
|
function getUserProfileLink(username: string): ServerAccessUserProfileLink {
|
||||||
if (!username || username.includes('@')) return undefined
|
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 {
|
function resendInviteCooldownSeconds(member: ServerAccessMember): number {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
:trigger-class="timeframePickerTriggerClass"
|
:trigger-class="timeframePickerTriggerClass"
|
||||||
/>
|
/>
|
||||||
<template v-if="slots.filters">
|
<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">
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<slot name="filters"></slot>
|
<slot name="filters"></slot>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="entry in filteredEntries"
|
v-for="entry in filteredEntries"
|
||||||
:key="entry.id"
|
: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
|
<AutoLink
|
||||||
v-tooltip="actorName(entry)"
|
v-tooltip="actorName(entry)"
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</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
|
<div
|
||||||
class="hidden min-h-14 bg-surface-3 @[800px]:grid @[800px]:h-14"
|
class="hidden min-h-14 bg-surface-3 @[800px]:grid @[800px]:h-14"
|
||||||
:class="
|
:class="
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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) }}
|
{{ formatMessage(emptyStateMessage) }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,10 +22,14 @@
|
|||||||
searchable
|
searchable
|
||||||
show-search-icon
|
show-search-icon
|
||||||
:show-chevron="false"
|
: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-autocorrect="off"
|
||||||
search-autocapitalize="none"
|
search-autocapitalize="none"
|
||||||
:search-spellcheck="false"
|
:search-spellcheck="false"
|
||||||
|
:search-input-attrs="passwordManagerIgnoreAttrs"
|
||||||
@open="targetComboboxOpen = true"
|
@open="targetComboboxOpen = true"
|
||||||
@close="targetComboboxOpen = false"
|
@close="targetComboboxOpen = false"
|
||||||
@search-input="handleTargetSearch"
|
@search-input="handleTargetSearch"
|
||||||
@@ -191,6 +195,13 @@ const targetLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle')
|
|||||||
const targetLookupRequestId = ref(0)
|
const targetLookupRequestId = ref(0)
|
||||||
const hasSelectedTarget = ref(false)
|
const hasSelectedTarget = ref(false)
|
||||||
const targetComboboxOpen = 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({
|
const messages = defineMessages({
|
||||||
header: {
|
header: {
|
||||||
|
|||||||
@@ -135,29 +135,29 @@ const cachedState = ref({
|
|||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
header: {
|
header: {
|
||||||
id: 'servers.remove-access-modal.header',
|
id: 'servers.remove-access-modal.header',
|
||||||
defaultMessage: 'Remove user',
|
defaultMessage: 'Revoke access',
|
||||||
},
|
},
|
||||||
cancelHeader: {
|
cancelHeader: {
|
||||||
id: 'servers.remove-access-modal.cancel-header',
|
id: 'servers.remove-access-modal.cancel-header',
|
||||||
defaultMessage: 'Cancel invite',
|
defaultMessage: 'Revoke invite',
|
||||||
},
|
},
|
||||||
warningBody: {
|
warningBody: {
|
||||||
id: 'servers.remove-access-modal.warning-body',
|
id: 'servers.remove-access-modal.warning-body',
|
||||||
defaultMessage:
|
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: {
|
cancelWarningBody: {
|
||||||
id: 'servers.remove-access-modal.cancel-warning-body',
|
id: 'servers.remove-access-modal.cancel-warning-body',
|
||||||
defaultMessage:
|
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: {
|
removeButton: {
|
||||||
id: 'servers.remove-access-modal.remove-button',
|
id: 'servers.remove-access-modal.remove-button',
|
||||||
defaultMessage: 'Remove user',
|
defaultMessage: 'Revoke access',
|
||||||
},
|
},
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
id: 'servers.remove-access-modal.cancel-button',
|
id: 'servers.remove-access-modal.cancel-button',
|
||||||
defaultMessage: 'Cancel invite',
|
defaultMessage: 'Revoke invite',
|
||||||
},
|
},
|
||||||
userAvatarAlt: {
|
userAvatarAlt: {
|
||||||
id: 'servers.remove-access-modal.user-avatar-alt',
|
id: 'servers.remove-access-modal.user-avatar-alt',
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
removed: {
|
removed: {
|
||||||
id: 'servers.audit-log.event.user-removed',
|
id: 'servers.audit-log.event.user-removed',
|
||||||
defaultMessage: 'Removed <target-user></target-user>',
|
defaultMessage: 'Revoked access for <target-user></target-user>',
|
||||||
},
|
},
|
||||||
ownerRole: {
|
ownerRole: {
|
||||||
id: 'servers.access-role.owner',
|
id: 'servers.access-role.owner',
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
import type { AuditActor, AuditWorld, ParsedAuditEvent } from './events/types'
|
import type { AuditActor, AuditWorld, ParsedAuditEvent } from './events/types'
|
||||||
|
|
||||||
export type ServerAccessRole = 'owner' | 'editor' | 'viewer'
|
export type ServerAccessRole = 'owner' | 'editor' | 'viewer'
|
||||||
|
export type ServerAccessUserProfileLink =
|
||||||
|
| RouteLocationRaw
|
||||||
|
| (() => void | Promise<void>)
|
||||||
|
| undefined
|
||||||
|
|
||||||
export interface ServerAccessUser extends AuditActor {
|
export interface ServerAccessUser extends AuditActor {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ClipboardCopyIcon,
|
ClipboardCopyIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
|
IntercomBubbleIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
RotateCounterClockwiseIcon,
|
RotateCounterClockwiseIcon,
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
@@ -15,6 +16,8 @@ import { computed, ref } from 'vue'
|
|||||||
import { useFormatDateTime } from '../../../composables'
|
import { useFormatDateTime } from '../../../composables'
|
||||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||||
import { commonMessages, truncatedTooltip } from '../../../utils'
|
import { commonMessages, truncatedTooltip } from '../../../utils'
|
||||||
|
import AutoLink from '../../base/AutoLink.vue'
|
||||||
|
import Avatar from '../../base/Avatar.vue'
|
||||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||||
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
|
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ const emit = defineEmits<{
|
|||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
backup: Archon.BackupsQueue.v1.BackupQueueBackup
|
backup: Archon.BackupsQueue.v1.BackupQueueBackup
|
||||||
|
creator?: Archon.BackupsQueue.v1.UserInfo | null
|
||||||
preview?: boolean
|
preview?: boolean
|
||||||
kyrosUrl?: string
|
kyrosUrl?: string
|
||||||
jwt?: string
|
jwt?: string
|
||||||
@@ -44,6 +48,7 @@ const props = withDefaults(
|
|||||||
highlighted?: boolean
|
highlighted?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
|
creator: undefined,
|
||||||
preview: false,
|
preview: false,
|
||||||
kyrosUrl: undefined,
|
kyrosUrl: undefined,
|
||||||
jwt: undefined,
|
jwt: undefined,
|
||||||
@@ -59,6 +64,22 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const nameRef = ref<HTMLElement | null>(null)
|
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(() => {
|
const backupIcon = computed(() => {
|
||||||
if (props.backup.automated) {
|
if (props.backup.automated) {
|
||||||
return ShieldIcon
|
return ShieldIcon
|
||||||
@@ -137,7 +158,29 @@ const messages = defineMessages({
|
|||||||
id: 'servers.backups.item.manual-backup',
|
id: 'servers.backups.item.manual-backup',
|
||||||
defaultMessage: '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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
@@ -174,10 +217,36 @@ const messages = defineMessages({
|
|||||||
{{ formatMessage(messages.auto) }}
|
{{ formatMessage(messages.auto) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<template v-if="preview">
|
||||||
<span>{{ formatDateTime(backup.created_at) }}</span>
|
<span>{{ formatDateTime(backup.created_at) }}</span>
|
||||||
</template>
|
</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>
|
<template v-else>
|
||||||
<span>
|
<span>
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg
|
<svg
|
||||||
v-if="loader === 'Fabric'"
|
v-if="normalizedLoader === 'Fabric'"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xml:space="preserve"
|
xml:space="preserve"
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else-if="loader === 'Quilt'"
|
v-else-if="normalizedLoader === 'Quilt'"
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
xml:space="preserve"
|
xml:space="preserve"
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else-if="loader === 'Forge'"
|
v-else-if="normalizedLoader === 'Forge'"
|
||||||
ml:space="preserve"
|
ml:space="preserve"
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else-if="loader === 'NeoForge'"
|
v-else-if="normalizedLoader === 'NeoForge'"
|
||||||
enable-background="new 0 0 24 24"
|
enable-background="new 0 0 24 24"
|
||||||
version="1.1"
|
version="1.1"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else-if="loader === 'Paper'"
|
v-else-if="normalizedLoader === 'Paper'"
|
||||||
xml:space="preserve"
|
xml:space="preserve"
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
<path fill="currentColor" d="m12 18-4-2 10-9-6 11Z" />
|
<path fill="currentColor" d="m12 18-4-2 10-9-6 11Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else-if="loader === 'Spigot'"
|
v-else-if="normalizedLoader === 'Spigot'"
|
||||||
viewBox="0 0 332 284"
|
viewBox="0 0 332 284"
|
||||||
style="
|
style="
|
||||||
fill-rule: evenodd;
|
fill-rule: evenodd;
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else-if="loader === 'Bukkit'"
|
v-else-if="normalizedLoader === 'Bukkit'"
|
||||||
viewBox="0 0 292 319"
|
viewBox="0 0 292 319"
|
||||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linecap: round; stroke-linejoin: round"
|
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linecap: round; stroke-linejoin: round"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else-if="loader === 'Purpur'"
|
v-else-if="normalizedLoader === 'Purpur'"
|
||||||
xml:space="preserve"
|
xml:space="preserve"
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
transform="matrix(-1.125 0 0 1.2569 309 -40.78)"
|
transform="matrix(-1.125 0 0 1.2569 309 -40.78)"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</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
|
<path
|
||||||
fill-rule="evenodd"
|
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"
|
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">
|
<script setup lang="ts">
|
||||||
import { LoaderIcon } from '@modrinth/assets'
|
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
|
loader: ServerLoader
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const normalizedLoader = computed(() => formatLoaderLabel(props.loader))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
Configuring server...
|
Configuring server...
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-wrap items-center gap-2">
|
<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" />
|
<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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Archon } from '@modrinth/api-client'
|
import type { Archon } from '@modrinth/api-client'
|
||||||
import { NuxtModrinthClient } 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 { useStorage } from '@vueuse/core'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
@@ -91,6 +91,9 @@ import {
|
|||||||
injectModrinthServerContext,
|
injectModrinthServerContext,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
} from '#ui/providers'
|
} from '#ui/providers'
|
||||||
|
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||||
|
|
||||||
|
import LoaderIcon from '../icons/LoaderIcon.vue'
|
||||||
|
|
||||||
type ServerProjectSummary = {
|
type ServerProjectSummary = {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
:roles="roleOptions"
|
:roles="roleOptions"
|
||||||
:can-manage-users="canManageUsers"
|
:can-manage-users="canManageUsers"
|
||||||
:permission-denied-message="permissionDeniedMessage"
|
:permission-denied-message="permissionDeniedMessage"
|
||||||
|
:user-profile-link="props.userProfileLink"
|
||||||
@update-role="updateMemberRole"
|
@update-role="updateMemberRole"
|
||||||
@resend-invite="resendInvite"
|
@resend-invite="resendInvite"
|
||||||
@cancel-invite="requestCancelInvite"
|
@cancel-invite="requestCancelInvite"
|
||||||
@@ -127,6 +128,7 @@ import {
|
|||||||
type ServerAccessMember,
|
type ServerAccessMember,
|
||||||
type ServerAccessRole,
|
type ServerAccessRole,
|
||||||
type ServerAccessRoleOption,
|
type ServerAccessRoleOption,
|
||||||
|
type ServerAccessUserProfileLink,
|
||||||
} from '#ui/components/servers/access'
|
} from '#ui/components/servers/access'
|
||||||
import { useVIntl } from '#ui/composables/i18n'
|
import { useVIntl } from '#ui/composables/i18n'
|
||||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||||
@@ -144,6 +146,7 @@ type RoleFilter = ServerAccessRole | 'all'
|
|||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
showAuditLogInstances?: boolean
|
showAuditLogInstances?: boolean
|
||||||
|
userProfileLink?: (username: string) => ServerAccessUserProfileLink
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
showAuditLogInstances: false,
|
showAuditLogInstances: false,
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export function useAccessAuditLog({
|
|||||||
key: group.key,
|
key: group.key,
|
||||||
label: formatMessage(group.label),
|
label: formatMessage(group.label),
|
||||||
icon: group.icon,
|
icon: group.icon,
|
||||||
|
dividerBefore: true,
|
||||||
},
|
},
|
||||||
...group.actions.map((action) => ({
|
...group.actions.map((action) => ({
|
||||||
value: action,
|
value: action,
|
||||||
|
|||||||
@@ -103,19 +103,19 @@ export const accessMessages = defineMessages({
|
|||||||
},
|
},
|
||||||
inviteCancelledTitle: {
|
inviteCancelledTitle: {
|
||||||
id: 'servers.access-page.notification.invite-cancelled.title',
|
id: 'servers.access-page.notification.invite-cancelled.title',
|
||||||
defaultMessage: 'Invite cancelled',
|
defaultMessage: 'Invite revoked',
|
||||||
},
|
},
|
||||||
inviteCancelledText: {
|
inviteCancelledText: {
|
||||||
id: 'servers.access-page.notification.invite-cancelled.text',
|
id: 'servers.access-page.notification.invite-cancelled.text',
|
||||||
defaultMessage: 'Cancelled the invite for {target}.',
|
defaultMessage: 'Revoked the invite for {target}.',
|
||||||
},
|
},
|
||||||
memberRemovedTitle: {
|
memberRemovedTitle: {
|
||||||
id: 'servers.access-page.notification.member-removed.title',
|
id: 'servers.access-page.notification.member-removed.title',
|
||||||
defaultMessage: 'Access removed',
|
defaultMessage: 'Access revoked',
|
||||||
},
|
},
|
||||||
memberRemovedText: {
|
memberRemovedText: {
|
||||||
id: 'servers.access-page.notification.member-removed.text',
|
id: 'servers.access-page.notification.member-removed.text',
|
||||||
defaultMessage: 'Removed {target} from this server.',
|
defaultMessage: 'Revoked access for {target}.',
|
||||||
},
|
},
|
||||||
loadFailedTitle: {
|
loadFailedTitle: {
|
||||||
id: 'servers.access-page.notification.load-failed.title',
|
id: 'servers.access-page.notification.load-failed.title',
|
||||||
@@ -178,7 +178,7 @@ export const actionLogActionMessages = defineMessages({
|
|||||||
},
|
},
|
||||||
user_removed: {
|
user_removed: {
|
||||||
id: 'servers.access-page.activity-log-filter.action.user-removed',
|
id: 'servers.access-page.activity-log-filter.action.user-removed',
|
||||||
defaultMessage: 'Removed user',
|
defaultMessage: 'Revoked access',
|
||||||
},
|
},
|
||||||
addon_added: {
|
addon_added: {
|
||||||
id: 'servers.access-page.activity-log-filter.action.addon-added',
|
id: 'servers.access-page.activity-log-filter.action.addon-added',
|
||||||
|
|||||||
@@ -156,6 +156,7 @@
|
|||||||
<BackupItem
|
<BackupItem
|
||||||
class="my-1.5 min-w-0 flex-1"
|
class="my-1.5 min-w-0 flex-1"
|
||||||
:backup="backup"
|
:backup="backup"
|
||||||
|
:creator="backupCreator(backup)"
|
||||||
:selected="selectedIds.has(backup.id)"
|
:selected="selectedIds.has(backup.id)"
|
||||||
:highlighted="highlightedBackupId === backup.id"
|
:highlighted="highlightedBackupId === backup.id"
|
||||||
:restore-disabled="backupRestoreDisabled"
|
:restore-disabled="backupRestoreDisabled"
|
||||||
@@ -461,6 +462,15 @@ type BackupGroup = {
|
|||||||
backups: Archon.BackupsQueue.v1.BackupQueueBackup[]
|
backups: Archon.BackupsQueue.v1.BackupQueueBackup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function backupCreator(
|
||||||
|
backup: Archon.BackupsQueue.v1.BackupQueueBackup,
|
||||||
|
): Archon.BackupsQueue.v1.UserInfo | null {
|
||||||
|
return (
|
||||||
|
backup.history.find((operation) => operation.operation_type === 'create' && operation.user_info)
|
||||||
|
?.user_info ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const groupedBackups = computed((): BackupGroup[] => {
|
const groupedBackups = computed((): BackupGroup[] => {
|
||||||
if (!filteredBackups.value.length) return []
|
if (!filteredBackups.value.length) return []
|
||||||
|
|
||||||
|
|||||||
@@ -3414,7 +3414,7 @@
|
|||||||
"defaultMessage": "Changed user permissions"
|
"defaultMessage": "Changed user permissions"
|
||||||
},
|
},
|
||||||
"servers.access-page.activity-log-filter.action.user-removed": {
|
"servers.access-page.activity-log-filter.action.user-removed": {
|
||||||
"defaultMessage": "Removed user"
|
"defaultMessage": "Revoked access"
|
||||||
},
|
},
|
||||||
"servers.access-page.activity-log-filter.add": {
|
"servers.access-page.activity-log-filter.add": {
|
||||||
"defaultMessage": "Add filter"
|
"defaultMessage": "Add filter"
|
||||||
@@ -3450,10 +3450,10 @@
|
|||||||
"defaultMessage": "Friend request could not be sent"
|
"defaultMessage": "Friend request could not be sent"
|
||||||
},
|
},
|
||||||
"servers.access-page.notification.invite-cancelled.text": {
|
"servers.access-page.notification.invite-cancelled.text": {
|
||||||
"defaultMessage": "Cancelled the invite for {target}."
|
"defaultMessage": "Revoked the invite for {target}."
|
||||||
},
|
},
|
||||||
"servers.access-page.notification.invite-cancelled.title": {
|
"servers.access-page.notification.invite-cancelled.title": {
|
||||||
"defaultMessage": "Invite cancelled"
|
"defaultMessage": "Invite revoked"
|
||||||
},
|
},
|
||||||
"servers.access-page.notification.invite-failed.title": {
|
"servers.access-page.notification.invite-failed.title": {
|
||||||
"defaultMessage": "Invite could not be sent"
|
"defaultMessage": "Invite could not be sent"
|
||||||
@@ -3477,10 +3477,10 @@
|
|||||||
"defaultMessage": "Access could not be loaded"
|
"defaultMessage": "Access could not be loaded"
|
||||||
},
|
},
|
||||||
"servers.access-page.notification.member-removed.text": {
|
"servers.access-page.notification.member-removed.text": {
|
||||||
"defaultMessage": "Removed {target} from this server."
|
"defaultMessage": "Revoked access for {target}."
|
||||||
},
|
},
|
||||||
"servers.access-page.notification.member-removed.title": {
|
"servers.access-page.notification.member-removed.title": {
|
||||||
"defaultMessage": "Access removed"
|
"defaultMessage": "Access revoked"
|
||||||
},
|
},
|
||||||
"servers.access-page.notification.remove-failed.title": {
|
"servers.access-page.notification.remove-failed.title": {
|
||||||
"defaultMessage": "Access could not be removed"
|
"defaultMessage": "Access could not be removed"
|
||||||
@@ -3525,10 +3525,10 @@
|
|||||||
"defaultMessage": "Limited"
|
"defaultMessage": "Limited"
|
||||||
},
|
},
|
||||||
"servers.access-table.action.cancel-invite": {
|
"servers.access-table.action.cancel-invite": {
|
||||||
"defaultMessage": "Cancel invite"
|
"defaultMessage": "Revoke invite"
|
||||||
},
|
},
|
||||||
"servers.access-table.action.remove-user": {
|
"servers.access-table.action.remove-user": {
|
||||||
"defaultMessage": "Remove user"
|
"defaultMessage": "Revoke access"
|
||||||
},
|
},
|
||||||
"servers.access-table.action.resend-invite": {
|
"servers.access-table.action.resend-invite": {
|
||||||
"defaultMessage": "Resend invite"
|
"defaultMessage": "Resend invite"
|
||||||
@@ -3807,7 +3807,7 @@
|
|||||||
"defaultMessage": "Changed permissions for <target-user></target-user> to <permission-label>{permissions}</permission-label>"
|
"defaultMessage": "Changed permissions for <target-user></target-user> to <permission-label>{permissions}</permission-label>"
|
||||||
},
|
},
|
||||||
"servers.audit-log.event.user-removed": {
|
"servers.audit-log.event.user-removed": {
|
||||||
"defaultMessage": "Removed <target-user></target-user>"
|
"defaultMessage": "Revoked access for <target-user></target-user>"
|
||||||
},
|
},
|
||||||
"servers.audit-log.scope.server": {
|
"servers.audit-log.scope.server": {
|
||||||
"defaultMessage": "Server"
|
"defaultMessage": "Server"
|
||||||
@@ -3935,6 +3935,12 @@
|
|||||||
"servers.backups.item.backup-schedule": {
|
"servers.backups.item.backup-schedule": {
|
||||||
"defaultMessage": "Backup schedule"
|
"defaultMessage": "Backup schedule"
|
||||||
},
|
},
|
||||||
|
"servers.backups.item.creator-avatar-alt": {
|
||||||
|
"defaultMessage": "{username}'s avatar"
|
||||||
|
},
|
||||||
|
"servers.backups.item.creator.support": {
|
||||||
|
"defaultMessage": "Support"
|
||||||
|
},
|
||||||
"servers.backups.item.manual-backup": {
|
"servers.backups.item.manual-backup": {
|
||||||
"defaultMessage": "Manual backup"
|
"defaultMessage": "Manual backup"
|
||||||
},
|
},
|
||||||
@@ -4440,7 +4446,7 @@
|
|||||||
"defaultMessage": "Added {time}"
|
"defaultMessage": "Added {time}"
|
||||||
},
|
},
|
||||||
"servers.remove-access-modal.cancel-button": {
|
"servers.remove-access-modal.cancel-button": {
|
||||||
"defaultMessage": "Cancel invite"
|
"defaultMessage": "Revoke invite"
|
||||||
},
|
},
|
||||||
"servers.remove-access-modal.cancel-effect-access": {
|
"servers.remove-access-modal.cancel-effect-access": {
|
||||||
"defaultMessage": "They will not be added to this server"
|
"defaultMessage": "They will not be added to this server"
|
||||||
@@ -4449,13 +4455,13 @@
|
|||||||
"defaultMessage": "You can send them another invite later"
|
"defaultMessage": "You can send them another invite later"
|
||||||
},
|
},
|
||||||
"servers.remove-access-modal.cancel-header": {
|
"servers.remove-access-modal.cancel-header": {
|
||||||
"defaultMessage": "Cancel invite"
|
"defaultMessage": "Revoke invite"
|
||||||
},
|
},
|
||||||
"servers.remove-access-modal.cancel-warning-body": {
|
"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."
|
"defaultMessage": "If you revoke this invite, {username} will need a new invitation before they can join this server."
|
||||||
},
|
},
|
||||||
"servers.remove-access-modal.header": {
|
"servers.remove-access-modal.header": {
|
||||||
"defaultMessage": "Remove user"
|
"defaultMessage": "Revoke access"
|
||||||
},
|
},
|
||||||
"servers.remove-access-modal.invited-label": {
|
"servers.remove-access-modal.invited-label": {
|
||||||
"defaultMessage": "Invited {time}"
|
"defaultMessage": "Invited {time}"
|
||||||
@@ -4464,7 +4470,7 @@
|
|||||||
"defaultMessage": "Pending invite"
|
"defaultMessage": "Pending invite"
|
||||||
},
|
},
|
||||||
"servers.remove-access-modal.remove-button": {
|
"servers.remove-access-modal.remove-button": {
|
||||||
"defaultMessage": "Remove user"
|
"defaultMessage": "Revoke access"
|
||||||
},
|
},
|
||||||
"servers.remove-access-modal.remove-effect-access": {
|
"servers.remove-access-modal.remove-effect-access": {
|
||||||
"defaultMessage": "They will immediately lose access to the server panel and will no longer be able to edit content"
|
"defaultMessage": "They will immediately lose access to the server panel and will no longer be able to edit content"
|
||||||
@@ -4479,7 +4485,7 @@
|
|||||||
"defaultMessage": "{username}'s avatar"
|
"defaultMessage": "{username}'s avatar"
|
||||||
},
|
},
|
||||||
"servers.remove-access-modal.warning-body": {
|
"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."
|
"defaultMessage": "If you revoke a user's access to your server, you'll need to re-invite them to restore access."
|
||||||
},
|
},
|
||||||
"servers.remove-access-modal.what-happens-label": {
|
"servers.remove-access-modal.what-happens-label": {
|
||||||
"defaultMessage": "What happens?"
|
"defaultMessage": "What happens?"
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ const meta = {
|
|||||||
export default meta
|
export default meta
|
||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
const creator: Archon.BackupsQueue.v1.UserInfo = {
|
||||||
|
id: 'traben',
|
||||||
|
username: 'Traben',
|
||||||
|
avatar_url: 'https://cdn.modrinth.com/user/6Qo4A5QT/9d81be1a9fb1afd163b7f2f05a791955e7693c90.png',
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportCreator: Archon.BackupsQueue.v1.UserInfo = {
|
||||||
|
id: 'support',
|
||||||
|
username: 'Support',
|
||||||
|
avatar_url: null,
|
||||||
|
}
|
||||||
|
|
||||||
function makeBackup(
|
function makeBackup(
|
||||||
overrides: Partial<Archon.BackupsQueue.v1.BackupQueueBackup> = {},
|
overrides: Partial<Archon.BackupsQueue.v1.BackupQueueBackup> = {},
|
||||||
): Archon.BackupsQueue.v1.BackupQueueBackup {
|
): Archon.BackupsQueue.v1.BackupQueueBackup {
|
||||||
@@ -36,6 +48,7 @@ export const Default: Story = {
|
|||||||
name: 'Default (manual)',
|
name: 'Default (manual)',
|
||||||
args: {
|
args: {
|
||||||
backup: makeBackup({ name: 'Base finished!!' }),
|
backup: makeBackup({ name: 'Base finished!!' }),
|
||||||
|
creator,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +59,14 @@ export const Automated: Story = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SupportCreated: Story = {
|
||||||
|
name: 'Support created',
|
||||||
|
args: {
|
||||||
|
backup: makeBackup({ name: 'Support recovery point' }),
|
||||||
|
creator: supportCreator,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const Preview: Story = {
|
export const Preview: Story = {
|
||||||
name: 'Preview (compact, used in delete modal)',
|
name: 'Preview (compact, used in delete modal)',
|
||||||
args: {
|
args: {
|
||||||
@@ -85,12 +106,16 @@ export const CommonStates: Story = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
manual: makeBackup({ name: 'Base finished!!' }),
|
manual: makeBackup({ name: 'Base finished!!' }),
|
||||||
|
support: makeBackup({ id: 'backup-support', name: 'Support recovery point' }),
|
||||||
automated: makeBackup({ automated: true, name: 'Backup #2' }),
|
automated: makeBackup({ automated: true, name: 'Backup #2' }),
|
||||||
|
creator,
|
||||||
|
supportCreator,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
template: /* html */ `
|
template: /* html */ `
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.75rem; max-width: 900px;">
|
<div style="display: flex; flex-direction: column; gap: 0.75rem; max-width: 900px;">
|
||||||
<BackupItem :backup="manual" />
|
<BackupItem :backup="manual" :creator="creator" />
|
||||||
|
<BackupItem :backup="support" :creator="supportCreator" />
|
||||||
<BackupItem :backup="automated" />
|
<BackupItem :backup="automated" />
|
||||||
<BackupItem :backup="manual" preview />
|
<BackupItem :backup="manual" preview />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const loaderDisplayNames: Record<string, string> = {
|
|||||||
forge: 'Forge',
|
forge: 'Forge',
|
||||||
quilt: 'Quilt',
|
quilt: 'Quilt',
|
||||||
paper: 'Paper',
|
paper: 'Paper',
|
||||||
|
spigot: 'Spigot',
|
||||||
purpur: 'Purpur',
|
purpur: 'Purpur',
|
||||||
bukkit: 'Bukkit',
|
bukkit: 'Bukkit',
|
||||||
vanilla: 'Vanilla',
|
vanilla: 'Vanilla',
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Surface System
|
||||||
|
|
||||||
|
Use `surface-*` variables to describe UI elevation and separation. The scale is ordered from the page base up through stronger raised surfaces and strokes.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
| Token | Use |
|
||||||
|
| ----------- | ------------------------------------------------------------------- |
|
||||||
|
| `surface-1` | Page background. |
|
||||||
|
| `surface-2` | Default raised surfaces, table rows, and standard card backgrounds. |
|
||||||
|
| `surface-3` | Header bands, inputs, dropdown surfaces, and card hover states. |
|
||||||
|
| `surface-4` | Standard strokes and outlines, including table outlines. |
|
||||||
|
| `surface-5` | Strong strokes for surfaces that need extra separation. |
|
||||||
|
|
||||||
|
## Strokes
|
||||||
|
|
||||||
|
Use `surface-4` for normal outlines and dividers. Tables should use `surface-4` for their outer border and row separators.
|
||||||
|
|
||||||
|
Reserve `surface-5` for stronger outlines, such as modal frames, high-emphasis separators, or hover states on elements that already sit on `surface-4`.
|
||||||
|
|
||||||
|
## Backgrounds
|
||||||
|
|
||||||
|
Use `surface-1` for page backgrounds and `surface-2` for ordinary raised content. Use `surface-3` for header strips, inputs, and temporary elevation such as hover states. Use `surface-4` sparingly as a stronger raised background, usually for controls or badges that need to sit above nearby content.
|
||||||
|
|
||||||
|
Avoid using legacy aliased background variables for new UI. Prefer explicit `bg-surface-*` and `border-surface-*` utilities so the layer intent is visible in the component.
|
||||||
Reference in New Issue
Block a user