feat(communities): add icon support to community model and UI components for enhanced visual representation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s

This commit is contained in:
2026-02-24 12:03:07 +07:00
parent 02a45fce71
commit 5e6435e2e1
7 changed files with 80 additions and 29 deletions
+3 -1
View File
@@ -24,7 +24,8 @@ async function getCommunities(req, res) {
description: c.description ? String(c.description) : '', description: c.description ? String(c.description) : '',
tags: Array.isArray(c.tags) ? c.tags.map(String) : [], tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '', gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '',
color: c.color ? String(c.color) : '' color: c.color ? String(c.color) : '',
icon: c.icon ? String(c.icon) : ''
})); }));
}, },
defaultValue: [] defaultValue: []
@@ -70,6 +71,7 @@ async function postCommunities(req, res) {
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [], tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
gatewayDefault: entry.gatewayDefault ? String(entry.gatewayDefault) : '', gatewayDefault: entry.gatewayDefault ? String(entry.gatewayDefault) : '',
color: entry.color ? String(entry.color) : '', color: entry.color ? String(entry.color) : '',
icon: entry.icon ? String(entry.icon) : '',
category: entry.category ? String(entry.category) : '', category: entry.category ? String(entry.category) : '',
priority: typeof entry.priority === 'number' ? entry.priority : 0, priority: typeof entry.priority === 'number' ? entry.priority : 0,
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true, enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true,
+39 -17
View File
@@ -11,7 +11,6 @@ import {
IconFilter, IconFilter,
IconChartBar, IconChartBar,
IconList, IconList,
IconLink,
IconRefresh, IconRefresh,
IconSortAscending, IconSortAscending,
IconSortDescending, IconSortDescending,
@@ -29,6 +28,8 @@ import ConfirmModal from './components/ConfirmModal.jsx';
import FormField from './components/FormField.jsx'; import FormField from './components/FormField.jsx';
import ErrorAlert from './components/ErrorAlert.jsx'; import ErrorAlert from './components/ErrorAlert.jsx';
import Pagination from './components/Pagination.jsx'; import Pagination from './components/Pagination.jsx';
import IconPicker from './components/IconPicker.jsx';
import { getIconById } from './lib/brandIcons.js';
const API_URL = '/api'; const API_URL = '/api';
@@ -53,9 +54,9 @@ function CommunitiesManager() {
// Состояние для переключения между списком и статистикой // Состояние для переключения между списком и статистикой
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats' const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats'
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true }); const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', icon: '', category: '', priority: 0, enabled: true });
const [editingValue, setEditingValue] = useState(null); const [editingValue, setEditingValue] = useState(null);
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true }); const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', icon: '', category: '', priority: 0, enabled: true });
const [filterUsage, setFilterUsage] = useState({}); const [filterUsage, setFilterUsage] = useState({});
const [filterUsageLoading, setFilterUsageLoading] = useState(false); const [filterUsageLoading, setFilterUsageLoading] = useState(false);
const [filterUsageError, setFilterUsageError] = useState(''); const [filterUsageError, setFilterUsageError] = useState('');
@@ -79,7 +80,7 @@ function CommunitiesManager() {
Object.keys(filterUsage).forEach((value) => { Object.keys(filterUsage).forEach((value) => {
if (!value) return; if (!value) return;
if (!map.has(value)) { if (!map.has(value)) {
map.set(value, { value, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true }); map.set(value, { value, name: '', description: '', tags: [], gatewayDefault: '', color: '', icon: '', _external: true });
} }
}); });
return Array.from(map.values()); return Array.from(map.values());
@@ -130,13 +131,14 @@ function CommunitiesManager() {
tags: Array.isArray(d.tags) ? d.tags : [], tags: Array.isArray(d.tags) ? d.tags : [],
gatewayDefault: String(d.gatewayDefault || ''), gatewayDefault: String(d.gatewayDefault || ''),
color: String(d.color || ''), color: String(d.color || ''),
icon: String(d.icon || ''),
_external: false, _external: false,
}); });
} }
// add unknown ones from usage // add unknown ones from usage
for (const v of usedCommunities) { for (const v of usedCommunities) {
if (!map.has(v)) { if (!map.has(v)) {
map.set(v, { value: v, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true }); map.set(v, { value: v, name: '', description: '', tags: [], gatewayDefault: '', color: '', icon: '', _external: true });
} }
} }
setItems(Array.from(map.values())); setItems(Array.from(map.values()));
@@ -217,8 +219,9 @@ function CommunitiesManager() {
tags: toTagsArray(newItem.tags), tags: toTagsArray(newItem.tags),
gatewayDefault: String(newItem.gatewayDefault || ''), gatewayDefault: String(newItem.gatewayDefault || ''),
color: String(newItem.color || ''), color: String(newItem.color || ''),
icon: String(newItem.icon || ''),
}]); }]);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' }); setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', icon: '' });
setAddModalOpen(false); setAddModalOpen(false);
setSuccess('Community успешно добавлен!'); setSuccess('Community успешно добавлен!');
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 3000);
@@ -233,6 +236,7 @@ function CommunitiesManager() {
tags: fromTagsArray(item.tags), tags: fromTagsArray(item.tags),
gatewayDefault: item.gatewayDefault || '', gatewayDefault: item.gatewayDefault || '',
color: item.color || '', color: item.color || '',
icon: item.icon || '',
}); });
setEditModalOpen(true); setEditModalOpen(true);
}; };
@@ -249,6 +253,7 @@ function CommunitiesManager() {
tags: toTagsArray(editingDraft.tags), tags: toTagsArray(editingDraft.tags),
gatewayDefault: String(editingDraft.gatewayDefault || ''), gatewayDefault: String(editingDraft.gatewayDefault || ''),
color: String(editingDraft.color || ''), color: String(editingDraft.color || ''),
icon: String(editingDraft.icon || ''),
} : i); } : i);
setItems(next); setItems(next);
setEditingValue(null); setEditingValue(null);
@@ -278,7 +283,7 @@ function CommunitiesManager() {
}; };
const handleImport = () => { const handleImport = () => {
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)'); const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color, icon)');
if (!text) return; if (!text) return;
try { try {
const arr = JSON.parse(text); const arr = JSON.parse(text);
@@ -297,6 +302,7 @@ function CommunitiesManager() {
tags: Array.isArray(x.tags) ? x.tags : [], tags: Array.isArray(x.tags) ? x.tags : [],
gatewayDefault: String(x.gatewayDefault || ''), gatewayDefault: String(x.gatewayDefault || ''),
color: String(x.color || ''), color: String(x.color || ''),
icon: String(x.icon || ''),
}); });
}); });
return Array.from(map.values()); return Array.from(map.values());
@@ -553,6 +559,7 @@ function CommunitiesManager() {
<table className="table card-table table-vcenter table-nowrap mb-0"> <table className="table card-table table-vcenter table-nowrap mb-0">
<thead> <thead>
<tr> <tr>
<th style={{ width: '8%' }}>Иконка</th>
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}> <th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" /> <IconHash size={16} className="me-1 text-muted" />
@@ -584,9 +591,9 @@ function CommunitiesManager() {
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr> <tr><td colSpan={8} className="text-center text-muted py-4">Загрузка...</td></tr>
) : paginated.length === 0 ? ( ) : paginated.length === 0 ? (
<tr><td colSpan={7} className="text-center text-muted py-4"> <tr><td colSpan={8} className="text-center text-muted py-4">
<EmptyState <EmptyState
title="Нет community" title="Нет community"
description="Добавьте запись, чтобы начать." description="Добавьте запись, чтобы начать."
@@ -600,8 +607,18 @@ function CommunitiesManager() {
</td></tr> </td></tr>
) : paginated.map((item) => { ) : paginated.map((item) => {
const usageCount = filterUsage[item.value] || 0; const usageCount = filterUsage[item.value] || 0;
const iconInfo = item.icon ? getIconById(item.icon) : null;
return ( return (
<tr key={item.value}> <tr key={item.value}>
<td>
{iconInfo ? (
<span className="avatar avatar-xs bg-blue-lt text-blue border">
<iconInfo.Icon size={14} stroke={1.5} />
</span>
) : (
<span className="text-muted">—</span>
)}
</td>
<td> <td>
<code className="text-blue">{item.value}</code> <code className="text-blue">{item.value}</code>
{item._external && ( {item._external && (
@@ -629,13 +646,6 @@ function CommunitiesManager() {
</td> </td>
<td className="text-end"> <td className="text-end">
<div className="btn-list"> <div className="btn-list">
<a
className="btn btn-outline-primary btn-icon btn-sm"
href={`/filters?community=${encodeURIComponent(item.value)}`}
title="Открыть в /filters с поиском по community"
>
<IconLink size={16} />
</a>
<button <button
className="btn btn-outline-primary btn-icon btn-sm" className="btn btn-outline-primary btn-icon btn-sm"
onClick={()=>startEdit(item)} onClick={()=>startEdit(item)}
@@ -681,7 +691,7 @@ function CommunitiesManager() {
show={addModalOpen} show={addModalOpen}
onClose={() => { onClose={() => {
setAddModalOpen(false); setAddModalOpen(false);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' }); setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', icon: '' });
setError(''); setError('');
}} }}
onSubmit={addItem} onSubmit={addItem}
@@ -758,6 +768,12 @@ function CommunitiesManager() {
placeholder="blue, green, red ..." placeholder="blue, green, red ..."
helpText="Цвет для отображения бейджа в интерфейсе" helpText="Цвет для отображения бейджа в интерфейсе"
/> />
<IconPicker
value={newItem.icon}
onChange={(id) => setNewItem({...newItem, icon: id})}
label="Иконка бренда (опционально)"
allowNone
/>
</FormModal> </FormModal>
{/* Модальное окно редактирования */} {/* Модальное окно редактирования */}
@@ -840,6 +856,12 @@ function CommunitiesManager() {
placeholder="blue, green, red ..." placeholder="blue, green, red ..."
helpText="Цвет для отображения бейджа в интерфейсе" helpText="Цвет для отображения бейджа в интерфейсе"
/> />
<IconPicker
value={editingDraft.icon}
onChange={(id) => setEditingDraft({...editingDraft, icon: id})}
label="Иконка бренда (опционально)"
allowNone
/>
</FormModal> </FormModal>
{/* Модальное окно подтверждения удаления */} {/* Модальное окно подтверждения удаления */}
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { IconHash } from '@tabler/icons-react'; import { IconHash } from '@tabler/icons-react';
import { getIconById } from '../lib/brandIcons.js';
function CommunityAutocompleteInput({ function CommunityAutocompleteInput({
value, value,
@@ -105,6 +106,7 @@ function CommunityAutocompleteInput({
const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`; const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`;
const description = s.description || ''; const description = s.description || '';
const tags = Array.isArray(s.tags) ? s.tags : []; const tags = Array.isArray(s.tags) ? s.tags : [];
const iconInfo = s.icon ? getIconById(s.icon) : null;
return ( return (
<button <button
type="button" type="button"
@@ -114,9 +116,15 @@ function CommunityAutocompleteInput({
onMouseEnter={() => setActiveIndex(idx)} onMouseEnter={() => setActiveIndex(idx)}
> >
<div className="d-flex align-items-start"> <div className="d-flex align-items-start">
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}> {iconInfo ? (
<IconHash size={14} /> <span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
</span> <iconInfo.Icon size={14} stroke={1.5} />
</span>
) : (
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
<IconHash size={14} />
</span>
)}
<div className="flex-fill text-start"> <div className="flex-fill text-start">
<div className="fw-medium">{label}</div> <div className="fw-medium">{label}</div>
{(description || tags.length > 0) && ( {(description || tags.length > 0) && (
+7 -1
View File
@@ -1,3 +1,5 @@
import { getIconById } from '../lib/brandIcons.js';
function normalizeTablerColor(input) { function normalizeTablerColor(input) {
const allowed = new Set(['blue','azure','indigo','purple','pink','red','orange','yellow','lime','green','teal','cyan','grape','gray']); const allowed = new Set(['blue','azure','indigo','purple','pink','red','orange','yellow','lime','green','teal','cyan','grape','gray']);
const c = String(input || 'blue').toLowerCase().replace(/[^a-z-]/g, ''); const c = String(input || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
@@ -11,8 +13,12 @@ function CommunityBadge({ value, communities = [] }) {
const color = normalizeTablerColor(meta?.color || 'blue'); const color = normalizeTablerColor(meta?.color || 'blue');
const cls = `badge bg-${color}-lt text-${color}`; const cls = `badge bg-${color}-lt text-${color}`;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${Array.isArray(meta.tags) && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : ''; const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${Array.isArray(meta.tags) && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
const iconInfo = meta?.icon ? getIconById(meta.icon) : null;
return ( return (
<span className={cls} title={title}>{v}</span> <span className={cls} title={title}>
{iconInfo ? <iconInfo.Icon size={12} stroke={1.8} className="me-1" /> : null}
{v}
</span>
); );
} }
+16 -7
View File
@@ -1,17 +1,19 @@
import { useState, useMemo, useRef, useEffect } from 'react'; import { useState, useMemo, useRef, useEffect } from 'react';
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js'; import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
import { IconSearch } from '@tabler/icons-react'; import { IconSearch, IconMinus } from '@tabler/icons-react';
/** /**
* Выбор иконки бренда с поиском и предпросмотром. * Выбор иконки бренда с поиском и предпросмотром.
* value — id иконки (например IconBrandGoogle), onChange(id) — при выборе. * value — id иконки (например IconBrandGoogle), onChange(id) — при выборе.
*/ */
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда' }) { export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда', allowNone = false }) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const selected = getIconById(value); const selected = allowNone && !value
const filtered = useMemo(() => { ? { id: '', label: 'Без иконки', Icon: IconMinus }
: getIconById(value);
const filteredIcons = useMemo(() => {
const q = (search || '').trim().toLowerCase(); const q = (search || '').trim().toLowerCase();
if (!q) return BRAND_ICONS; if (!q) return BRAND_ICONS;
return BRAND_ICONS.filter( return BRAND_ICONS.filter(
@@ -19,6 +21,13 @@ export default function IconPicker({ value, onChange, disabled = false, label =
item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q) item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)
); );
}, [search]); }, [search]);
const filtered = useMemo(() => {
if (!allowNone) return filteredIcons;
const q = (search || '').trim().toLowerCase();
const noneMatches = q.length === 0 || 'без иконки'.includes(q) || 'none'.includes(q);
if (!noneMatches) return filteredIcons;
return [{ id: '', label: 'Без иконки', Icon: IconMinus }, ...filteredIcons];
}, [allowNone, filteredIcons, search]);
const handleSelect = (item) => { const handleSelect = (item) => {
onChange?.(item.id); onChange?.(item.id);
@@ -56,7 +65,7 @@ export default function IconPicker({ value, onChange, disabled = false, label =
<span>{selected.label}</span> <span>{selected.label}</span>
</button> </button>
{open && ( {open && (
<div className="border rounded p-2 shadow-sm bg-body" style={{ minWidth: 280, maxWidth: 360 }}> <div className="border rounded p-3 shadow-sm bg-body" style={{ minWidth: 360, maxWidth: 520 }}>
<div className="input-group input-group-sm mb-2"> <div className="input-group input-group-sm mb-2">
<span className="input-group-text"> <span className="input-group-text">
<IconSearch size={14} /> <IconSearch size={14} />
@@ -72,7 +81,7 @@ export default function IconPicker({ value, onChange, disabled = false, label =
</div> </div>
<div <div
className="d-flex flex-wrap gap-1 overflow-auto" className="d-flex flex-wrap gap-1 overflow-auto"
style={{ maxHeight: 200 }} style={{ maxHeight: 260 }}
role="listbox" role="listbox"
> >
{filtered.map((item) => ( {filtered.map((item) => (
@@ -82,7 +91,7 @@ export default function IconPicker({ value, onChange, disabled = false, label =
className={`btn btn-sm d-flex align-items-center justify-content-center rounded ${ className={`btn btn-sm d-flex align-items-center justify-content-center rounded ${
value === item.id ? 'btn-primary' : 'btn-outline-secondary' value === item.id ? 'btn-primary' : 'btn-outline-secondary'
}`} }`}
style={{ width: 40, height: 40 }} style={{ width: 48, height: 48 }}
onClick={() => handleSelect(item)} onClick={() => handleSelect(item)}
title={item.label} title={item.label}
role="option" role="option"
+3
View File
@@ -37,11 +37,14 @@ import {
IconBrandVisa, IconBrandVisa,
IconBrandMastercard, IconBrandMastercard,
IconWorld, IconWorld,
IconCloud,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
const BRAND_ICONS = [ const BRAND_ICONS = [
{ id: 'IconBrandGoogle', label: 'Google', Icon: IconBrandGoogle }, { id: 'IconBrandGoogle', label: 'Google', Icon: IconBrandGoogle },
{ id: 'IconBrandCloudflare', label: 'Cloudflare', Icon: IconBrandCloudflare }, { id: 'IconBrandCloudflare', label: 'Cloudflare', Icon: IconBrandCloudflare },
{ id: 'IconCdnBunny', label: 'Bunny CDN', Icon: IconCloud },
{ id: 'IconCdnFastly', label: 'Fastly CDN', Icon: IconCloud },
{ id: 'IconBrandYandex', label: 'Yandex', Icon: IconBrandYandex }, { id: 'IconBrandYandex', label: 'Yandex', Icon: IconBrandYandex },
{ id: 'IconBrandInstagram', label: 'Instagram', Icon: IconBrandInstagram }, { id: 'IconBrandInstagram', label: 'Instagram', Icon: IconBrandInstagram },
{ id: 'IconBrandGithub', label: 'GitHub', Icon: IconBrandGithub }, { id: 'IconBrandGithub', label: 'GitHub', Icon: IconBrandGithub },
+1
View File
@@ -58,6 +58,7 @@ export interface Community {
name?: string; name?: string;
description?: string; description?: string;
gatewayDefault?: string; gatewayDefault?: string;
icon?: string;
category?: string; category?: string;
} }