add icon picker

This commit is contained in:
pa
2025-11-17 10:43:31 +09:00
committed by Natsumi
parent 274c3ccea4
commit 44241268de
7 changed files with 1958 additions and 16 deletions

View File

@@ -0,0 +1,250 @@
<template>
<el-popover v-model:visible="visible" trigger="click" placement="bottom-start" :width="620">
<template #reference>
<el-button class="icon-picker__trigger" plain>
<i v-if="modelValue" :class="modelValue"></i>
<span>{{ t('nav_menu.icon_picker.pick_icon') }}</span>
</el-button>
</template>
<div class="icon-picker">
<el-input
v-model="search"
clearable
class="icon-picker__search"
:placeholder="t('nav_menu.icon_picker.search_placeholder')" />
<el-scrollbar v-if="filteredCategories.length" height="600px" class="icon-picker__scroll">
<div v-for="category in filteredCategories" :key="category.name" class="icon-picker__category">
<div class="icon-picker__category-title">
{{ category.name }}
</div>
<div class="icon-picker__grid">
<div v-for="group in category.groups" :key="group.id" class="icon-picker__group">
<div class="icon-picker__group-label">
{{ group.label }}
</div>
<div class="icon-picker__variants">
<button
v-for="variant in group.variants"
:key="variant.className"
type="button"
class="icon-picker__variant"
:class="{ 'is-active': variant.className === modelValue }"
:title="group.tooltip"
@click="handleSelect(variant.className)">
<i :class="[variant.className, 'ri-2x']"></i>
</button>
</div>
</div>
</div>
</div>
</el-scrollbar>
<div v-else class="icon-picker__empty">{{ t('nav_menu.icon_picker.no_icon_found') }}</div>
</div>
</el-popover>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import remixIconTags from '../shared/constants/remixIconTags.json';
const { t } = useI18n();
defineProps({
modelValue: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue']);
const visible = ref(false);
const search = ref('');
const parseTags = (tagsText) =>
typeof tagsText === 'string'
? tagsText
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: [];
const formatLabel = (baseName) =>
baseName
.split('-')
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ');
const createGroup = (categoryName, baseName, tagsText) => {
const normalizedTags = parseTags(tagsText);
const label = formatLabel(baseName);
const variants = ['line', 'fill'].map((variant) => ({
className: `ri-${baseName}-${variant}`,
variant
}));
const searchText = [
baseName,
label,
...baseName.split('-'),
...normalizedTags,
...variants.map((variant) => variant.className),
'line',
'fill'
]
.join(' ')
.toLowerCase();
return {
id: `${categoryName}-${baseName}`,
label,
tooltip: normalizedTags.length ? `${label}${normalizedTags.join(', ')}` : label,
variants,
searchable: searchText
};
};
const categories = computed(() =>
Object.entries(remixIconTags)
.filter(([key]) => key !== '_comment')
.map(([name, icons]) => ({
name,
groups: Object.entries(icons || {}).map(([baseName, tags]) => createGroup(name, baseName, tags))
}))
);
const filteredCategories = computed(() => {
const query = search.value.trim().toLowerCase();
if (!query) {
return categories.value;
}
return categories.value
.map((category) => ({
name: category.name,
groups: category.groups.filter((group) => group.searchable.includes(query))
}))
.filter((category) => category.groups.length > 0);
});
const handleSelect = (className) => {
emit('update:modelValue', className);
visible.value = false;
};
watch(visible, (nextVisible) => {
if (!nextVisible) {
search.value = '';
}
});
</script>
<style scoped>
.icon-picker__trigger {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 110px;
justify-content: center;
}
.icon-picker__trigger i {
font-size: 16px;
}
.icon-picker {
display: flex;
flex-direction: column;
gap: 8px;
height: 600px;
width: 100%;
}
.icon-picker__search {
flex-shrink: 0;
}
.icon-picker__scroll {
padding-right: 6px;
}
.icon-picker__category {
margin-bottom: 12px;
}
.icon-picker__category-title {
font-weight: 600;
margin-bottom: 6px;
}
.icon-picker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.icon-picker__group {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 4px;
align-items: center;
}
.icon-picker__group-label {
font-size: 12px;
font-weight: 600;
text-align: center;
color: var(--el-text-color-primary);
}
.icon-picker__variants {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.icon-picker__variant {
border: 1px solid transparent;
background: transparent;
cursor: pointer;
width: 84px;
height: 84px;
border-radius: 10px;
color: var(--el-text-color-regular);
transition:
color 0.2s ease,
background 0.2s ease,
transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.icon-picker__variant i {
color: inherit;
}
.icon-picker__variant:hover {
border-color: var(--el-color-primary);
background: var(--el-fill-color-light);
transform: translateY(-1px);
}
.icon-picker__variant.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
.icon-picker__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>

View File

@@ -204,7 +204,7 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue';
import { ElMessageBox, dayjs } from 'element-plus';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
@@ -222,11 +222,12 @@
import { THEME_CONFIG, links, navDefinitions } from '../shared/constants';
import { openExternalLink } from '../shared/utils';
import CustomNavDialog from './dialogs/CustomNavDialog.vue';
import configRepository from '../service/config';
import 'remixicon/fonts/remixicon.css';
const CustomNavDialog = defineAsyncComponent(() => import('./dialogs/CustomNavDialog.vue'));
const { t, locale } = useI18n();
const router = useRouter();

View File

@@ -4,7 +4,6 @@
class="custom-nav-dialog"
:title="t('nav_menu.custom_nav.dialog_title')"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
destroy-on-close>
<div class="custom-nav-dialog__list" v-if="localLayout.length">
@@ -86,10 +85,10 @@
<template #footer>
<div class="custom-nav-dialog__footer">
<div class="custom-nav-dialog__footer-left">
<el-button size="small" type="primary" plain @click="openFolderEditor()">
<el-button type="primary" plain @click="openFolderEditor()">
{{ t('nav_menu.custom_nav.add_folder') }}
</el-button>
<el-button size="small" type="warning" plain @click="handleReset">
<el-button type="warning" plain @click="handleReset">
{{ t('nav_menu.custom_nav.restore_default') }}
</el-button>
</div>
@@ -110,16 +109,13 @@
class="folder-editor-dialog"
:title="folderEditor.isEditing ? t('nav_menu.custom_nav.edit_folder') : t('nav_menu.custom_nav.add_folder')"
width="900px"
:close-on-click-modal="false"
destroy-on-close>
<div class="folder-editor">
<div class="folder-editor__form">
<el-input
v-model="folderEditor.data.name"
:placeholder="t('nav_menu.custom_nav.folder_name_placeholder')" />
<el-input
v-model="folderEditor.data.icon"
:placeholder="t('nav_menu.custom_nav.folder_icon_placeholder')" />
<IconPicker v-model="folderEditor.data.icon" class="folder-editor__icon-picker" />
</div>
<div class="folder-editor__lists">
<div class="folder-editor__column">
@@ -211,6 +207,8 @@
import { navDefinitions } from '../../shared/constants/ui.js';
import IconPicker from '../IconPicker.vue';
const props = defineProps({
visible: {
type: Boolean,
@@ -564,12 +562,17 @@
}
.folder-editor__form {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.folder-editor__icon-picker {
justify-self: end;
}
.folder-editor__lists {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(260px, 1.1fr);

View File

@@ -49,6 +49,11 @@
"restore_default_confirm": "Restore navigation to its default order?",
"save": "Save",
"cancel": "Cancel"
},
"icon_picker": {
"search_placeholder": "Search icon name or tags",
"pick_icon": "Pick Icon",
"no_icon_found": "No icons found"
}
},
"view": {

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,7 @@ const navDefinitions = [
},
{
key: 'favorite-friends',
icon: 'ri-heart-2-line',
icon: 'ri-user-heart-line',
tooltip: 'nav_tooltip.favorite_friends',
labelKey: 'nav_tooltip.favorite_friends',
routeName: 'favorite-friends'
@@ -50,21 +50,21 @@ const navDefinitions = [
},
{
key: 'favorite-avatars',
icon: 'ri-user-heart-line',
icon: 'ri-empathize-line',
tooltip: 'nav_tooltip.favorite_avatars',
labelKey: 'nav_tooltip.favorite_avatars',
routeName: 'favorite-avatars'
},
{
key: 'friend-log',
icon: 'ri-booklet-line',
icon: 'ri-contacts-line',
tooltip: 'nav_tooltip.friend_log',
labelKey: 'nav_tooltip.friend_log',
routeName: 'friend-log'
},
{
key: 'friend-list',
icon: 'ri-group-line',
icon: 'ri-booklet-line',
tooltip: 'nav_tooltip.friend_list',
labelKey: 'nav_tooltip.friend_list',
routeName: 'friend-list'

View File

@@ -67,8 +67,8 @@
<script setup>
import { Calendar, Download, Star, StarFilled } from '@element-plus/icons-vue';
import { computed, defineEmits } from 'vue';
import { ElMessage } from 'element-plus';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { AppDebug } from '../../../service/appConfig';