mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-07 18:27:44 +02:00
Support for rating system (#10124)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import ratingPlugin, { DocReaction, ReactionKind } from '@hcengineering/rating'
|
||||
import { Icon } from '@hcengineering/ui'
|
||||
import ReactionPresenter from './ReactionPresenter.svelte'
|
||||
|
||||
export let value: DocReaction | null = null
|
||||
</script>
|
||||
|
||||
{#if value}
|
||||
{#if value.reactionType === ReactionKind.Star}
|
||||
<Icon icon={ratingPlugin.icon.StarYellow} size={'small'} />
|
||||
{:else}
|
||||
<ReactionPresenter emoji={value?.emoji ?? ''} />
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,53 @@
|
||||
<!--
|
||||
// Copyright © 2025 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 { Ref, type Class, type Doc } from '@hcengineering/core'
|
||||
import { createQuery } from '@hcengineering/presentation'
|
||||
import { FixedColumn, ObjectPresenter } from '@hcengineering/view-resources'
|
||||
import RatingEditor from './RatingEditor.svelte'
|
||||
import type { DocReaction } from '@hcengineering/rating'
|
||||
|
||||
export let _class: Class<Doc>
|
||||
export let docs: { _id: Ref<Doc>, reactions: DocReaction[] }[] = []
|
||||
|
||||
const query = createQuery()
|
||||
const limit: number = 20
|
||||
|
||||
let objects: { doc: Doc, reactions: DocReaction[] }[] = []
|
||||
|
||||
$: query.query(
|
||||
_class._id,
|
||||
{ _id: { $in: docs.map((it) => it._id) } },
|
||||
(res) => {
|
||||
objects = res.map((doc) => ({
|
||||
doc,
|
||||
reactions: docs.find((it) => it._id === doc._id)?.reactions ?? []
|
||||
}))
|
||||
},
|
||||
{ limit, total: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="ml-2 flex-col">
|
||||
{#each objects as doc (doc.doc._id)}
|
||||
<div class="flex-presenter p-1 flex flex-between">
|
||||
<FixedColumn key={'reactions_link'}>
|
||||
<ObjectPresenter _class={doc.doc._class} objectId={doc.doc._id} value={doc.doc} noUnderline />
|
||||
</FixedColumn>
|
||||
<RatingEditor _class={doc.doc._class} _id={doc.doc._id} reactions={doc.reactions} showMy={false} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import type { PersonRating } from '@hcengineering/rating'
|
||||
|
||||
export let rating: PersonRating | undefined
|
||||
|
||||
// Sorted months
|
||||
$: months = [...(rating?.months ?? [])].sort((a, b) => a[0] - b[0])
|
||||
|
||||
$: fullYears = Array.from(new Set(months.map((it) => Math.floor(it[0] / 100))))
|
||||
|
||||
$: ymin = Math.min(...fullYears)
|
||||
$: ymax = Math.max(...fullYears)
|
||||
|
||||
const monthMax: number = 500
|
||||
|
||||
// Color scale: 0 = #ebedf0, low = #c6e48b, mid = #7bc96f, high = #239a3b, max = #196127
|
||||
function getColor (val: number, max: number): string {
|
||||
max = Math.max(max, monthMax)
|
||||
const COLORS = [
|
||||
'transparent',
|
||||
'#c6e48b',
|
||||
'#a0d16e',
|
||||
'#8ec760',
|
||||
'#7bc96f',
|
||||
'#6bb85e',
|
||||
'#5ba94d',
|
||||
'#4b9a3c',
|
||||
'#3b8b2b',
|
||||
'#2b7c1a',
|
||||
'#239a3b',
|
||||
'#1f7a2c',
|
||||
'#1b7820',
|
||||
'#177614',
|
||||
'#156c11',
|
||||
'#196127',
|
||||
'#144f0e'
|
||||
]
|
||||
if (val <= 0) return COLORS[0]
|
||||
const idx = 1 + Math.min(COLORS.length - 2, Math.floor((val / max) * (COLORS.length - 1)))
|
||||
return COLORS[idx]
|
||||
}
|
||||
|
||||
function getMax (year: number, rating?: PersonRating): number {
|
||||
const monthData = rating?.months?.filter((it) => Math.floor(it[0] / 100) === year)
|
||||
if (monthData === undefined) return monthMax
|
||||
return Math.max(...monthData.map((val) => (val[1] ?? 0) + (val[2] ?? 0) + (val[3] ?? 0)))
|
||||
}
|
||||
|
||||
import ratingPlugin from '@hcengineering/rating'
|
||||
import { tooltip } from '@hcengineering/ui'
|
||||
|
||||
// Formatter for localized month names (short form, e.g. 'Jan', 'Feb' or localized equivalent)
|
||||
const monthFormatter = new Intl.DateTimeFormat(undefined, { month: 'short' })
|
||||
</script>
|
||||
|
||||
{#if rating}
|
||||
<div class="flex flex-col">
|
||||
<div class="rating-activities">
|
||||
<!-- Monthly grid -->
|
||||
{#each Array.from({ length: ymax - ymin + 1 }, (_, i) => i + ymin) as year}
|
||||
{@const ymonths = months.filter((it) => Math.floor(it[0] / 100) === year)}
|
||||
<div class="flex-row flex flex-col p-1">
|
||||
<div class="flex flex-grow justify-center">
|
||||
{year}
|
||||
</div>
|
||||
<div class="contribution-grid">
|
||||
{#each Array.from({ length: 12 }, (_, i) => i) as m, i}
|
||||
{@const monthval = year * 100 + m}
|
||||
{@const yval = ymonths.find((it) => it[0] === monthval)}
|
||||
{#if yval}
|
||||
{@const ops = (yval[1] ?? 0) + (yval[2] ?? 0) + (yval[3] ?? 0)}
|
||||
{@const monthLabel = monthFormatter.format(new Date(year, i, 1))}
|
||||
<div
|
||||
class="contribution-cell"
|
||||
use:tooltip={{ label: ratingPlugin.string.MonthOps, props: { month: monthLabel, ops: String(ops) } }}
|
||||
style="background: {getColor(ops, getMax(year, rating))}"
|
||||
></div>
|
||||
{:else}
|
||||
{@const monthLabel = monthFormatter.format(new Date(year, i, 1))}
|
||||
<div
|
||||
class="contribution-cell"
|
||||
use:tooltip={{ label: ratingPlugin.string.MonthNoOps, props: { month: monthLabel } }}
|
||||
style="background: {getColor(0, monthMax)}"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.rating-activities {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 1em;
|
||||
max-width: fit-content;
|
||||
max-height: fit-content;
|
||||
}
|
||||
.contribution-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 1em;
|
||||
max-width: fit-content;
|
||||
max-height: fit-content;
|
||||
}
|
||||
.contribution-cell {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #e0e0e0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import type { Class, Doc, Ref } from '@hcengineering/core'
|
||||
import core, { getCurrentAccount, groupByArray } from '@hcengineering/core'
|
||||
import emojiPlugin from '@hcengineering/emoji'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import type { DocReaction } from '@hcengineering/rating'
|
||||
import ratingPlugin, { ReactionKind } from '@hcengineering/rating'
|
||||
import { Icon, showPopup, tooltip } from '@hcengineering/ui'
|
||||
import ReactionPresenter from './ReactionPresenter.svelte'
|
||||
import { translateCB } from '@hcengineering/platform'
|
||||
|
||||
export let _id: Ref<Doc>
|
||||
export let _class: Ref<Class<Doc>>
|
||||
export let showMy = true
|
||||
|
||||
export let reactions: DocReaction[] = []
|
||||
|
||||
let _reactions = reactions
|
||||
|
||||
// Compute reaction groups
|
||||
$: starReactions = _reactions.filter((r) => r.reactionType === ReactionKind.Star)
|
||||
$: otherReactions = groupByArray(
|
||||
_reactions.filter((r) => r.reactionType === ReactionKind.Emoji),
|
||||
(it) => it.emoji ?? ''
|
||||
).entries()
|
||||
$: starCount = starReactions.filter((it) => it.value === 1).length
|
||||
|
||||
const account = getCurrentAccount()
|
||||
|
||||
const socialIds = new Set(account.fullSocialIds.map((it) => it._id))
|
||||
|
||||
const client = getClient()
|
||||
|
||||
const reactionsQuery = createQuery()
|
||||
$: if (reactions.length === 0) {
|
||||
reactionsQuery.query(ratingPlugin.class.DocReaction, { attachedTo: _id, attachedToClass: _class }, (result) => {
|
||||
_reactions = result
|
||||
})
|
||||
} else {
|
||||
reactionsQuery.unsubscribe()
|
||||
_reactions = reactions
|
||||
}
|
||||
|
||||
async function addStarReaction (): Promise<void> {
|
||||
const existing = _reactions.filter((it) => it.reactionType === ReactionKind.Star && socialIds.has(it.modifiedBy))
|
||||
if (existing.length > 0) {
|
||||
for (const e of existing) {
|
||||
if (e.value === 0) {
|
||||
await client.update(e, { value: 1 })
|
||||
} else {
|
||||
await client.update(e, { value: 0 })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await client.addCollection(ratingPlugin.class.DocReaction, core.space.Workspace, _id, _class, '_reactions', {
|
||||
reactionType: ReactionKind.Star,
|
||||
value: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function addEmojiReaction (event: MouseEvent): Promise<void> {
|
||||
showPopup(emojiPlugin.component.EmojiPopup, {}, event.target as HTMLElement, async (emoji) => {
|
||||
if (emoji?.text === undefined) return
|
||||
|
||||
const existing = _reactions.filter(
|
||||
(it) => it.reactionType === ReactionKind.Emoji && it.emoji === emoji.text && socialIds.has(it.modifiedBy)
|
||||
)
|
||||
if (existing.length > 0) {
|
||||
for (const e of existing) {
|
||||
await client.remove(e)
|
||||
}
|
||||
} else {
|
||||
await client.addCollection(ratingPlugin.class.DocReaction, core.space.Workspace, _id, _class, '_reactions', {
|
||||
reactionType: ReactionKind.Emoji,
|
||||
value: 0,
|
||||
emoji: emoji.text,
|
||||
image: emoji.image
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function removeReaction (reactions: DocReaction[]): Promise<void> {
|
||||
for (const reaction of reactions) {
|
||||
if (account.fullSocialIds.some((it) => it._id === reaction.modifiedBy)) {
|
||||
await client.removeDoc(reaction._class, reaction.space, reaction._id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: hasSelectedStar = _reactions.some(
|
||||
(it) => it.reactionType === ReactionKind.Star && socialIds.has(it.createdBy ?? it.modifiedBy) && it.value === 1
|
||||
)
|
||||
|
||||
let addStarAria: string | undefined = undefined
|
||||
$: translateCB(ratingPlugin.string.AddStarAria, {}, undefined, (r) => (addStarAria = r))
|
||||
</script>
|
||||
|
||||
<div class="rating-editor">
|
||||
<!-- Inline Reactions -->
|
||||
<div class="reactions-container">
|
||||
<!-- Star Reactions (Premium) -->
|
||||
<button
|
||||
class="star-reaction"
|
||||
on:click={addStarReaction}
|
||||
use:tooltip={{ label: ratingPlugin.string.AddStar }}
|
||||
aria-label={addStarAria}
|
||||
>
|
||||
<span class="star-icon">
|
||||
<Icon icon={hasSelectedStar ? ratingPlugin.icon.StarYellow : ratingPlugin.icon.Rating} size={'small'} />
|
||||
</span>
|
||||
{#if showMy && starCount > 0}
|
||||
<span class="star-count">{starCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Other Emoji Reactions -->
|
||||
{#each otherReactions as oreact (oreact[0])}
|
||||
{@const emoji = oreact[1][0].emoji}
|
||||
{@const data = oreact[1]}
|
||||
|
||||
<ReactionPresenter
|
||||
{emoji}
|
||||
selected={showMy && data.some((it) => socialIds.has(it.modifiedBy))}
|
||||
socialIds={data.map((it) => it.modifiedBy)}
|
||||
count={showMy ? data.length : 0}
|
||||
on:click={() => removeReaction(oreact[1])}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Add Emoji Reaction Button -->
|
||||
<ReactionPresenter icon={emojiPlugin.icon.EmojiAdd} iconSize="small" active={true} on:click={addEmojiReaction} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.rating-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.1rem;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.reactions-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.reaction-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--button-secondary-BorderColor);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
min-height: 1.5rem;
|
||||
gap: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--button-secondary-hover-BackgroundColor);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.star-reaction {
|
||||
@extend .reaction-btn;
|
||||
}
|
||||
|
||||
.star-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.star-count {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--global-primary-TextColor);
|
||||
min-width: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reaction-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-emoji {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reaction-remove {
|
||||
position: absolute;
|
||||
top: -0.35rem;
|
||||
right: -0.35rem;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
padding: 0;
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 0.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.add-reaction-btn {
|
||||
@extend .reaction-btn;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!-- Circular Level Display with Progress -->
|
||||
<script lang="ts">
|
||||
import { translateCB } from '@hcengineering/platform'
|
||||
import ratingPlugin, { getLevelInfo } from '@hcengineering/rating'
|
||||
|
||||
export let rating: number = 0
|
||||
export let showValues = false
|
||||
|
||||
$: levelInfo = getLevelInfo(rating)
|
||||
|
||||
let levelLabel: string | undefined = undefined
|
||||
$: translateCB(ratingPlugin.string.Level, {}, undefined, (r) => (levelLabel = r))
|
||||
</script>
|
||||
|
||||
{#if rating > 0}
|
||||
<div class="flex-row-center">
|
||||
{#if levelLabel}{levelLabel}{/if}
|
||||
<div class="level-circle">
|
||||
<svg class="progress-ring" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle -->
|
||||
<circle cx="60" cy="60" r="50" class="progress-ring-bg" />
|
||||
<!-- Progress circle (bold overlay) -->
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="50"
|
||||
class="progress-ring-fill"
|
||||
style="stroke-dashoffset: {314.16 * (1 - levelInfo.progress / 100)}px"
|
||||
/>
|
||||
</svg>
|
||||
<div class="level-content">
|
||||
<span class="level-number">{levelInfo.level}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if showValues}
|
||||
<span class="text-sm caption-color">({Math.round(rating * 100)}/{Math.round(levelInfo.nextThreshold * 100)})</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.level-circle {
|
||||
position: relative;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.progress-ring-bg {
|
||||
fill: none;
|
||||
stroke: var(--global-secondary-TextColor);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.progress-ring-fill {
|
||||
fill: none;
|
||||
stroke: #ffc107;
|
||||
stroke-width: 3.5;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.3s ease;
|
||||
stroke-dasharray: 314.16;
|
||||
|
||||
&:hover {
|
||||
filter: drop-shadow(0 0 0.2rem rgba(255, 193, 7, 0.7));
|
||||
}
|
||||
}
|
||||
|
||||
.level-content {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.level-number {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--global-primary-TextColor);
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentAccount, groupByArray, SortingOrder, type Class, type Doc, type Ref } from '@hcengineering/core'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import rating, { ReactionKind, type DocReaction } from '@hcengineering/rating'
|
||||
import { NavGroup, Label } from '@hcengineering/ui'
|
||||
import ScrollBox from '@hcengineering/ui/src/components/ScrollBox.svelte'
|
||||
import NavigatorRating from './NavigatorRating.svelte'
|
||||
|
||||
const current = getCurrentAccount()
|
||||
|
||||
const query = createQuery()
|
||||
|
||||
let reactions: DocReaction[] = []
|
||||
|
||||
query.query(
|
||||
rating.class.DocReaction,
|
||||
{
|
||||
createdBy: { $in: current.socialIds }
|
||||
},
|
||||
(results) => {
|
||||
reactions = results
|
||||
},
|
||||
{ limit: 5000, sort: { modifiedOn: SortingOrder.Descending } }
|
||||
)
|
||||
|
||||
function groupReactions (reactions: DocReaction[]): {
|
||||
_class: Ref<Class<Doc>>
|
||||
objects: {
|
||||
_id: Ref<Doc>
|
||||
reactions: DocReaction[]
|
||||
}[]
|
||||
}[] {
|
||||
const starred = new Set(
|
||||
reactions.filter((it) => it.reactionType === ReactionKind.Star && it.value === 1).map((it) => it.attachedTo)
|
||||
)
|
||||
|
||||
const validReactions = reactions.filter(
|
||||
(it) => it.attachedTo != null && it.attachedToClass != null && starred.has(it.attachedTo)
|
||||
)
|
||||
|
||||
// I need to filter reactions without star's
|
||||
|
||||
const byBaseClass = groupByArray(validReactions, (it) => it.attachedToClass)
|
||||
return Array.from(byBaseClass.entries()).map(([baseClass, reactions]) => {
|
||||
const byDoc = Array.from(groupByArray(reactions, (it) => it.attachedTo)).map((it) => ({
|
||||
_id: it[0],
|
||||
reactions: it[1]
|
||||
}))
|
||||
return {
|
||||
_class: baseClass,
|
||||
objects: byDoc
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$: groupped = groupReactions(reactions)
|
||||
|
||||
$: isEmpty = groupped.length === 0 || groupped.every((g) => g.objects.length === 0)
|
||||
</script>
|
||||
|
||||
<div class="mt-2" />
|
||||
{#if isEmpty}
|
||||
<div class="p-4 text-center content-dark-color h-full w-full flex-grow" style:align-content="center">
|
||||
<Label label={rating.string.RatingWidgetEmpty} />
|
||||
</div>
|
||||
{:else}
|
||||
<ScrollBox vertical>
|
||||
{#each groupped as group}
|
||||
{@const classifier = getClient().getHierarchy().findClass(group._class)}
|
||||
{#if classifier !== undefined}
|
||||
<NavGroup
|
||||
_id={group._class}
|
||||
categoryName={'features'}
|
||||
label={classifier.label}
|
||||
type="nested"
|
||||
isFold
|
||||
empty={group.objects.length === 0}
|
||||
noDivider
|
||||
>
|
||||
<div class="ml-2 mr-2">
|
||||
<NavigatorRating _class={classifier} docs={group.objects} />
|
||||
</div>
|
||||
</NavGroup>
|
||||
{/if}
|
||||
{/each}
|
||||
</ScrollBox>
|
||||
{/if}
|
||||
@@ -0,0 +1,84 @@
|
||||
<!-- Copyright © 2025 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 { type PersonId } from '@hcengineering/core'
|
||||
import { EmojiPresenter } from '@hcengineering/emoji-resources'
|
||||
import { Icon, IconComponent, IconSize, tooltip } from '@hcengineering/ui'
|
||||
|
||||
import ReactionsTooltip from './ReactionsTooltip.svelte'
|
||||
|
||||
export let icon: IconComponent | undefined = undefined
|
||||
export let iconSize: IconSize | undefined = undefined
|
||||
export let emoji: string = ''
|
||||
export let count: number | undefined = undefined
|
||||
export let selected: boolean = false
|
||||
export let active: boolean = false
|
||||
export let socialIds: PersonId[] = []
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="reaction"
|
||||
class:selected
|
||||
class:active
|
||||
on:click
|
||||
use:tooltip={(count ?? 0) > 0 ? { component: ReactionsTooltip, props: { socialIds, emoji } } : undefined}
|
||||
>
|
||||
<div class="reaction__emoji" class:foreground={icon != null}>
|
||||
{#if icon}
|
||||
<Icon {icon} size={iconSize ?? 'small'} />
|
||||
{:else}
|
||||
<EmojiPresenter {emoji} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if count !== undefined && count > 0}
|
||||
<div class="reaction__count">{count}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.reaction {
|
||||
display: flex;
|
||||
height: 1.75rem;
|
||||
padding: 0.375rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--selector-BackgroundColor);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background: var(--global-ui-hover-BackgroundColor);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--global-accent-BackgroundColor);
|
||||
}
|
||||
}
|
||||
|
||||
.reaction__emoji {
|
||||
color: var(--global-primary-TextColor);
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.reaction__count {
|
||||
color: var(--global-primary-TextColor);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { PersonId } from '@hcengineering/core'
|
||||
import { ObjectPresenter } from '@hcengineering/view-resources'
|
||||
import contact from '@hcengineering/contact'
|
||||
import { getPersonRefByPersonIdStore } from '@hcengineering/contact-resources'
|
||||
|
||||
export let socialIds: PersonId[] = []
|
||||
|
||||
$: personRefByPersonIdStore = getPersonRefByPersonIdStore(socialIds)
|
||||
$: persons = socialIds.map((si) => $personRefByPersonIdStore.get(si))
|
||||
</script>
|
||||
|
||||
<div class="m-2 flex-col flex-gap-2">
|
||||
{#each persons as person}
|
||||
<ObjectPresenter objectId={person} _class={contact.class.Person} disabled />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright © 2025 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.
|
||||
|
||||
import { type Resources } from '@hcengineering/platform'
|
||||
import RatingEditor from './components/RatingEditor.svelte'
|
||||
import RatingRing from './components/RatingRing.svelte'
|
||||
import RatingWidget from './components/RatingWidget.svelte'
|
||||
import DocReactionPresenter from './components/DocReactionPresenter.svelte'
|
||||
import RatingActivities from './components/RatingActivities.svelte'
|
||||
|
||||
export default async (): Promise<Resources> => ({
|
||||
component: {
|
||||
RatingEditor,
|
||||
RatingRing,
|
||||
RatingWidget,
|
||||
DocReactionPresenter,
|
||||
RatingActivities
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright © 2022 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.
|
||||
|
||||
import { mergeIds } from '@hcengineering/platform'
|
||||
import rating, { ratingId } from '@hcengineering/rating'
|
||||
import { type AnyComponent } from '@hcengineering/ui/src/types'
|
||||
export default mergeIds(ratingId, rating, {
|
||||
component: {
|
||||
RatingEditor: '' as AnyComponent
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user