feat: UsersTable and UserCardList get Email, Display name (dash when empty), Role (new RoleBadge), Invite status and Created
This commit is contained in:
1 parent
153fd8719a
commit
1ece778caf
19 files changed
+485
-204
No files matched your search
@@ -48,9 +48,7 @@ function formatDetailDate(iso: string, relative = false) {
|
||||
}
|
||||
|
||||
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
|
||||
const updatedLabel = computed(() =>
|
||||
formatDetailDate(props.article.updated_at, true),
|
||||
)
|
||||
const updatedLabel = computed(() => formatDetailDate(props.article.updated_at, true))
|
||||
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
|
||||
const imagesCount = computed(() => props.article.images.length)
|
||||
const apiNote = computed(() =>
|
||||
@@ -94,9 +92,7 @@ async function copyUrl() {
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-base font-semibold text-highlighted">
|
||||
Details
|
||||
</h2>
|
||||
<h2 class="text-base font-semibold text-highlighted">Details</h2>
|
||||
</template>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<dt class="text-muted">Created</dt>
|
||||
@@ -110,9 +106,7 @@ async function copyUrl() {
|
||||
</dl>
|
||||
<div class="flex flex-col gap-1.5 text-sm">
|
||||
<p class="text-muted">Public API URL</p>
|
||||
<div
|
||||
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs">
|
||||
<span class="flex-1">{{ publicUrl }}</span>
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
@@ -126,20 +120,13 @@ async function copyUrl() {
|
||||
</div>
|
||||
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-1 border-t border-default pt-3 text-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-1 border-t border-default pt-3 text-sm">
|
||||
<p class="text-muted">Title</p>
|
||||
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="right"
|
||||
title="Details"
|
||||
class="lg:hidden"
|
||||
>
|
||||
<USlideover v-model:open="open" side="right" title="Details" class="lg:hidden">
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4 text-sm">
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
|
||||
@@ -172,9 +159,7 @@ async function copyUrl() {
|
||||
</div>
|
||||
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-1 border-t border-default pt-3"
|
||||
>
|
||||
<div class="flex flex-col gap-1 border-t border-default pt-3">
|
||||
<p class="text-muted">Title</p>
|
||||
<p class="text-xs/[18px] text-toned">
|
||||
{{ TITLE_NOTE }}
|
||||
|
||||
@@ -33,26 +33,18 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
|
||||
const controlClass = computed(() =>
|
||||
belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base',
|
||||
)
|
||||
const inputClass = computed(() =>
|
||||
belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base',
|
||||
)
|
||||
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
|
||||
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
|
||||
|
||||
const mainClass = computed(() => [
|
||||
'flex min-h-screen flex-col items-center gap-5',
|
||||
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
|
||||
])
|
||||
|
||||
const cardClass = computed(() =>
|
||||
belowSm.value ? 'w-full' : 'w-full max-w-[400px]',
|
||||
)
|
||||
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
|
||||
|
||||
const cardUi = computed(() => ({
|
||||
root: belowSm.value
|
||||
? 'rounded-none ring-0'
|
||||
: 'rounded-none ring-0 border border-default',
|
||||
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
|
||||
container: belowSm.value
|
||||
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
|
||||
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
|
||||
|
||||
@@ -25,12 +25,8 @@ const error = ref('')
|
||||
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
let settled = false
|
||||
|
||||
const confirmColor = computed(() =>
|
||||
props.tone === 'danger' ? 'error' : 'primary',
|
||||
)
|
||||
const confirmText = computed(() =>
|
||||
pending.value ? props.pendingLabel : props.confirmLabel,
|
||||
)
|
||||
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
|
||||
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
@@ -62,10 +58,7 @@ async function onConfirm() {
|
||||
await props.action()
|
||||
settle(true)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Something went wrong. Try again.'
|
||||
error.value = err instanceof Error ? err.message : 'Something went wrong. Try again.'
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
@@ -95,8 +88,7 @@ function onCancel() {
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
|
||||
title: 'text-lg max-sm:text-[1.0625rem]/6',
|
||||
description:
|
||||
'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
|
||||
description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
|
||||
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
|
||||
footer: 'p-6 sm:px-6 max-sm:p-5',
|
||||
}"
|
||||
@@ -112,9 +104,7 @@ function onCancel() {
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end"
|
||||
>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
ref="cancelButton"
|
||||
color="neutral"
|
||||
|
||||
@@ -44,15 +44,8 @@ const items = computed(() =>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="flex items-center gap-2 text-xs/[18px]"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.icon"
|
||||
:class="['size-3.5 shrink-0', item.iconClass]"
|
||||
/>
|
||||
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
|
||||
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
|
||||
<span :class="item.textClass">{{ item.label }}</span>
|
||||
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
role: 'owner' | 'admin'
|
||||
}>()
|
||||
|
||||
const roleClass = computed(() =>
|
||||
props.role === 'owner'
|
||||
? 'border border-primary bg-primary text-(--ui-bg)'
|
||||
: 'border border-accented bg-transparent text-toned',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:class="roleClass"
|
||||
:ui="{ base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium' }"
|
||||
>
|
||||
{{ role }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -76,10 +76,7 @@ const savedText = computed(() => {
|
||||
<span>Unsaved changes</span>
|
||||
</template>
|
||||
<template v-else-if="state === 'saving'">
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-3.5 animate-spin"
|
||||
/>
|
||||
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
||||
<span>Saving…</span>
|
||||
</template>
|
||||
<template v-else-if="state === 'error'">
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
||||
import UserNameCell from '@/components/UserNameCell.vue'
|
||||
import RoleBadge from '@/components/RoleBadge.vue'
|
||||
import UserEmailCell from '@/components/UserEmailCell.vue'
|
||||
import { formatCreatedDate } from '@/lib/relativeDate'
|
||||
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
|
||||
import {
|
||||
USER_SKELETON_ROWS,
|
||||
userDisplayName,
|
||||
userRole,
|
||||
userRowKey,
|
||||
type UserListItem,
|
||||
} from '@/composables/useUsers'
|
||||
|
||||
defineProps<{
|
||||
items: UserListItem[]
|
||||
@@ -36,12 +43,16 @@ function rowKey(item: UserListItem) {
|
||||
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
|
||||
:class="{ 'bg-elevated': rowKey(item) === highlightedKey }"
|
||||
>
|
||||
<UserNameCell
|
||||
:item="item"
|
||||
<UserEmailCell
|
||||
:email="item.email"
|
||||
:is-you="item.kind === 'user' && item.id === sessionUserId"
|
||||
large
|
||||
/>
|
||||
<div class="flex items-center gap-2.5 text-xs text-muted">
|
||||
<span v-if="userDisplayName(item)" class="text-toned">{{
|
||||
userDisplayName(item)
|
||||
}}</span>
|
||||
<div class="flex items-center gap-2 text-xs text-muted">
|
||||
<RoleBadge :role="userRole(item)" />
|
||||
<InviteStatusBadge :status="item.status" size="sm" />
|
||||
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
email: string
|
||||
isYou: boolean
|
||||
large?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class="truncate font-medium text-highlighted"
|
||||
:class="large ? 'text-[0.9375rem]/[21px]' : ''"
|
||||
>{{ email }}</span
|
||||
>
|
||||
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted"
|
||||
>you</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { UserListItem } from '@/composables/useUsers'
|
||||
|
||||
const props = defineProps<{
|
||||
item: UserListItem
|
||||
isYou: boolean
|
||||
large?: boolean
|
||||
}>()
|
||||
|
||||
const primary = computed(() => {
|
||||
if (props.item.kind === 'invite') {
|
||||
return props.item.email
|
||||
}
|
||||
return props.item.display_name || props.item.username
|
||||
})
|
||||
|
||||
const secondary = computed(() =>
|
||||
props.item.kind === 'user' && props.item.display_name ? props.item.username : '',
|
||||
)
|
||||
|
||||
const primaryClass = computed(() => [
|
||||
props.item.kind === 'invite' ? 'text-toned' : 'font-medium text-highlighted',
|
||||
props.large ? 'text-[0.9375rem]/[21px]' : '',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate" :class="primaryClass">{{ primary }}</span>
|
||||
<span v-if="secondary" class="shrink-0 text-muted">{{ secondary }}</span>
|
||||
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted"
|
||||
>you</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,10 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
||||
import UserNameCell from '@/components/UserNameCell.vue'
|
||||
import RoleBadge from '@/components/RoleBadge.vue'
|
||||
import UserEmailCell from '@/components/UserEmailCell.vue'
|
||||
import { formatCreatedDate } from '@/lib/relativeDate'
|
||||
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
|
||||
import {
|
||||
USER_SKELETON_ROWS,
|
||||
userDisplayName,
|
||||
userRole,
|
||||
userRowKey,
|
||||
type UserListItem,
|
||||
} from '@/composables/useUsers'
|
||||
|
||||
const ROW_GRID = 'grid grid-cols-[minmax(0,1fr)_160px_160px] items-center px-3'
|
||||
const ROW_GRID =
|
||||
'grid grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_84px_120px_110px_150px] items-center gap-3 px-3'
|
||||
|
||||
defineProps<{
|
||||
items: UserListItem[]
|
||||
@@ -21,12 +29,15 @@ function rowKey(item: UserListItem) {
|
||||
<template>
|
||||
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
|
||||
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
|
||||
<span>Username</span>
|
||||
<span>Email</span>
|
||||
<span>Display name</span>
|
||||
<span>Role</span>
|
||||
<span>Invite status</span>
|
||||
<span class="flex items-center gap-1 text-highlighted">
|
||||
Created
|
||||
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
|
||||
</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<template v-if="loading">
|
||||
<span role="status" class="sr-only">Loading users</span>
|
||||
@@ -34,9 +45,11 @@ function rowKey(item: UserListItem) {
|
||||
v-for="n in USER_SKELETON_ROWS"
|
||||
:key="n"
|
||||
:class="ROW_GRID"
|
||||
class="h-11 border-t border-default"
|
||||
class="h-12 border-t border-default"
|
||||
>
|
||||
<USkeleton class="h-3 w-[30%] rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-[60%] rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-[50%] rounded-none bg-accented" />
|
||||
<USkeleton class="h-5 w-13 rounded-none bg-accented" />
|
||||
<USkeleton class="h-5 w-19 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
||||
</div>
|
||||
@@ -47,15 +60,21 @@ function rowKey(item: UserListItem) {
|
||||
:key="rowKey(item)"
|
||||
data-testid="user-row"
|
||||
:class="[ROW_GRID, { 'bg-elevated': rowKey(item) === highlightedKey }]"
|
||||
class="h-11 border-t border-default"
|
||||
class="h-12 border-t border-default"
|
||||
>
|
||||
<UserNameCell
|
||||
:item="item"
|
||||
<UserEmailCell
|
||||
:email="item.email"
|
||||
:is-you="item.kind === 'user' && item.id === sessionUserId"
|
||||
class="pr-3"
|
||||
/>
|
||||
<span
|
||||
class="truncate"
|
||||
:class="userDisplayName(item) ? 'text-highlighted' : 'text-muted'"
|
||||
>{{ userDisplayName(item) || '—' }}</span
|
||||
>
|
||||
<span><RoleBadge :role="userRole(item)" /></span>
|
||||
<span><InviteStatusBadge :status="item.status" /></span>
|
||||
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user