Use logo for workspaces (#4828)

Signed-off-by: Vyacheslav Tumanov <me@slavatumanov.me>
This commit is contained in:
Vyacheslav Tumanov 2024-02-29 19:40:00 +05:00 committed by GitHub
parent 6cbb497a01
commit 36bc394e7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 193 additions and 16 deletions

View File

@ -28,6 +28,7 @@ import {
type Integration, type Integration,
type IntegrationType, type IntegrationType,
type InviteSettings, type InviteSettings,
type WorkspaceSetting,
type SettingsCategory, type SettingsCategory,
type UserMixin type UserMixin
} from '@hcengineering/setting' } from '@hcengineering/setting'
@ -99,6 +100,11 @@ export class TInviteSettings extends TConfiguration implements InviteSettings {
limit!: number limit!: number
} }
@Model(setting.class.WorkspaceSetting, core.class.Doc, DOMAIN_SETTING)
export class TWorkspaceSetting extends TDoc implements WorkspaceSetting {
icon?: string
}
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
builder.createModel( builder.createModel(
TIntegration, TIntegration,
@ -107,7 +113,8 @@ export function createModel (builder: Builder): void {
TWorkspaceSettingCategory, TWorkspaceSettingCategory,
TEditable, TEditable,
TUserMixin, TUserMixin,
TInviteSettings TInviteSettings,
TWorkspaceSetting
) )
builder.mixin(setting.class.Integration, core.class.Class, notification.mixin.ClassCollaborators, { builder.mixin(setting.class.Integration, core.class.Class, notification.mixin.ClassCollaborators, {
@ -205,6 +212,19 @@ export function createModel (builder: Builder): void {
}, },
setting.ids.Configure setting.ids.Configure
) )
builder.createDoc(
setting.class.WorkspaceSettingCategory,
core.space.Model,
{
name: 'workspaceSettings',
label: setting.string.Branding,
icon: setting.icon.AccountSettings,
component: setting.component.WorkspaceSetting,
order: 1002,
secured: true
},
setting.ids.WorkspaceSetting
)
builder.createDoc( builder.createDoc(
setting.class.WorkspaceSettingCategory, setting.class.WorkspaceSettingCategory,
core.space.Model, core.space.Model,

View File

@ -20,6 +20,7 @@
import presentation from '@hcengineering/presentation' import presentation from '@hcengineering/presentation'
export let file: Blob export let file: Blob
export let lessCrop: boolean = false
let inputRef: HTMLInputElement let inputRef: HTMLInputElement
const targetMimes = ['image/png', 'image/jpg', 'image/jpeg'] const targetMimes = ['image/png', 'image/jpg', 'image/jpeg']
@ -64,7 +65,7 @@
<div class="editavatar-container"> <div class="editavatar-container">
{#await CropperP then Cropper} {#await CropperP then Cropper}
<div class="cropper"> <div class="cropper">
<Cropper bind:this={cropper} image={file} /> <Cropper bind:this={cropper} image={file} {lessCrop} />
</div> </div>
<div class="footer"> <div class="footer">
<Button label={presentation.string.Save} kind={'primary'} size={'large'} on:click={onCrop} /> <Button label={presentation.string.Save} kind={'primary'} size={'large'} on:click={onCrop} />

View File

@ -35,6 +35,8 @@
export let direct: Blob | undefined = undefined export let direct: Blob | undefined = undefined
export let icon: Asset | AnySvelteComponent | undefined = undefined export let icon: Asset | AnySvelteComponent | undefined = undefined
export let disabled: boolean = false export let disabled: boolean = false
export let imageOnly: boolean = false
export let lessCrop: boolean = false
$: [schema, uri] = avatar?.split('://') || [] $: [schema, uri] = avatar?.split('://') || []
@ -91,6 +93,8 @@
name, name,
file: direct, file: direct,
icon, icon,
imageOnly,
lessCrop,
onSubmit: handlePopupSubmit onSubmit: handlePopupSubmit
}) })
} }

View File

@ -41,6 +41,8 @@
export let email: string | undefined export let email: string | undefined
export let file: Blob | undefined export let file: Blob | undefined
export let icon: Asset | AnySvelteComponent | undefined = undefined export let icon: Asset | AnySvelteComponent | undefined = undefined
export let imageOnly: boolean = false
export let lessCrop: boolean = false
export let onSubmit: (avatarType?: AvatarType, avatar?: string, file?: Blob) => void export let onSubmit: (avatarType?: AvatarType, avatar?: string, file?: Blob) => void
const [schema, uri] = avatar?.split('://') || [] const [schema, uri] = avatar?.split('://') || []
@ -110,18 +112,18 @@
if (selectedFile !== undefined) { if (selectedFile !== undefined) {
editableFile = selectedFile editableFile = selectedFile
} else if (selectedAvatar) { } else if (selectedAvatar && !(imageOnly && selectedAvatar === initialSelectedAvatar)) {
const url = getFileUrl(selectedAvatar, 'full') const url = getFileUrl(selectedAvatar, 'full')
editableFile = await (await fetch(url)).blob() editableFile = await (await fetch(url)).blob()
} else { } else {
inputRef.click() inputRef.click()
return return
} }
showCropper(editableFile) if (editableFile.size > 0) showCropper(editableFile)
} }
function showCropper (editableFile: Blob) { function showCropper (editableFile: Blob) {
showPopup(EditAvatarPopup, { file: editableFile }, undefined, (blob) => { showPopup(EditAvatarPopup, { file: editableFile, lessCrop }, undefined, (blob) => {
if (blob === undefined) { if (blob === undefined) {
if (!selectedFile && (!avatar || avatar.includes('://'))) { if (!selectedFile && (!avatar || avatar.includes('://'))) {
selectedAvatarType = AvatarType.COLOR selectedAvatarType = AvatarType.COLOR
@ -131,7 +133,7 @@
} }
if (blob === null) { if (blob === null) {
selectedAvatarType = AvatarType.COLOR selectedAvatarType = AvatarType.COLOR
selectedAvatar = getPlatformAvatarColorForTextDef(name ?? '', $themeStore.dark).name selectedAvatar = imageOnly ? '' : getPlatformAvatarColorForTextDef(name ?? '', $themeStore.dark).name
selectedFile = undefined selectedFile = undefined
} else { } else {
selectedFile = blob selectedFile = blob
@ -203,8 +205,12 @@
<div <div
class="cursor-pointer" class="cursor-pointer"
on:click|self={(e) => { on:click|self={(e) => {
if (selectedAvatarType === AvatarType.IMAGE) handleImageAvatarClick() if (imageOnly) {
else if (selectedAvatarType === AvatarType.COLOR) showColorPopup(e) handleImageAvatarClick()
} else {
if (selectedAvatarType === AvatarType.IMAGE) handleImageAvatarClick()
else if (selectedAvatarType === AvatarType.COLOR) showColorPopup(e)
}
}} }}
> >
<AvatarComponent <AvatarComponent
@ -220,7 +226,7 @@
/> />
</div> </div>
<TabList <TabList
items={getAvatarTypeDropdownItems(hasGravatar)} items={getAvatarTypeDropdownItems(hasGravatar, imageOnly)}
kind={'separated-free'} kind={'separated-free'}
bind:selected={selectedAvatarType} bind:selected={selectedAvatarType}
on:select={handleDropdownSelection} on:select={handleDropdownSelection}

View File

@ -335,7 +335,15 @@ function fillStores (): void {
fillStores() fillStores()
export function getAvatarTypeDropdownItems (hasGravatar: boolean): TabItem[] { export function getAvatarTypeDropdownItems (hasGravatar: boolean, imageOnly?: boolean): TabItem[] {
if (imageOnly === true) {
return [
{
id: AvatarType.IMAGE,
labelIntl: contact.string.UseImage
}
]
}
return [ return [
{ {
id: AvatarType.COLOR, id: AvatarType.COLOR,

View File

@ -18,6 +18,7 @@
export let image: Blob export let image: Blob
export let cropSize = 1200 export let cropSize = 1200
export let lessCrop: boolean = false
let imgRef: HTMLImageElement let imgRef: HTMLImageElement
let cropper: Cropper | undefined let cropper: Cropper | undefined
@ -86,7 +87,7 @@
} }
</script> </script>
<div class="w-full h-full flex"> <div class="w-full h-full flex" class:less-crop={lessCrop}>
<img class="image" bind:this={imgRef} alt="img" /> <img class="image" bind:this={imgRef} alt="img" />
{#await init(image)} {#await init(image)}
Waiting... Waiting...
@ -99,6 +100,9 @@
:global(.cropper-view-box, .cropper-face) { :global(.cropper-view-box, .cropper-face) {
border-radius: 50%; border-radius: 50%;
} }
:global(.less-crop .cropper-view-box, .less-crop .cropper-face) {
border-radius: 10%;
}
.image { .image {
max-width: 100%; max-width: 100%;

View File

@ -99,6 +99,7 @@
"TaskTypes": "Task types", "TaskTypes": "Task types",
"Automations": "Automations", "Automations": "Automations",
"Collections": "Collections", "Collections": "Collections",
"ClassColon": "Class:" "ClassColon": "Class:",
"Branding": "Branding"
} }
} }

View File

@ -100,6 +100,7 @@
"TaskTypes": "Типы задач", "TaskTypes": "Типы задач",
"Automations": "Автоматизация", "Automations": "Автоматизация",
"Collections": "Коллекции", "Collections": "Коллекции",
"ClassColon": "Класс:" "ClassColon": "Класс:",
"Branding": "Брендинг"
} }
} }

View File

@ -0,0 +1,91 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte'
import contact, { Employee, PersonAccount, combineName, getFirstName, getLastName } from '@hcengineering/contact'
import { ChannelsEditor, EditableAvatar, employeeByIdStore } from '@hcengineering/contact-resources'
import { AttributeEditor, getClient, MessageBox } from '@hcengineering/presentation'
import {
Button,
createFocusManager,
EditBox,
FocusHandler,
showPopup,
Header,
Breadcrumb,
Label
} from '@hcengineering/ui'
import setting from '../plugin'
import { WorkspaceSetting } from '@hcengineering/setting'
import { getEmbeddedLabel } from '@hcengineering/platform'
export let visibleNav: boolean = true
const dispatch = createEventDispatcher()
let workspaceSettings: WorkspaceSetting | undefined = undefined
const client = getClient()
client.findOne(setting.class.WorkspaceSetting, {}).then((r) => {
workspaceSettings = r
})
let avatarEditor: EditableAvatar
async function onAvatarDone (e: any): Promise<void> {
if (workspaceSettings === undefined) {
const avatar = await avatarEditor.createAvatar()
await client.createDoc(
setting.class.WorkspaceSetting,
setting.space.Setting,
{ icon: avatar },
setting.ids.WorkspaceSetting
)
return
}
if (workspaceSettings.icon != null) {
await avatarEditor.removeAvatar(workspaceSettings.icon)
}
const avatar = await avatarEditor.createAvatar()
await client.update(workspaceSettings, {
icon: avatar
})
}
const manager = createFocusManager()
</script>
<FocusHandler {manager} />
<div class="hulyComponent p-10 flex ac-body row">
<EditableAvatar
avatar={workspaceSettings?.icon}
size={'x-large'}
bind:this={avatarEditor}
on:done={onAvatarDone}
imageOnly
lessCrop
/>
<div class="heading-medium-20 p-4">
<Label label={getEmbeddedLabel('Workspace Logo')} />
</div>
</div>
<style lang="scss">
.row {
flex-direction: row;
align-content: center;
}
</style>

View File

@ -40,6 +40,7 @@ import NumberTypeEditor from './components/typeEditors/NumberTypeEditor.svelte'
import ArrayEditor from './components/typeEditors/ArrayEditor.svelte' import ArrayEditor from './components/typeEditors/ArrayEditor.svelte'
import RefEditor from './components/typeEditors/RefEditor.svelte' import RefEditor from './components/typeEditors/RefEditor.svelte'
import StringTypeEditor from './components/typeEditors/StringTypeEditor.svelte' import StringTypeEditor from './components/typeEditors/StringTypeEditor.svelte'
import WorkspaceSetting from './components/WorkspaceSetting.svelte'
import WorkspaceSettings from './components/WorkspaceSettings.svelte' import WorkspaceSettings from './components/WorkspaceSettings.svelte'
import InviteSetting from './components/InviteSetting.svelte' import InviteSetting from './components/InviteSetting.svelte'
import Configure from './components/Configure.svelte' import Configure from './components/Configure.svelte'
@ -84,6 +85,7 @@ export default async (): Promise<Resources> => ({
Settings, Settings,
Profile, Profile,
Password, Password,
WorkspaceSetting,
WorkspaceSettings, WorkspaceSettings,
Integrations, Integrations,
Support, Support,

View File

@ -97,6 +97,13 @@ export interface InviteSettings extends Configuration {
limit: number limit: number
} }
/**
* @public
*/
export interface WorkspaceSetting extends Doc {
icon?: string
}
/** /**
* @public * @public
*/ */
@ -114,7 +121,8 @@ export default plugin(settingId, {
Terms: '' as Ref<Doc>, Terms: '' as Ref<Doc>,
ClassSetting: '' as Ref<Doc>, ClassSetting: '' as Ref<Doc>,
Owners: '' as Ref<Doc>, Owners: '' as Ref<Doc>,
InviteSettings: '' as Ref<Doc> InviteSettings: '' as Ref<Doc>,
WorkspaceSetting: '' as Ref<Doc>
}, },
mixin: { mixin: {
Editable: '' as Ref<Mixin<Editable>>, Editable: '' as Ref<Mixin<Editable>>,
@ -128,12 +136,14 @@ export default plugin(settingId, {
WorkspaceSettingCategory: '' as Ref<Class<SettingsCategory>>, WorkspaceSettingCategory: '' as Ref<Class<SettingsCategory>>,
Integration: '' as Ref<Class<Integration>>, Integration: '' as Ref<Class<Integration>>,
IntegrationType: '' as Ref<Class<IntegrationType>>, IntegrationType: '' as Ref<Class<IntegrationType>>,
InviteSettings: '' as Ref<Class<InviteSettings>> InviteSettings: '' as Ref<Class<InviteSettings>>,
WorkspaceSetting: '' as Ref<Class<WorkspaceSetting>>
}, },
component: { component: {
Settings: '' as AnyComponent, Settings: '' as AnyComponent,
Profile: '' as AnyComponent, Profile: '' as AnyComponent,
Password: '' as AnyComponent, Password: '' as AnyComponent,
WorkspaceSetting: '' as AnyComponent,
WorkspaceSettings: '' as AnyComponent, WorkspaceSettings: '' as AnyComponent,
Integrations: '' as AnyComponent, Integrations: '' as AnyComponent,
Support: '' as AnyComponent, Support: '' as AnyComponent,
@ -145,6 +155,7 @@ export default plugin(settingId, {
Settings: '' as IntlString, Settings: '' as IntlString,
Setting: '' as IntlString, Setting: '' as IntlString,
WorkspaceSettings: '' as IntlString, WorkspaceSettings: '' as IntlString,
Branding: '' as IntlString,
Integrations: '' as IntlString, Integrations: '' as IntlString,
Support: '' as IntlString, Support: '' as IntlString,
Privacy: '' as IntlString, Privacy: '' as IntlString,

View File

@ -13,11 +13,32 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import setting, { WorkspaceSetting } from '@hcengineering/setting'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Component, Icon } from '@hcengineering/ui'
import contact, { type GetAvatarUrl } from '@hcengineering/contact'
import { getResource } from '@hcengineering/platform'
export let mini: boolean = false export let mini: boolean = false
export let workspace: string export let workspace: string
const wsSettingQuery = createQuery()
let getFileUrl: undefined | GetAvatarUrl = undefined
getResource(contact.function.GetFileUrl).then((r) => (getFileUrl = r))
const client = getClient()
let workspaceSetting: WorkspaceSetting | undefined = undefined
wsSettingQuery.query(setting.class.WorkspaceSetting, {}, (res) => {
workspaceSetting = res[0]
})
$: url =
getFileUrl !== undefined && workspaceSetting?.icon != null ? getFileUrl(workspaceSetting.icon, 'large') : ['']
$: srcset = url?.slice(1)?.join(', ')
</script> </script>
<div class="antiLogo red" class:mini>{workspace?.toUpperCase()?.[0] ?? ''}</div> {#if getFileUrl !== undefined && workspaceSetting?.icon != null}
<img class="logo-medium" src={url[0]} {srcset} alt={''} />
{:else}
<div class="antiLogo red" class:mini>{workspace?.toUpperCase()?.[0] ?? ''}</div>
{/if}
<style lang="scss"> <style lang="scss">
.antiLogo { .antiLogo {
@ -46,4 +67,11 @@
background-color: rgb(246, 105, 77); background-color: rgb(246, 105, 77);
} }
} }
.logo-medium {
outline: none;
cursor: pointer;
width: 2rem;
height: 2rem;
border-radius: 0.25rem;
}
</style> </style>