Add news feature with CRUD operations and real-time notifications
- 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { listNews } from './api'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function NewsFeed() {
|
||||
const { t } = useTranslation()
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['news', page],
|
||||
queryFn: () => listNews(page, PAGE_SIZE),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||
|
||||
{isError && (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
||||
{t('activation.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('news.empty')}</p>}
|
||||
|
||||
{data && data.items.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
{data.items.map((post) => (
|
||||
<Card key={post.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{post.title}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleString()}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body}</ReactMarkdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('admin.users.total', { count: data.total })}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t('admin.prev')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
|
||||
{t('admin.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { NewsPostDto, PagedList } from '@/shared/api/types'
|
||||
|
||||
export function listNews(page: number, pageSize: number) {
|
||||
return apiRequest<PagedList<NewsPostDto>>(`/news?page=${page}&pageSize=${pageSize}`)
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as RegisterRouteImport } from './routes/register'
|
||||
import { Route as NewsRouteImport } from './routes/news'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as InstructionsRouteImport } from './routes/instructions'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
@@ -20,6 +21,7 @@ import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
||||
import { Route as AdminNodesRouteImport } from './routes/admin/nodes'
|
||||
import { Route as AdminNewsRouteImport } from './routes/admin/news'
|
||||
import { Route as AdminAuditRouteImport } from './routes/admin/audit'
|
||||
import { Route as AdminAppsRouteImport } from './routes/admin/apps'
|
||||
import { Route as AdminActivationRouteImport } from './routes/admin/activation'
|
||||
@@ -34,6 +36,11 @@ const RegisterRoute = RegisterRouteImport.update({
|
||||
path: '/register',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const NewsRoute = NewsRouteImport.update({
|
||||
id: '/news',
|
||||
path: '/news',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
@@ -79,6 +86,11 @@ const AdminNodesRoute = AdminNodesRouteImport.update({
|
||||
path: '/nodes',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminNewsRoute = AdminNewsRouteImport.update({
|
||||
id: '/news',
|
||||
path: '/news',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminAuditRoute = AdminAuditRouteImport.update({
|
||||
id: '/audit',
|
||||
path: '/audit',
|
||||
@@ -101,11 +113,13 @@ export interface FileRoutesByFullPath {
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/instructions': typeof InstructionsRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/news': typeof NewsRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/activation': typeof AdminActivationRoute
|
||||
'/admin/apps': typeof AdminAppsRoute
|
||||
'/admin/audit': typeof AdminAuditRoute
|
||||
'/admin/news': typeof AdminNewsRoute
|
||||
'/admin/nodes': typeof AdminNodesRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
@@ -116,11 +130,13 @@ export interface FileRoutesByTo {
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/instructions': typeof InstructionsRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/news': typeof NewsRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/activation': typeof AdminActivationRoute
|
||||
'/admin/apps': typeof AdminAppsRoute
|
||||
'/admin/audit': typeof AdminAuditRoute
|
||||
'/admin/news': typeof AdminNewsRoute
|
||||
'/admin/nodes': typeof AdminNodesRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
@@ -133,11 +149,13 @@ export interface FileRoutesById {
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/instructions': typeof InstructionsRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/news': typeof NewsRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/activation': typeof AdminActivationRoute
|
||||
'/admin/apps': typeof AdminAppsRoute
|
||||
'/admin/audit': typeof AdminAuditRoute
|
||||
'/admin/news': typeof AdminNewsRoute
|
||||
'/admin/nodes': typeof AdminNodesRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
@@ -151,11 +169,13 @@ export interface FileRouteTypes {
|
||||
| '/dashboard'
|
||||
| '/instructions'
|
||||
| '/login'
|
||||
| '/news'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/activation'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
| '/admin/news'
|
||||
| '/admin/nodes'
|
||||
| '/admin/roles'
|
||||
| '/admin/users'
|
||||
@@ -166,11 +186,13 @@ export interface FileRouteTypes {
|
||||
| '/dashboard'
|
||||
| '/instructions'
|
||||
| '/login'
|
||||
| '/news'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/activation'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
| '/admin/news'
|
||||
| '/admin/nodes'
|
||||
| '/admin/roles'
|
||||
| '/admin/users'
|
||||
@@ -182,11 +204,13 @@ export interface FileRouteTypes {
|
||||
| '/dashboard'
|
||||
| '/instructions'
|
||||
| '/login'
|
||||
| '/news'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/activation'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
| '/admin/news'
|
||||
| '/admin/nodes'
|
||||
| '/admin/roles'
|
||||
| '/admin/users'
|
||||
@@ -199,6 +223,7 @@ export interface RootRouteChildren {
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
InstructionsRoute: typeof InstructionsRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
NewsRoute: typeof NewsRoute
|
||||
RegisterRoute: typeof RegisterRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
}
|
||||
@@ -219,6 +244,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof RegisterRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/news': {
|
||||
id: '/news'
|
||||
path: '/news'
|
||||
fullPath: '/news'
|
||||
preLoaderRoute: typeof NewsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
@@ -282,6 +314,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminNodesRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/news': {
|
||||
id: '/admin/news'
|
||||
path: '/news'
|
||||
fullPath: '/admin/news'
|
||||
preLoaderRoute: typeof AdminNewsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/audit': {
|
||||
id: '/admin/audit'
|
||||
path: '/audit'
|
||||
@@ -310,6 +349,7 @@ interface AdminRouteChildren {
|
||||
AdminActivationRoute: typeof AdminActivationRoute
|
||||
AdminAppsRoute: typeof AdminAppsRoute
|
||||
AdminAuditRoute: typeof AdminAuditRoute
|
||||
AdminNewsRoute: typeof AdminNewsRoute
|
||||
AdminNodesRoute: typeof AdminNodesRoute
|
||||
AdminRolesRoute: typeof AdminRolesRoute
|
||||
AdminUsersRoute: typeof AdminUsersRoute
|
||||
@@ -320,6 +360,7 @@ const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminActivationRoute: AdminActivationRoute,
|
||||
AdminAppsRoute: AdminAppsRoute,
|
||||
AdminAuditRoute: AdminAuditRoute,
|
||||
AdminNewsRoute: AdminNewsRoute,
|
||||
AdminNodesRoute: AdminNodesRoute,
|
||||
AdminRolesRoute: AdminRolesRoute,
|
||||
AdminUsersRoute: AdminUsersRoute,
|
||||
@@ -334,6 +375,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
DashboardRoute: DashboardRoute,
|
||||
InstructionsRoute: InstructionsRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
NewsRoute: NewsRoute,
|
||||
RegisterRoute: RegisterRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ function RootLayout() {
|
||||
<Link to="/instructions" className="text-muted-foreground hover:text-foreground">
|
||||
{t('nav.instructions')}
|
||||
</Link>
|
||||
<Link to="/news" className="text-muted-foreground hover:text-foreground">
|
||||
{t('nav.news')}
|
||||
</Link>
|
||||
<Link to="/settings" className="text-muted-foreground hover:text-foreground">
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
|
||||
@@ -12,6 +12,7 @@ const TABS = [
|
||||
{ to: '/admin/roles', key: 'roles' },
|
||||
{ to: '/admin/nodes', key: 'nodes' },
|
||||
{ to: '/admin/apps', key: 'apps' },
|
||||
{ to: '/admin/news', key: 'news' },
|
||||
{ to: '/admin/audit', key: 'audit' },
|
||||
] as const
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { listAdminNews, deletePost } from '@/features/admin/news/api'
|
||||
import { NewsFormDialog } from '@/features/admin/news/NewsFormDialog'
|
||||
import type { NewsPostDto } from '@/shared/api/types'
|
||||
|
||||
export const Route = createFileRoute('/admin/news')({ component: AdminNewsPage })
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
function AdminNewsPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [editing, setEditing] = useState<NewsPostDto | null>(null)
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['admin-news', page],
|
||||
queryFn: () => listAdminNews(page, PAGE_SIZE),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deletePost,
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.news.deleted'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-news'] })
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<NewsFormDialog />
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||
|
||||
{isError && (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
||||
{t('activation.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.news.empty')}</p>}
|
||||
|
||||
{data && data.items.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.items.map((post) => (
|
||||
<div key={post.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
|
||||
<div className="flex flex-col">
|
||||
<span>{post.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(post)}>
|
||||
{t('admin.roles.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('admin.news.confirmDelete'))) deleteMutation.mutate(post.id)
|
||||
}}
|
||||
>
|
||||
{t('admin.roles.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('admin.users.total', { count: data.total })}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t('admin.prev')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
|
||||
{t('admin.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editing && <NewsFormDialog post={editing} open={!!editing} onOpenChange={(open) => !open && setEditing(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRequireAuth } from '@/features/auth/guards'
|
||||
import { NewsFeed } from '@/features/news/NewsFeed'
|
||||
|
||||
export const Route = createFileRoute('/news')({ component: NewsPage })
|
||||
|
||||
function NewsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { isReady } = useRequireAuth()
|
||||
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8 px-6 py-10">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('news.title')}</h1>
|
||||
</div>
|
||||
<NewsFeed />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -89,6 +89,15 @@ export type ClientAppDto = {
|
||||
/** GET /api/apps — сгруппировано по ОС на бэкенде; отсутствующая ОС значит "нет приложений". */
|
||||
export type AppsByOs = Partial<Record<OsPlatform, ClientAppDto[]>>
|
||||
|
||||
/** Один DTO на пользовательскую ленту и админку — у новости нет полей, скрытых от юзера. */
|
||||
export type NewsPostDto = {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export type LinkTokenResponse = {
|
||||
deepLink: string | null
|
||||
expiresAt: string
|
||||
|
||||
@@ -48,6 +48,7 @@ const resources = {
|
||||
nav: {
|
||||
dashboard: 'Мои конфиги',
|
||||
instructions: 'Инструкции',
|
||||
news: 'Новости',
|
||||
settings: 'Настройки',
|
||||
admin: 'Админка',
|
||||
logout: 'Выйти',
|
||||
@@ -114,6 +115,11 @@ const resources = {
|
||||
},
|
||||
},
|
||||
|
||||
news: {
|
||||
title: 'Новости',
|
||||
empty: 'Пока нет новостей.',
|
||||
},
|
||||
|
||||
settings: {
|
||||
changePassword: 'Сменить пароль',
|
||||
currentPassword: 'Текущий пароль',
|
||||
@@ -150,6 +156,7 @@ const resources = {
|
||||
roles: 'Роли',
|
||||
nodes: 'Ноды',
|
||||
apps: 'Приложения',
|
||||
news: 'Новости',
|
||||
audit: 'Аудит',
|
||||
},
|
||||
users: {
|
||||
@@ -251,6 +258,17 @@ const resources = {
|
||||
deleted: 'Приложение удалено.',
|
||||
confirmDelete: 'Удалить приложение из каталога?',
|
||||
},
|
||||
news: {
|
||||
create: 'Добавить новость',
|
||||
title: 'Заголовок',
|
||||
body: 'Текст (Markdown)',
|
||||
preview: 'Предпросмотр',
|
||||
empty: 'Новостей пока нет.',
|
||||
created: 'Новость опубликована.',
|
||||
updated: 'Новость обновлена.',
|
||||
deleted: 'Новость удалена.',
|
||||
confirmDelete: 'Удалить новость?',
|
||||
},
|
||||
audit: {
|
||||
time: 'Время',
|
||||
action: 'Действие',
|
||||
@@ -317,6 +335,7 @@ const resources = {
|
||||
nav: {
|
||||
dashboard: 'My configs',
|
||||
instructions: 'Instructions',
|
||||
news: 'News',
|
||||
settings: 'Settings',
|
||||
admin: 'Admin',
|
||||
logout: 'Log out',
|
||||
@@ -383,6 +402,11 @@ const resources = {
|
||||
},
|
||||
},
|
||||
|
||||
news: {
|
||||
title: 'News',
|
||||
empty: 'No news yet.',
|
||||
},
|
||||
|
||||
settings: {
|
||||
changePassword: 'Change password',
|
||||
currentPassword: 'Current password',
|
||||
@@ -419,6 +443,7 @@ const resources = {
|
||||
roles: 'Roles',
|
||||
nodes: 'Nodes',
|
||||
apps: 'Apps',
|
||||
news: 'News',
|
||||
audit: 'Audit',
|
||||
},
|
||||
users: {
|
||||
@@ -520,6 +545,17 @@ const resources = {
|
||||
deleted: 'App deleted.',
|
||||
confirmDelete: 'Remove this app from the catalog?',
|
||||
},
|
||||
news: {
|
||||
create: 'Add post',
|
||||
title: 'Title',
|
||||
body: 'Body (Markdown)',
|
||||
preview: 'Preview',
|
||||
empty: 'No news yet.',
|
||||
created: 'Post published.',
|
||||
updated: 'Post updated.',
|
||||
deleted: 'Post deleted.',
|
||||
confirmDelete: 'Delete this post?',
|
||||
},
|
||||
audit: {
|
||||
time: 'Time',
|
||||
action: 'Action',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getConnection, startConnection, stopConnection } from './connection'
|
||||
type ConfigTrafficUpdated = { configId: string; usedUpBytes: number; usedDownBytes: number }
|
||||
type ConfigStatusChanged = { configId: string; status: ConfigStatus }
|
||||
type UserActivated = { userId: string }
|
||||
type NewsPublished = { id: string; title: string; createdAt: string }
|
||||
|
||||
/** Живые обновления по SignalR: точечно патчит кэш TanStack Query вместо инвалидации всего списка. */
|
||||
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -49,9 +50,14 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['me-poll'] })
|
||||
}
|
||||
|
||||
const onNewsPublished = (_payload: NewsPublished) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['news'] })
|
||||
}
|
||||
|
||||
connection.on('configTrafficUpdated', onTrafficUpdated)
|
||||
connection.on('configStatusChanged', onStatusChanged)
|
||||
connection.on('userActivated', onUserActivated)
|
||||
connection.on('newsPublished', onNewsPublished)
|
||||
|
||||
void startConnection()
|
||||
|
||||
@@ -59,6 +65,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
connection.off('configTrafficUpdated', onTrafficUpdated)
|
||||
connection.off('configStatusChanged', onStatusChanged)
|
||||
connection.off('userActivated', onUserActivated)
|
||||
connection.off('newsPublished', onNewsPublished)
|
||||
}
|
||||
}, [user, queryClient])
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type TextareaHTMLAttributes, forwardRef } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-32 w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
Reference in New Issue
Block a user