Add news feature with CRUD operations and real-time notifications
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- Implemented news management functionality, allowing admins to create, read, update, and delete news posts.
- Introduced a new SignalR event for broadcasting news updates to all connected clients.
- Updated API documentation to include new endpoints for news management.
- Enhanced frontend with a dedicated news page and admin interface for managing news posts.
- Added necessary localization for news-related terms in both Russian and English.
This commit is contained in:
Leonid Pershin
2026-07-03 15:28:33 +03:00
parent bea2b5fcf7
commit b6637a1c03
47 changed files with 2523 additions and 9 deletions
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { toast } from '@/shared/ui/toast-store'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Textarea } from '@/shared/ui/textarea'
import { HttpError } from '@/shared/api/client'
import type { NewsPostDto } from '@/shared/api/types'
import { createPost, updatePost } from './api'
export function NewsFormDialog({
post,
open,
onOpenChange,
}: {
post?: NewsPostDto
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [internalOpen, setInternalOpen] = useState(false)
const [title, setTitle] = useState(post?.title ?? '')
const [body, setBody] = useState(post?.body ?? '')
const [previewMode, setPreviewMode] = useState(false)
const isControlled = open !== undefined
const dialogOpen = isControlled ? open : internalOpen
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
const mutation = useMutation({
mutationFn: () => (post ? updatePost(post.id, title.trim(), body.trim()) : createPost(title.trim(), body.trim())),
onSuccess: async () => {
toast.success(post ? t('admin.news.updated') : t('admin.news.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-news'] })
setDialogOpen(false)
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const canSubmit = title.trim() && body.trim()
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{!isControlled && (
<DialogTrigger asChild>
<Button size="sm">{t('admin.news.create')}</Button>
</DialogTrigger>
)}
<DialogContent>
<DialogHeader>
<DialogTitle>{post ? post.title : t('admin.news.create')}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) mutation.mutate()
}}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="newsTitle">{t('admin.news.title')}</Label>
<Input id="newsTitle" value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="newsBody">{t('admin.news.body')}</Label>
<Button type="button" size="sm" variant="ghost" onClick={() => setPreviewMode((v) => !v)}>
{t('admin.news.preview')}
</Button>
</div>
{previewMode ? (
<div className="flex min-h-32 flex-col gap-2 rounded-md border border-border px-3 py-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body || t('admin.news.body')}</ReactMarkdown>
</div>
) : (
<Textarea id="newsBody" value={body} onChange={(e) => setBody(e.target.value)} required />
)}
</div>
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
{post ? t('admin.roles.save') : t('admin.news.create')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { apiRequest } from '@/shared/api/client'
import type { NewsPostDto, PagedList } from '@/shared/api/types'
export function listAdminNews(page: number, pageSize: number) {
return apiRequest<PagedList<NewsPostDto>>(`/admin/news?page=${page}&pageSize=${pageSize}`)
}
export function createPost(title: string, body: string) {
return apiRequest<NewsPostDto>('/admin/news', { method: 'POST', body: { title, body } })
}
export function updatePost(id: string, title: string, body: string) {
return apiRequest<NewsPostDto>(`/admin/news/${id}`, { method: 'PUT', body: { title, body } })
}
export function deletePost(id: string) {
return apiRequest<void>(`/admin/news/${id}`, { method: 'DELETE' })
}