mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
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)
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import type {
|
import type {
|
||||||
NewsArticle,
|
NewsArticle,
|
||||||
|
NewsCategory,
|
||||||
|
NewsTag,
|
||||||
NewsListResponse,
|
NewsListResponse,
|
||||||
NewsCreateRequest,
|
NewsCreateRequest,
|
||||||
NewsUpdateRequest,
|
NewsUpdateRequest,
|
||||||
@@ -72,4 +74,50 @@ export const newsApi = {
|
|||||||
deleteMedia: async (filename: string): Promise<void> => {
|
deleteMedia: async (filename: string): Promise<void> => {
|
||||||
await apiClient.delete(`/cabinet/admin/news/media/${encodeURIComponent(filename)}`);
|
await apiClient.delete(`/cabinet/admin/news/media/${encodeURIComponent(filename)}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
getCategories: async (): Promise<NewsCategory[]> => {
|
||||||
|
const response = await apiClient.get<NewsCategory[]>('/cabinet/admin/news/categories');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createCategory: async (data: { name: string; color: string }): Promise<NewsCategory> => {
|
||||||
|
const response = await apiClient.post<NewsCategory>('/cabinet/admin/news/categories', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCategory: async (
|
||||||
|
id: number,
|
||||||
|
data: { name?: string; color?: string },
|
||||||
|
): Promise<NewsCategory> => {
|
||||||
|
const response = await apiClient.put<NewsCategory>(
|
||||||
|
`/cabinet/admin/news/categories/${id}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCategory: async (id: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/cabinet/admin/news/categories/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
getTags: async (): Promise<NewsTag[]> => {
|
||||||
|
const response = await apiClient.get<NewsTag[]>('/cabinet/admin/news/tags');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createTag: async (data: { name: string; color: string }): Promise<NewsTag> => {
|
||||||
|
const response = await apiClient.post<NewsTag>('/cabinet/admin/news/tags', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTag: async (id: number, data: { name?: string; color?: string }): Promise<NewsTag> => {
|
||||||
|
const response = await apiClient.put<NewsTag>(`/cabinet/admin/news/tags/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTag: async (id: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/cabinet/admin/news/tags/${id}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
336
src/components/admin/ColoredItemCombobox.tsx
Normal file
336
src/components/admin/ColoredItemCombobox.tsx
Normal file
@@ -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<ColoredItem>;
|
||||||
|
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<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div ref={containerRef} className={cn('relative', className)}>
|
||||||
|
{/* Trigger button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[44px] w-full items-center gap-3 rounded-xl border bg-dark-800 px-4 py-2.5 text-left text-sm transition-colors',
|
||||||
|
isOpen ? 'border-accent-500/50' : 'border-dark-700 hover:border-dark-600',
|
||||||
|
isLoading && 'animate-pulse',
|
||||||
|
)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
>
|
||||||
|
{value ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: value.color }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate text-dark-100">{value.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="shrink-0 rounded p-0.5 text-dark-500 transition-colors hover:text-dark-300"
|
||||||
|
aria-label={t('news.admin.combobox.clear')}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="h-3 w-3 shrink-0 rounded-full bg-dark-600" />
|
||||||
|
<span className="flex-1 truncate text-dark-500">
|
||||||
|
{placeholder ?? t('news.admin.combobox.placeholder')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0 text-dark-500 transition-transform duration-200',
|
||||||
|
isOpen && 'rotate-180',
|
||||||
|
)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M7 10l5 5 5-5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 right-0 z-50 mt-2 overflow-hidden rounded-xl border border-dark-700 bg-dark-900/95 shadow-xl shadow-black/30 backdrop-blur-lg',
|
||||||
|
)}
|
||||||
|
role="listbox"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="border-b border-dark-700 p-3">
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items list */}
|
||||||
|
<div className="max-h-60 overflow-y-auto">
|
||||||
|
{filteredItems.length > 0 ? (
|
||||||
|
<div className="p-1.5">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[44px] w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
|
||||||
|
value?.id === item.id
|
||||||
|
? 'bg-accent-500/10 text-accent-400'
|
||||||
|
: 'text-dark-200 hover:bg-dark-800',
|
||||||
|
)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={value?.id === item.id}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">{item.name}</span>
|
||||||
|
{value?.id === item.id && (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 shrink-0 text-accent-400"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!showCreateSection && (
|
||||||
|
<div className="px-4 py-6 text-center text-sm text-dark-500">
|
||||||
|
{t('news.admin.combobox.noItems')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create new section */}
|
||||||
|
{showCreateSection && (
|
||||||
|
<div className="border-t border-dark-700 p-3">
|
||||||
|
<div className="mb-2.5 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('news.admin.combobox.createNew')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name preview */}
|
||||||
|
<div className="mb-3 flex items-center gap-2.5 rounded-lg bg-dark-800 px-3 py-2">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: newColor }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-dark-200">{search.trim()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color swatches */}
|
||||||
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||||
|
{colors.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
haptic.selectionChanged();
|
||||||
|
setNewColor(color);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 rounded-lg border-2 transition-all',
|
||||||
|
newColor === color
|
||||||
|
? 'scale-110 border-white'
|
||||||
|
: 'border-transparent hover:border-dark-500',
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
aria-label={t('news.admin.selectColor', { color })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isCreating}
|
||||||
|
className="flex min-h-[44px] w-full items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||||
|
</svg>
|
||||||
|
{t('news.admin.combobox.create')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ColoredItem, ColoredItemComboboxProps };
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Components
|
// Components
|
||||||
export * from './AdminBackButton';
|
export * from './AdminBackButton';
|
||||||
|
export * from './ColoredItemCombobox';
|
||||||
export * from './icons';
|
export * from './icons';
|
||||||
export * from './Toggle';
|
export * from './Toggle';
|
||||||
export * from './SettingInput';
|
export * from './SettingInput';
|
||||||
|
|||||||
@@ -4514,6 +4514,16 @@
|
|||||||
"uploadError": "Upload failed",
|
"uploadError": "Upload failed",
|
||||||
"fileTooLarge": "File is too large",
|
"fileTooLarge": "File is too large",
|
||||||
"uploadFeaturedImage": "Upload image",
|
"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": {
|
"toolbar": {
|
||||||
"bold": "Bold",
|
"bold": "Bold",
|
||||||
"italic": "Italic",
|
"italic": "Italic",
|
||||||
|
|||||||
@@ -5081,6 +5081,16 @@
|
|||||||
"uploadError": "Ошибка загрузки",
|
"uploadError": "Ошибка загрузки",
|
||||||
"fileTooLarge": "Файл слишком большой",
|
"fileTooLarge": "Файл слишком большой",
|
||||||
"uploadFeaturedImage": "Загрузить изображение",
|
"uploadFeaturedImage": "Загрузить изображение",
|
||||||
|
"combobox": {
|
||||||
|
"selectCategory": "Выберите категорию",
|
||||||
|
"selectTag": "Выберите тег",
|
||||||
|
"create": "Создать",
|
||||||
|
"createNew": "Создать новый",
|
||||||
|
"searchOrCreate": "Поиск или создание...",
|
||||||
|
"noItems": "Ничего не найдено",
|
||||||
|
"placeholder": "Выберите...",
|
||||||
|
"clear": "Очистить"
|
||||||
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"bold": "Жирный",
|
"bold": "Жирный",
|
||||||
"italic": "Курсив",
|
"italic": "Курсив",
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import HighlightExtension from '@tiptap/extension-highlight';
|
|||||||
import { VideoExtension } from '../lib/tiptap-video';
|
import { VideoExtension } from '../lib/tiptap-video';
|
||||||
import { newsApi } from '../api/news';
|
import { newsApi } from '../api/news';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
import { ColoredItemCombobox } from '../components/admin/ColoredItemCombobox';
|
||||||
import { Toggle } from '../components/admin/Toggle';
|
import { Toggle } from '../components/admin/Toggle';
|
||||||
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import type { NewsCreateRequest } from '../types/news';
|
import type { NewsCategory, NewsTag, NewsCreateRequest } from '../types/news';
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
const BoldIcon = () => (
|
const BoldIcon = () => (
|
||||||
@@ -174,18 +175,6 @@ function generateSlug(title: string): string {
|
|||||||
.substring(0, 100);
|
.substring(0, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Predefined category colors ---
|
|
||||||
const CATEGORY_COLORS = [
|
|
||||||
'#00e5a0',
|
|
||||||
'#00b4d8',
|
|
||||||
'#f72585',
|
|
||||||
'#ffd60a',
|
|
||||||
'#7c3aed',
|
|
||||||
'#f97316',
|
|
||||||
'#06b6d4',
|
|
||||||
'#ec4899',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AdminNewsCreate() {
|
export default function AdminNewsCreate() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -199,9 +188,8 @@ export default function AdminNewsCreate() {
|
|||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
||||||
const [category, setCategory] = useState('');
|
const [selectedCategory, setSelectedCategory] = useState<NewsCategory | null>(null);
|
||||||
const [categoryColor, setCategoryColor] = useState('#00e5a0');
|
const [selectedTag, setSelectedTag] = useState<NewsTag | null>(null);
|
||||||
const [tag, setTag] = useState('');
|
|
||||||
const [excerpt, setExcerpt] = useState('');
|
const [excerpt, setExcerpt] = useState('');
|
||||||
const [featuredImageUrl, setFeaturedImageUrl] = useState('');
|
const [featuredImageUrl, setFeaturedImageUrl] = useState('');
|
||||||
const [readTimeMinutes, setReadTimeMinutes] = useState(3);
|
const [readTimeMinutes, setReadTimeMinutes] = useState(3);
|
||||||
@@ -427,13 +415,35 @@ export default function AdminNewsCreate() {
|
|||||||
[handleMediaUpload],
|
[handleMediaUpload],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch existing categories for suggestions
|
// Fetch categories and tags for combobox selectors
|
||||||
const { data: newsData } = useQuery({
|
const { data: categoriesData } = useQuery({
|
||||||
queryKey: ['admin', 'news', 'categories'],
|
queryKey: ['admin', 'news', 'categories'],
|
||||||
queryFn: () => newsApi.getAdminNews({ limit: 1 }),
|
queryFn: () => newsApi.getCategories(),
|
||||||
staleTime: 60_000,
|
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
|
// Fetch article for editing
|
||||||
const { data: articleData, isLoading: isLoadingArticle } = useQuery({
|
const { data: articleData, isLoading: isLoadingArticle } = useQuery({
|
||||||
@@ -456,9 +466,21 @@ export default function AdminNewsCreate() {
|
|||||||
setTitle(articleData.title);
|
setTitle(articleData.title);
|
||||||
setSlug(articleData.slug);
|
setSlug(articleData.slug);
|
||||||
setSlugManuallyEdited(true);
|
setSlugManuallyEdited(true);
|
||||||
setCategory(articleData.category);
|
// Reconstruct category/tag objects from article data
|
||||||
setCategoryColor(articleData.category_color);
|
if (articleData.category) {
|
||||||
setTag(articleData.tag ?? '');
|
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 ?? '');
|
setExcerpt(articleData.excerpt ?? '');
|
||||||
setFeaturedImageUrl(articleData.featured_image_url ?? '');
|
setFeaturedImageUrl(articleData.featured_image_url ?? '');
|
||||||
setReadTimeMinutes(articleData.read_time_minutes);
|
setReadTimeMinutes(articleData.read_time_minutes);
|
||||||
@@ -499,7 +521,7 @@ export default function AdminNewsCreate() {
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
if (!title.trim() || !slug.trim() || !category.trim()) return;
|
if (!title.trim() || !slug.trim() || !selectedCategory) return;
|
||||||
|
|
||||||
const content = editor?.getHTML() ?? '';
|
const content = editor?.getHTML() ?? '';
|
||||||
const data: NewsCreateRequest = {
|
const data: NewsCreateRequest = {
|
||||||
@@ -507,9 +529,11 @@ export default function AdminNewsCreate() {
|
|||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
content,
|
content,
|
||||||
excerpt: excerpt.trim() || null,
|
excerpt: excerpt.trim() || null,
|
||||||
category: category.trim(),
|
category: selectedCategory.name,
|
||||||
category_color: categoryColor,
|
category_color: selectedCategory.color,
|
||||||
tag: tag.trim() || null,
|
category_id: selectedCategory.id || null,
|
||||||
|
tag: selectedTag?.name || null,
|
||||||
|
tag_id: selectedTag?.id || null,
|
||||||
featured_image_url: isSafeUrl(featuredImageUrl.trim()) ? featuredImageUrl.trim() : null,
|
featured_image_url: isSafeUrl(featuredImageUrl.trim()) ? featuredImageUrl.trim() : null,
|
||||||
is_published: isPublished,
|
is_published: isPublished,
|
||||||
is_featured: isFeatured,
|
is_featured: isFeatured,
|
||||||
@@ -556,7 +580,7 @@ export default function AdminNewsCreate() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saveMutation.isPending || !title.trim() || !slug.trim() || !category.trim()}
|
disabled={saveMutation.isPending || !title.trim() || !slug.trim() || !selectedCategory}
|
||||||
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? t('news.admin.saving') : t('news.admin.save')}
|
{saveMutation.isPending ? t('news.admin.saving') : t('news.admin.save')}
|
||||||
@@ -592,78 +616,41 @@ export default function AdminNewsCreate() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category + color row */}
|
{/* Category + Tag row */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">{t('news.admin.categoryLabel')}</label>
|
<label className="label">{t('news.admin.categoryLabel')}</label>
|
||||||
<input
|
<ColoredItemCombobox
|
||||||
type="text"
|
items={categoriesData ?? []}
|
||||||
value={category}
|
value={selectedCategory}
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={setSelectedCategory}
|
||||||
className="input"
|
onCreateNew={handleCreateCategory}
|
||||||
list="category-suggestions"
|
placeholder={t('news.admin.combobox.selectCategory')}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<datalist id="category-suggestions">
|
|
||||||
{existingCategories.map((cat) => (
|
|
||||||
<option key={cat} value={cat} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">{t('news.admin.categoryColorLabel')}</label>
|
<label className="label">{t('news.admin.tagLabel')}</label>
|
||||||
<div className="flex items-center gap-2">
|
<ColoredItemCombobox
|
||||||
<input
|
items={tagsData ?? []}
|
||||||
type="text"
|
value={selectedTag}
|
||||||
value={categoryColor}
|
onChange={setSelectedTag}
|
||||||
onChange={(e) => setCategoryColor(e.target.value)}
|
onCreateNew={handleCreateTag}
|
||||||
className="input flex-1 font-mono text-sm"
|
placeholder={t('news.admin.combobox.selectTag')}
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
className="h-10 w-10 shrink-0 rounded-lg border border-dark-700"
|
|
||||||
style={{ background: categoryColor }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
||||||
{CATEGORY_COLORS.map((color) => (
|
|
||||||
<button
|
|
||||||
key={color}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCategoryColor(color)}
|
|
||||||
className={cn(
|
|
||||||
'min-h-[44px] min-w-[44px] rounded-lg border-2 transition-all',
|
|
||||||
categoryColor === color ? 'scale-110 border-white' : 'border-transparent',
|
|
||||||
)}
|
|
||||||
style={{ background: color }}
|
|
||||||
aria-label={t('news.admin.selectColor', { color })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag + Read time row */}
|
{/* Read time */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div>
|
||||||
<div>
|
<label className="label">{t('news.admin.readTimeLabel')}</label>
|
||||||
<label className="label">{t('news.admin.tagLabel')}</label>
|
<input
|
||||||
<input
|
type="number"
|
||||||
type="text"
|
value={readTimeMinutes}
|
||||||
value={tag}
|
onChange={(e) => setReadTimeMinutes(Number(e.target.value) || 1)}
|
||||||
onChange={(e) => setTag(e.target.value)}
|
min={1}
|
||||||
className="input font-mono text-sm uppercase"
|
max={60}
|
||||||
/>
|
className="input max-w-xs"
|
||||||
</div>
|
/>
|
||||||
<div>
|
|
||||||
<label className="label">{t('news.admin.readTimeLabel')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={readTimeMinutes}
|
|
||||||
onChange={(e) => setReadTimeMinutes(Number(e.target.value) || 1)}
|
|
||||||
min={1}
|
|
||||||
max={60}
|
|
||||||
className="input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Excerpt */}
|
{/* Excerpt */}
|
||||||
@@ -928,7 +915,7 @@ export default function AdminNewsCreate() {
|
|||||||
{/* Bottom save button for long forms */}
|
{/* Bottom save button for long forms */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saveMutation.isPending || !title.trim() || !slug.trim() || !category.trim()}
|
disabled={saveMutation.isPending || !title.trim() || !slug.trim() || !selectedCategory}
|
||||||
className="min-h-[44px] w-full rounded-lg bg-accent-500 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
className="min-h-[44px] w-full rounded-lg bg-accent-500 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? t('news.admin.saving') : t('news.admin.save')}
|
{saveMutation.isPending ? t('news.admin.saving') : t('news.admin.save')}
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
export interface NewsCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsTag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NewsArticle {
|
export interface NewsArticle {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -6,7 +18,9 @@ export interface NewsArticle {
|
|||||||
excerpt: string | null;
|
excerpt: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
category_color: string;
|
category_color: string;
|
||||||
|
category_id: number | null;
|
||||||
tag: string | null;
|
tag: string | null;
|
||||||
|
tag_id: number | null;
|
||||||
featured_image_url: string | null;
|
featured_image_url: string | null;
|
||||||
is_published: boolean;
|
is_published: boolean;
|
||||||
is_featured: boolean;
|
is_featured: boolean;
|
||||||
@@ -25,7 +39,9 @@ export interface NewsListItem {
|
|||||||
excerpt: string | null;
|
excerpt: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
category_color: string;
|
category_color: string;
|
||||||
|
category_id: number | null;
|
||||||
tag: string | null;
|
tag: string | null;
|
||||||
|
tag_id: number | null;
|
||||||
featured_image_url: string | null;
|
featured_image_url: string | null;
|
||||||
is_published: boolean;
|
is_published: boolean;
|
||||||
is_featured: boolean;
|
is_featured: boolean;
|
||||||
@@ -47,7 +63,9 @@ export interface NewsCreateRequest {
|
|||||||
excerpt: string | null;
|
excerpt: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
category_color: string;
|
category_color: string;
|
||||||
|
category_id: number | null;
|
||||||
tag: string | null;
|
tag: string | null;
|
||||||
|
tag_id: number | null;
|
||||||
featured_image_url: string | null;
|
featured_image_url: string | null;
|
||||||
is_published: boolean;
|
is_published: boolean;
|
||||||
is_featured: boolean;
|
is_featured: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user