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
+2
View File
@@ -34,7 +34,9 @@
"react-dom": "^19.2.7",
"react-hook-form": "^7.80.0",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"recharts": "^3.9.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
+881 -2
View File
File diff suppressed because it is too large Load Diff
@@ -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' })
}
+67
View File
@@ -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>
)
}
+6
View File
@@ -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}`)
}
+42
View File
@@ -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,
}
+3
View File
@@ -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>
+1
View File
@@ -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
+99
View File
@@ -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>
)
}
+22
View File
@@ -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>
)
}
+9
View File
@@ -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
+36
View File
@@ -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])
+16
View File
@@ -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'