From 2ae01c95aececf97ddec12337c7251ce6c0a82ec Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 15:30:02 +0300 Subject: [PATCH] feat: add category/tag management UI with ColoredItemCombobox - ColoredItemCombobox component with search, color swatches, inline creation - Replace text inputs with combobox dropdowns for category and tag selection - API methods for categories/tags CRUD - TypeScript types for NewsCategory, NewsTag - i18n keys for combobox (ru, en) --- src/api/news.ts | 48 +++ src/components/admin/ColoredItemCombobox.tsx | 336 +++++++++++++++++++ src/components/admin/index.ts | 1 + src/locales/en.json | 10 + src/locales/ru.json | 10 + src/pages/AdminNewsCreate.tsx | 171 +++++----- src/types/news.ts | 18 + 7 files changed, 502 insertions(+), 92 deletions(-) create mode 100644 src/components/admin/ColoredItemCombobox.tsx diff --git a/src/api/news.ts b/src/api/news.ts index 5dbcd15..70ff5c0 100644 --- a/src/api/news.ts +++ b/src/api/news.ts @@ -1,6 +1,8 @@ import apiClient from './client'; import type { NewsArticle, + NewsCategory, + NewsTag, NewsListResponse, NewsCreateRequest, NewsUpdateRequest, @@ -72,4 +74,50 @@ export const newsApi = { deleteMedia: async (filename: string): Promise => { await apiClient.delete(`/cabinet/admin/news/media/${encodeURIComponent(filename)}`); }, + + // Categories + getCategories: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/news/categories'); + return response.data; + }, + + createCategory: async (data: { name: string; color: string }): Promise => { + const response = await apiClient.post('/cabinet/admin/news/categories', data); + return response.data; + }, + + updateCategory: async ( + id: number, + data: { name?: string; color?: string }, + ): Promise => { + const response = await apiClient.put( + `/cabinet/admin/news/categories/${id}`, + data, + ); + return response.data; + }, + + deleteCategory: async (id: number): Promise => { + await apiClient.delete(`/cabinet/admin/news/categories/${id}`); + }, + + // Tags + getTags: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/news/tags'); + return response.data; + }, + + createTag: async (data: { name: string; color: string }): Promise => { + const response = await apiClient.post('/cabinet/admin/news/tags', data); + return response.data; + }, + + updateTag: async (id: number, data: { name?: string; color?: string }): Promise => { + const response = await apiClient.put(`/cabinet/admin/news/tags/${id}`, data); + return response.data; + }, + + deleteTag: async (id: number): Promise => { + await apiClient.delete(`/cabinet/admin/news/tags/${id}`); + }, }; diff --git a/src/components/admin/ColoredItemCombobox.tsx b/src/components/admin/ColoredItemCombobox.tsx new file mode 100644 index 0000000..3d187b3 --- /dev/null +++ b/src/components/admin/ColoredItemCombobox.tsx @@ -0,0 +1,336 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '../../lib/utils'; +import { useHapticFeedback } from '../../platform/hooks/useHaptic'; + +const DEFAULT_COLORS = [ + '#00e5a0', + '#00b4d8', + '#f72585', + '#ffd60a', + '#7c3aed', + '#f97316', + '#06b6d4', + '#ec4899', +]; + +interface ColoredItem { + id: number; + name: string; + color: string; +} + +interface ColoredItemComboboxProps { + items: ColoredItem[]; + value: ColoredItem | null; + onChange: (item: ColoredItem | null) => void; + onCreateNew: (name: string, color: string) => Promise; + placeholder?: string; + isLoading?: boolean; + colors?: string[]; + className?: string; +} + +export function ColoredItemCombobox({ + items, + value, + onChange, + onCreateNew, + placeholder, + isLoading = false, + colors = DEFAULT_COLORS, + className, +}: ColoredItemComboboxProps) { + const { t } = useTranslation(); + const haptic = useHapticFeedback(); + + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + const [newColor, setNewColor] = useState(colors[0] ?? '#00e5a0'); + const [isCreating, setIsCreating] = useState(false); + + const containerRef = useRef(null); + const searchInputRef = useRef(null); + + // Filtered items based on search + const filteredItems = useMemo(() => { + if (!search.trim()) return items; + const query = search.toLowerCase().trim(); + return items.filter((item) => item.name.toLowerCase().includes(query)); + }, [items, search]); + + // Whether search text matches an existing item exactly + const hasExactMatch = useMemo(() => { + const query = search.toLowerCase().trim(); + return query.length > 0 && items.some((item) => item.name.toLowerCase() === query); + }, [items, search]); + + // Show create section when search text doesn't match existing items + const showCreateSection = search.trim().length > 0 && !hasExactMatch; + + // Click outside handler + useEffect(() => { + if (!isOpen) return; + + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSearch(''); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + // Focus search input when dropdown opens + useEffect(() => { + if (isOpen) { + // Small delay to allow the dropdown to render + const timer = setTimeout(() => searchInputRef.current?.focus(), 50); + return () => clearTimeout(timer); + } + }, [isOpen]); + + const handleToggle = useCallback(() => { + haptic.buttonPress(); + setIsOpen((prev) => !prev); + setSearch(''); + }, [haptic]); + + const handleSelect = useCallback( + (item: ColoredItem) => { + haptic.selectionChanged(); + onChange(item); + setIsOpen(false); + setSearch(''); + }, + [onChange, haptic], + ); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + haptic.buttonPress(); + onChange(null); + }, + [onChange, haptic], + ); + + const handleCreate = useCallback(async () => { + const name = search.trim(); + if (!name || isCreating) return; + + haptic.buttonPress(); + setIsCreating(true); + + try { + const created = await onCreateNew(name, newColor); + haptic.success(); + onChange(created); + setIsOpen(false); + setSearch(''); + } catch { + haptic.error(); + } finally { + setIsCreating(false); + } + }, [search, newColor, isCreating, onCreateNew, onChange, haptic]); + + // Keyboard handling + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOpen(false); + setSearch(''); + } + if (e.key === 'Enter' && showCreateSection) { + e.preventDefault(); + handleCreate(); + } + }, + [showCreateSection, handleCreate], + ); + + return ( +
+ {/* Trigger button */} + + + ) : ( + <> + + + {placeholder ?? t('news.admin.combobox.placeholder')} + + + )} + + + + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Search input */} +
+ setSearch(e.target.value)} + placeholder={t('news.admin.combobox.searchOrCreate')} + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50" + /> +
+ + {/* Items list */} +
+ {filteredItems.length > 0 ? ( +
+ {filteredItems.map((item) => ( + + ))} +
+ ) : ( + !showCreateSection && ( +
+ {t('news.admin.combobox.noItems')} +
+ ) + )} +
+ + {/* Create new section */} + {showCreateSection && ( +
+
+ {t('news.admin.combobox.createNew')} +
+ + {/* Name preview */} +
+ + {search.trim()} +
+ + {/* Color swatches */} +
+ {colors.map((color) => ( +
+ + {/* Create button */} + +
+ )} +
+ )} +
+ ); +} + +export type { ColoredItem, ColoredItemComboboxProps }; diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index bd7cb4b..a6a7d72 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -1,5 +1,6 @@ // Components export * from './AdminBackButton'; +export * from './ColoredItemCombobox'; export * from './icons'; export * from './Toggle'; export * from './SettingInput'; diff --git a/src/locales/en.json b/src/locales/en.json index 4b6a51b..8aa7b7d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4514,6 +4514,16 @@ "uploadError": "Upload failed", "fileTooLarge": "File is too large", "uploadFeaturedImage": "Upload image", + "combobox": { + "selectCategory": "Select category", + "selectTag": "Select tag", + "create": "Create", + "createNew": "Create new", + "searchOrCreate": "Search or create...", + "noItems": "Nothing found", + "placeholder": "Select...", + "clear": "Clear" + }, "toolbar": { "bold": "Bold", "italic": "Italic", diff --git a/src/locales/ru.json b/src/locales/ru.json index a05ac9d..a257abb 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -5081,6 +5081,16 @@ "uploadError": "Ошибка загрузки", "fileTooLarge": "Файл слишком большой", "uploadFeaturedImage": "Загрузить изображение", + "combobox": { + "selectCategory": "Выберите категорию", + "selectTag": "Выберите тег", + "create": "Создать", + "createNew": "Создать новый", + "searchOrCreate": "Поиск или создание...", + "noItems": "Ничего не найдено", + "placeholder": "Выберите...", + "clear": "Очистить" + }, "toolbar": { "bold": "Жирный", "italic": "Курсив", diff --git a/src/pages/AdminNewsCreate.tsx b/src/pages/AdminNewsCreate.tsx index 48b76ab..c63e990 100644 --- a/src/pages/AdminNewsCreate.tsx +++ b/src/pages/AdminNewsCreate.tsx @@ -13,10 +13,11 @@ import HighlightExtension from '@tiptap/extension-highlight'; import { VideoExtension } from '../lib/tiptap-video'; import { newsApi } from '../api/news'; import { AdminBackButton } from '../components/admin'; +import { ColoredItemCombobox } from '../components/admin/ColoredItemCombobox'; import { Toggle } from '../components/admin/Toggle'; import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { cn } from '../lib/utils'; -import type { NewsCreateRequest } from '../types/news'; +import type { NewsCategory, NewsTag, NewsCreateRequest } from '../types/news'; // --- Icons --- const BoldIcon = () => ( @@ -174,18 +175,6 @@ function generateSlug(title: string): string { .substring(0, 100); } -// --- Predefined category colors --- -const CATEGORY_COLORS = [ - '#00e5a0', - '#00b4d8', - '#f72585', - '#ffd60a', - '#7c3aed', - '#f97316', - '#06b6d4', - '#ec4899', -]; - export default function AdminNewsCreate() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -199,9 +188,8 @@ export default function AdminNewsCreate() { const [title, setTitle] = useState(''); const [slug, setSlug] = useState(''); const [slugManuallyEdited, setSlugManuallyEdited] = useState(false); - const [category, setCategory] = useState(''); - const [categoryColor, setCategoryColor] = useState('#00e5a0'); - const [tag, setTag] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(null); + const [selectedTag, setSelectedTag] = useState(null); const [excerpt, setExcerpt] = useState(''); const [featuredImageUrl, setFeaturedImageUrl] = useState(''); const [readTimeMinutes, setReadTimeMinutes] = useState(3); @@ -427,13 +415,35 @@ export default function AdminNewsCreate() { [handleMediaUpload], ); - // Fetch existing categories for suggestions - const { data: newsData } = useQuery({ + // Fetch categories and tags for combobox selectors + const { data: categoriesData } = useQuery({ queryKey: ['admin', 'news', 'categories'], - queryFn: () => newsApi.getAdminNews({ limit: 1 }), + queryFn: () => newsApi.getCategories(), staleTime: 60_000, }); - const existingCategories = newsData?.categories ?? []; + const { data: tagsData } = useQuery({ + queryKey: ['admin', 'news', 'tags'], + queryFn: () => newsApi.getTags(), + staleTime: 60_000, + }); + + const handleCreateCategory = useCallback( + async (name: string, color: string) => { + const cat = await newsApi.createCategory({ name, color }); + queryClient.invalidateQueries({ queryKey: ['admin', 'news', 'categories'] }); + return cat; + }, + [queryClient], + ); + + const handleCreateTag = useCallback( + async (name: string, color: string) => { + const tag = await newsApi.createTag({ name, color }); + queryClient.invalidateQueries({ queryKey: ['admin', 'news', 'tags'] }); + return tag; + }, + [queryClient], + ); // Fetch article for editing const { data: articleData, isLoading: isLoadingArticle } = useQuery({ @@ -456,9 +466,21 @@ export default function AdminNewsCreate() { setTitle(articleData.title); setSlug(articleData.slug); setSlugManuallyEdited(true); - setCategory(articleData.category); - setCategoryColor(articleData.category_color); - setTag(articleData.tag ?? ''); + // Reconstruct category/tag objects from article data + if (articleData.category) { + setSelectedCategory({ + id: articleData.category_id ?? 0, + name: articleData.category, + color: articleData.category_color, + }); + } + if (articleData.tag) { + setSelectedTag({ + id: articleData.tag_id ?? 0, + name: articleData.tag, + color: articleData.category_color, + }); + } setExcerpt(articleData.excerpt ?? ''); setFeaturedImageUrl(articleData.featured_image_url ?? ''); setReadTimeMinutes(articleData.read_time_minutes); @@ -499,7 +521,7 @@ export default function AdminNewsCreate() { const handleSave = () => { setSaveError(null); - if (!title.trim() || !slug.trim() || !category.trim()) return; + if (!title.trim() || !slug.trim() || !selectedCategory) return; const content = editor?.getHTML() ?? ''; const data: NewsCreateRequest = { @@ -507,9 +529,11 @@ export default function AdminNewsCreate() { slug: slug.trim(), content, excerpt: excerpt.trim() || null, - category: category.trim(), - category_color: categoryColor, - tag: tag.trim() || null, + category: selectedCategory.name, + category_color: selectedCategory.color, + category_id: selectedCategory.id || null, + tag: selectedTag?.name || null, + tag_id: selectedTag?.id || null, featured_image_url: isSafeUrl(featuredImageUrl.trim()) ? featuredImageUrl.trim() : null, is_published: isPublished, is_featured: isFeatured, @@ -556,7 +580,7 @@ export default function AdminNewsCreate() {