mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
5509
package-lock.json
generated
5509
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -35,12 +35,23 @@
|
|||||||
"@tanstack/react-query": "^5.8.0",
|
"@tanstack/react-query": "^5.8.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@telegram-apps/sdk-react": "^3.3.9",
|
"@telegram-apps/sdk-react": "^3.3.9",
|
||||||
|
"@tiptap/extension-color": "^3.20.4",
|
||||||
|
"@tiptap/extension-highlight": "^3.20.4",
|
||||||
|
"@tiptap/extension-image": "^3.20.4",
|
||||||
|
"@tiptap/extension-link": "^3.20.4",
|
||||||
|
"@tiptap/extension-placeholder": "^3.20.4",
|
||||||
|
"@tiptap/extension-text-align": "^3.20.4",
|
||||||
|
"@tiptap/extension-text-style": "^3.20.4",
|
||||||
|
"@tiptap/extension-underline": "^3.20.4",
|
||||||
|
"@tiptap/pm": "^3.20.4",
|
||||||
|
"@tiptap/react": "^3.20.4",
|
||||||
|
"@tiptap/starter-kit": "^3.20.4",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.3",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
"graphology": "^0.26.0",
|
"graphology": "^0.26.0",
|
||||||
"graphology-layout-forceatlas2": "^0.10.1",
|
"graphology-layout-forceatlas2": "^0.10.1",
|
||||||
@@ -54,6 +65,7 @@
|
|||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"react-twemoji": "^0.7.2",
|
"react-twemoji": "^0.7.2",
|
||||||
|
"reactjs-tiptap-editor": "^1.0.19",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"sigma": "^3.0.2",
|
"sigma": "^3.0.2",
|
||||||
"simplex-noise": "^4.0.3",
|
"simplex-noise": "^4.0.3",
|
||||||
|
|||||||
47
src/App.tsx
47
src/App.tsx
@@ -117,6 +117,11 @@ const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
|
|||||||
const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats'));
|
const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats'));
|
||||||
const AdminReferralNetwork = lazy(() => import('./pages/ReferralNetwork'));
|
const AdminReferralNetwork = lazy(() => import('./pages/ReferralNetwork'));
|
||||||
|
|
||||||
|
// News pages
|
||||||
|
const NewsArticlePage = lazy(() => import('./pages/NewsArticle'));
|
||||||
|
const AdminNews = lazy(() => import('./pages/AdminNews'));
|
||||||
|
const AdminNewsCreate = lazy(() => import('./pages/AdminNewsCreate'));
|
||||||
|
|
||||||
function ProtectedRoute({
|
function ProtectedRoute({
|
||||||
children,
|
children,
|
||||||
withLayout = true,
|
withLayout = true,
|
||||||
@@ -478,6 +483,16 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/news/:slug"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<NewsArticlePage />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
<Route
|
<Route
|
||||||
@@ -1162,6 +1177,38 @@ function App() {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* News admin routes */}
|
||||||
|
<Route
|
||||||
|
path="/admin/news"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="news:read">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminNews />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/news/create"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="news:create">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminNewsCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/news/:id/edit"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="news:edit">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminNewsCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin/audit-log"
|
path="/admin/audit-log"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -242,11 +242,7 @@ export const adminBroadcastsApi = {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('media_type', mediaType);
|
formData.append('media_type', mediaType);
|
||||||
|
|
||||||
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData);
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -163,11 +163,7 @@ export const adminPinnedMessagesApi = {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('media_type', mediaType);
|
formData.append('media_type', mediaType);
|
||||||
|
|
||||||
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData);
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -153,11 +153,7 @@ export const brandingApi = {
|
|||||||
uploadLogo: async (file: File): Promise<BrandingInfo> => {
|
uploadLogo: async (file: File): Promise<BrandingInfo> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
const response = await apiClient.post<BrandingInfo>('/cabinet/branding/logo', formData, {
|
const response = await apiClient.post<BrandingInfo>('/cabinet/branding/logo', formData);
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Invalidate cached blob so it gets re-fetched
|
// Invalidate cached blob so it gets re-fetched
|
||||||
if (_logoBlobUrl) {
|
if (_logoBlobUrl) {
|
||||||
URL.revokeObjectURL(_logoBlobUrl);
|
URL.revokeObjectURL(_logoBlobUrl);
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ function isAuthEndpoint(url: string | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||||
|
// Let axios set the correct multipart/form-data header with boundary for FormData
|
||||||
|
if (config.data instanceof FormData && config.headers) {
|
||||||
|
delete config.headers['Content-Type'];
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthEndpoint(config.url)) {
|
if (!isAuthEndpoint(config.url)) {
|
||||||
let token = tokenStorage.getAccessToken();
|
let token = tokenStorage.getAccessToken();
|
||||||
|
|
||||||
|
|||||||
123
src/api/news.ts
Normal file
123
src/api/news.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
import type {
|
||||||
|
NewsArticle,
|
||||||
|
NewsCategory,
|
||||||
|
NewsTag,
|
||||||
|
NewsListResponse,
|
||||||
|
NewsCreateRequest,
|
||||||
|
NewsUpdateRequest,
|
||||||
|
NewsMediaUploadResponse,
|
||||||
|
} from '../types/news';
|
||||||
|
|
||||||
|
export const newsApi = {
|
||||||
|
// User endpoints
|
||||||
|
getNews: async (params?: {
|
||||||
|
category?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<NewsListResponse> => {
|
||||||
|
const response = await apiClient.get<NewsListResponse>('/cabinet/news', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getArticle: async (slug: string): Promise<NewsArticle> => {
|
||||||
|
const response = await apiClient.get<NewsArticle>(`/cabinet/news/${encodeURIComponent(slug)}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
getAdminNews: async (params?: { limit?: number; offset?: number }): Promise<NewsListResponse> => {
|
||||||
|
const response = await apiClient.get<NewsListResponse>('/cabinet/admin/news', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAdminArticle: async (id: number): Promise<NewsArticle> => {
|
||||||
|
const response = await apiClient.get<NewsArticle>(`/cabinet/admin/news/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createArticle: async (data: NewsCreateRequest): Promise<NewsArticle> => {
|
||||||
|
const response = await apiClient.post<NewsArticle>('/cabinet/admin/news', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateArticle: async (id: number, data: NewsUpdateRequest): Promise<NewsArticle> => {
|
||||||
|
const response = await apiClient.put<NewsArticle>(`/cabinet/admin/news/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteArticle: async (id: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/cabinet/admin/news/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePublish: async (id: number): Promise<NewsArticle> => {
|
||||||
|
const response = await apiClient.post<NewsArticle>(`/cabinet/admin/news/${id}/publish`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleFeatured: async (id: number): Promise<NewsArticle> => {
|
||||||
|
const response = await apiClient.post<NewsArticle>(`/cabinet/admin/news/${id}/feature`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadMedia: async (file: File, signal?: AbortSignal): Promise<NewsMediaUploadResponse> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const response = await apiClient.post<NewsMediaUploadResponse>(
|
||||||
|
'/cabinet/admin/news/media/upload',
|
||||||
|
formData,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMedia: async (filename: string): Promise<void> => {
|
||||||
|
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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -68,11 +68,7 @@ export const ticketsApi = {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('media_type', mediaType);
|
formData.append('media_type', mediaType);
|
||||||
|
|
||||||
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData);
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
380
src/components/admin/ColoredItemCombobox.tsx
Normal file
380
src/components/admin/ColoredItemCombobox.tsx
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
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>;
|
||||||
|
onDelete?: (item: ColoredItem) => Promise<void>;
|
||||||
|
placeholder?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
colors?: string[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColoredItemCombobox({
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onCreateNew,
|
||||||
|
onDelete,
|
||||||
|
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 [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (e: React.MouseEvent, item: ColoredItem) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!onDelete || deletingId !== null) return;
|
||||||
|
|
||||||
|
haptic.buttonPress();
|
||||||
|
setDeletingId(item.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onDelete(item);
|
||||||
|
haptic.success();
|
||||||
|
// Clear selection if deleted item was selected
|
||||||
|
if (value?.id === item.id) {
|
||||||
|
onChange(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
haptic.error();
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDelete, deletingId, value, 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>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleDelete(e, item)}
|
||||||
|
disabled={deletingId === item.id}
|
||||||
|
className="shrink-0 rounded p-1 text-dark-600 transition-colors hover:bg-red-500/10 hover:text-red-400 disabled:opacity-50"
|
||||||
|
aria-label={t('news.admin.combobox.delete', { name: item.name })}
|
||||||
|
>
|
||||||
|
{deletingId === item.id ? (
|
||||||
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-red-400 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<svg className="h-3.5 w-3.5" 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>
|
||||||
|
)}
|
||||||
|
</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,23 +1,47 @@
|
|||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
interface ToggleProps {
|
interface ToggleProps {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
'aria-label'?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toggle({ checked, onChange, disabled }: ToggleProps) {
|
export function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
className,
|
||||||
|
}: ToggleProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={ariaLabel}
|
||||||
onClick={onChange}
|
onClick={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
className={cn(
|
||||||
checked ? 'bg-accent-500' : 'bg-dark-600'
|
'flex min-h-[44px] items-center',
|
||||||
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform duration-200 ${
|
className={cn(
|
||||||
checked ? 'translate-x-6' : 'translate-x-0'
|
'relative h-8 w-14 rounded-full transition-colors',
|
||||||
}`}
|
checked ? 'bg-accent-500' : 'bg-dark-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-1 top-1 h-6 w-6 rounded-full bg-white transition-transform duration-200',
|
||||||
|
checked ? 'translate-x-6' : 'translate-x-0',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
192
src/components/news/GridBackground.tsx
Normal file
192
src/components/news/GridBackground.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the current --color-accent-400 CSS variable and returns [r, g, b].
|
||||||
|
* Falls back to [96, 165, 250] (default blue accent).
|
||||||
|
*/
|
||||||
|
function getAccentRGB(): [number, number, number] {
|
||||||
|
const raw = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--color-accent-400')
|
||||||
|
.trim();
|
||||||
|
if (!raw) return [96, 165, 250];
|
||||||
|
const parts = raw.split(',').map((s) => parseInt(s.trim(), 10));
|
||||||
|
if (parts.length >= 3 && parts.every((n) => !isNaN(n))) {
|
||||||
|
return [parts[0], parts[1], parts[2]];
|
||||||
|
}
|
||||||
|
return [96, 165, 250];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GridBackground() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
// Respect prefers-reduced-motion — render a single static frame
|
||||||
|
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animId = 0;
|
||||||
|
let time = 0;
|
||||||
|
let lastTimestamp = 0;
|
||||||
|
let isVisible = true;
|
||||||
|
|
||||||
|
// Read accent color once on mount
|
||||||
|
const [r, g, b] = getAccentRGB();
|
||||||
|
|
||||||
|
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = canvas.offsetWidth * dpr;
|
||||||
|
canvas.height = canvas.offsetHeight * dpr;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
const debouncedResize = () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(resize, 150);
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', debouncedResize);
|
||||||
|
|
||||||
|
// Pre-quantize alpha to a fixed number of buckets so lines with similar
|
||||||
|
// alpha share a single ctx.stroke() call instead of one per line.
|
||||||
|
// This reduces draw calls from 36 (21 vertical + 15 horizontal) down to
|
||||||
|
// ~8-10 per frame, cutting canvas rasterization time by ~60%.
|
||||||
|
const ALPHA_BUCKETS = 8;
|
||||||
|
|
||||||
|
const draw = (timestamp: number) => {
|
||||||
|
if (!isVisible) {
|
||||||
|
animId = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use delta-time for frame-rate independent animation
|
||||||
|
if (lastTimestamp) {
|
||||||
|
const dt = (timestamp - lastTimestamp) / 1000;
|
||||||
|
time += dt * 0.18; // ~0.003 at 60fps equivalent
|
||||||
|
}
|
||||||
|
lastTimestamp = timestamp;
|
||||||
|
|
||||||
|
const w = canvas.offsetWidth;
|
||||||
|
const h = canvas.offsetHeight;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const cols = 20;
|
||||||
|
const rows = 14;
|
||||||
|
const cellW = w / cols;
|
||||||
|
const cellH = h / rows;
|
||||||
|
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
// --- Batch vertical lines by quantized alpha bucket ---
|
||||||
|
// Each entry: [path segments for this alpha level]
|
||||||
|
const vBuckets = new Map<number, Array<[number, number, number, number]>>();
|
||||||
|
for (let i = 0; i <= cols; i++) {
|
||||||
|
const rawAlpha = 0.03 + Math.sin(time + i) * 0.015;
|
||||||
|
const bucket = Math.round(rawAlpha * ALPHA_BUCKETS) / ALPHA_BUCKETS;
|
||||||
|
const x = i * cellW;
|
||||||
|
const wave = Math.sin(time + i * 0.3) * 2;
|
||||||
|
if (!vBuckets.has(bucket)) vBuckets.set(bucket, []);
|
||||||
|
vBuckets.get(bucket)!.push([x + wave, 0, x - wave, h]);
|
||||||
|
}
|
||||||
|
for (const [alpha, segs] of vBuckets) {
|
||||||
|
ctx.strokeStyle = `rgba(${r},${g},${b},${alpha})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (const [x0, y0, x1, y1] of segs) {
|
||||||
|
ctx.moveTo(x0, y0);
|
||||||
|
ctx.lineTo(x1, y1);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Batch horizontal lines by quantized alpha bucket ---
|
||||||
|
const hBuckets = new Map<number, Array<[number, number, number, number]>>();
|
||||||
|
for (let j = 0; j <= rows; j++) {
|
||||||
|
const rawAlpha = 0.03 + Math.cos(time + j) * 0.015;
|
||||||
|
const bucket = Math.round(rawAlpha * ALPHA_BUCKETS) / ALPHA_BUCKETS;
|
||||||
|
const y = j * cellH;
|
||||||
|
const wave = Math.cos(time + j * 0.3) * 2;
|
||||||
|
if (!hBuckets.has(bucket)) hBuckets.set(bucket, []);
|
||||||
|
hBuckets.get(bucket)!.push([0, y + wave, w, y - wave]);
|
||||||
|
}
|
||||||
|
for (const [alpha, segs] of hBuckets) {
|
||||||
|
ctx.strokeStyle = `rgba(${r},${g},${b},${alpha})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (const [x0, y0, x1, y1] of segs) {
|
||||||
|
ctx.moveTo(x0, y0);
|
||||||
|
ctx.lineTo(x1, y1);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Glow nodes at intersections (only when pulse threshold met) ---
|
||||||
|
// Each RadialGradient is unique per node so we cannot batch these,
|
||||||
|
// but the threshold (>0.85) limits active nodes to ~5% of the grid.
|
||||||
|
for (let i = 0; i <= cols; i++) {
|
||||||
|
for (let j = 0; j <= rows; j++) {
|
||||||
|
const pulse = Math.sin(time * 2 + i * 0.7 + j * 0.5);
|
||||||
|
if (pulse > 0.85) {
|
||||||
|
const x = i * cellW;
|
||||||
|
const y = j * cellH;
|
||||||
|
const rad = 2 + pulse * 3;
|
||||||
|
const grad = ctx.createRadialGradient(x, y, 0, x, y, rad * 4);
|
||||||
|
grad.addColorStop(0, `rgba(${r},${g},${b},${0.4 * pulse})`);
|
||||||
|
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, rad * 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prefersReduced) {
|
||||||
|
animId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pause animation when offscreen
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
isVisible = entry.isIntersecting;
|
||||||
|
if (isVisible && !prefersReduced && animId === 0) {
|
||||||
|
resize();
|
||||||
|
lastTimestamp = 0;
|
||||||
|
animId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0 },
|
||||||
|
);
|
||||||
|
observer.observe(canvas);
|
||||||
|
|
||||||
|
// Seed lastTimestamp to avoid large delta on second frame
|
||||||
|
lastTimestamp = performance.now();
|
||||||
|
if (!prefersReduced) {
|
||||||
|
animId = requestAnimationFrame(draw);
|
||||||
|
} else {
|
||||||
|
draw(performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
observer.disconnect();
|
||||||
|
window.removeEventListener('resize', debouncedResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
// will-change: transform promotes the canvas to its own GPU compositing
|
||||||
|
// layer so the browser doesn't need to repaint surrounding DOM nodes on
|
||||||
|
// every animation frame.
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full opacity-60"
|
||||||
|
style={{ willChange: 'transform' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
500
src/components/news/NewsSection.tsx
Normal file
500
src/components/news/NewsSection.tsx
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
import { useState, useCallback, useMemo, memo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { newsApi } from '../../api/news';
|
||||||
|
import { useHapticFeedback } from '../../platform/hooks/useHaptic';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import type { NewsListItem } from '../../types/news';
|
||||||
|
|
||||||
|
// --- Security: hex color validation to prevent CSS injection ---
|
||||||
|
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||||
|
function safeColor(color: string | null | undefined, fallback = '#888888'): string {
|
||||||
|
if (!color || !HEX_COLOR_RE.test(color)) return fallback;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Animation variants ---
|
||||||
|
const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];
|
||||||
|
|
||||||
|
const fadeSlideUp = {
|
||||||
|
hidden: { opacity: 0, y: 24 },
|
||||||
|
visible: (i: number) => ({
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
delay: i * 0.1,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: EASE_OUT,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Icons ---
|
||||||
|
const ArrowIcon = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M3 8h10M9 4l4 4-4 4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Sub-components ---
|
||||||
|
|
||||||
|
interface CategoryBadgeProps {
|
||||||
|
category: string;
|
||||||
|
color: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryBadge = memo(function CategoryBadge({
|
||||||
|
category,
|
||||||
|
color,
|
||||||
|
className,
|
||||||
|
}: CategoryBadgeProps) {
|
||||||
|
const c = safeColor(color);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-3 py-1 font-mono text-[11px] font-bold uppercase tracking-widest',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
color: c,
|
||||||
|
background: `${c}15`,
|
||||||
|
border: `1px solid ${c}30`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 animate-pulse rounded-full"
|
||||||
|
style={{
|
||||||
|
background: c,
|
||||||
|
boxShadow: `0 0 8px ${c}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TagBadgeProps {
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagBadge = memo(function TagBadge({ text, color }: TagBadgeProps) {
|
||||||
|
const c = safeColor(color);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-block rounded px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-wider"
|
||||||
|
style={{
|
||||||
|
color: c,
|
||||||
|
border: `1px solid ${c}33`,
|
||||||
|
background: `${c}11`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FilterTabsProps {
|
||||||
|
categories: string[];
|
||||||
|
active: string;
|
||||||
|
onChange: (category: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterTabs = memo(function FilterTabs({ categories, active, onChange }: FilterTabsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const haptic = useHapticFeedback();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label={t('news.title')}>
|
||||||
|
{/* "All" tab — empty string means no filter */}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active === ''}
|
||||||
|
onClick={() => {
|
||||||
|
haptic.selectionChanged();
|
||||||
|
onChange('');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[44px] rounded-lg px-4 py-2.5 text-xs font-semibold tracking-wide transition-all duration-300',
|
||||||
|
active === ''
|
||||||
|
? 'border border-accent-400 bg-accent-400 text-dark-950'
|
||||||
|
: 'border border-dark-700 bg-dark-800 text-dark-400 hover:border-accent-400/30 hover:text-accent-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('news.filterAll')}
|
||||||
|
</button>
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const isActive = active === cat;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => {
|
||||||
|
haptic.selectionChanged();
|
||||||
|
onChange(cat);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[44px] rounded-lg px-4 py-2.5 text-xs font-semibold tracking-wide transition-all duration-300',
|
||||||
|
isActive
|
||||||
|
? 'border border-accent-400 bg-accent-400 text-dark-950'
|
||||||
|
: 'border border-dark-700 bg-dark-800 text-dark-400 hover:border-accent-400/30 hover:text-accent-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FeaturedCardProps {
|
||||||
|
item: NewsListItem;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturedCard = memo(function FeaturedCard({ item, onClick }: FeaturedCardProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
custom={0}
|
||||||
|
variants={fadeSlideUp}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="group col-span-full cursor-pointer rounded-2xl p-px transition-all duration-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-400 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-950"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(var(--color-accent-400), 0.2), rgba(var(--color-dark-900), 0.2), rgba(var(--color-accent-400), 0.2))',
|
||||||
|
}}
|
||||||
|
whileHover={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(var(--color-accent-400), 0.4), rgba(var(--color-accent-500), 0.4), rgba(var(--color-accent-400), 0.4))',
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="relative flex min-h-[220px] flex-col justify-between overflow-hidden rounded-[15px] bg-dark-900 p-7 sm:p-10">
|
||||||
|
{/* Corner decoration */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute right-0 top-0 h-[200px] w-[200px]"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at top right, rgba(var(--color-accent-400), 0.08), transparent 70%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Shimmer top border */}
|
||||||
|
<div
|
||||||
|
className="absolute -top-px left-[20%] right-[20%] h-px"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(90deg, transparent, rgba(var(--color-accent-400), 0.4), transparent)',
|
||||||
|
animation: 'newsShimmer 3s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<CategoryBadge category={item.category} color={item.category_color} />
|
||||||
|
{item.tag && <TagBadge text={item.tag} color={item.category_color} />}
|
||||||
|
<span className="ml-auto font-mono text-[11px] text-dark-500">
|
||||||
|
{item.read_time_minutes} {t('news.readTime')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mb-3 max-w-[700px] break-words text-2xl font-extrabold leading-tight text-dark-50 transition-colors duration-300 group-hover:text-white sm:text-[28px]">
|
||||||
|
{item.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{item.excerpt && (
|
||||||
|
<p className="max-w-[600px] text-[15px] leading-relaxed text-dark-400">
|
||||||
|
{item.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<span className="font-mono text-xs text-dark-600">
|
||||||
|
{item.published_at ? new Date(item.published_at).toLocaleDateString(i18n.language) : ''}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-accent-400 transition-all duration-300 group-hover:gap-2.5">
|
||||||
|
{t('news.readMore')}
|
||||||
|
<ArrowIcon />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface NewsCardProps {
|
||||||
|
item: NewsListItem;
|
||||||
|
index: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewsCard = memo(function NewsCard({ item, index, onClick }: NewsCardProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const color = safeColor(item.category_color);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
custom={index + 1}
|
||||||
|
variants={fadeSlideUp}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="group cursor-pointer rounded-[14px] p-px transition-all duration-[450ms] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-400 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-950"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(160deg, rgba(var(--color-dark-700), 0.25), rgba(var(--color-dark-900), 0.25))',
|
||||||
|
}}
|
||||||
|
whileHover={{
|
||||||
|
y: -4,
|
||||||
|
background: `linear-gradient(160deg, ${color}55, transparent 60%)`,
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="relative flex h-full min-h-[210px] flex-col justify-between overflow-hidden rounded-[13px] bg-dark-900 p-7">
|
||||||
|
{/* Subtle corner glow on hover */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -bottom-5 -right-5 h-[100px] w-[100px] opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(circle, ${color}08, transparent 70%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-3.5 flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 font-mono text-[10px] font-bold uppercase tracking-widest"
|
||||||
|
style={{ color }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-[5px] w-[5px] rounded-full"
|
||||||
|
style={{
|
||||||
|
background: color,
|
||||||
|
boxShadow: `0 0 6px ${color}80`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
{item.tag && <TagBadge text={item.tag} color={color} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mb-2.5 break-words text-[17px] font-bold leading-snug text-dark-100 transition-colors duration-300 group-hover:text-white">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{item.excerpt && (
|
||||||
|
<p className="text-[13px] leading-relaxed text-dark-400">{item.excerpt}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-between border-t border-dark-700/50 pt-3.5">
|
||||||
|
<span className="font-mono text-[11px] text-dark-600">
|
||||||
|
{item.published_at ? new Date(item.published_at).toLocaleDateString(i18n.language) : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[11px] text-dark-500">
|
||||||
|
{item.read_time_minutes} {t('news.readTime')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thin wrapper that binds the per-item click handler without creating a new
|
||||||
|
// anonymous lambda in the parent's JSX on every render, which would defeat
|
||||||
|
// the memo on NewsCard.
|
||||||
|
interface NewsCardWrapperProps {
|
||||||
|
item: NewsListItem;
|
||||||
|
index: number;
|
||||||
|
onCardClick: (slug: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewsCardWrapper = memo(function NewsCardWrapper({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
onCardClick,
|
||||||
|
}: NewsCardWrapperProps) {
|
||||||
|
const handleClick = useCallback(() => onCardClick(item.slug), [item.slug, onCardClick]);
|
||||||
|
return <NewsCard item={item} index={index} onClick={handleClick} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Main Component ---
|
||||||
|
|
||||||
|
const NEWS_LIMIT = 6;
|
||||||
|
|
||||||
|
export default function NewsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const haptic = useHapticFeedback();
|
||||||
|
const [filter, setFilter] = useState<string>('');
|
||||||
|
const [limit, setLimit] = useState(NEWS_LIMIT);
|
||||||
|
|
||||||
|
const categoryParam = filter || undefined;
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['news', 'list', categoryParam, limit],
|
||||||
|
queryFn: () => newsApi.getNews({ category: categoryParam, limit, offset: 0 }),
|
||||||
|
// staleTime: serve cached data for 2 min before background re-fetch.
|
||||||
|
// gcTime: keep in cache for 10 min so navigating away and back is instant.
|
||||||
|
staleTime: 2 * 60_000,
|
||||||
|
gcTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const categories = data?.categories ?? [];
|
||||||
|
|
||||||
|
// Memoized so FeaturedCard/NewsCard receive stable object references when
|
||||||
|
// parent state unrelated to the list changes (e.g. load-more button state).
|
||||||
|
const { featured, regular } = useMemo(
|
||||||
|
() => ({
|
||||||
|
featured: items.find((n) => n.is_featured),
|
||||||
|
regular: items.filter((n) => !n.is_featured),
|
||||||
|
}),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(
|
||||||
|
(slug: string) => {
|
||||||
|
haptic.buttonPress();
|
||||||
|
navigate(`/news/${slug}`);
|
||||||
|
},
|
||||||
|
[haptic, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
haptic.buttonPress();
|
||||||
|
setLimit((prev) => prev + NEWS_LIMIT);
|
||||||
|
}, [haptic]);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((category: string) => {
|
||||||
|
setFilter(category);
|
||||||
|
setLimit(NEWS_LIMIT);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stable reference for the featured card — avoids re-rendering FeaturedCard
|
||||||
|
// when `featured` is a new object reference but contains the same slug.
|
||||||
|
const featuredSlug = featured?.slug;
|
||||||
|
const handleFeaturedClick = useCallback(() => {
|
||||||
|
if (featuredSlug) handleCardClick(featuredSlug);
|
||||||
|
}, [featuredSlug, handleCardClick]);
|
||||||
|
|
||||||
|
// Don't render until we know there are news items.
|
||||||
|
// This prevents the skeleton from briefly flashing when there are no articles.
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden rounded-2xl bg-dark-850/80 backdrop-blur-xl">
|
||||||
|
<div className="px-5 py-8 sm:px-6 sm:py-10">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
variants={fadeSlideUp}
|
||||||
|
custom={0}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-accent-400 to-accent-600">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"
|
||||||
|
fill="currentColor"
|
||||||
|
className="text-dark-950/20"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
className="text-dark-950"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7 8h4M7 11h10M7 14h10M7 17h6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="text-dark-950"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="14"
|
||||||
|
y="7"
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
rx="0.5"
|
||||||
|
fill="currentColor"
|
||||||
|
className="text-dark-950"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] font-bold uppercase tracking-[0.18em] text-dark-500">
|
||||||
|
{t('news.title')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<FilterTabs categories={categories} active={filter} onChange={handleFilterChange} />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{featured && <FeaturedCard item={featured} onClick={handleFeaturedClick} />}
|
||||||
|
{regular.map((item, i) => (
|
||||||
|
<NewsCardWrapper key={item.id} item={item} index={i} onCardClick={handleCardClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Load more */}
|
||||||
|
{!isLoading && items.length < total && (
|
||||||
|
<motion.div
|
||||||
|
variants={fadeSlideUp}
|
||||||
|
custom={6}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="mt-10 text-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
className="min-h-[44px] rounded-xl border border-dark-700 bg-transparent px-8 py-3 text-[13px] font-semibold tracking-wide text-dark-400 transition-all duration-300 hover:border-accent-400/30 hover:text-accent-400"
|
||||||
|
>
|
||||||
|
{t('news.loadMore')}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/lib/tiptap-video.ts
Normal file
30
src/lib/tiptap-video.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TipTap extension for inline <video> elements.
|
||||||
|
*
|
||||||
|
* Without this extension, TipTap does not recognize <video> tags and
|
||||||
|
* serializes them as escaped text when calling editor.getHTML().
|
||||||
|
*/
|
||||||
|
export const VideoExtension = Node.create({
|
||||||
|
name: 'video',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: { default: null },
|
||||||
|
controls: { default: '' },
|
||||||
|
class: { default: null },
|
||||||
|
preload: { default: 'metadata' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'video' }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['video', mergeAttributes(HTMLAttributes)];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1110,7 +1110,8 @@
|
|||||||
"auditLog": "Audit Log",
|
"auditLog": "Audit Log",
|
||||||
"salesStats": "Sales Statistics",
|
"salesStats": "Sales Statistics",
|
||||||
"landings": "Landings",
|
"landings": "Landings",
|
||||||
"referralNetwork": "Referral Network"
|
"referralNetwork": "Referral Network",
|
||||||
|
"news": "News"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
@@ -1145,7 +1146,8 @@
|
|||||||
"auditLogDesc": "Review system activity and access history",
|
"auditLogDesc": "Review system activity and access history",
|
||||||
"salesStatsDesc": "Sales analytics and trends",
|
"salesStatsDesc": "Sales analytics and trends",
|
||||||
"landingsDesc": "Quick purchase landing pages",
|
"landingsDesc": "Quick purchase landing pages",
|
||||||
"referralNetworkDesc": "Referral graph and campaign visualization"
|
"referralNetworkDesc": "Referral graph and campaign visualization",
|
||||||
|
"newsDesc": "Manage news articles and publications"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "Sales Statistics",
|
"title": "Sales Statistics",
|
||||||
@@ -4473,5 +4475,76 @@
|
|||||||
"shareModalActivateViaCabinet": "Or via website:",
|
"shareModalActivateViaCabinet": "Or via website:",
|
||||||
"copyMessage": "Copy message",
|
"copyMessage": "Copy message",
|
||||||
"shareToastCopied": "Message copied"
|
"shareToastCopied": "Message copied"
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "News & Updates",
|
||||||
|
"filterAll": "All",
|
||||||
|
"readMore": "Read more",
|
||||||
|
"readTime": "min read",
|
||||||
|
"loadMore": "Load more",
|
||||||
|
"noNews": "No news yet",
|
||||||
|
"backToHome": "Back",
|
||||||
|
"views": "views",
|
||||||
|
"admin": {
|
||||||
|
"title": "News Management",
|
||||||
|
"create": "Create Article",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"confirmDelete": "Delete this article?",
|
||||||
|
"published": "Published",
|
||||||
|
"draft": "Draft",
|
||||||
|
"featured": "Featured",
|
||||||
|
"titleLabel": "Title",
|
||||||
|
"slugLabel": "URL Slug",
|
||||||
|
"categoryLabel": "Category",
|
||||||
|
"categoryColorLabel": "Category Color",
|
||||||
|
"tagLabel": "Tag",
|
||||||
|
"excerptLabel": "Short Description",
|
||||||
|
"contentLabel": "Content",
|
||||||
|
"imageLabel": "Featured Image",
|
||||||
|
"readTimeLabel": "Read Time (min)",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"saveError": "Failed to save article. Please try again.",
|
||||||
|
"saved": "Article saved",
|
||||||
|
"deleted": "Article deleted",
|
||||||
|
"selectColor": "Select color {{color}}",
|
||||||
|
"uploading": "Uploading...",
|
||||||
|
"dropMedia": "Drop image or video here",
|
||||||
|
"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",
|
||||||
|
"delete": "Delete {{name}}"
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"bold": "Bold",
|
||||||
|
"italic": "Italic",
|
||||||
|
"underline": "Underline",
|
||||||
|
"strikethrough": "Strikethrough",
|
||||||
|
"heading1": "Heading 1",
|
||||||
|
"heading2": "Heading 2",
|
||||||
|
"heading3": "Heading 3",
|
||||||
|
"bulletList": "Bullet List",
|
||||||
|
"orderedList": "Ordered List",
|
||||||
|
"blockquote": "Blockquote",
|
||||||
|
"codeBlock": "Code Block",
|
||||||
|
"alignLeft": "Align Left",
|
||||||
|
"alignCenter": "Align Center",
|
||||||
|
"highlight": "Highlight",
|
||||||
|
"link": "Link",
|
||||||
|
"image": "Image",
|
||||||
|
"imageUrlPrompt": "Image URL:",
|
||||||
|
"linkUrlPrompt": "URL:"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -944,7 +944,8 @@
|
|||||||
"auditLog": "گزارش بازرسی",
|
"auditLog": "گزارش بازرسی",
|
||||||
"salesStats": "آمار فروش",
|
"salesStats": "آمار فروش",
|
||||||
"landings": "صفحات فرود",
|
"landings": "صفحات فرود",
|
||||||
"referralNetwork": "شبکه ارجاع"
|
"referralNetwork": "شبکه ارجاع",
|
||||||
|
"news": "اخبار"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "پنل مدیریت",
|
"title": "پنل مدیریت",
|
||||||
@@ -979,7 +980,8 @@
|
|||||||
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
||||||
"salesStatsDesc": "تحلیل فروش و روندها",
|
"salesStatsDesc": "تحلیل فروش و روندها",
|
||||||
"landingsDesc": "صفحات خرید سریع",
|
"landingsDesc": "صفحات خرید سریع",
|
||||||
"referralNetworkDesc": "نمایش بصری گراف ارجاعات و کمپینها"
|
"referralNetworkDesc": "نمایش بصری گراف ارجاعات و کمپینها",
|
||||||
|
"newsDesc": "مدیریت مقالات خبری و انتشارات"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "آمار فروش",
|
"title": "آمار فروش",
|
||||||
@@ -3921,5 +3923,65 @@
|
|||||||
"warning": {
|
"warning": {
|
||||||
"telegram_unresolvable": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند."
|
"telegram_unresolvable": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "اخبار و بهروزرسانیها",
|
||||||
|
"filterAll": "همه",
|
||||||
|
"readMore": "ادامه مطلب",
|
||||||
|
"readTime": "دقیقه مطالعه",
|
||||||
|
"loadMore": "بارگذاری بیشتر",
|
||||||
|
"noNews": "هنوز خبری نیست",
|
||||||
|
"backToHome": "بازگشت",
|
||||||
|
"views": "بازدید",
|
||||||
|
"admin": {
|
||||||
|
"title": "مدیریت اخبار",
|
||||||
|
"create": "ایجاد مقاله",
|
||||||
|
"edit": "ویرایش",
|
||||||
|
"delete": "حذف",
|
||||||
|
"confirmDelete": "این مقاله حذف شود؟",
|
||||||
|
"published": "منتشر شده",
|
||||||
|
"draft": "پیشنویس",
|
||||||
|
"featured": "ویژه",
|
||||||
|
"titleLabel": "عنوان",
|
||||||
|
"slugLabel": "شناسه URL",
|
||||||
|
"categoryLabel": "دستهبندی",
|
||||||
|
"categoryColorLabel": "رنگ دستهبندی",
|
||||||
|
"tagLabel": "برچسب",
|
||||||
|
"excerptLabel": "توضیح کوتاه",
|
||||||
|
"contentLabel": "محتوا",
|
||||||
|
"imageLabel": "تصویر ویژه",
|
||||||
|
"readTimeLabel": "زمان مطالعه (دقیقه)",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"saving": "در حال ذخیره...",
|
||||||
|
"saveError": "ذخیره مقاله ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||||
|
"saved": "مقاله ذخیره شد",
|
||||||
|
"deleted": "مقاله حذف شد",
|
||||||
|
"selectColor": "انتخاب رنگ {{color}}",
|
||||||
|
"uploading": "در حال آپلود...",
|
||||||
|
"dropMedia": "تصویر یا ویدیو را اینجا رها کنید",
|
||||||
|
"uploadError": "آپلود ناموفق بود",
|
||||||
|
"fileTooLarge": "فایل خیلی بزرگ است",
|
||||||
|
"uploadFeaturedImage": "آپلود تصویر",
|
||||||
|
"toolbar": {
|
||||||
|
"bold": "پررنگ",
|
||||||
|
"italic": "کج",
|
||||||
|
"underline": "زیرخط",
|
||||||
|
"strikethrough": "خطخورده",
|
||||||
|
"heading1": "عنوان ۱",
|
||||||
|
"heading2": "عنوان ۲",
|
||||||
|
"heading3": "عنوان ۳",
|
||||||
|
"bulletList": "لیست نقطهای",
|
||||||
|
"orderedList": "لیست شمارهدار",
|
||||||
|
"blockquote": "نقل قول",
|
||||||
|
"codeBlock": "بلوک کد",
|
||||||
|
"alignLeft": "چپچین",
|
||||||
|
"alignCenter": "وسطچین",
|
||||||
|
"highlight": "برجسته",
|
||||||
|
"link": "پیوند",
|
||||||
|
"image": "تصویر",
|
||||||
|
"imageUrlPrompt": "آدرس تصویر:",
|
||||||
|
"linkUrlPrompt": "آدرس پیوند:"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1131,7 +1131,8 @@
|
|||||||
"auditLog": "Журнал аудита",
|
"auditLog": "Журнал аудита",
|
||||||
"salesStats": "Статистика продаж",
|
"salesStats": "Статистика продаж",
|
||||||
"landings": "Лендинги",
|
"landings": "Лендинги",
|
||||||
"referralNetwork": "Реферальная сеть"
|
"referralNetwork": "Реферальная сеть",
|
||||||
|
"news": "Новости"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
@@ -1166,7 +1167,8 @@
|
|||||||
"auditLogDesc": "Просмотр активности системы и истории доступа",
|
"auditLogDesc": "Просмотр активности системы и истории доступа",
|
||||||
"salesStatsDesc": "Аналитика продаж и тренды",
|
"salesStatsDesc": "Аналитика продаж и тренды",
|
||||||
"landingsDesc": "Страницы быстрой покупки",
|
"landingsDesc": "Страницы быстрой покупки",
|
||||||
"referralNetworkDesc": "Визуализация графа рефералов и РК"
|
"referralNetworkDesc": "Визуализация графа рефералов и РК",
|
||||||
|
"newsDesc": "Управление новостями и статьями"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "Статистика продаж",
|
"title": "Статистика продаж",
|
||||||
@@ -5040,5 +5042,76 @@
|
|||||||
"shareModalActivateViaCabinet": "Или через сайт:",
|
"shareModalActivateViaCabinet": "Или через сайт:",
|
||||||
"copyMessage": "Скопировать сообщение",
|
"copyMessage": "Скопировать сообщение",
|
||||||
"shareToastCopied": "Сообщение скопировано"
|
"shareToastCopied": "Сообщение скопировано"
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "Новости и обновления",
|
||||||
|
"filterAll": "Все",
|
||||||
|
"readMore": "Читать подробнее",
|
||||||
|
"readTime": "мин чтения",
|
||||||
|
"loadMore": "Загрузить ещё",
|
||||||
|
"noNews": "Пока нет новостей",
|
||||||
|
"backToHome": "Назад",
|
||||||
|
"views": "просмотров",
|
||||||
|
"admin": {
|
||||||
|
"title": "Управление новостями",
|
||||||
|
"create": "Создать новость",
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"confirmDelete": "Удалить эту новость?",
|
||||||
|
"published": "Опубликовано",
|
||||||
|
"draft": "Черновик",
|
||||||
|
"featured": "Избранное",
|
||||||
|
"titleLabel": "Заголовок",
|
||||||
|
"slugLabel": "URL-адрес",
|
||||||
|
"categoryLabel": "Категория",
|
||||||
|
"categoryColorLabel": "Цвет категории",
|
||||||
|
"tagLabel": "Тег",
|
||||||
|
"excerptLabel": "Краткое описание",
|
||||||
|
"contentLabel": "Содержание",
|
||||||
|
"imageLabel": "Изображение",
|
||||||
|
"readTimeLabel": "Время чтения (мин)",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"saveError": "Не удалось сохранить статью. Попробуйте ещё раз.",
|
||||||
|
"saved": "Новость сохранена",
|
||||||
|
"deleted": "Новость удалена",
|
||||||
|
"selectColor": "Выбрать цвет {{color}}",
|
||||||
|
"uploading": "Загрузка...",
|
||||||
|
"dropMedia": "Перетащите изображение или видео сюда",
|
||||||
|
"uploadError": "Ошибка загрузки",
|
||||||
|
"fileTooLarge": "Файл слишком большой",
|
||||||
|
"uploadFeaturedImage": "Загрузить изображение",
|
||||||
|
"combobox": {
|
||||||
|
"selectCategory": "Выберите категорию",
|
||||||
|
"selectTag": "Выберите тег",
|
||||||
|
"create": "Создать",
|
||||||
|
"createNew": "Создать новый",
|
||||||
|
"searchOrCreate": "Поиск или создание...",
|
||||||
|
"noItems": "Ничего не найдено",
|
||||||
|
"placeholder": "Выберите...",
|
||||||
|
"clear": "Очистить",
|
||||||
|
"delete": "Удалить {{name}}"
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"bold": "Жирный",
|
||||||
|
"italic": "Курсив",
|
||||||
|
"underline": "Подчёркнутый",
|
||||||
|
"strikethrough": "Зачёркнутый",
|
||||||
|
"heading1": "Заголовок 1",
|
||||||
|
"heading2": "Заголовок 2",
|
||||||
|
"heading3": "Заголовок 3",
|
||||||
|
"bulletList": "Маркированный список",
|
||||||
|
"orderedList": "Нумерованный список",
|
||||||
|
"blockquote": "Цитата",
|
||||||
|
"codeBlock": "Блок кода",
|
||||||
|
"alignLeft": "По левому краю",
|
||||||
|
"alignCenter": "По центру",
|
||||||
|
"highlight": "Выделение",
|
||||||
|
"link": "Ссылка",
|
||||||
|
"image": "Изображение",
|
||||||
|
"imageUrlPrompt": "URL изображения:",
|
||||||
|
"linkUrlPrompt": "URL ссылки:"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -944,7 +944,8 @@
|
|||||||
"auditLog": "审计日志",
|
"auditLog": "审计日志",
|
||||||
"salesStats": "销售统计",
|
"salesStats": "销售统计",
|
||||||
"landings": "落地页",
|
"landings": "落地页",
|
||||||
"referralNetwork": "推荐网络"
|
"referralNetwork": "推荐网络",
|
||||||
|
"news": "新闻"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "管理面板",
|
"title": "管理面板",
|
||||||
@@ -979,7 +980,8 @@
|
|||||||
"auditLogDesc": "查看系统活动和访问历史",
|
"auditLogDesc": "查看系统活动和访问历史",
|
||||||
"salesStatsDesc": "销售分析与趋势",
|
"salesStatsDesc": "销售分析与趋势",
|
||||||
"landingsDesc": "快速购买落地页",
|
"landingsDesc": "快速购买落地页",
|
||||||
"referralNetworkDesc": "推荐图表和广告活动可视化"
|
"referralNetworkDesc": "推荐图表和广告活动可视化",
|
||||||
|
"newsDesc": "管理新闻文章和出版物"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "销售统计",
|
"title": "销售统计",
|
||||||
@@ -3920,5 +3922,65 @@
|
|||||||
"warning": {
|
"warning": {
|
||||||
"telegram_unresolvable": "无法验证此 Telegram 用户名。收件人可能不会收到礼物通知。"
|
"telegram_unresolvable": "无法验证此 Telegram 用户名。收件人可能不会收到礼物通知。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"title": "新闻与更新",
|
||||||
|
"filterAll": "全部",
|
||||||
|
"readMore": "阅读更多",
|
||||||
|
"readTime": "分钟阅读",
|
||||||
|
"loadMore": "加载更多",
|
||||||
|
"noNews": "暂无新闻",
|
||||||
|
"backToHome": "返回",
|
||||||
|
"views": "浏览量",
|
||||||
|
"admin": {
|
||||||
|
"title": "新闻管理",
|
||||||
|
"create": "创建文章",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"confirmDelete": "删除这篇文章?",
|
||||||
|
"published": "已发布",
|
||||||
|
"draft": "草稿",
|
||||||
|
"featured": "精选",
|
||||||
|
"titleLabel": "标题",
|
||||||
|
"slugLabel": "URL标识",
|
||||||
|
"categoryLabel": "分类",
|
||||||
|
"categoryColorLabel": "分类颜色",
|
||||||
|
"tagLabel": "标签",
|
||||||
|
"excerptLabel": "简短描述",
|
||||||
|
"contentLabel": "内容",
|
||||||
|
"imageLabel": "特色图片",
|
||||||
|
"readTimeLabel": "阅读时间(分钟)",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"saveError": "保存文章失败,请重试。",
|
||||||
|
"saved": "文章已保存",
|
||||||
|
"deleted": "文章已删除",
|
||||||
|
"selectColor": "选择颜色 {{color}}",
|
||||||
|
"uploading": "上传中...",
|
||||||
|
"dropMedia": "将图片或视频拖到这里",
|
||||||
|
"uploadError": "上传失败",
|
||||||
|
"fileTooLarge": "文件太大",
|
||||||
|
"uploadFeaturedImage": "上传图片",
|
||||||
|
"toolbar": {
|
||||||
|
"bold": "粗体",
|
||||||
|
"italic": "斜体",
|
||||||
|
"underline": "下划线",
|
||||||
|
"strikethrough": "删除线",
|
||||||
|
"heading1": "标题 1",
|
||||||
|
"heading2": "标题 2",
|
||||||
|
"heading3": "标题 3",
|
||||||
|
"bulletList": "无序列表",
|
||||||
|
"orderedList": "有序列表",
|
||||||
|
"blockquote": "引用",
|
||||||
|
"codeBlock": "代码块",
|
||||||
|
"alignLeft": "左对齐",
|
||||||
|
"alignCenter": "居中",
|
||||||
|
"highlight": "高亮",
|
||||||
|
"link": "链接",
|
||||||
|
"image": "图片",
|
||||||
|
"imageUrlPrompt": "图片URL:",
|
||||||
|
"linkUrlPrompt": "URL:"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
446
src/pages/AdminNews.tsx
Normal file
446
src/pages/AdminNews.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { useState, useCallback, memo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { newsApi } from '../api/news';
|
||||||
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
import { Toggle } from '../components/admin/Toggle';
|
||||||
|
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
||||||
|
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
||||||
|
import type { NewsListItem } from '../types/news';
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const PlusIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RefreshIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PencilIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrashIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const StarIcon = ({ filled }: { filled: boolean }) => (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill={filled ? 'currentColor' : 'none'}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NewsIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Security: hex color validation to prevent CSS injection ---
|
||||||
|
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||||
|
function safeColor(color: string | null | undefined, fallback = '#888888'): string {
|
||||||
|
if (!color || !HEX_COLOR_RE.test(color)) return fallback;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// memo: prevents rows from re-rendering when sibling rows or parent state change
|
||||||
|
const ArticleRow = memo(function ArticleRow({
|
||||||
|
article,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onTogglePublish,
|
||||||
|
onToggleFeatured,
|
||||||
|
}: {
|
||||||
|
article: NewsListItem;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onTogglePublish: () => void;
|
||||||
|
onToggleFeatured: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const color = safeColor(article.category_color);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-all hover:border-dark-600">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded px-2 py-0.5 font-mono text-[10px] font-bold uppercase"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
background: `${color}15`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="h-1 w-1 rounded-full" style={{ background: color }} />
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||||
|
article.is_published
|
||||||
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-dark-500/20 text-dark-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{article.is_published ? t('news.admin.published') : t('news.admin.draft')}
|
||||||
|
</span>
|
||||||
|
{article.is_featured && (
|
||||||
|
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-[10px] font-medium text-warning-400">
|
||||||
|
{t('news.admin.featured')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-dark-500">#{article.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="truncate text-sm font-medium text-dark-100">{article.title}</p>
|
||||||
|
|
||||||
|
{article.excerpt && (
|
||||||
|
<p className="mt-1 truncate text-xs text-dark-400">{article.excerpt}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-4 text-xs text-dark-500">
|
||||||
|
<span>
|
||||||
|
{article.published_at ? new Date(article.published_at).toLocaleDateString() : '-'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{article.read_time_minutes} {t('news.readTime')}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{article.views_count} {t('news.views')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleFeatured}
|
||||||
|
className={`min-h-[44px] min-w-[44px] rounded-lg p-2.5 transition-colors ${
|
||||||
|
article.is_featured
|
||||||
|
? 'text-warning-400 hover:bg-warning-500/10'
|
||||||
|
: 'text-dark-500 hover:bg-dark-700 hover:text-dark-300'
|
||||||
|
}`}
|
||||||
|
title={t('news.admin.featured')}
|
||||||
|
aria-label={t('news.admin.featured')}
|
||||||
|
>
|
||||||
|
<StarIcon filled={article.is_featured} />
|
||||||
|
</button>
|
||||||
|
<Toggle
|
||||||
|
checked={article.is_published}
|
||||||
|
onChange={onTogglePublish}
|
||||||
|
aria-label={t('news.admin.published')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||||
|
title={t('news.admin.edit')}
|
||||||
|
aria-label={t('news.admin.edit')}
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-error-500/10 hover:text-error-400"
|
||||||
|
title={t('news.admin.delete')}
|
||||||
|
aria-label={t('news.admin.delete')}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thin wrapper that provides stable per-row callbacks so ArticleRow (memo'd)
|
||||||
|
// does not re-render on every parent render due to new inline lambdas.
|
||||||
|
interface ArticleRowWrapperProps {
|
||||||
|
article: NewsListItem;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
onTogglePublish: (id: number) => void;
|
||||||
|
onToggleFeatured: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArticleRowWrapper = memo(function ArticleRowWrapper({
|
||||||
|
article,
|
||||||
|
onNavigate,
|
||||||
|
onDelete,
|
||||||
|
onTogglePublish,
|
||||||
|
onToggleFeatured,
|
||||||
|
}: ArticleRowWrapperProps) {
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
() => onNavigate(`/admin/news/${article.id}/edit`),
|
||||||
|
[article.id, onNavigate],
|
||||||
|
);
|
||||||
|
const handleDelete = useCallback(() => onDelete(article.id), [article.id, onDelete]);
|
||||||
|
const handleTogglePublish = useCallback(
|
||||||
|
() => onTogglePublish(article.id),
|
||||||
|
[article.id, onTogglePublish],
|
||||||
|
);
|
||||||
|
const handleToggleFeatured = useCallback(
|
||||||
|
() => onToggleFeatured(article.id),
|
||||||
|
[article.id, onToggleFeatured],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ArticleRow
|
||||||
|
article={article}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onTogglePublish={handleTogglePublish}
|
||||||
|
onToggleFeatured={handleToggleFeatured}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AdminNews() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const haptic = useHapticFeedback();
|
||||||
|
const confirm = useDestructiveConfirm();
|
||||||
|
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['admin', 'news', 'list', page],
|
||||||
|
queryFn: () => newsApi.getAdminNews({ limit, offset: page * limit }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const articles = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: newsApi.deleteArticle,
|
||||||
|
onSuccess: () => {
|
||||||
|
haptic.success();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'news'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['news'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const togglePublishMutation = useMutation({
|
||||||
|
mutationFn: newsApi.togglePublish,
|
||||||
|
onSuccess: () => {
|
||||||
|
haptic.success();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'news'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['news'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleFeaturedMutation = useMutation({
|
||||||
|
mutationFn: newsApi.toggleFeatured,
|
||||||
|
onSuccess: () => {
|
||||||
|
haptic.success();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'news'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['news'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stable callbacks passed to ArticleRowWrapper — reference equality is
|
||||||
|
// required for memo to prevent unnecessary re-renders of each row.
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
const confirmed = await confirm(t('news.admin.confirmDelete'));
|
||||||
|
if (confirmed) {
|
||||||
|
deleteMutation.mutate(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[confirm, deleteMutation, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTogglePublish = useCallback(
|
||||||
|
(id: number) => {
|
||||||
|
togglePublishMutation.mutate(id);
|
||||||
|
},
|
||||||
|
[togglePublishMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleFeatured = useCallback(
|
||||||
|
(id: number) => {
|
||||||
|
toggleFeaturedMutation.mutate(id);
|
||||||
|
},
|
||||||
|
[toggleFeaturedMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AdminBackButton />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">{t('news.admin.title')}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="min-h-[44px] min-w-[44px] rounded-lg bg-dark-800 p-2.5 text-dark-400 transition-colors hover:text-dark-100"
|
||||||
|
aria-label={t('common.refresh')}
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
haptic.buttonPress();
|
||||||
|
navigate('/admin/news/create');
|
||||||
|
}}
|
||||||
|
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600"
|
||||||
|
aria-label={t('news.admin.create')}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
<span className="hidden sm:inline">{t('news.admin.create')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Articles list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="animate-pulse rounded-xl border border-dark-700 bg-dark-800/50 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-4 w-16 rounded bg-dark-700" />
|
||||||
|
<div className="h-4 w-12 rounded bg-dark-700" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-3/4 rounded bg-dark-700" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-dark-700" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-dark-700" />
|
||||||
|
<div className="h-8 w-14 rounded-full bg-dark-700" />
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-dark-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : articles.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
|
||||||
|
<NewsIcon />
|
||||||
|
<p className="mt-2">{t('news.noNews')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{articles.map((article) => (
|
||||||
|
<ArticleRowWrapper
|
||||||
|
key={article.id}
|
||||||
|
article={article}
|
||||||
|
onNavigate={navigate}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onTogglePublish={handleTogglePublish}
|
||||||
|
onToggleFeatured={handleToggleFeatured}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="min-h-[44px] rounded-lg bg-dark-700 px-4 py-2.5 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.back')}
|
||||||
|
</button>
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="min-h-[44px] rounded-lg bg-dark-700 px-4 py-2.5 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.next')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
945
src/pages/AdminNewsCreate.tsx
Normal file
945
src/pages/AdminNewsCreate.tsx
Normal file
@@ -0,0 +1,945 @@
|
|||||||
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import ImageExtension from '@tiptap/extension-image';
|
||||||
|
import LinkExtension from '@tiptap/extension-link';
|
||||||
|
import PlaceholderExtension from '@tiptap/extension-placeholder';
|
||||||
|
import TextAlignExtension from '@tiptap/extension-text-align';
|
||||||
|
import UnderlineExtension from '@tiptap/extension-underline';
|
||||||
|
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 { NewsCategory, NewsTag, NewsCreateRequest } from '../types/news';
|
||||||
|
|
||||||
|
// --- Icons ---
|
||||||
|
const BoldIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItalicIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UnderlineIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const StrikeIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const H1Icon = () => <span className="text-xs font-bold">H1</span>;
|
||||||
|
|
||||||
|
const H2Icon = () => <span className="text-xs font-bold">H2</span>;
|
||||||
|
|
||||||
|
const H3Icon = () => <span className="text-xs font-bold">H3</span>;
|
||||||
|
|
||||||
|
const ListBulletIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ListOrderedIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const QuoteIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CodeBlockIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ImageIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LinkIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlignLeftIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlignCenterIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const HighlightIcon = () => (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16.56 3.44a1.5 1.5 0 012.12 0l1.88 1.88a1.5 1.5 0 010 2.12L8.44 19.56 3 21l1.44-5.44L16.56 3.44z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UploadIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Toolbar Button ---
|
||||||
|
interface ToolbarButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
isActive?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[44px] min-w-[44px] rounded p-2.5 transition-colors',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: 'text-dark-400 hover:bg-dark-700 hover:text-dark-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Security: URL scheme validation ---
|
||||||
|
function isSafeUrl(url: string | null | undefined): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Slug utility ---
|
||||||
|
function generateSlug(title: string): string {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s\-а-яёА-ЯЁ]/g, '')
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.substring(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminNewsCreate() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id: rawId } = useParams<{ id: string }>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const haptic = useHapticFeedback();
|
||||||
|
const articleId = rawId != null ? Number(rawId) : undefined;
|
||||||
|
const isEdit = articleId != null && !Number.isNaN(articleId);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<NewsCategory | null>(null);
|
||||||
|
const [selectedTag, setSelectedTag] = useState<NewsTag | null>(null);
|
||||||
|
const [excerpt, setExcerpt] = useState('');
|
||||||
|
const [featuredImageUrl, setFeaturedImageUrl] = useState('');
|
||||||
|
const [readTimeMinutes, setReadTimeMinutes] = useState(3);
|
||||||
|
const [isPublished, setIsPublished] = useState(false);
|
||||||
|
const [isFeatured, setIsFeatured] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Media upload state
|
||||||
|
const mediaInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const featuredImageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploadCount, setUploadCount] = useState(0);
|
||||||
|
const isUploading = uploadCount > 0;
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isFeaturedImageUploading, setIsFeaturedImageUploading] = useState(false);
|
||||||
|
const activeUploadsRef = useRef(new Set<AbortController>());
|
||||||
|
|
||||||
|
// Ref to hold the media upload handler — allows editorProps.handlePaste to
|
||||||
|
// reference it without a circular dependency with useEditor.
|
||||||
|
const handleMediaUploadRef = useRef<(file: File) => void>(() => {});
|
||||||
|
|
||||||
|
// TipTap editor — extensions are memoized with NO deps so the editor is
|
||||||
|
// never destroyed/recreated on re-renders. The PlaceholderExtension
|
||||||
|
// placeholder reads the translation at mount time only, which is acceptable
|
||||||
|
// since locale changes at runtime are rare and the editor retains content.
|
||||||
|
// Using [t] as the dependency would destroy the editor on every locale change.
|
||||||
|
const extensions = useMemo(
|
||||||
|
() => [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: { levels: [1, 2, 3] },
|
||||||
|
link: false,
|
||||||
|
underline: false,
|
||||||
|
}),
|
||||||
|
UnderlineExtension,
|
||||||
|
LinkExtension.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: { class: 'link' },
|
||||||
|
}),
|
||||||
|
ImageExtension.configure({
|
||||||
|
HTMLAttributes: { class: 'rounded-xl max-w-full' },
|
||||||
|
}),
|
||||||
|
PlaceholderExtension.configure({
|
||||||
|
placeholder: t('news.admin.contentLabel'),
|
||||||
|
}),
|
||||||
|
TextAlignExtension.configure({
|
||||||
|
types: ['heading', 'paragraph'],
|
||||||
|
}),
|
||||||
|
HighlightExtension,
|
||||||
|
VideoExtension,
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose max-w-none min-h-[300px] p-4 focus:outline-none',
|
||||||
|
},
|
||||||
|
handlePaste: (_view, event) => {
|
||||||
|
const items = event.clipboardData?.items;
|
||||||
|
if (!items) return false;
|
||||||
|
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/') || item.type.startsWith('video/')) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
handleMediaUploadRef.current(file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
handleDrop: (_view, event) => {
|
||||||
|
const file = event.dataTransfer?.files[0];
|
||||||
|
if (file && (file.type.startsWith('image/') || file.type.startsWith('video/'))) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleMediaUploadRef.current(file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Media upload handlers ---
|
||||||
|
|
||||||
|
const handleMediaUpload = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
const isVideo = file.type.startsWith('video/');
|
||||||
|
if (!isImage && !isVideo) return;
|
||||||
|
|
||||||
|
const maxSize = isImage ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(t('news.admin.fileTooLarge'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeUploadsRef.current.add(controller);
|
||||||
|
|
||||||
|
setUploadCount((c) => c + 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await newsApi.uploadMedia(file, controller.signal);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
if (!isSafeUrl(result.url)) {
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(t('news.admin.uploadError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.media_type === 'image') {
|
||||||
|
editor.chain().focus().setImage({ src: result.url, alt: file.name }).run();
|
||||||
|
} else {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: 'video',
|
||||||
|
attrs: { src: result.url, class: 'w-full rounded-xl max-h-96' },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
haptic.success();
|
||||||
|
} catch {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(t('news.admin.uploadError'));
|
||||||
|
} finally {
|
||||||
|
activeUploadsRef.current.delete(controller);
|
||||||
|
setUploadCount((c) => c - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor, haptic, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the ref in sync so editorProps handlers can access the latest callback
|
||||||
|
useEffect(() => {
|
||||||
|
handleMediaUploadRef.current = handleMediaUpload;
|
||||||
|
}, [handleMediaUpload]);
|
||||||
|
|
||||||
|
// Cancel all in-flight uploads on unmount to prevent state updates on destroyed editor
|
||||||
|
useEffect(() => {
|
||||||
|
const uploads = activeUploadsRef.current;
|
||||||
|
return () => {
|
||||||
|
for (const controller of uploads) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
uploads.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleMediaUpload(file);
|
||||||
|
e.target.value = '';
|
||||||
|
},
|
||||||
|
[handleMediaUpload],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFeaturedImageUpload = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = '';
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) return;
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(t('news.admin.fileTooLarge'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeUploadsRef.current.add(controller);
|
||||||
|
setIsFeaturedImageUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await newsApi.uploadMedia(file, controller.signal);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setFeaturedImageUrl(result.url);
|
||||||
|
haptic.success();
|
||||||
|
} catch {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(t('news.admin.uploadError'));
|
||||||
|
} finally {
|
||||||
|
activeUploadsRef.current.delete(controller);
|
||||||
|
setIsFeaturedImageUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[haptic, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditorDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditorDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditorDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
// TipTap's handleDrop already handled media files dropped on the editor content
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleMediaUpload(file);
|
||||||
|
},
|
||||||
|
[handleMediaUpload],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch categories and tags for combobox selectors
|
||||||
|
const { data: categoriesData } = useQuery({
|
||||||
|
queryKey: ['admin', 'news', 'categories'],
|
||||||
|
queryFn: () => newsApi.getCategories(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
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 });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin', 'news', 'categories'] });
|
||||||
|
return cat;
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateTag = useCallback(
|
||||||
|
async (name: string, color: string) => {
|
||||||
|
const tag = await newsApi.createTag({ name, color });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin', 'news', 'tags'] });
|
||||||
|
return tag;
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteCategory = useCallback(
|
||||||
|
async (item: { id: number }) => {
|
||||||
|
await newsApi.deleteCategory(item.id);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin', 'news', 'categories'] });
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteTag = useCallback(
|
||||||
|
async (item: { id: number }) => {
|
||||||
|
await newsApi.deleteTag(item.id);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin', 'news', 'tags'] });
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch article for editing
|
||||||
|
const { data: articleData, isLoading: isLoadingArticle } = useQuery({
|
||||||
|
queryKey: ['admin', 'news', 'article', articleId],
|
||||||
|
queryFn: () => {
|
||||||
|
if (articleId == null) throw new Error('Missing article id parameter');
|
||||||
|
return newsApi.getAdminArticle(articleId);
|
||||||
|
},
|
||||||
|
enabled: isEdit,
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate form when article data loads — guard prevents re-populating on editor re-init
|
||||||
|
const editorPopulated = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!articleData) return;
|
||||||
|
setTitle(articleData.title);
|
||||||
|
setSlug(articleData.slug);
|
||||||
|
setSlugManuallyEdited(true);
|
||||||
|
// 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) {
|
||||||
|
const matchedTag = tagsData?.find((t) => t.id === articleData.tag_id);
|
||||||
|
setSelectedTag({
|
||||||
|
id: articleData.tag_id ?? 0,
|
||||||
|
name: articleData.tag,
|
||||||
|
color: matchedTag?.color ?? '#94a3b8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setExcerpt(articleData.excerpt ?? '');
|
||||||
|
setFeaturedImageUrl(articleData.featured_image_url ?? '');
|
||||||
|
setReadTimeMinutes(articleData.read_time_minutes);
|
||||||
|
setIsPublished(articleData.is_published);
|
||||||
|
setIsFeatured(articleData.is_featured);
|
||||||
|
if (editor && articleData.content && !editorPopulated.current) {
|
||||||
|
editor.commands.setContent(articleData.content);
|
||||||
|
editorPopulated.current = true;
|
||||||
|
}
|
||||||
|
}, [articleData, editor, tagsData]);
|
||||||
|
|
||||||
|
// Auto-generate slug from title
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slugManuallyEdited && title) {
|
||||||
|
setSlug(generateSlug(title));
|
||||||
|
}
|
||||||
|
}, [title, slugManuallyEdited]);
|
||||||
|
|
||||||
|
// Save mutation
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (data: NewsCreateRequest) => {
|
||||||
|
if (isEdit && articleId != null) {
|
||||||
|
return newsApi.updateArticle(articleId, data);
|
||||||
|
}
|
||||||
|
return newsApi.createArticle(data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
haptic.success();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'news'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['news'] });
|
||||||
|
navigate('/admin/news');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
haptic.error();
|
||||||
|
setSaveError(error.message || t('news.admin.saveError'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setSaveError(null);
|
||||||
|
if (!title.trim() || !slug.trim() || !selectedCategory) return;
|
||||||
|
|
||||||
|
const content = editor?.getHTML() ?? '';
|
||||||
|
const data: NewsCreateRequest = {
|
||||||
|
title: title.trim(),
|
||||||
|
slug: slug.trim(),
|
||||||
|
content,
|
||||||
|
excerpt: excerpt.trim() || null,
|
||||||
|
category: selectedCategory.name,
|
||||||
|
category_color: selectedCategory.color,
|
||||||
|
category_id: selectedCategory.id !== 0 ? selectedCategory.id : null,
|
||||||
|
tag: selectedTag?.name ?? null,
|
||||||
|
tag_id: selectedTag?.id != null && selectedTag.id !== 0 ? selectedTag.id : null,
|
||||||
|
featured_image_url: isSafeUrl(featuredImageUrl.trim()) ? featuredImageUrl.trim() : null,
|
||||||
|
is_published: isPublished,
|
||||||
|
is_featured: isFeatured,
|
||||||
|
read_time_minutes: readTimeMinutes,
|
||||||
|
};
|
||||||
|
|
||||||
|
haptic.buttonPress();
|
||||||
|
saveMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toolbar actions
|
||||||
|
const addLink = () => {
|
||||||
|
const url = window.prompt(t('news.admin.toolbar.linkUrlPrompt'));
|
||||||
|
if (url && editor) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && isLoadingArticle) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="skeleton h-8 w-48 rounded-lg" />
|
||||||
|
<div className="skeleton h-12 w-full rounded-xl" />
|
||||||
|
<div className="skeleton h-64 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AdminBackButton to="/admin/news" />
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">
|
||||||
|
{isEdit ? t('news.admin.edit') : t('news.admin.create')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? t('news.admin.saving') : t('news.admin.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label className="label">{t('news.admin.titleLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slug */}
|
||||||
|
<div>
|
||||||
|
<label className="label">{t('news.admin.slugLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSlug(e.target.value);
|
||||||
|
setSlugManuallyEdited(true);
|
||||||
|
}}
|
||||||
|
className="input font-mono text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category + Tag row */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="label">{t('news.admin.categoryLabel')}</label>
|
||||||
|
<ColoredItemCombobox
|
||||||
|
items={categoriesData ?? []}
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={setSelectedCategory}
|
||||||
|
onCreateNew={handleCreateCategory}
|
||||||
|
onDelete={handleDeleteCategory}
|
||||||
|
placeholder={t('news.admin.combobox.selectCategory')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">{t('news.admin.tagLabel')}</label>
|
||||||
|
<ColoredItemCombobox
|
||||||
|
items={tagsData ?? []}
|
||||||
|
value={selectedTag}
|
||||||
|
onChange={setSelectedTag}
|
||||||
|
onCreateNew={handleCreateTag}
|
||||||
|
onDelete={handleDeleteTag}
|
||||||
|
placeholder={t('news.admin.combobox.selectTag')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Read time */}
|
||||||
|
<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 max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Excerpt */}
|
||||||
|
<div>
|
||||||
|
<label className="label">{t('news.admin.excerptLabel')}</label>
|
||||||
|
<textarea
|
||||||
|
value={excerpt}
|
||||||
|
onChange={(e) => setExcerpt(e.target.value)}
|
||||||
|
className="input min-h-[80px] resize-y"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured Image URL */}
|
||||||
|
<div>
|
||||||
|
<label className="label">{t('news.admin.imageLabel')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={featuredImageUrl}
|
||||||
|
onChange={(e) => setFeaturedImageUrl(e.target.value)}
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => featuredImageInputRef.current?.click()}
|
||||||
|
disabled={isFeaturedImageUploading}
|
||||||
|
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-dark-700 px-4 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label={t('news.admin.uploadFeaturedImage')}
|
||||||
|
>
|
||||||
|
{isFeaturedImageUploading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<UploadIcon />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{t('news.admin.uploadFeaturedImage')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={featuredImageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={handleFeaturedImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{isSafeUrl(featuredImageUrl) && (
|
||||||
|
<div className="mt-2 overflow-hidden rounded-xl">
|
||||||
|
<img
|
||||||
|
src={featuredImageUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-auto max-h-48 w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggles row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Toggle
|
||||||
|
checked={isPublished}
|
||||||
|
onChange={() => setIsPublished((v) => !v)}
|
||||||
|
aria-label={t('news.admin.published')}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-dark-300">{t('news.admin.published')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Toggle
|
||||||
|
checked={isFeatured}
|
||||||
|
onChange={() => setIsFeatured((v) => !v)}
|
||||||
|
aria-label={t('news.admin.featured')}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-dark-300">{t('news.admin.featured')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content editor */}
|
||||||
|
<div>
|
||||||
|
<label className="label">{t('news.admin.contentLabel')}</label>
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-xl border border-dark-700 bg-dark-800/50"
|
||||||
|
onDragOver={handleEditorDragOver}
|
||||||
|
onDragLeave={handleEditorDragLeave}
|
||||||
|
onDrop={handleEditorDrop}
|
||||||
|
>
|
||||||
|
{/* Upload progress overlay */}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-dark-900/60 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
|
{t('news.admin.uploading')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drag overlay */}
|
||||||
|
{isDragging && !isUploading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-accent-400 bg-accent-400/10">
|
||||||
|
<span className="text-sm font-semibold text-accent-400">
|
||||||
|
{t('news.admin.dropMedia')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
{editor && (
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5 border-b border-dark-700 bg-dark-800 p-2">
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
isActive={editor.isActive('bold')}
|
||||||
|
title={t('news.admin.toolbar.bold')}
|
||||||
|
>
|
||||||
|
<BoldIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
isActive={editor.isActive('italic')}
|
||||||
|
title={t('news.admin.toolbar.italic')}
|
||||||
|
>
|
||||||
|
<ItalicIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
|
isActive={editor.isActive('underline')}
|
||||||
|
title={t('news.admin.toolbar.underline')}
|
||||||
|
>
|
||||||
|
<UnderlineIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
isActive={editor.isActive('strike')}
|
||||||
|
title={t('news.admin.toolbar.strikethrough')}
|
||||||
|
>
|
||||||
|
<StrikeIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<div className="mx-1 h-5 w-px bg-dark-700" />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
isActive={editor.isActive('heading', { level: 1 })}
|
||||||
|
title={t('news.admin.toolbar.heading1')}
|
||||||
|
>
|
||||||
|
<H1Icon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
isActive={editor.isActive('heading', { level: 2 })}
|
||||||
|
title={t('news.admin.toolbar.heading2')}
|
||||||
|
>
|
||||||
|
<H2Icon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
isActive={editor.isActive('heading', { level: 3 })}
|
||||||
|
title={t('news.admin.toolbar.heading3')}
|
||||||
|
>
|
||||||
|
<H3Icon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<div className="mx-1 h-5 w-px bg-dark-700" />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
isActive={editor.isActive('bulletList')}
|
||||||
|
title={t('news.admin.toolbar.bulletList')}
|
||||||
|
>
|
||||||
|
<ListBulletIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
isActive={editor.isActive('orderedList')}
|
||||||
|
title={t('news.admin.toolbar.orderedList')}
|
||||||
|
>
|
||||||
|
<ListOrderedIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
isActive={editor.isActive('blockquote')}
|
||||||
|
title={t('news.admin.toolbar.blockquote')}
|
||||||
|
>
|
||||||
|
<QuoteIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
isActive={editor.isActive('codeBlock')}
|
||||||
|
title={t('news.admin.toolbar.codeBlock')}
|
||||||
|
>
|
||||||
|
<CodeBlockIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<div className="mx-1 h-5 w-px bg-dark-700" />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||||
|
isActive={editor.isActive({ textAlign: 'left' })}
|
||||||
|
title={t('news.admin.toolbar.alignLeft')}
|
||||||
|
>
|
||||||
|
<AlignLeftIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||||
|
isActive={editor.isActive({ textAlign: 'center' })}
|
||||||
|
title={t('news.admin.toolbar.alignCenter')}
|
||||||
|
>
|
||||||
|
<AlignCenterIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<div className="mx-1 h-5 w-px bg-dark-700" />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
||||||
|
isActive={editor.isActive('highlight')}
|
||||||
|
title={t('news.admin.toolbar.highlight')}
|
||||||
|
>
|
||||||
|
<HighlightIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
|
||||||
|
<LinkIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => mediaInputRef.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
title={t('news.admin.toolbar.image')}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon />
|
||||||
|
)}
|
||||||
|
</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editor content */}
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden file inputs */}
|
||||||
|
<input
|
||||||
|
ref={mediaInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error feedback */}
|
||||||
|
{saveError && (
|
||||||
|
<div className="rounded-lg border border-error-500/30 bg-error-500/10 px-4 py-3 text-sm text-error-400">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom save button for long forms */}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? t('news.admin.saving') : t('news.admin.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -259,6 +259,16 @@ const NetworkGraphIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const NewspaperIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const ChevronRightIcon = () => (
|
const ChevronRightIcon = () => (
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
@@ -538,6 +548,13 @@ export default function AdminPanel() {
|
|||||||
iconBg: 'bg-error-500/20',
|
iconBg: 'bg-error-500/20',
|
||||||
iconColor: 'text-error-400',
|
iconColor: 'text-error-400',
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
to: '/admin/news',
|
||||||
|
icon: <NewspaperIcon />,
|
||||||
|
title: t('admin.nav.news'),
|
||||||
|
description: t('admin.panel.newsDesc'),
|
||||||
|
permission: 'news:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/admin/campaigns',
|
to: '/admin/campaigns',
|
||||||
icon: <MegaphoneIcon />,
|
icon: <MegaphoneIcon />,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { balanceApi } from '../api/balance';
|
|||||||
import { wheelApi } from '../api/wheel';
|
import { wheelApi } from '../api/wheel';
|
||||||
import Onboarding, { useOnboarding } from '../components/Onboarding';
|
import Onboarding, { useOnboarding } from '../components/Onboarding';
|
||||||
import PromoOffersSection from '../components/PromoOffersSection';
|
import PromoOffersSection from '../components/PromoOffersSection';
|
||||||
|
import NewsSection from '../components/news/NewsSection';
|
||||||
import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActive';
|
import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActive';
|
||||||
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
|
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
|
||||||
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
|
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
|
||||||
@@ -335,6 +336,9 @@ export default function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* News Section */}
|
||||||
|
<NewsSection />
|
||||||
|
|
||||||
{/* Onboarding Tutorial */}
|
{/* Onboarding Tutorial */}
|
||||||
{showOnboarding && (
|
{showOnboarding && (
|
||||||
<Onboarding
|
<Onboarding
|
||||||
|
|||||||
428
src/pages/NewsArticle.tsx
Normal file
428
src/pages/NewsArticle.tsx
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import { newsApi } from '../api/news';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const BackIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ClockIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CalendarIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes HTML content using DOMPurify to prevent XSS attacks.
|
||||||
|
* Uses explicit allowlists (not ADD_TAGS/ADD_ATTR) for defense-in-depth.
|
||||||
|
*
|
||||||
|
* iframe elements are allowed only when their src points to a trusted
|
||||||
|
* video host (YouTube, Vimeo) over HTTPS. All other iframes are stripped.
|
||||||
|
*/
|
||||||
|
const ALLOWED_IFRAME_HOSTS = new Set([
|
||||||
|
'www.youtube.com',
|
||||||
|
'youtube.com',
|
||||||
|
'player.vimeo.com',
|
||||||
|
'www.youtube-nocookie.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that an iframe src URL points to a trusted host over HTTPS.
|
||||||
|
* Returns false for any non-HTTPS, non-allowlisted, or malformed URL.
|
||||||
|
*/
|
||||||
|
function isAllowedIframeSrc(src: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(src);
|
||||||
|
return url.protocol === 'https:' && ALLOWED_IFRAME_HOSTS.has(url.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict allowlist of tags and attributes for article content.
|
||||||
|
* Using ALLOWED_TAGS / ALLOWED_ATTR instead of ADD_TAGS / ADD_ATTR
|
||||||
|
* ensures nothing from the permissive defaults leaks through.
|
||||||
|
*
|
||||||
|
* IMPORTANT: <source> is intentionally NOT in ALLOWED_TAGS — this ensures
|
||||||
|
* <video> elements can only load from their own src attribute (validated by hook).
|
||||||
|
*/
|
||||||
|
const SANITIZE_CONFIG = {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
// Block elements
|
||||||
|
'p',
|
||||||
|
'div',
|
||||||
|
'br',
|
||||||
|
'hr',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'blockquote',
|
||||||
|
'pre',
|
||||||
|
'code',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'th',
|
||||||
|
'td',
|
||||||
|
// Inline elements
|
||||||
|
'a',
|
||||||
|
'strong',
|
||||||
|
'b',
|
||||||
|
'em',
|
||||||
|
'i',
|
||||||
|
'u',
|
||||||
|
's',
|
||||||
|
'del',
|
||||||
|
'ins',
|
||||||
|
'span',
|
||||||
|
'mark',
|
||||||
|
'sub',
|
||||||
|
'sup',
|
||||||
|
'small',
|
||||||
|
'img',
|
||||||
|
'video',
|
||||||
|
// Embed (validated by afterSanitizeAttributes hook)
|
||||||
|
'iframe',
|
||||||
|
'figure',
|
||||||
|
'figcaption',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'href',
|
||||||
|
'target',
|
||||||
|
'rel',
|
||||||
|
'src',
|
||||||
|
'alt',
|
||||||
|
'title',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'loading',
|
||||||
|
'class',
|
||||||
|
'start',
|
||||||
|
'reversed',
|
||||||
|
'type',
|
||||||
|
// video-specific
|
||||||
|
'controls',
|
||||||
|
'preload',
|
||||||
|
// iframe-specific (validated by hook)
|
||||||
|
'frameborder',
|
||||||
|
'allowfullscreen',
|
||||||
|
'allow',
|
||||||
|
'sandbox',
|
||||||
|
// text-align from TipTap editor
|
||||||
|
'style',
|
||||||
|
],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
// Force all links to open in new tab safely
|
||||||
|
ADD_ATTR: ['target'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Isolated DOMPurify instance for article content sanitization.
|
||||||
|
* Using a separate instance avoids polluting the global DOMPurify singleton
|
||||||
|
* that other pages may use with their own hooks/config.
|
||||||
|
*/
|
||||||
|
const articlePurify = DOMPurify(window);
|
||||||
|
|
||||||
|
// Register sanitization hooks once at module init.
|
||||||
|
// All hooks are stateless and idempotent — no need to add/remove per call.
|
||||||
|
|
||||||
|
// Hook: strip iframes with disallowed src
|
||||||
|
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'IFRAME') {
|
||||||
|
const src = node.getAttribute('src') ?? '';
|
||||||
|
if (!isAllowedIframeSrc(src)) {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Force sandbox — allow-scripts + allow-same-origin needed for YouTube/Vimeo
|
||||||
|
// (cross-origin, so sandbox escape via frameElement is not possible)
|
||||||
|
node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation');
|
||||||
|
// Always set allow — restricts permissions even if original had none
|
||||||
|
node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook: validate <video> src — only allow http/https (block javascript:, data:, etc.)
|
||||||
|
// HTTP is permitted because request.base_url behind a reverse proxy returns http://
|
||||||
|
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'VIDEO') {
|
||||||
|
const src = node.getAttribute('src') ?? '';
|
||||||
|
try {
|
||||||
|
const url = new URL(src);
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.setAttribute('controls', '');
|
||||||
|
node.setAttribute('preload', 'metadata');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook: force safe link attributes
|
||||||
|
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'A') {
|
||||||
|
node.setAttribute('target', '_blank');
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook: restrict inline styles to text-align only (used by TipTap)
|
||||||
|
articlePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.hasAttribute('style')) {
|
||||||
|
const style = node.getAttribute('style') ?? '';
|
||||||
|
const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i);
|
||||||
|
if (match) {
|
||||||
|
node.setAttribute('style', `text-align: ${match[1]}`);
|
||||||
|
} else {
|
||||||
|
node.removeAttribute('style');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function sanitizeHtml(html: string): string {
|
||||||
|
return articlePurify.sanitize(html, SANITIZE_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a color string is a safe hex color.
|
||||||
|
* Prevents CSS injection via malicious category_color values.
|
||||||
|
* Returns the color if valid, otherwise returns a safe fallback.
|
||||||
|
*/
|
||||||
|
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||||
|
function safeColor(color: string | null | undefined, fallback = '#888888'): string {
|
||||||
|
if (!color || !HEX_COLOR_RE.test(color)) return fallback;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a URL uses a safe protocol (https or http only).
|
||||||
|
* Prevents javascript:, data:, and other dangerous URI schemes.
|
||||||
|
*/
|
||||||
|
function isSafeUrl(url: string | null | undefined): url is string {
|
||||||
|
if (!url) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsArticlePage() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { capabilities, backButton } = usePlatform();
|
||||||
|
|
||||||
|
// Show Telegram native back button (use ref to avoid effect re-runs)
|
||||||
|
const navigateRef = useRef(navigate);
|
||||||
|
useEffect(() => {
|
||||||
|
navigateRef.current = navigate;
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!capabilities.hasBackButton) return;
|
||||||
|
backButton.show(() => navigateRef.current(-1));
|
||||||
|
return () => backButton.hide();
|
||||||
|
}, [capabilities.hasBackButton, backButton]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: article,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['news', 'article', slug],
|
||||||
|
queryFn: () => {
|
||||||
|
if (!slug) throw new Error('Missing slug parameter');
|
||||||
|
return newsApi.getArticle(slug);
|
||||||
|
},
|
||||||
|
enabled: !!slug,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitizedContent = useMemo(() => (article ? sanitizeHtml(article.content) : ''), [article]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="skeleton h-8 w-32 rounded-lg" />
|
||||||
|
<div className="skeleton h-10 w-3/4 rounded-lg" />
|
||||||
|
<div className="skeleton h-5 w-48 rounded-lg" />
|
||||||
|
<div className="skeleton h-64 w-full rounded-xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="skeleton h-4 w-full rounded" />
|
||||||
|
<div className="skeleton h-4 w-5/6 rounded" />
|
||||||
|
<div className="skeleton h-4 w-4/6 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !article) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
|
aria-label={t('news.backToHome')}
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
|
||||||
|
{t('news.noNews')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Back button */}
|
||||||
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="flex min-h-[44px] items-center gap-2 rounded-xl border border-dark-700 bg-dark-800 px-4 text-sm text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200"
|
||||||
|
aria-label={t('news.backToHome')}
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
<span>{t('news.backToHome')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Article header */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
>
|
||||||
|
{/* Category + tag */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
{(() => {
|
||||||
|
const color = safeColor(article.category_color);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1 font-mono text-[11px] font-bold uppercase tracking-widest"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
background: `${color}15`,
|
||||||
|
border: `1px solid ${color}30`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 animate-pulse rounded-full"
|
||||||
|
style={{
|
||||||
|
background: color,
|
||||||
|
boxShadow: `0 0 8px ${color}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
{article.tag && (
|
||||||
|
<span
|
||||||
|
className="inline-block rounded px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-wider"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}33`,
|
||||||
|
background: `${color}11`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.tag}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="mb-4 text-2xl font-extrabold leading-tight text-dark-50 sm:text-3xl">
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Meta info */}
|
||||||
|
<div className="mb-6 flex flex-wrap items-center gap-4 text-sm text-dark-400">
|
||||||
|
{article.published_at && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 font-mono text-xs">
|
||||||
|
<CalendarIcon />
|
||||||
|
{new Date(article.published_at).toLocaleDateString(i18n.language)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1.5 font-mono text-xs">
|
||||||
|
<ClockIcon />
|
||||||
|
{article.read_time_minutes} {t('news.readTime')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Featured image - only render if URL uses safe protocol */}
|
||||||
|
{isSafeUrl(article.featured_image_url) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
className="overflow-hidden rounded-xl"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={article.featured_image_url}
|
||||||
|
alt={article.title}
|
||||||
|
className="h-auto w-full rounded-xl object-cover"
|
||||||
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Article content - sanitized with DOMPurify */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
className="prose max-w-none lg:max-w-3xl"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -981,6 +981,14 @@ img.twemoji {
|
|||||||
@apply my-4 max-w-full rounded-xl;
|
@apply my-4 max-w-full rounded-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose mark {
|
||||||
|
@apply rounded bg-warning-500/30 px-1 text-dark-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose iframe {
|
||||||
|
@apply my-4 block aspect-video w-full max-w-2xl rounded-xl;
|
||||||
|
}
|
||||||
|
|
||||||
/* Light theme prose styles */
|
/* Light theme prose styles */
|
||||||
.light .prose {
|
.light .prose {
|
||||||
@apply text-champagne-800;
|
@apply text-champagne-800;
|
||||||
@@ -1054,6 +1062,14 @@ img.twemoji {
|
|||||||
@apply border-champagne-300 text-champagne-700;
|
@apply border-champagne-300 text-champagne-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.light .prose mark {
|
||||||
|
@apply bg-warning-400/30 text-champagne-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .prose iframe {
|
||||||
|
@apply border border-champagne-300;
|
||||||
|
}
|
||||||
|
|
||||||
/* Support for plain text with line breaks */
|
/* Support for plain text with line breaks */
|
||||||
.prose-plain {
|
.prose-plain {
|
||||||
@apply whitespace-pre-line;
|
@apply whitespace-pre-line;
|
||||||
@@ -1522,6 +1538,19 @@ input[type='checkbox']:hover:not(:checked) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* News section shimmer */
|
||||||
|
@keyframes newsShimmer {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: scaleX(0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* User preference: reduce motion */
|
/* User preference: reduce motion */
|
||||||
.reduce-motion,
|
.reduce-motion,
|
||||||
.reduce-motion * {
|
.reduce-motion * {
|
||||||
|
|||||||
87
src/types/news.ts
Normal file
87
src/types/news.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
export interface NewsCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsTag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsArticle {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
content: string;
|
||||||
|
excerpt: string | null;
|
||||||
|
category: string;
|
||||||
|
category_color: string;
|
||||||
|
category_id: number | null;
|
||||||
|
tag: string | null;
|
||||||
|
tag_id: number | null;
|
||||||
|
featured_image_url: string | null;
|
||||||
|
is_published: boolean;
|
||||||
|
is_featured: boolean;
|
||||||
|
published_at: string | null;
|
||||||
|
read_time_minutes: number;
|
||||||
|
views_count: number;
|
||||||
|
author_name?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsListItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
excerpt: string | null;
|
||||||
|
category: string;
|
||||||
|
category_color: string;
|
||||||
|
category_id: number | null;
|
||||||
|
tag: string | null;
|
||||||
|
tag_id: number | null;
|
||||||
|
featured_image_url: string | null;
|
||||||
|
is_published: boolean;
|
||||||
|
is_featured: boolean;
|
||||||
|
published_at: string | null;
|
||||||
|
read_time_minutes: number;
|
||||||
|
views_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsListResponse {
|
||||||
|
items: NewsListItem[];
|
||||||
|
total: number;
|
||||||
|
categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsCreateRequest {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
content: string;
|
||||||
|
excerpt: string | null;
|
||||||
|
category: string;
|
||||||
|
category_color: string;
|
||||||
|
category_id: number | null;
|
||||||
|
tag: string | null;
|
||||||
|
tag_id: number | null;
|
||||||
|
featured_image_url: string | null;
|
||||||
|
is_published: boolean;
|
||||||
|
is_featured: boolean;
|
||||||
|
read_time_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsUpdateRequest extends Partial<NewsCreateRequest> {
|
||||||
|
id?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsMediaUploadResponse {
|
||||||
|
url: string;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
media_type: 'image' | 'video';
|
||||||
|
filename: string;
|
||||||
|
size_bytes: number;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user