Initial commit: base slice (auth, roles, users, admin) scaffold
Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL + Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4 with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's conventions, scoped down to the current base feature set.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createRole, deleteRole, listRoles, updateRole } from './api'
|
||||
|
||||
const schema = z.object({ name: z.string().min(1).max(64) })
|
||||
|
||||
export function RolesPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: roles, isLoading } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => createRole(name),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteRole(id),
|
||||
onSuccess: invalidate,
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
},
|
||||
})
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => updateRole(id, name),
|
||||
onSuccess: invalidate,
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
},
|
||||
})
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const { register, handleSubmit, reset } = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onCreate = async (values: z.infer<typeof schema>) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(values.name)
|
||||
reset()
|
||||
setOpen(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.roles.title')}</h2>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4" /> {t('admin.roles.create')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.roles.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onCreate)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="roleName">{t('admin.roles.name')}</Label>
|
||||
<Input id="roleName" {...register('name')} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.roles.name')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.roles.system')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={3}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{roles?.map((role) => (
|
||||
<tr key={role.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">{role.name}</td>
|
||||
<td className="px-4 py-2">
|
||||
{role.isSystem ? <Badge variant="muted">{t('common.yes')}</Badge> : t('common.no')}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={role.isSystem}
|
||||
onClick={() => {
|
||||
const nextName = window.prompt(t('admin.roles.rename'), role.name)
|
||||
if (nextName && nextName !== role.name)
|
||||
renameMutation.mutate({ id: role.id, name: nextName })
|
||||
}}
|
||||
>
|
||||
{t('admin.roles.rename')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={role.isSystem}
|
||||
onClick={() => deleteMutation.mutate(role.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { RoleDto } from '@/shared/api/types'
|
||||
|
||||
export function listRoles() {
|
||||
return apiRequest<RoleDto[]>('/admin/roles')
|
||||
}
|
||||
|
||||
export function createRole(name: string) {
|
||||
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name } })
|
||||
}
|
||||
|
||||
export function updateRole(id: string, name: string) {
|
||||
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { name } })
|
||||
}
|
||||
|
||||
export function deleteRole(id: string) {
|
||||
return apiRequest<void>(`/admin/roles/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function changeUserRole(userId: string, roleId: string) {
|
||||
return apiRequest<void>(`/admin/users/${userId}/role`, { method: 'PATCH', body: { roleId } })
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { changeUserRole } from '@/features/admin/roles/api'
|
||||
import { listRoles } from '@/features/admin/roles/api'
|
||||
import { blockUser, deleteUser, listUsers, unblockUser } from './api'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function UsersPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleId, setRoleId] = useState<string>('')
|
||||
|
||||
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'users', page, search, roleId],
|
||||
queryFn: () => listUsers({ page, pageSize: PAGE_SIZE, search: search || undefined, roleId: roleId || undefined }),
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
|
||||
const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const blockMutation = useMutation({ mutationFn: blockUser, onSuccess: invalidate, onError })
|
||||
const unblockMutation = useMutation({ mutationFn: unblockUser, onSuccess: invalidate, onError })
|
||||
const deleteMutation = useMutation({ mutationFn: deleteUser, onSuccess: invalidate, onError })
|
||||
const changeRoleMutation = useMutation({
|
||||
mutationFn: ({ userId, roleId: newRoleId }: { userId: string; roleId: string }) =>
|
||||
changeUserRole(userId, newRoleId),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const totalPages = data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.users.title')}</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('common.search')}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setPage(1)
|
||||
setSearch(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={roleId || 'all'}
|
||||
onValueChange={(value) => {
|
||||
setPage(1)
|
||||
setRoleId(value === 'all' ? '' : value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="max-w-48">
|
||||
<SelectValue placeholder={t('admin.users.filterAll')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('admin.users.filterAll')}</SelectItem>
|
||||
{roles?.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.users.userName')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.users.role')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.users.status')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.users.createdAt')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.items.map((user) => (
|
||||
<tr key={user.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">{user.userName}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Select
|
||||
value={roles?.find((r) => r.name === user.role)?.id}
|
||||
onValueChange={(newRoleId) => changeRoleMutation.mutate({ userId: user.id, roleId: newRoleId })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32">
|
||||
<SelectValue>{user.role}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles?.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{user.isBlocked ? (
|
||||
<Badge variant="destructive">{t('admin.users.blocked')}</Badge>
|
||||
) : (
|
||||
<Badge>{t('admin.users.active')}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-2">
|
||||
{user.isBlocked ? (
|
||||
<Button size="sm" variant="outline" onClick={() => unblockMutation.mutate(user.id)}>
|
||||
{t('admin.users.unblock')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => blockMutation.mutate(user.id)}>
|
||||
{t('admin.users.block')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(user.id)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
‹
|
||||
</Button>
|
||||
<span>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
›
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { PagedList, UserSummaryDto } from '@/shared/api/types'
|
||||
|
||||
export type ListUsersParams = {
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
roleId?: string
|
||||
isBlocked?: boolean
|
||||
}
|
||||
|
||||
export function listUsers(params: ListUsersParams) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
})
|
||||
if (params.search) query.set('search', params.search)
|
||||
if (params.roleId) query.set('roleId', params.roleId)
|
||||
if (params.isBlocked !== undefined) query.set('isBlocked', String(params.isBlocked))
|
||||
|
||||
return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${query.toString()}`)
|
||||
}
|
||||
|
||||
export function blockUser(id: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}/block`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function unblockUser(id: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}/unblock`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function deleteUser(id: string) {
|
||||
return apiRequest<void>(`/admin/users/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { applyAuthResponse, login } from './api'
|
||||
|
||||
const schema = z.object({
|
||||
userName: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function LoginForm({ onSuccess }: { onSuccess: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
register: registerField,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const auth = await login(values.userName, values.password)
|
||||
applyAuthResponse(auth)
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof HttpError && error.status === 401
|
||||
? t('auth.invalidCredentials')
|
||||
: error instanceof HttpError && error.status === 403
|
||||
? t('auth.blocked')
|
||||
: t('auth.genericError')
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="userName">{t('auth.userName')}</Label>
|
||||
<Input id="userName" autoComplete="username" {...registerField('userName')} />
|
||||
{errors.userName && <p className="text-xs text-red-500">{errors.userName.message}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Input id="password" type="password" autoComplete="current-password" {...registerField('password')} />
|
||||
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
||||
</div>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{t('auth.submitLogin')}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { applyAuthResponse, register } from './api'
|
||||
|
||||
const schema = z.object({
|
||||
userName: z.string().min(3).max(64),
|
||||
password: z.string().min(8),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function RegisterForm({ onSuccess }: { onSuccess: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
register: registerField,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const auth = await register(values.userName, values.password)
|
||||
applyAuthResponse(auth)
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof HttpError && error.status === 409
|
||||
? t('auth.userNameTaken')
|
||||
: t('auth.genericError')
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="userName">{t('auth.userName')}</Label>
|
||||
<Input id="userName" autoComplete="username" {...registerField('userName')} />
|
||||
{errors.userName && <p className="text-xs text-red-500">{errors.userName.message}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Input id="password" type="password" autoComplete="new-password" {...registerField('password')} />
|
||||
{errors.password && <p className="text-xs text-red-500">{errors.password.message}</p>}
|
||||
</div>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{t('auth.submitRegister')}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { apiRequest, setAccessToken } from '@/shared/api/client'
|
||||
import type { AuthResponse, CurrentUser } from '@/shared/api/types'
|
||||
import { useAuthStore } from './store'
|
||||
|
||||
export function login(userName: string, password: string) {
|
||||
return apiRequest<AuthResponse>('/auth/login', { method: 'POST', body: { userName, password } })
|
||||
}
|
||||
|
||||
export function register(userName: string, password: string) {
|
||||
return apiRequest<AuthResponse>('/auth/register', { method: 'POST', body: { userName, password } })
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return apiRequest<void>('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function fetchCurrentUser() {
|
||||
return apiRequest<CurrentUser>('/auth/me')
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string) {
|
||||
return apiRequest<void>('/auth/change-password', { method: 'POST', body: { currentPassword, newPassword } })
|
||||
}
|
||||
|
||||
export function changeUserName(newUserName: string) {
|
||||
return apiRequest<void>('/auth/change-username', { method: 'POST', body: { newUserName } })
|
||||
}
|
||||
|
||||
export function deleteAccount() {
|
||||
return apiRequest<void>('/auth/me', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function applyAuthResponse(auth: AuthResponse) {
|
||||
setAccessToken(auth.accessToken)
|
||||
useAuthStore.getState().setUser(auth.user)
|
||||
}
|
||||
|
||||
/** Тихая попытка восстановить сессию по refresh-cookie при загрузке приложения. */
|
||||
export async function bootstrapSession() {
|
||||
try {
|
||||
const auth = await apiRequest<AuthResponse>('/auth/refresh', { method: 'POST', skipRefresh: true })
|
||||
applyAuthResponse(auth)
|
||||
} catch {
|
||||
setAccessToken(null)
|
||||
useAuthStore.getState().setUser(null)
|
||||
} finally {
|
||||
useAuthStore.getState().finishBootstrap()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
setAccessToken(null)
|
||||
useAuthStore.getState().setUser(null)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useAuthStore } from './store'
|
||||
|
||||
/** Редиректит на /login, если пользователь не вошёл (после завершения bootstrap-попытки refresh). */
|
||||
export function useRequireAuth() {
|
||||
const { user, isBootstrapping } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBootstrapping && !user) void navigate({ to: '/login' })
|
||||
}, [isBootstrapping, user, navigate])
|
||||
|
||||
return { user, isReady: !isBootstrapping && !!user }
|
||||
}
|
||||
|
||||
/** Редиректит уже вошедшего пользователя с login/register на дашборд. */
|
||||
export function useRequireGuest() {
|
||||
const { user, isBootstrapping } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBootstrapping && user) void navigate({ to: '/dashboard' })
|
||||
}, [isBootstrapping, user, navigate])
|
||||
}
|
||||
|
||||
/** Как useRequireAuth, но дополнительно требует роль admin — иначе редирект на дашборд. */
|
||||
export function useRequireAdmin() {
|
||||
const { user, isBootstrapping } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (isBootstrapping) return
|
||||
if (!user) void navigate({ to: '/login' })
|
||||
else if (user.role !== 'admin') void navigate({ to: '/dashboard' })
|
||||
}, [isBootstrapping, user, navigate])
|
||||
|
||||
return { user, isReady: !isBootstrapping && !!user && user.role === 'admin' }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand'
|
||||
import type { CurrentUser } from '@/shared/api/types'
|
||||
|
||||
type AuthState = {
|
||||
user: CurrentUser | null
|
||||
/** Пока не завершилась попытка тихого восстановления сессии при старте приложения. */
|
||||
isBootstrapping: boolean
|
||||
setUser: (user: CurrentUser | null) => void
|
||||
finishBootstrap: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isBootstrapping: true,
|
||||
setUser: (user) => set({ user }),
|
||||
finishBootstrap: () => set({ isBootstrapping: false }),
|
||||
}))
|
||||
@@ -0,0 +1,110 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Класс-стратегия тёмной темы: .dark на <html> (см. theme/ThemeProvider.tsx). */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Ретро-эфир/CRT: фосфорно-зелёный терминал в тёмной теме, янтарная бумага в светлой. */
|
||||
:root {
|
||||
--background: #f2ecd8;
|
||||
--foreground: #241f14;
|
||||
--muted: #e6dcc0;
|
||||
--muted-foreground: #6b6247;
|
||||
--border: #c9bd94;
|
||||
--primary: #7a5a12;
|
||||
--primary-foreground: #f2ecd8;
|
||||
--accent: #b45309;
|
||||
--scanline-opacity: 0.05;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #05080a;
|
||||
--foreground: #baffcb;
|
||||
--muted: #0e1611;
|
||||
--muted-foreground: #5fae7c;
|
||||
--border: #1e3a26;
|
||||
--primary: #33ff66;
|
||||
--primary-foreground: #05080a;
|
||||
--accent: #22d3ee;
|
||||
--scanline-opacity: 0.09;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--font-sans: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, 'Courier New', monospace;
|
||||
|
||||
--text-xs: 0.844rem;
|
||||
--text-sm: 0.984rem;
|
||||
--text-base: 1.125rem;
|
||||
--text-lg: 1.266rem;
|
||||
--text-xl: 1.406rem;
|
||||
--text-2xl: 1.688rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
margin: 0;
|
||||
min-height: 100svh;
|
||||
position: relative;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Тонкие горизонтальные строки развёртки поверх всего экрана. */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, var(--scanline-opacity)) 0px,
|
||||
rgba(0, 0, 0, var(--scanline-opacity)) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
/* Лёгкое затемнение по углам экрана — эффект кинескопа. */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
box-shadow: inset 0 0 min(18vw, 220px) rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.crt-glow {
|
||||
text-shadow:
|
||||
0 0 6px color-mix(in srgb, var(--primary) 70%, transparent),
|
||||
0 0 16px color-mix(in srgb, var(--primary) 35%, transparent);
|
||||
}
|
||||
|
||||
.crt-panel {
|
||||
background: color-mix(in srgb, var(--muted) 82%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--primary) 12%, transparent) inset,
|
||||
0 8px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import './index.css'
|
||||
import './shared/lib/i18n'
|
||||
import { ThemeProvider } from './theme/ThemeProvider'
|
||||
import { ToastProvider } from './shared/ui/toast-store'
|
||||
import { Toaster } from './shared/ui/toaster'
|
||||
import { router } from './router'
|
||||
import { setUnauthorizedHandler } from './shared/api/client'
|
||||
import { clearSession } from './features/auth/api'
|
||||
|
||||
// Если refresh-токен недействителен (истёк/отозван) — очищаем стор авторизации, чтобы
|
||||
// useRequireAuth/useRequireAdmin увидели user === null и сами увели на /login.
|
||||
setUnauthorizedHandler(clearSession)
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,237 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as RegisterRouteImport } from './routes/register'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
||||
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminRoute = AdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DashboardRoute = DashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const RegisterRoute = RegisterRouteImport.update({
|
||||
id: '/register',
|
||||
path: '/register',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminIndexRoute = AdminIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminRolesRoute = AdminRolesRouteImport.update({
|
||||
id: '/roles',
|
||||
path: '/roles',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminUsersRoute = AdminUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRouteWithChildren
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin': typeof AdminIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRouteWithChildren
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/dashboard'
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/roles'
|
||||
| '/admin/users'
|
||||
| '/admin/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/dashboard'
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/roles'
|
||||
| '/admin/users'
|
||||
| '/admin'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/dashboard'
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/roles'
|
||||
| '/admin/users'
|
||||
| '/admin/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AdminRoute: typeof AdminRouteWithChildren
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
RegisterRoute: typeof RegisterRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin': {
|
||||
id: '/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof AdminRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/dashboard': {
|
||||
id: '/dashboard'
|
||||
path: '/dashboard'
|
||||
fullPath: '/dashboard'
|
||||
preLoaderRoute: typeof DashboardRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/register': {
|
||||
id: '/register'
|
||||
path: '/register'
|
||||
fullPath: '/register'
|
||||
preLoaderRoute: typeof RegisterRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin/': {
|
||||
id: '/admin/'
|
||||
path: '/'
|
||||
fullPath: '/admin/'
|
||||
preLoaderRoute: typeof AdminIndexRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/roles': {
|
||||
id: '/admin/roles'
|
||||
path: '/roles'
|
||||
fullPath: '/admin/roles'
|
||||
preLoaderRoute: typeof AdminRolesRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/users': {
|
||||
id: '/admin/users'
|
||||
path: '/users'
|
||||
fullPath: '/admin/users'
|
||||
preLoaderRoute: typeof AdminUsersRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AdminRouteChildren {
|
||||
AdminRolesRoute: typeof AdminRolesRoute
|
||||
AdminUsersRoute: typeof AdminUsersRoute
|
||||
AdminIndexRoute: typeof AdminIndexRoute
|
||||
}
|
||||
|
||||
const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminRolesRoute: AdminRolesRoute,
|
||||
AdminUsersRoute: AdminUsersRoute,
|
||||
AdminIndexRoute: AdminIndexRoute,
|
||||
}
|
||||
|
||||
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AdminRoute: AdminRouteWithChildren,
|
||||
DashboardRoute: DashboardRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
RegisterRoute: RegisterRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export const router = createRouter({ routeTree, defaultPreload: 'intent' })
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Menu, Radio, X } from 'lucide-react'
|
||||
import { useAuthStore } from '@/features/auth/store'
|
||||
import { bootstrapSession, logout, clearSession } from '@/features/auth/api'
|
||||
import { useTheme } from '@/theme/ThemeProvider'
|
||||
import { setLanguage } from '@/shared/lib/i18n'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Route = createRootRoute({ component: RootLayout })
|
||||
|
||||
function RootLayout() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { user } = useAuthStore()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrapSession()
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
} finally {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b border-border">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-3">
|
||||
<Link to="/" className="crt-glow flex items-center gap-2 text-lg font-bold tracking-widest">
|
||||
<Radio className="h-5 w-5" />
|
||||
{t('appName')}
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-4 text-sm md:flex">
|
||||
{user && (
|
||||
<>
|
||||
<Link to="/dashboard" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
<Link to="/settings" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
||||
{t('nav.admin')}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="rounded-sm border border-border bg-transparent px-2 py-1 text-xs uppercase"
|
||||
value={i18n.language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
>
|
||||
<option value="ru">{t('lang.ru')}</option>
|
||||
<option value="en">{t('lang.en')}</option>
|
||||
</select>
|
||||
<select
|
||||
className="hidden rounded-sm border border-border bg-transparent px-2 py-1 text-xs uppercase sm:block"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as typeof theme)}
|
||||
>
|
||||
<option value="light">{t('theme.light')}</option>
|
||||
<option value="dark">{t('theme.dark')}</option>
|
||||
<option value="system">{t('theme.system')}</option>
|
||||
</select>
|
||||
|
||||
{user ? (
|
||||
<button
|
||||
className="hidden rounded-sm border border-border px-3 py-1 text-xs uppercase tracking-wide hover:bg-muted md:block"
|
||||
onClick={() => void handleLogout()}
|
||||
>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="hidden rounded-sm border border-border px-3 py-1 text-xs uppercase tracking-wide hover:bg-muted md:block"
|
||||
>
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="rounded-sm border border-border p-1.5 md:hidden"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
{menuOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{menuOpen && (
|
||||
<nav className="flex flex-col gap-1 border-t border-border px-4 py-3 text-sm md:hidden">
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/dashboard" onClick={() => setMenuOpen(false)}>
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
<Link to="/settings" onClick={() => setMenuOpen(false)}>
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" onClick={() => setMenuOpen(false)}>
|
||||
{t('nav.admin')}
|
||||
</Link>
|
||||
)}
|
||||
<button className="py-1 text-left" onClick={() => void handleLogout()}>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" onClick={() => setMenuOpen(false)}>
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className={cn('mx-auto w-full max-w-5xl flex-1 px-4 py-8')}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createFileRoute, Link, Outlet } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRequireAdmin } from '@/features/auth/guards'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Route = createFileRoute('/admin')({ component: AdminLayout })
|
||||
|
||||
function AdminLayout() {
|
||||
const { t } = useTranslation()
|
||||
const { isReady } = useRequireAdmin()
|
||||
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.admin')}</h1>
|
||||
<nav className="flex gap-4 border-b border-border text-sm">
|
||||
<Link
|
||||
to="/admin/roles"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.roles.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/users"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.users.title')}
|
||||
</Link>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Navigate } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/admin/')({
|
||||
component: () => <Navigate to="/admin/users" />,
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { RolesPanel } from '@/features/admin/roles/RolesPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/roles')({ component: RolesPanel })
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { UsersPanel } from '@/features/admin/users/UsersPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/users')({ component: UsersPanel })
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tv } from 'lucide-react'
|
||||
import { useRequireAuth } from '@/features/auth/guards'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({ component: DashboardPage })
|
||||
|
||||
function DashboardPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user, isReady } = useRequireAuth()
|
||||
|
||||
if (!isReady || !user) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('dashboard.welcome', { userName: user.userName })}</h1>
|
||||
<Badge>
|
||||
{t('dashboard.role')}: {user.role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Tv className="h-5 w-5" />
|
||||
{t('nav.dashboard')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{t('dashboard.placeholder')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tv } from 'lucide-react'
|
||||
import { useAuthStore } from '@/features/auth/store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
export const Route = createFileRoute('/')({ component: HomePage })
|
||||
|
||||
function HomePage() {
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuthStore()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 py-16 text-center">
|
||||
<div className="crt-panel flex h-40 w-full max-w-md items-center justify-center rounded-md">
|
||||
<Tv className="crt-glow h-16 w-16" strokeWidth={1} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="crt-glow text-4xl font-bold tracking-[0.2em]">{t('home.title')}</h1>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-muted-foreground">{t('home.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<p className="max-w-md text-muted-foreground">{t('home.tagline')}</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{user ? (
|
||||
<Button asChild size="lg">
|
||||
<Link to="/dashboard">{t('home.cta')}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button asChild size="lg">
|
||||
<Link to="/login">{t('home.cta')}</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<Link to="/register">{t('home.ctaRegister')}</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LoginForm } from '@/features/auth/LoginForm'
|
||||
import { useRequireGuest } from '@/features/auth/guards'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
|
||||
export const Route = createFileRoute('/login')({ component: LoginPage })
|
||||
|
||||
function LoginPage() {
|
||||
useRequireGuest()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('auth.loginTitle')}</CardTitle>
|
||||
<CardDescription>{t('auth.loginSubtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoginForm onSuccess={() => void navigate({ to: '/dashboard' })} />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Link to="/register" className="text-primary hover:underline">
|
||||
{t('nav.register')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RegisterForm } from '@/features/auth/RegisterForm'
|
||||
import { useRequireGuest } from '@/features/auth/guards'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
|
||||
export const Route = createFileRoute('/register')({ component: RegisterPage })
|
||||
|
||||
function RegisterPage() {
|
||||
useRequireGuest()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('auth.registerTitle')}</CardTitle>
|
||||
<CardDescription>{t('auth.registerSubtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RegisterForm onSuccess={() => void navigate({ to: '/dashboard' })} />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{t('auth.haveAccount')}{' '}
|
||||
<Link to="/login" className="text-primary hover:underline">
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { changePassword, changeUserName, clearSession, deleteAccount } from '@/features/auth/api'
|
||||
import { useAuthStore } from '@/features/auth/store'
|
||||
import { useRequireAuth } from '@/features/auth/guards'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
|
||||
export const Route = createFileRoute('/settings')({ component: SettingsPage })
|
||||
|
||||
const userNameSchema = z.object({ newUserName: z.string().min(3).max(64) })
|
||||
const passwordSchema = z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(8) })
|
||||
|
||||
function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { isReady } = useRequireAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const userNameForm = useForm<z.infer<typeof userNameSchema>>({ resolver: zodResolver(userNameSchema) })
|
||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({ resolver: zodResolver(passwordSchema) })
|
||||
|
||||
if (!isReady) return null
|
||||
|
||||
const onSaveUserName = async (values: z.infer<typeof userNameSchema>) => {
|
||||
try {
|
||||
await changeUserName(values.newUserName)
|
||||
useAuthStore.getState().setUser({ ...useAuthStore.getState().user!, userName: values.newUserName })
|
||||
toast.success(t('settings.saved'))
|
||||
userNameForm.reset()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof HttpError && error.status === 409 ? t('auth.userNameTaken') : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const onSavePassword = async (values: z.infer<typeof passwordSchema>) => {
|
||||
try {
|
||||
await changePassword(values.currentPassword, values.newPassword)
|
||||
toast.success(t('settings.saved'))
|
||||
passwordForm.reset()
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const onDeleteAccount = async () => {
|
||||
if (!window.confirm(t('settings.deleteAccountConfirm'))) return
|
||||
try {
|
||||
await deleteAccount()
|
||||
clearSession()
|
||||
void navigate({ to: '/' })
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('settings.title')}</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.changeUserName')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={userNameForm.handleSubmit(onSaveUserName)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="newUserName">{t('settings.newUserName')}</Label>
|
||||
<Input id="newUserName" {...userNameForm.register('newUserName')} />
|
||||
</div>
|
||||
<Button type="submit" className="self-start" disabled={userNameForm.formState.isSubmitting}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.changePassword')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={passwordForm.handleSubmit(onSavePassword)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="currentPassword">{t('settings.currentPassword')}</Label>
|
||||
<Input id="currentPassword" type="password" {...passwordForm.register('currentPassword')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="newPassword">{t('settings.newPassword')}</Label>
|
||||
<Input id="newPassword" type="password" {...passwordForm.register('newPassword')} />
|
||||
</div>
|
||||
<Button type="submit" className="self-start" disabled={passwordForm.formState.isSubmitting}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-red-700/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-500">{t('settings.dangerZone')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="destructive" onClick={() => void onDeleteAccount()}>
|
||||
{t('settings.deleteAccount')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ApiError } from './types'
|
||||
|
||||
let accessToken: string | null = null
|
||||
let refreshInFlight: Promise<boolean> | null = null
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return accessToken
|
||||
}
|
||||
|
||||
/** Вызывается, когда refresh-токен недействителен — обычно очищает стор авторизации и шлёт на /login. */
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
onUnauthorized = handler
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
body?: unknown
|
||||
/** Не пытаться освежить токен на 401 (используется самим refresh-запросом, чтобы не зациклиться). */
|
||||
skipRefresh?: boolean
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
|
||||
if (!response.ok) return false
|
||||
const data = (await response.json()) as { accessToken: string }
|
||||
setAccessToken(data.accessToken)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
refreshInFlight = null
|
||||
}
|
||||
})()
|
||||
}
|
||||
return refreshInFlight
|
||||
}
|
||||
|
||||
export class HttpError extends Error implements ApiError {
|
||||
title: string
|
||||
detail: string
|
||||
status: number
|
||||
|
||||
constructor(problem: Partial<ApiError>, status: number) {
|
||||
super(problem.detail ?? problem.title ?? `HTTP ${status}`)
|
||||
this.title = problem.title ?? 'Error'
|
||||
this.detail = problem.detail ?? this.message
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<HttpError> {
|
||||
try {
|
||||
const problem = (await response.json()) as Partial<ApiError>
|
||||
return new HttpError(problem, response.status)
|
||||
} catch {
|
||||
return new HttpError({ title: response.statusText }, response.status)
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
})
|
||||
|
||||
if (response.status === 401 && !options.skipRefresh) {
|
||||
const refreshed = await refreshAccessToken()
|
||||
if (refreshed) return apiRequest<T>(path, { ...options, skipRefresh: true })
|
||||
onUnauthorized?.()
|
||||
throw await parseError(response)
|
||||
}
|
||||
|
||||
if (!response.ok) throw await parseError(response)
|
||||
|
||||
if (response.status === 204) return undefined as T
|
||||
|
||||
const text = await response.text()
|
||||
return (text ? JSON.parse(text) : undefined) as T
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ApiError = {
|
||||
title: string
|
||||
detail: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export type CurrentUser = {
|
||||
id: string
|
||||
userName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
accessToken: string
|
||||
expiresAt: string
|
||||
user: CurrentUser
|
||||
}
|
||||
|
||||
export type RoleDto = {
|
||||
id: string
|
||||
name: string
|
||||
isSystem: boolean
|
||||
}
|
||||
|
||||
export type UserSummaryDto = {
|
||||
id: string
|
||||
userName: string
|
||||
role: string
|
||||
isBlocked: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type PagedList<T> = {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
const resources = {
|
||||
ru: {
|
||||
translation: {
|
||||
appName: 'TeleWave',
|
||||
nav: {
|
||||
home: 'Главная',
|
||||
dashboard: 'Эфир',
|
||||
admin: 'Админка',
|
||||
settings: 'Настройки',
|
||||
login: 'Войти',
|
||||
register: 'Регистрация',
|
||||
logout: 'Выйти',
|
||||
},
|
||||
theme: { light: 'Светлая', dark: 'Тёмная', system: 'Системная' },
|
||||
lang: { ru: 'RU', en: 'EN' },
|
||||
common: {
|
||||
save: 'Сохранить',
|
||||
cancel: 'Отмена',
|
||||
delete: 'Удалить',
|
||||
create: 'Создать',
|
||||
loading: 'Загрузка…',
|
||||
error: 'Что-то пошло не так',
|
||||
confirm: 'Подтвердить',
|
||||
search: 'Поиск',
|
||||
actions: 'Действия',
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
},
|
||||
home: {
|
||||
title: 'TELEWAVE',
|
||||
subtitle: 'ЭФИРНАЯ СЕТКА КАНАЛОВ',
|
||||
tagline: 'Твои каналы. Твой эфир. В любое время.',
|
||||
cta: 'Войти в эфир',
|
||||
ctaRegister: 'Создать аккаунт',
|
||||
},
|
||||
auth: {
|
||||
userName: 'Имя пользователя',
|
||||
password: 'Пароль',
|
||||
loginTitle: 'Вход в эфир',
|
||||
loginSubtitle: 'Введите учётные данные для доступа к сетке каналов',
|
||||
registerTitle: 'Новый зритель',
|
||||
registerSubtitle: 'Создайте аккаунт, чтобы настроить свою сетку каналов',
|
||||
submitLogin: 'Войти',
|
||||
submitRegister: 'Зарегистрироваться',
|
||||
noAccount: 'Нет аккаунта?',
|
||||
haveAccount: 'Уже есть аккаунт?',
|
||||
invalidCredentials: 'Неверное имя пользователя или пароль',
|
||||
userNameTaken: 'Это имя пользователя уже занято',
|
||||
blocked: 'Аккаунт заблокирован администратором',
|
||||
genericError: 'Не удалось выполнить вход. Попробуйте ещё раз',
|
||||
},
|
||||
dashboard: {
|
||||
welcome: 'На связи, {{userName}}',
|
||||
placeholder: 'Список каналов появится здесь позже — пока в эфире только тестовая заставка.',
|
||||
role: 'Роль',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки аккаунта',
|
||||
changeUserName: 'Смена имени пользователя',
|
||||
newUserName: 'Новое имя пользователя',
|
||||
changePassword: 'Смена пароля',
|
||||
currentPassword: 'Текущий пароль',
|
||||
newPassword: 'Новый пароль',
|
||||
dangerZone: 'Опасная зона',
|
||||
deleteAccount: 'Удалить аккаунт',
|
||||
deleteAccountConfirm: 'Аккаунт и все данные будут удалены безвозвратно. Продолжить?',
|
||||
saved: 'Сохранено',
|
||||
},
|
||||
admin: {
|
||||
roles: {
|
||||
title: 'Роли',
|
||||
name: 'Название',
|
||||
system: 'Системная',
|
||||
create: 'Новая роль',
|
||||
rename: 'Переименовать',
|
||||
cannotModifySystem: 'Системную роль нельзя изменить или удалить',
|
||||
roleInUse: 'Роль назначена пользователям',
|
||||
},
|
||||
users: {
|
||||
title: 'Пользователи',
|
||||
userName: 'Имя пользователя',
|
||||
role: 'Роль',
|
||||
status: 'Статус',
|
||||
createdAt: 'Регистрация',
|
||||
blocked: 'Заблокирован',
|
||||
active: 'Активен',
|
||||
block: 'Заблокировать',
|
||||
unblock: 'Разблокировать',
|
||||
filterAll: 'Все роли',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
translation: {
|
||||
appName: 'TeleWave',
|
||||
nav: {
|
||||
home: 'Home',
|
||||
dashboard: 'On Air',
|
||||
admin: 'Admin',
|
||||
settings: 'Settings',
|
||||
login: 'Log in',
|
||||
register: 'Sign up',
|
||||
logout: 'Log out',
|
||||
},
|
||||
theme: { light: 'Light', dark: 'Dark', system: 'System' },
|
||||
lang: { ru: 'RU', en: 'EN' },
|
||||
common: {
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
create: 'Create',
|
||||
loading: 'Loading…',
|
||||
error: 'Something went wrong',
|
||||
confirm: 'Confirm',
|
||||
search: 'Search',
|
||||
actions: 'Actions',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
},
|
||||
home: {
|
||||
title: 'TELEWAVE',
|
||||
subtitle: 'BROADCAST CHANNEL GRID',
|
||||
tagline: 'Your channels. Your broadcast. Anytime.',
|
||||
cta: 'Go on air',
|
||||
ctaRegister: 'Create account',
|
||||
},
|
||||
auth: {
|
||||
userName: 'Username',
|
||||
password: 'Password',
|
||||
loginTitle: 'Sign in',
|
||||
loginSubtitle: 'Enter your credentials to access the channel grid',
|
||||
registerTitle: 'New viewer',
|
||||
registerSubtitle: 'Create an account to set up your channel grid',
|
||||
submitLogin: 'Log in',
|
||||
submitRegister: 'Sign up',
|
||||
noAccount: "Don't have an account?",
|
||||
haveAccount: 'Already have an account?',
|
||||
invalidCredentials: 'Invalid username or password',
|
||||
userNameTaken: 'This username is already taken',
|
||||
blocked: 'Account blocked by an administrator',
|
||||
genericError: 'Could not sign in. Please try again',
|
||||
},
|
||||
dashboard: {
|
||||
welcome: 'On air, {{userName}}',
|
||||
placeholder: 'The channel list will show up here later — for now, enjoy the test card.',
|
||||
role: 'Role',
|
||||
},
|
||||
settings: {
|
||||
title: 'Account settings',
|
||||
changeUserName: 'Change username',
|
||||
newUserName: 'New username',
|
||||
changePassword: 'Change password',
|
||||
currentPassword: 'Current password',
|
||||
newPassword: 'New password',
|
||||
dangerZone: 'Danger zone',
|
||||
deleteAccount: 'Delete account',
|
||||
deleteAccountConfirm: 'The account and all its data will be permanently deleted. Continue?',
|
||||
saved: 'Saved',
|
||||
},
|
||||
admin: {
|
||||
roles: {
|
||||
title: 'Roles',
|
||||
name: 'Name',
|
||||
system: 'System',
|
||||
create: 'New role',
|
||||
rename: 'Rename',
|
||||
cannotModifySystem: 'A system role cannot be modified or deleted',
|
||||
roleInUse: 'Role is assigned to users',
|
||||
},
|
||||
users: {
|
||||
title: 'Users',
|
||||
userName: 'Username',
|
||||
role: 'Role',
|
||||
status: 'Status',
|
||||
createdAt: 'Joined',
|
||||
blocked: 'Blocked',
|
||||
active: 'Active',
|
||||
block: 'Block',
|
||||
unblock: 'Unblock',
|
||||
filterAll: 'All roles',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'tw-lang'
|
||||
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: stored ?? 'ru',
|
||||
fallbackLng: 'ru',
|
||||
interpolation: { escapeValue: false },
|
||||
})
|
||||
|
||||
export function setLanguage(lng: string) {
|
||||
localStorage.setItem(STORAGE_KEY, lng)
|
||||
void i18n.changeLanguage(lng)
|
||||
}
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-sm border px-2 py-0.5 text-xs font-medium uppercase tracking-wide',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-primary/40 bg-primary/10 text-primary',
|
||||
muted: 'border-border bg-muted text-muted-foreground',
|
||||
destructive: 'border-red-700/40 bg-red-700/10 text-red-500',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
export type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { type ButtonHTMLAttributes, forwardRef } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium uppercase tracking-wide transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:opacity-90',
|
||||
outline: 'border border-border bg-transparent hover:bg-muted',
|
||||
ghost: 'hover:bg-muted normal-case tracking-normal',
|
||||
destructive: 'bg-red-700 text-white hover:bg-red-800',
|
||||
link: 'text-primary underline-offset-4 hover:underline normal-case tracking-normal',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-sm px-3',
|
||||
lg: 'h-11 rounded-sm px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & { asChild?: boolean }
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
|
||||
},
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type HTMLAttributes, forwardRef } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('crt-panel rounded-md', className)} {...props} />
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('crt-glow text-xl font-semibold tracking-tight', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />,
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Dialog = DialogPrimitive.Root
|
||||
export const DialogTrigger = DialogPrimitive.Trigger
|
||||
export const DialogClose = DialogPrimitive.Close
|
||||
|
||||
export const DialogOverlay = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/60', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
export const DialogContent = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'crt-panel fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-md p-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('mb-4 flex flex-col gap-1.5', className)} {...props} />
|
||||
}
|
||||
|
||||
export const DialogTitle = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Title>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn('crt-glow text-lg font-semibold', className)} {...props} />
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
export const DialogDescription = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Description>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('mt-6 flex justify-end gap-2', className)} {...props} />
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type InputHTMLAttributes, forwardRef } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-sm 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}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { forwardRef } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Label = forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-xs font-medium uppercase tracking-wide text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Select = SelectPrimitive.Root
|
||||
export const SelectValue = SelectPrimitive.Value
|
||||
|
||||
export const SelectTrigger = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-sm border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-60" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
export const SelectContent = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
position={position}
|
||||
className={cn(
|
||||
'crt-panel z-50 max-h-72 min-w-32 overflow-hidden rounded-sm',
|
||||
position === 'popper' && 'translate-y-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
export const SelectItem = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Item>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-7 pr-2 text-sm outline-none data-[highlighted]:bg-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
export type ToastVariant = 'default' | 'success' | 'error'
|
||||
export type ToastItem = { id: number; message: string; variant: ToastVariant }
|
||||
|
||||
let nextId = 1
|
||||
let pushImpl: ((message: string, variant: ToastVariant) => void) | null = null
|
||||
|
||||
type ToastContextValue = {
|
||||
toasts: ToastItem[]
|
||||
dismiss: (id: number) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
|
||||
const push = useCallback((message: string, variant: ToastVariant) => {
|
||||
setToasts((prev) => [...prev, { id: nextId++, message, variant }])
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
pushImpl = push
|
||||
|
||||
return <ToastContext value={{ toasts, dismiss }}>{children}</ToastContext>
|
||||
}
|
||||
|
||||
export function useToastContext() {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) throw new Error('useToastContext must be used within ToastProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Императивный вызов из любого места (не только компонентов). */
|
||||
export const toast = {
|
||||
success: (message: string) => pushImpl?.(message, 'success'),
|
||||
error: (message: string) => pushImpl?.(message, 'error'),
|
||||
message: (message: string) => pushImpl?.(message, 'default'),
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { useToastContext } from './toast-store'
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts, dismiss } = useToastContext()
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[10000] flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} id={t.id} message={t.message} variant={t.variant} onDismiss={dismiss} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastItem({
|
||||
id,
|
||||
message,
|
||||
variant,
|
||||
onDismiss,
|
||||
}: {
|
||||
id: number
|
||||
message: string
|
||||
variant: 'default' | 'success' | 'error'
|
||||
onDismiss: (id: number) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => onDismiss(id), 4000)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [id, onDismiss])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'crt-panel pointer-events-auto rounded-md px-4 py-3 text-sm shadow-lg',
|
||||
variant === 'success' && 'border-primary/60',
|
||||
variant === 'error' && 'border-red-700/60 text-red-400',
|
||||
)}
|
||||
onClick={() => onDismiss(id)}
|
||||
role="status"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'tw-theme'
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
|
||||
|
||||
function resolve(theme: Theme): 'light' | 'dark' {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement
|
||||
root.classList.toggle('dark', resolve(theme) === 'dark')
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(
|
||||
() => (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'dark',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme)
|
||||
if (theme !== 'system') return
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => applyTheme('system')
|
||||
media.addEventListener('change', onChange)
|
||||
return () => media.removeEventListener('change', onChange)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = (next: Theme) => {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
setThemeState(next)
|
||||
}
|
||||
|
||||
return <ThemeContext value={{ theme, setTheme }}>{children}</ThemeContext>
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
Reference in New Issue
Block a user