Standardise usage of user avatars and colours across the entire platform
This commit is contained in:
parent
f246a982db
commit
cc7df474c9
|
@ -13,10 +13,12 @@
|
|||
export let url = ""
|
||||
export let disabled = false
|
||||
export let initials = "JD"
|
||||
export let color = null
|
||||
|
||||
const DefaultColor = "#3aab87"
|
||||
|
||||
$: color = getColor(initials)
|
||||
$: avatarColor = color || getColor(initials)
|
||||
$: style = getStyle(size, avatarColor)
|
||||
|
||||
const getColor = initials => {
|
||||
if (!initials?.length) {
|
||||
|
@ -26,6 +28,12 @@
|
|||
const hue = ((code % 26) / 26) * 360
|
||||
return `hsl(${hue}, 50%, 50%)`
|
||||
}
|
||||
|
||||
const getStyle = (sizeKey, color) => {
|
||||
const size = `var(${sizes.get(sizeKey)})`
|
||||
const fontSize = `calc(${size} / 2)`
|
||||
return `width:${size}; height:${size}; font-size:${fontSize}; background:${color};`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if url}
|
||||
|
@ -37,13 +45,7 @@
|
|||
style="width: var({sizes.get(size)}); height: var({sizes.get(size)});"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="spectrum-Avatar"
|
||||
class:is-disabled={disabled}
|
||||
style="width: var({sizes.get(size)}); height: var({sizes.get(
|
||||
size
|
||||
)}); font-size: calc(var({sizes.get(size)}) / 2); background: {color};"
|
||||
>
|
||||
<div class="spectrum-Avatar" class:is-disabled={disabled} {style}>
|
||||
{initials || ""}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -9,11 +9,11 @@ export const getUserStore = () => {
|
|||
|
||||
const updateUser = user => {
|
||||
const $users = get(store)
|
||||
if (!$users.some(x => x.id === user.id)) {
|
||||
if (!$users.some(x => x.sessionId === user.sessionId)) {
|
||||
store.set([...$users, user])
|
||||
} else {
|
||||
store.update(state => {
|
||||
const index = state.findIndex(x => x.id === user.id)
|
||||
const index = state.findIndex(x => x.sessionId === user.sessionId)
|
||||
state[index] = user
|
||||
return state.slice()
|
||||
})
|
||||
|
@ -22,7 +22,7 @@ export const getUserStore = () => {
|
|||
|
||||
const removeUser = user => {
|
||||
store.update(state => {
|
||||
return state.filter(x => x.id !== user.id)
|
||||
return state.filter(x => x.sessionId !== user.sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,143 +0,0 @@
|
|||
<script>
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ModalContent,
|
||||
Modal,
|
||||
notifications,
|
||||
ProgressCircle,
|
||||
Layout,
|
||||
Body,
|
||||
Icon,
|
||||
} from "@budibase/bbui"
|
||||
import { auth, apps } from "stores/portal"
|
||||
import { processStringSync } from "@budibase/string-templates"
|
||||
import { API } from "api"
|
||||
|
||||
export let app
|
||||
export let buttonSize = "M"
|
||||
|
||||
let APP_DEV_LOCK_SECONDS = 600 //common area for this?
|
||||
let appLockModal
|
||||
let processing = false
|
||||
|
||||
$: lockedBy = app?.lockedBy
|
||||
$: lockedByYou = $auth.user.email === lockedBy?.email
|
||||
|
||||
$: lockIdentifer = `${
|
||||
lockedBy && lockedBy.firstName ? lockedBy?.firstName : lockedBy?.email
|
||||
}`
|
||||
|
||||
$: lockedByHeading =
|
||||
lockedBy && lockedByYou ? "Locked by you" : `Locked by ${lockIdentifer}`
|
||||
|
||||
const getExpiryDuration = app => {
|
||||
if (!app?.lockedBy?.lockedAt) {
|
||||
return -1
|
||||
}
|
||||
let expiry =
|
||||
new Date(app.lockedBy.lockedAt).getTime() + APP_DEV_LOCK_SECONDS * 1000
|
||||
return expiry - new Date().getTime()
|
||||
}
|
||||
|
||||
const releaseLock = async () => {
|
||||
processing = true
|
||||
if (app) {
|
||||
try {
|
||||
await API.releaseAppLock(app.devId)
|
||||
await apps.load()
|
||||
notifications.success("Lock released successfully")
|
||||
} catch (err) {
|
||||
notifications.error("Error releasing lock")
|
||||
}
|
||||
} else {
|
||||
notifications.error("No application is selected")
|
||||
}
|
||||
processing = false
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if lockedBy}
|
||||
<div class="lock-status">
|
||||
<Icon
|
||||
name="User"
|
||||
hoverable
|
||||
size={buttonSize}
|
||||
on:click={e => {
|
||||
e.stopPropagation()
|
||||
appLockModal.show()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal bind:this={appLockModal}>
|
||||
<ModalContent
|
||||
title={lockedByHeading}
|
||||
showConfirmButton={false}
|
||||
showCancelButton={false}
|
||||
>
|
||||
<Layout noPadding>
|
||||
<Body size="S">
|
||||
Apps are locked to prevent work being lost from overlapping changes
|
||||
between your team.
|
||||
</Body>
|
||||
{#if lockedByYou && getExpiryDuration(app) > 0}
|
||||
<span class="lock-expiry-body">
|
||||
{processStringSync(
|
||||
"This lock will expire in {{ duration time 'millisecond' }} from now.",
|
||||
{
|
||||
time: getExpiryDuration(app),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="lock-modal-actions">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
secondary
|
||||
quiet={lockedBy && lockedByYou}
|
||||
disabled={processing}
|
||||
on:click={() => {
|
||||
appLockModal.hide()
|
||||
}}
|
||||
>
|
||||
<span class="cancel"
|
||||
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
|
||||
>
|
||||
</Button>
|
||||
{#if lockedByYou}
|
||||
<Button
|
||||
cta
|
||||
disabled={processing}
|
||||
on:click={() => {
|
||||
releaseLock()
|
||||
appLockModal.hide()
|
||||
}}
|
||||
>
|
||||
{#if processing}
|
||||
<ProgressCircle overBackground={true} size="S" />
|
||||
{:else}
|
||||
<span class="unlock">Release Lock</span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</Layout>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.lock-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--spacing-l);
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
.lock-status {
|
||||
display: flex;
|
||||
gap: var(--spacing-s);
|
||||
max-width: 175px;
|
||||
}
|
||||
</style>
|
|
@ -1,13 +1,16 @@
|
|||
<script>
|
||||
import { Heading, Body, Button, Icon, notifications } from "@budibase/bbui"
|
||||
import AppLockModal from "../common/AppLockModal.svelte"
|
||||
import { Heading, Body, Button, Icon, Tooltip } from "@budibase/bbui"
|
||||
import { processStringSync } from "@budibase/string-templates"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
import { UserAvatar } from "@budibase/frontend-core"
|
||||
|
||||
export let app
|
||||
|
||||
export let lockedAction
|
||||
|
||||
$: editing = app?.lockedBy != null
|
||||
$: initials = helpers.getUserInitials(app?.lockedBy)
|
||||
|
||||
const handleDefaultClick = () => {
|
||||
if (window.innerWidth < 640) {
|
||||
goToOverview()
|
||||
|
@ -38,7 +41,10 @@
|
|||
</div>
|
||||
|
||||
<div class="updated">
|
||||
{#if app.updatedAt}
|
||||
{#if editing}
|
||||
Currently editing
|
||||
<UserAvatar user={app.lockedBy} />
|
||||
{:else if app.updatedAt}
|
||||
{processStringSync("Updated {{ duration time 'millisecond' }} ago", {
|
||||
time: new Date().getTime() - new Date(app.updatedAt).getTime(),
|
||||
})}
|
||||
|
@ -53,12 +59,12 @@
|
|||
</div>
|
||||
|
||||
<div class="app-row-actions">
|
||||
<AppLockModal {app} buttonSize="M" />
|
||||
<Button size="S" secondary on:click={lockedAction || goToOverview}
|
||||
>Manage</Button
|
||||
>
|
||||
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button
|
||||
>
|
||||
<Button size="S" secondary on:click={lockedAction || goToOverview}>
|
||||
Manage
|
||||
</Button>
|
||||
<Button size="S" primary on:click={lockedAction || goToBuilder}>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -81,6 +87,9 @@
|
|||
|
||||
.updated {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title,
|
||||
|
|
|
@ -1,12 +1,22 @@
|
|||
<script>
|
||||
import { UserAvatar } from "@budibase/frontend-core"
|
||||
|
||||
export let users = []
|
||||
|
||||
$: uniqueUsers = unique(users)
|
||||
|
||||
const unique = users => {
|
||||
let uniqueUsers = {}
|
||||
users?.forEach(user => {
|
||||
uniqueUsers[user.email] = user
|
||||
})
|
||||
return Object.values(uniqueUsers)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="avatars">
|
||||
{#each users as user}
|
||||
<div class="avatar" style="background:{user.color};" title={user.email}>
|
||||
{user.email[0]}
|
||||
</div>
|
||||
{#each uniqueUsers as user}
|
||||
<UserAvatar {user} tooltipDirection="bottom" />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
@ -15,19 +25,4 @@
|
|||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
line-height: 0;
|
||||
}
|
||||
.avatar:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -218,7 +218,9 @@
|
|||
<!-- This should probably be some kind of loading state? -->
|
||||
<div class="loading" />
|
||||
{:then _}
|
||||
<slot />
|
||||
<div class="body">
|
||||
<slot />
|
||||
</div>
|
||||
{:catch error}
|
||||
<p>Something went wrong: {error.message}</p>
|
||||
{/await}
|
||||
|
@ -254,6 +256,7 @@
|
|||
box-sizing: border-box;
|
||||
align-items: stretch;
|
||||
border-bottom: var(--border-light);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.topleftnav {
|
||||
|
@ -294,4 +297,11 @@
|
|||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
Divider,
|
||||
ActionMenu,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
Page,
|
||||
Icon,
|
||||
Body,
|
||||
|
@ -22,6 +21,8 @@
|
|||
import { processStringSync } from "@budibase/string-templates"
|
||||
import Spaceman from "assets/bb-space-man.svg"
|
||||
import Logo from "assets/bb-emblem.svg"
|
||||
import { UserAvatar } from "@budibase/frontend-core"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
||||
let loaded = false
|
||||
let userInfoModal
|
||||
|
@ -96,11 +97,7 @@
|
|||
<img class="logo" alt="logo" src={$organisation.logoUrl || Logo} />
|
||||
<ActionMenu align="right">
|
||||
<div slot="control" class="avatar">
|
||||
<Avatar
|
||||
size="M"
|
||||
initials={$auth.initials}
|
||||
url={$auth.user.pictureUrl}
|
||||
/>
|
||||
<UserAvatar user={$auth.user} showTooltip={false} />
|
||||
<Icon size="XL" name="ChevronDown" />
|
||||
</div>
|
||||
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>
|
||||
|
@ -125,7 +122,7 @@
|
|||
</div>
|
||||
<Layout noPadding gap="XS">
|
||||
<Heading size="M">
|
||||
Hey {$auth.user.firstName || $auth.user.email}
|
||||
Hey {helpers.getUserLabel($auth.user)}
|
||||
</Heading>
|
||||
<Body>
|
||||
Welcome to the {$organisation.company} portal. Below you'll find the
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
<script>
|
||||
import { auth } from "stores/portal"
|
||||
import { ActionMenu, Avatar, MenuItem, Icon, Modal } from "@budibase/bbui"
|
||||
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
|
||||
import { goto } from "@roxi/routify"
|
||||
import ProfileModal from "components/settings/ProfileModal.svelte"
|
||||
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
|
||||
import ThemeModal from "components/settings/ThemeModal.svelte"
|
||||
import APIKeyModal from "components/settings/APIKeyModal.svelte"
|
||||
import { UserAvatar } from "@budibase/frontend-core"
|
||||
|
||||
let themeModal
|
||||
let profileModal
|
||||
|
@ -23,7 +24,7 @@
|
|||
|
||||
<ActionMenu align="right">
|
||||
<div slot="control" class="user-dropdown">
|
||||
<Avatar size="M" initials={$auth.initials} url={$auth.user.pictureUrl} />
|
||||
<UserAvatar user={$auth.user} showTooltip={false} />
|
||||
<Icon size="XL" name="ChevronDown" />
|
||||
</div>
|
||||
<MenuItem icon="UserEdit" on:click={() => profileModal.show()}>
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
<script>
|
||||
import { Avatar, Tooltip } from "@budibase/bbui"
|
||||
import { Tooltip } from "@budibase/bbui"
|
||||
import { UserAvatar } from "@budibase/frontend-core"
|
||||
|
||||
export let row
|
||||
|
||||
let showTooltip
|
||||
const getInitials = user => {
|
||||
let initials = ""
|
||||
initials += user.firstName ? user.firstName[0] : ""
|
||||
initials += user.lastName ? user.lastName[0] : ""
|
||||
|
||||
return initials === "" ? user.email[0] : initials
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if row?.user?.email}
|
||||
|
@ -19,7 +14,7 @@
|
|||
on:focus={() => (showTooltip = true)}
|
||||
on:mouseleave={() => (showTooltip = false)}
|
||||
>
|
||||
<Avatar size="M" initials={getInitials(row.user)} />
|
||||
<UserAvatar user={row.user} />
|
||||
</div>
|
||||
{#if showTooltip}
|
||||
<div class="tooltip">
|
||||
|
|
|
@ -24,7 +24,6 @@
|
|||
import { AppStatus } from "constants"
|
||||
import analytics, { Events, EventSource } from "analytics"
|
||||
import { store } from "builderStore"
|
||||
import AppLockModal from "components/common/AppLockModal.svelte"
|
||||
import EditableIcon from "components/common/EditableIcon.svelte"
|
||||
import { API } from "api"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
|
@ -128,7 +127,6 @@
|
|||
/>
|
||||
</div>
|
||||
<div slot="buttons">
|
||||
<AppLockModal {app} />
|
||||
<span class="desktop">
|
||||
<Button
|
||||
size="M"
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
<script>
|
||||
import getUserInitials from "helpers/userInitials.js"
|
||||
import { Avatar } from "@budibase/bbui"
|
||||
import { UserAvatar } from "@budibase/frontend-core"
|
||||
|
||||
export let value
|
||||
|
||||
$: initials = getUserInitials(value)
|
||||
</script>
|
||||
|
||||
<div title={value.email} class="cell">
|
||||
<Avatar size="M" {initials} />
|
||||
<div class="cell">
|
||||
<UserAvatar user={value} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -15,11 +15,12 @@
|
|||
import { store } from "builderStore"
|
||||
import { processStringSync } from "@budibase/string-templates"
|
||||
import { users, auth, apps, groups, overview } from "stores/portal"
|
||||
import { fetchData } from "@budibase/frontend-core"
|
||||
import { fetchData, UserAvatar } from "@budibase/frontend-core"
|
||||
import { API } from "api"
|
||||
import GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
||||
let appEditor
|
||||
let unpublishModal
|
||||
|
@ -56,14 +57,6 @@
|
|||
appEditor = await users.get(editorId)
|
||||
}
|
||||
|
||||
const getInitials = user => {
|
||||
let initials = ""
|
||||
initials += user.firstName ? user.firstName[0] : ""
|
||||
initials += user.lastName ? user.lastName[0] : ""
|
||||
|
||||
return initials === "" ? user.email[0] : initials
|
||||
}
|
||||
|
||||
const confirmUnpublishApp = async () => {
|
||||
try {
|
||||
await API.unpublishApp(app.prodId)
|
||||
|
@ -140,7 +133,7 @@
|
|||
<div class="last-edited-content">
|
||||
<div class="updated-by">
|
||||
{#if appEditor}
|
||||
<Avatar size="M" initials={getInitials(appEditor)} />
|
||||
<UserAvatar user={appEditor} showTooltip={false} />
|
||||
<div class="editor-name">
|
||||
{appEditor._id === $auth.user._id ? "You" : appEditorText}
|
||||
</div>
|
||||
|
@ -201,7 +194,7 @@
|
|||
<div class="users">
|
||||
<div class="list">
|
||||
{#each appUsers.slice(0, 4) as user}
|
||||
<Avatar size="M" initials={getInitials(user)} />
|
||||
<UserAvatar {user} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="text">
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
import { goto, url } from "@roxi/routify"
|
||||
import {
|
||||
ActionMenu,
|
||||
Avatar,
|
||||
Button,
|
||||
Layout,
|
||||
Heading,
|
||||
|
@ -25,13 +24,14 @@
|
|||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||
import DeleteUserModal from "./_components/DeleteUserModal.svelte"
|
||||
import GroupIcon from "../groups/_components/GroupIcon.svelte"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import { Constants, UserAvatar } from "@budibase/frontend-core"
|
||||
import { Breadcrumbs, Breadcrumb } from "components/portal/page"
|
||||
import RemoveGroupTableRenderer from "./_components/RemoveGroupTableRenderer.svelte"
|
||||
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
|
||||
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
|
||||
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
|
||||
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
||||
export let userId
|
||||
|
||||
|
@ -91,7 +91,7 @@
|
|||
$: readonly = !$auth.isAdmin || scimEnabled
|
||||
$: privileged = user?.admin?.global || user?.builder?.global
|
||||
$: nameLabel = getNameLabel(user)
|
||||
$: initials = getInitials(nameLabel)
|
||||
$: initials = helpers.getUserInitials(user)
|
||||
$: filteredGroups = getFilteredGroups($groups, searchTerm)
|
||||
$: availableApps = getAvailableApps($apps, privileged, user?.roles)
|
||||
$: userGroups = $groups.filter(x => {
|
||||
|
@ -150,17 +150,6 @@
|
|||
return label
|
||||
}
|
||||
|
||||
const getInitials = nameLabel => {
|
||||
if (!nameLabel) {
|
||||
return "?"
|
||||
}
|
||||
return nameLabel
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map(x => x[0])
|
||||
.join("")
|
||||
}
|
||||
|
||||
async function updateUserFirstName(evt) {
|
||||
try {
|
||||
await users.save({ ...user, firstName: evt.target.value })
|
||||
|
@ -238,7 +227,7 @@
|
|||
|
||||
<div class="title">
|
||||
<div class="user-info">
|
||||
<Avatar size="XXL" {initials} />
|
||||
<UserAvatar size="XXL" {user} showTooltip={false} />
|
||||
<div class="subtitle">
|
||||
<Heading size="M">{nameLabel}</Heading>
|
||||
{#if nameLabel !== user?.email}
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
<script>
|
||||
import { Avatar, Tooltip } from "@budibase/bbui"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
||||
export let user
|
||||
export let size
|
||||
export let tooltipDirection = "top"
|
||||
export let showTooltip = true
|
||||
|
||||
$: tooltipStyle = getTooltipStyle(tooltipDirection)
|
||||
|
||||
const getTooltipStyle = direction => {
|
||||
if (!direction) {
|
||||
return ""
|
||||
}
|
||||
if (direction === "top") {
|
||||
return "transform: translateX(-50%) translateY(-100%);"
|
||||
} else if (direction === "bottom") {
|
||||
return "transform: translateX(-50%) translateY(100%);"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if user}
|
||||
<div class="user-avatar">
|
||||
<Avatar
|
||||
{size}
|
||||
initials={helpers.getUserInitials(user)}
|
||||
color={helpers.getUserColor(user)}
|
||||
/>
|
||||
{#if showTooltip}
|
||||
<div class="tooltip" style={tooltipStyle}>
|
||||
<Tooltip
|
||||
direction={tooltipDirection}
|
||||
textWrapping
|
||||
text={user.email}
|
||||
size="S"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.user-avatar {
|
||||
position: relative;
|
||||
}
|
||||
.tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-avatar:hover .tooltip {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
|
@ -1,24 +0,0 @@
|
|||
<script>
|
||||
export let user
|
||||
</script>
|
||||
|
||||
<div class="user" style="background:{user.color};" title={user.email}>
|
||||
{user.email[0]}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
div:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -1,13 +1,23 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import Avatar from "./Avatar.svelte"
|
||||
import UserAvatar from "../../UserAvatar.svelte"
|
||||
|
||||
const { users } = getContext("grid")
|
||||
|
||||
$: uniqueUsers = unique($users)
|
||||
|
||||
const unique = users => {
|
||||
let uniqueUsers = {}
|
||||
users?.forEach(user => {
|
||||
uniqueUsers[user.email] = user
|
||||
})
|
||||
return Object.values(uniqueUsers)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="users">
|
||||
{#each $users as user}
|
||||
<Avatar {user} />
|
||||
{#each uniqueUsers as user}
|
||||
<UserAvatar {user} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,10 +1,22 @@
|
|||
import { writable, get, derived } from "svelte/store"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
||||
export const createStores = () => {
|
||||
const users = writable([])
|
||||
|
||||
const enrichedUsers = derived(users, $users => {
|
||||
return $users.map(user => ({
|
||||
...user,
|
||||
color: helpers.getUserColor(user),
|
||||
label: helpers.getUserLabel(user),
|
||||
}))
|
||||
})
|
||||
|
||||
return {
|
||||
users,
|
||||
users: {
|
||||
...users,
|
||||
subscribe: enrichedUsers.subscribe,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -28,11 +40,11 @@ export const deriveStores = context => {
|
|||
|
||||
const updateUser = user => {
|
||||
const $users = get(users)
|
||||
if (!$users.some(x => x.id === user.id)) {
|
||||
if (!$users.some(x => x.sessionId === user.sessionId)) {
|
||||
users.set([...$users, user])
|
||||
} else {
|
||||
users.update(state => {
|
||||
const index = state.findIndex(x => x.id === user.id)
|
||||
const index = state.findIndex(x => x.sessionId === user.sessionId)
|
||||
state[index] = user
|
||||
return state.slice()
|
||||
})
|
||||
|
@ -41,7 +53,7 @@ export const deriveStores = context => {
|
|||
|
||||
const removeUser = user => {
|
||||
users.update(state => {
|
||||
return state.filter(x => x.id !== user.id)
|
||||
return state.filter(x => x.sessionId !== user.sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export { default as SplitPage } from "./SplitPage.svelte"
|
||||
export { default as TestimonialPage } from "./TestimonialPage.svelte"
|
||||
export { default as Testimonial } from "./Testimonial.svelte"
|
||||
export { default as UserAvatar } from "./UserAvatar.svelte"
|
||||
export { Grid } from "./grid"
|
||||
|
|
|
@ -5,6 +5,7 @@ import http from "http"
|
|||
import Koa from "koa"
|
||||
import { Datasource, Table } from "@budibase/types"
|
||||
import { gridSocket } from "./index"
|
||||
import { clearLock } from "../utilities/redis"
|
||||
|
||||
export default class BuilderSocket extends Socket {
|
||||
constructor(app: Koa, server: http.Server) {
|
||||
|
@ -15,8 +16,7 @@ export default class BuilderSocket extends Socket {
|
|||
const user = socket.data.user
|
||||
const appId = socket.data.appId
|
||||
socket.join(appId)
|
||||
socket.to(appId).emit("user-update", socket.data.user)
|
||||
console.log(`Builder user connected: ${user?.id}`)
|
||||
socket.to(appId).emit("user-update", user)
|
||||
|
||||
// Initial identification of connected spreadsheet
|
||||
socket.on("get-users", async (payload, callback) => {
|
||||
|
@ -27,8 +27,22 @@ export default class BuilderSocket extends Socket {
|
|||
})
|
||||
|
||||
// Disconnection cleanup
|
||||
socket.on("disconnect", () => {
|
||||
socket.to(appId).emit("user-disconnect", socket.data.user)
|
||||
socket.on("disconnect", async () => {
|
||||
socket.to(appId).emit("user-disconnect", user)
|
||||
|
||||
// Remove app lock from this user if they have no other connections
|
||||
try {
|
||||
const sockets = await this.io.in(appId).fetchSockets()
|
||||
const hasOtherConnection = sockets.some(socket => {
|
||||
const { _id, sessionId } = socket.data.user
|
||||
return _id === user._id && sessionId !== user.sessionId
|
||||
})
|
||||
if (!hasOtherConnection) {
|
||||
await clearLock(appId, user)
|
||||
}
|
||||
} catch (e) {
|
||||
// This is fine, just means this user didn't hold the lock
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ export default class GridSocket extends Socket {
|
|||
|
||||
this.io.on("connection", socket => {
|
||||
const user = socket.data.user
|
||||
console.log(`Spreadsheet user connected: ${user?.id}`)
|
||||
|
||||
// Socket state
|
||||
let currentRoom: string
|
||||
|
@ -21,14 +20,14 @@ export default class GridSocket extends Socket {
|
|||
socket.on("select-table", async (tableId, callback) => {
|
||||
// Leave current room
|
||||
if (currentRoom) {
|
||||
socket.to(currentRoom).emit("user-disconnect", socket.data.user)
|
||||
socket.to(currentRoom).emit("user-disconnect", user)
|
||||
socket.leave(currentRoom)
|
||||
}
|
||||
|
||||
// Join new room
|
||||
currentRoom = tableId
|
||||
socket.join(currentRoom)
|
||||
socket.to(currentRoom).emit("user-update", socket.data.user)
|
||||
socket.to(currentRoom).emit("user-update", user)
|
||||
|
||||
// Reply with all users in current room
|
||||
const sockets = await this.io.in(currentRoom).fetchSockets()
|
||||
|
@ -41,14 +40,14 @@ export default class GridSocket extends Socket {
|
|||
socket.on("select-cell", cellId => {
|
||||
socket.data.user.focusedCellId = cellId
|
||||
if (currentRoom) {
|
||||
socket.to(currentRoom).emit("user-update", socket.data.user)
|
||||
socket.to(currentRoom).emit("user-update", user)
|
||||
}
|
||||
})
|
||||
|
||||
// Disconnection cleanup
|
||||
socket.on("disconnect", () => {
|
||||
if (currentRoom) {
|
||||
socket.to(currentRoom).emit("user-disconnect", socket.data.user)
|
||||
socket.to(currentRoom).emit("user-disconnect", user)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
@ -7,6 +7,7 @@ import { auth } from "@budibase/backend-core"
|
|||
import currentApp from "../middleware/currentapp"
|
||||
import { createAdapter } from "@socket.io/redis-adapter"
|
||||
import { getSocketPubSubClients } from "../utilities/redis"
|
||||
import uuid from "uuid"
|
||||
|
||||
export default class Socket {
|
||||
io: Server
|
||||
|
@ -64,29 +65,14 @@ export default class Socket {
|
|||
// Middlewares are finished
|
||||
// Extract some data from our enriched koa context to persist
|
||||
// as metadata for the socket
|
||||
|
||||
// Add user info, including a deterministic color and friendly
|
||||
// label
|
||||
// Add user info, including a deterministic color and label
|
||||
const { _id, email, firstName, lastName } = ctx.user
|
||||
let hue = 1
|
||||
for (let i = 0; i < email.length && i < 5; i++) {
|
||||
hue *= email.charCodeAt(i + 1)
|
||||
hue /= 17
|
||||
}
|
||||
hue = hue % 360
|
||||
const color = `hsl(${hue}, 50%, 40%)`
|
||||
let label = email
|
||||
if (firstName) {
|
||||
label = firstName
|
||||
if (lastName) {
|
||||
label += ` ${lastName}`
|
||||
}
|
||||
}
|
||||
socket.data.user = {
|
||||
id: _id,
|
||||
_id,
|
||||
email,
|
||||
color,
|
||||
label,
|
||||
firstName,
|
||||
lastName,
|
||||
sessionId: uuid.v4(),
|
||||
}
|
||||
|
||||
// Add app ID to help split sockets into rooms
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { User } from "@budibase/types"
|
||||
|
||||
/**
|
||||
* Gets a key within an object. The key supports dot syntax for retrieving deep
|
||||
* fields - e.g. "a.b.c".
|
||||
|
@ -21,3 +23,60 @@ export const deepGet = (obj: { [x: string]: any }, key: string) => {
|
|||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the initials to show in a user avatar.
|
||||
* @param user the user
|
||||
*/
|
||||
export const getUserInitials = (user: User) => {
|
||||
if (!user) {
|
||||
return "?"
|
||||
}
|
||||
let initials = ""
|
||||
initials += user.firstName ? user.firstName[0] : ""
|
||||
initials += user.lastName ? user.lastName[0] : ""
|
||||
return initials === "" ? user.email[0] : initials
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a deterministic colour for a particular user
|
||||
* @param user the user
|
||||
*/
|
||||
export const getUserColor = (user: User) => {
|
||||
let id = user?._id
|
||||
if (!id) {
|
||||
return "var(--spectrum-global-color-blue-400)"
|
||||
}
|
||||
|
||||
// In order to generate the same color for global users as app users, we need
|
||||
// to remove the app-specific table prefix
|
||||
id = id.replace("ro_ta_users_", "")
|
||||
|
||||
// Generate a hue based on the ID
|
||||
let hue = 1
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hue += id.charCodeAt(i)
|
||||
hue = hue % 36
|
||||
}
|
||||
return `hsl(${hue * 10}, 50%, 40%)`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a friendly label to describe who a user is.
|
||||
* @param user the user
|
||||
*/
|
||||
export const getUserLabel = (user: User) => {
|
||||
if (!user) {
|
||||
return ""
|
||||
}
|
||||
const { firstName, lastName, email } = user
|
||||
if (firstName && lastName) {
|
||||
return `${firstName} ${lastName}`
|
||||
} else if (firstName) {
|
||||
return firstName
|
||||
} else if (lastName) {
|
||||
return lastName
|
||||
} else {
|
||||
return email
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue