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:
Leonid Pershin
2026-07-24 05:40:34 +03:00
commit 8a3eebc48f
156 changed files with 9335 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
# Dependencies
node_modules
# Build output
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# TS build cache
*.tsbuildinfo
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TeleWave</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"typecheck": "tsc -b",
"preview": "vite preview",
"gen:api": "openapi-typescript http://localhost:8080/openapi/v1.json -o src/shared/api/schema.gen.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dialog": "^1.1.18",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-select": "^2.3.2",
"@radix-ui/react-slot": "^1.3.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.3.4",
"lucide-react": "^1.22.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.80.0",
"react-i18next": "^17.0.8",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@tanstack/router-plugin": "^1.168.18",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"openapi-typescript": "^7.13.0",
"oxlint": "^1.71.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+2803
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#0a0e0a"/>
<rect x="4" y="9" width="24" height="16" rx="2" fill="none" stroke="#33ff66" stroke-width="2"/>
<path d="M11 9 L16 4 L21 9" fill="none" stroke="#33ff66" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16" cy="17" r="4" fill="#33ff66"/>
</svg>

After

Width:  |  Height:  |  Size: 395 B

@@ -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>
)
}
+22
View File
@@ -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>
)
}
+34
View File
@@ -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' })
}
+60
View File
@@ -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>
)
}
+54
View File
@@ -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)
}
+39
View File
@@ -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' }
}
+17
View File
@@ -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 }),
}))
+110
View File
@@ -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);
}
}
+31
View File
@@ -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>,
)
+237
View File
@@ -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>()
+10
View File
@@ -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
}
}
+136
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { createFileRoute, Navigate } from '@tanstack/react-router'
export const Route = createFileRoute('/admin/')({
component: () => <Navigate to="/admin/users" />,
})
+4
View File
@@ -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 })
+4
View File
@@ -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 })
+38
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
+33
View File
@@ -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>
)
}
+33
View File
@@ -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>
)
}
+117
View File
@@ -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>
)
}
+93
View File
@@ -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
}
+38
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+206
View File
@@ -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
+23
View File
@@ -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} />
}
+37
View File
@@ -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'
+34
View File
@@ -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'
+68
View File
@@ -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} />
}
+17
View File
@@ -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'
+18
View File
@@ -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
+70
View File
@@ -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
+43
View File
@@ -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'),
}
+46
View File
@@ -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>
)
}
+51
View File
@@ -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
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"paths": { "@/*": ["./src/*"] },
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import path from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
// Бэкенд для dev-прокси (Api слушает http://localhost:8080).
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:8080'
export default defineConfig({
plugins: [
tanstackRouter({ target: 'react', autoCodeSplitting: true, routesDirectory: './src/routes' }),
react(),
tailwindcss(),
],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
port: process.env.PORT ? Number(process.env.PORT) : 5173,
proxy: {
'/api': { target: apiTarget, changeOrigin: true },
},
},
build: {
outDir: 'dist',
},
})