Merge branch 'develop' into feat/app-store-reviews

This commit is contained in:
Muhsin Keloth
2026-06-16 13:53:23 +04:00
committed by GitHub
154 changed files with 7386 additions and 7396 deletions
@@ -1,255 +0,0 @@
<script>
import emojis from './emojisGroup.json';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const SEARCH_KEY = 'Search';
export default {
components: { FluentIcon, NextButton },
props: {
onClick: {
type: Function,
default: () => {},
},
showRemoveButton: {
type: Boolean,
default: false,
},
},
data() {
return {
selectedKey: 'Search',
emojis,
search: '',
};
},
computed: {
categories() {
return [...this.emojis];
},
filterEmojisByCategory() {
const selectedCategoryName = this.emojis.find(category =>
category.name === this.selectedKey ? category.name : null
);
return selectedCategoryName?.emojis;
},
filterAllEmojisBySearch() {
return this.emojis.map(category => {
const allEmojis = category.emojis.filter(emoji =>
emoji.slug.replaceAll('_', ' ').includes(this.search.toLowerCase())
);
return allEmojis.length > 0
? { ...category, emojis: allEmojis }
: { ...category, emojis: [] };
});
},
hasNoSearch() {
return this.selectedKey !== SEARCH_KEY && this.search === '';
},
hasEmptySearchResult() {
return this.filterAllEmojisBySearch.every(
category => category.emojis.length === 0
);
},
},
watch: {
search() {
this.selectedKey = 'Search';
},
selectedKey() {
return this.selectedKey === 'Search' ? this.focusSearchInput() : null;
},
},
mounted() {
this.focusSearchInput();
},
methods: {
changeCategory(category) {
this.search = '';
this.$refs.emojiItem.scrollTo({ top: 0 });
this.selectedKey = category;
},
getFirstEmojiByCategoryName(categoryName) {
const categoryItem = this.emojis.find(category =>
category.name === categoryName ? category : null
);
return categoryItem ? categoryItem.emojis[0].emoji : '';
},
focusSearchInput() {
this.$nextTick(() => {
this.$refs.searchbar.focus();
});
},
},
};
</script>
<template>
<div
role="dialog"
class="emoji-dialog bg-n-background shadow-lg rounded-md outline outline-1 outline-n-weak box-content h-[18.75rem] absolute right-0 -top-[95px] w-80 z-20"
>
<div class="flex flex-col">
<div class="flex gap-2 m-2 sticky top-2">
<input
ref="searchbar"
v-model="search"
type="text"
class="focus:box-shadow-blue dark:focus:box-shadow-dark !mb-0 !h-8 !text-sm"
:placeholder="$t('EMOJI.PLACEHOLDER')"
/>
<NextButton
v-if="showRemoveButton"
faded
sm
slate
class="flex-shrink-0"
:label="$t('EMOJI.REMOVE')"
@click="onClick('')"
/>
</div>
<div v-if="hasNoSearch" ref="emojiItem" class="emoji-item">
<h5
class="text-sm text-n-slate-12 font-medium leading-normal m-0 py-1 px-2 capitalize"
>
{{ selectedKey }}
</h5>
<div class="emoji--row">
<button
v-for="item in filterEmojisByCategory"
:key="item.slug"
v-dompurify-html="item.emoji"
class="emoji--item"
track-by="$index"
@click="onClick(item.emoji)"
/>
</div>
</div>
<div v-else ref="emojiItem" class="emoji-item">
<div v-for="category in filterAllEmojisBySearch" :key="category.slug">
<h5
v-if="category.emojis.length > 0"
class="text-sm text-n-slate-12 font-medium leading-normal m-0 py-1 px-2 capitalize"
>
{{ category.name }}
</h5>
<div v-if="category.emojis.length > 0" class="emoji--row">
<button
v-for="item in category.emojis"
:key="item.slug"
v-dompurify-html="item.emoji"
class="emoji--item"
track-by="$index"
@click="onClick(item.emoji)"
/>
</div>
</div>
<div
v-if="hasEmptySearchResult"
class="items-center flex flex-col h-[13.25rem] justify-center"
>
<div class="text-n-slate-11 mb-2">
<FluentIcon icon="emoji" size="48" />
</div>
<span class="text-n-slate-11 text-sm font-medium">
{{ $t('EMOJI.NOT_FOUND') }}
</span>
</div>
</div>
<div
class="emoji-dialog--footer relative w-full py-0 rounded-b-[0.34rem] px-1 bg-n-slate-3"
role="menu"
>
<ul
class="flex relative left-[2px] rtl:left-[unset] rtl:right-[2px] list-none m-0 overflow-auto py-1 px-0"
>
<li>
<button
class="emoji--item"
:class="{ active: selectedKey === 'Search' }"
@click="changeCategory('Search')"
>
<FluentIcon icon="search" size="16" class="text-n-slate-11" />
</button>
</li>
<li
v-for="category in categories"
:key="category.slug"
@click="changeCategory(category.name)"
>
<button
v-dompurify-html="getFirstEmojiByCategoryName(category.name)"
class="emoji--item"
:class="{ active: selectedKey === category.name }"
@click="changeCategory(category.name)"
/>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
.box-shadow-blue {
box-shadow:
0 0 0 1px #1f93ff,
0 0 1px 2px #c7e3ff;
}
.box-shadow-dark {
box-shadow:
0 0 0 1px #212222,
0 0 1px 2px #4c5155;
}
}
</style>
<style lang="scss">
.emoji-dialog {
&::before {
@apply absolute -bottom-3 h-3 w-6 bg-n-slate-3 content-[""];
clip-path: polygon(50% 100%, 0% 0%, 100% 0%);
}
}
.emoji--item {
@apply bg-transparent border-0 rounded cursor-pointer text-lg h-6 m-0 py-0 px-1 hover:bg-n-slate-4;
}
.emoji--row {
@apply box-border p-1;
.emoji--item {
@apply h-[1.625rem] w-[1.625rem] leading-normal m-1;
}
}
.emoji-item {
@apply h-[13.25rem] overflow-y-auto;
}
.emoji-dialog--footer {
ul {
> li {
@apply items-center cursor-pointer flex justify-center p-1;
}
li .active {
@apply bg-n-background;
}
.emoji--item {
@apply items-center flex text-sm;
&:hover {
@apply bg-n-slate-2;
}
}
}
}
</style>
@@ -0,0 +1,99 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Virtualizer } from 'virtua/vue';
import {
buildEmojiSections,
getEmojiTint,
getRecentEmojis,
addRecentEmoji,
} from 'shared/components/emoji/pickerHelper';
const emit = defineEmits(['select']);
const { t } = useI18n();
const emojiSearch = ref('');
const recentEmojis = ref([]);
const searchInput = ref(null);
onMounted(() => {
recentEmojis.value = getRecentEmojis();
searchInput.value?.focus();
});
const emojiSections = computed(() =>
buildEmojiSections(
emojiSearch.value,
recentEmojis.value,
t('EMOJI_ICON_PICKER.FREQUENTLY_USED')
)
);
// Tints an emoji button with a light shade of the emoji's own color on hover.
const applyEmojiTint = (event, emoji) => {
event.currentTarget.style.setProperty('--ep-tint', getEmojiTint(emoji));
};
const selectEmoji = emoji => {
recentEmojis.value = addRecentEmoji(emoji);
emit('select', { type: 'emoji', value: emoji.emoji, emoji: emoji.emoji });
};
</script>
<template>
<div
role="dialog"
class="absolute z-20 flex flex-col overflow-hidden shadow-xl w-[22rem] bg-n-surface-2 backdrop-blur-[100px] rounded-2xl outline outline-1 outline-n-weak dark:outline-n-strong/50"
>
<div class="flex flex-col gap-1.5 pt-2">
<div class="relative px-2">
<span
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-5"
/>
<input
ref="searchInput"
v-model="emojiSearch"
type="text"
class="block w-full h-10 text-sm reset-base outline outline-1 outline-offset-[-1px] outline-n-weak focus:outline-n-brand border-none rounded-lg !ps-9 !pe-3 !py-2.5 bg-transparent text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_EMOJI')"
/>
</div>
<div
v-if="emojiSections.length"
class="h-60 overflow-y-auto px-2 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<Virtualizer v-slot="{ item: section }" :data="emojiSections">
<h5
class="px-1 pt-2 pb-1 m-0 text-xs font-medium tracking-wide uppercase text-n-slate-10"
>
{{ section.name }}
</h5>
<div class="grid grid-cols-10 gap-0.5">
<button
v-for="emoji in section.emojis"
:key="`${section.name}-${emoji.slug}`"
type="button"
:title="emoji.name"
class="flex items-center justify-center !p-0 w-full max-w-[2rem] text-xl transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
@mouseenter="applyEmojiTint($event, emoji.emoji)"
@click="selectEmoji(emoji)"
>
{{ emoji.emoji }}
</button>
</div>
</Virtualizer>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 h-60 text-n-slate-10"
>
<span class="i-lucide-smile-plus size-7" />
<span class="text-sm font-medium">
{{ t('EMOJI_ICON_PICKER.NO_EMOJI') }}
</span>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
import emojiGroups from 'shared/components/emoji/emojisGroup.json';
// Recently used emojis persisted in localStorage.
const RECENT_EMOJI_KEY = 'emoji-icon-picker.recent-emojis';
const MAX_RECENT_EMOJIS = 16;
const matchesSearch = (emoji, term) =>
emoji.slug.replaceAll('_', ' ').includes(term) ||
emoji.name.toLowerCase().includes(term);
// Emoji sections for the search term; prepends "Frequently used" when idle.
export const buildEmojiSections = (search, recentEmojis, frequentLabel) => {
const term = search.trim().toLowerCase();
if (term) {
return emojiGroups
.map(({ name, emojis }) => ({
name,
emojis: emojis.filter(e => matchesSearch(e, term)),
}))
.filter(group => group.emojis.length);
}
return [
...(recentEmojis.length
? [{ name: frequentLabel, emojis: recentEmojis }]
: []),
...emojiGroups,
];
};
// Samples an emoji's average color (via canvas) as a translucent hover tint. Cached per emoji.
const emojiTintCache = new Map();
const NEUTRAL_TINT = 'rgb(var(--slate-9) / 0.12)';
const sampleEmojiTint = emoji => {
const canvas = Object.assign(document.createElement('canvas'), {
width: 16,
height: 16,
});
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.font = '14px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(emoji, 8, 9);
const { data } = ctx.getImageData(0, 0, 16, 16);
const pixels = Array.from(
{ length: data.length / 4 },
(_, i) => i * 4
).filter(i => data[i + 3] > 16);
if (!pixels.length) return NEUTRAL_TINT;
const avg = offset =>
Math.round(
pixels.reduce((sum, i) => sum + data[i + offset], 0) / pixels.length
);
return `rgba(${avg(0)}, ${avg(1)}, ${avg(2)}, 0.16)`;
};
export const getEmojiTint = emoji => {
if (!emojiTintCache.has(emoji)) {
let tint = NEUTRAL_TINT;
try {
tint = sampleEmojiTint(emoji);
} catch {
/* canvas unavailable */
}
emojiTintCache.set(emoji, tint);
}
return emojiTintCache.get(emoji);
};
export const getRecentEmojis = () => {
try {
const stored = JSON.parse(localStorage.getItem(RECENT_EMOJI_KEY) ?? '[]');
return Array.isArray(stored) ? stored : [];
} catch {
return [];
}
};
export const addRecentEmoji = emoji => {
const updated = [
emoji,
...getRecentEmojis().filter(item => item.slug !== emoji.slug),
].slice(0, MAX_RECENT_EMOJIS);
try {
localStorage.setItem(RECENT_EMOJI_KEY, JSON.stringify(updated));
} catch {
/* private mode; recents are best-effort */
}
return updated;
};