This commit is contained in:
2026-01-14 01:10:01 +03:00
parent 24c5581d7b
commit 2ce092aa59
40 changed files with 2001 additions and 297 deletions

View File

@ -0,0 +1,93 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { Box, CircularProgress, Typography } from '@mui/material';
import keycloak from '../../services/keycloak';
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const didInit = useRef(false);
useEffect(() => {
// Предотвращаем двойную инициализацию в StrictMode
if (didInit.current) {
return;
}
didInit.current = true;
let refreshInterval: ReturnType<typeof setInterval> | null = null;
const initKeycloak = async () => {
try {
const authenticated = await keycloak.init({
onLoad: 'login-required',
checkLoginIframe: false,
pkceMethod: 'S256',
});
setIsAuthenticated(authenticated);
if (authenticated) {
// Автоматическое обновление токена
refreshInterval = setInterval(() => {
keycloak.updateToken(30).catch(() => {
console.error('Failed to refresh token');
void keycloak.login();
});
}, 10000);
}
} catch (error) {
console.error('Keycloak init failed:', error);
setIsAuthenticated(false);
} finally {
setIsLoading(false);
}
};
void initKeycloak();
return () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
};
}, []);
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
gap: 2,
}}
>
<CircularProgress />
<Typography>Авторизация...</Typography>
</Box>
);
}
if (!isAuthenticated) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<Typography>Ошибка авторизации. Перенаправление...</Typography>
</Box>
);
}
return <>{children}</>;
}

View File

@ -0,0 +1 @@
export { AuthProvider } from './AuthProvider';

View File

@ -0,0 +1,91 @@
import { createContext, useContext } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { TableRow, TableCell, Box } from '@mui/material';
import { DragIndicator } from '@mui/icons-material';
import { flexRender } from '@tanstack/react-table';
import type { Row } from '@tanstack/react-table';
import type { Idea } from '../../types/idea';
// Контекст для передачи информации о drag handle в ячейку
interface DragHandleContextValue {
attributes: ReturnType<typeof useSortable>['attributes'];
listeners: ReturnType<typeof useSortable>['listeners'];
isDragging: boolean;
}
const DragHandleContext = createContext<DragHandleContextValue | null>(null);
// Компонент drag handle для использования в колонке
export function DragHandle() {
const context = useContext(DragHandleContext);
if (!context) {
return null;
}
const { attributes, listeners, isDragging } = context;
return (
<Box
{...attributes}
{...listeners}
sx={{
cursor: isDragging ? 'grabbing' : 'grab',
display: 'flex',
alignItems: 'center',
color: 'text.secondary',
touchAction: 'none',
'&:hover': { color: 'text.primary' },
}}
>
<DragIndicator fontSize="small" />
</Box>
);
}
interface DraggableRowProps {
row: Row<Idea>;
}
export function DraggableRow({ row }: DraggableRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: row.original.id });
// Используем CSS.Translate вместо CSS.Transform для лучшей совместимости с таблицами
const style = {
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: row.original.color
? `${row.original.color}15`
: isDragging
? 'action.hover'
: undefined,
position: isDragging ? ('relative' as const) : undefined,
zIndex: isDragging ? 1000 : undefined,
'&:hover': {
backgroundColor: row.original.color
? `${row.original.color}25`
: undefined,
},
};
return (
<DragHandleContext.Provider value={{ attributes, listeners, isDragging }}>
<TableRow ref={setNodeRef} hover sx={style}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
</DragHandleContext.Provider>
);
}

View File

@ -1,9 +1,25 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import {
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
} from '@dnd-kit/core';
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
Table,
TableBody,
@ -19,18 +35,27 @@ import {
TablePagination,
} from '@mui/material';
import { Inbox } from '@mui/icons-material';
import { useIdeasQuery, useDeleteIdea } from '../../hooks/useIdeas';
import {
useIdeasQuery,
useDeleteIdea,
useReorderIdeas,
} from '../../hooks/useIdeas';
import { useIdeasStore } from '../../store/ideas';
import { createColumns } from './columns';
import { DraggableRow } from './DraggableRow';
const SKELETON_COLUMNS_COUNT = 7;
const SKELETON_COLUMNS_COUNT = 8;
export function IdeasTable() {
const { data, isLoading, isError } = useIdeasQuery();
const deleteIdea = useDeleteIdea();
const reorderIdeas = useReorderIdeas();
const { sorting, setSorting, pagination, setPage, setLimit } =
useIdeasStore();
// ID активно перетаскиваемого элемента
const [activeId, setActiveId] = useState<string | null>(null);
const columns = useMemo(
() => createColumns((id) => deleteIdea.mutate(id)),
[deleteIdea],
@ -43,8 +68,49 @@ export function IdeasTable() {
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
manualPagination: true,
getRowId: (row) => row.id,
});
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (over && active.id !== over.id && data?.data) {
const oldIndex = data.data.findIndex((item) => item.id === active.id);
const newIndex = data.data.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
// Создаём новый порядок
const items = [...data.data];
const [movedItem] = items.splice(oldIndex, 1);
items.splice(newIndex, 0, movedItem);
// Отправляем на сервер новый порядок
const reorderItems = items.map((item, index) => ({
id: item.id,
order: index,
}));
reorderIdeas.mutate(reorderItems);
}
}
};
const handleSort = (columnId: string) => {
setSorting(columnId);
};
@ -67,101 +133,131 @@ export function IdeasTable() {
);
}
const rows = table.getRowModel().rows;
const rowIds = rows.map((row) => row.original.id);
const activeRow = activeId
? rows.find((row) => row.original.id === activeId)
: null;
return (
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
<TableContainer>
<Table stickyHeader size="small">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableCell
key={header.id}
sx={{
fontWeight: 600,
backgroundColor: 'grey.100',
width: header.getSize(),
}}
>
{header.column.getCanSort() ? (
<TableSortLabel
active={sorting.sortBy === header.id}
direction={
sorting.sortBy === header.id
? (sorting.sortOrder.toLowerCase() as
| 'asc'
| 'desc')
: 'asc'
}
onClick={() => handleSort(header.id)}
>
{flexRender(
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<TableContainer>
<Table stickyHeader size="small">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableCell
key={header.id}
sx={{
fontWeight: 600,
backgroundColor: 'grey.100',
width: header.getSize(),
}}
>
{header.column.getCanSort() ? (
<TableSortLabel
active={sorting.sortBy === header.id}
direction={
sorting.sortBy === header.id
? (sorting.sortOrder.toLowerCase() as
| 'asc'
| 'desc')
: 'asc'
}
onClick={() => handleSort(header.id)}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableSortLabel>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableSortLabel>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</TableCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
{Array.from({ length: SKELETON_COLUMNS_COUNT }).map(
(_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton variant="text" />
</TableCell>
),
)}
)
)}
</TableCell>
))}
</TableRow>
))
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={SKELETON_COLUMNS_COUNT}>
<Box
sx={{
py: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
color: 'text.secondary',
}}
>
<Inbox sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
<Typography variant="h6">Идей пока нет</Typography>
<Typography variant="body2">
Создайте первую идею, чтобы начать
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
hover
sx={{
backgroundColor: row.original.color
? `${row.original.color}15`
: undefined,
'&:hover': {
backgroundColor: row.original.color
? `${row.original.color}25`
: undefined,
},
}}
))}
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
{Array.from({ length: SKELETON_COLUMNS_COUNT }).map(
(_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton variant="text" />
</TableCell>
),
)}
</TableRow>
))
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={SKELETON_COLUMNS_COUNT}>
<Box
sx={{
py: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
color: 'text.secondary',
}}
>
<Inbox sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
<Typography variant="h6">Идей пока нет</Typography>
<Typography variant="body2">
Создайте первую идею, чтобы начать
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
<SortableContext
items={rowIds}
strategy={verticalListSortingStrategy}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
)}
</TableBody>
</Table>
</TableContainer>
<DragOverlay dropAnimation={null}>
{activeRow ? (
<Table
size="small"
sx={{
backgroundColor: 'background.paper',
boxShadow: 6,
borderRadius: 1,
'& td': {
backgroundColor: activeRow.original.color
? `${activeRow.original.color}30`
: 'action.selected',
},
}}
>
<TableBody>
<TableRow>
{activeRow.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
sx={{ width: cell.column.getSize() }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
@ -169,11 +265,11 @@ export function IdeasTable() {
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</TableBody>
</Table>
) : null}
</DragOverlay>
</DndContext>
{data && (
<TablePagination
component="div"

View File

@ -4,6 +4,7 @@ import { Delete } from '@mui/icons-material';
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
import { EditableCell } from './EditableCell';
import { statusOptions, priorityOptions } from './constants';
import { DragHandle } from './DraggableRow';
const columnHelper = createColumnHelper<Idea>();
@ -29,6 +30,13 @@ const priorityColors: Record<
};
export const createColumns = (onDelete: (id: string) => void) => [
columnHelper.display({
id: 'drag',
header: '',
cell: () => <DragHandle />,
size: 40,
enableSorting: false,
}),
columnHelper.accessor('title', {
header: 'Название',
cell: (info) => (

View File

@ -69,3 +69,57 @@ export function useDeleteIdea() {
},
});
}
export function useReorderIdeas() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (items: { id: string; order: number }[]) =>
ideasApi.reorder(items),
onMutate: async (items) => {
// Получаем актуальное состояние store
const { filters, sorting, pagination } = useIdeasStore.getState();
// Отменяем исходящие запросы чтобы не перезаписать оптимистичное обновление
await queryClient.cancelQueries({ queryKey: [QUERY_KEY] });
// Сохраняем предыдущее состояние для отката
const queryKey = [QUERY_KEY, { ...filters, ...sorting, ...pagination }];
const previousData = queryClient.getQueryData(queryKey);
// Оптимистично обновляем кэш
queryClient.setQueryData(
queryKey,
(
old:
| { data: { id: string; order: number }[]; meta: unknown }
| undefined,
) => {
if (!old) return old;
// Создаём новый порядок на основе items
const orderMap = new Map(items.map((item) => [item.id, item.order]));
const newData = [...old.data].sort((a, b) => {
const orderA = orderMap.get(a.id) ?? a.order;
const orderB = orderMap.get(b.id) ?? b.order;
return orderA - orderB;
});
return { ...old, data: newData };
},
);
return { previousData, queryKey };
},
onError: (_err, _items, context) => {
// Откатываем к предыдущему состоянию при ошибке
if (context?.previousData) {
queryClient.setQueryData(context.queryKey, context.previousData);
}
},
onSettled: () => {
// Инвалидируем для синхронизации с сервером
void queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
}

View File

@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { createTheme } from '@mui/material/styles';
import { AuthProvider } from './components/AuthProvider';
import App from './App.tsx';
const queryClient = new QueryClient({
@ -30,7 +31,9 @@ createRoot(rootElement).render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
<AuthProvider>
<App />
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,

View File

@ -1,4 +1,5 @@
import axios from 'axios';
import axios, { type AxiosError } from 'axios';
import keycloak from './keycloak';
export const api = axios.create({
baseURL: '/api',
@ -6,3 +7,33 @@ export const api = axios.create({
'Content-Type': 'application/json',
},
});
// Interceptor для добавления Authorization header
api.interceptors.request.use(
async (config) => {
if (keycloak.token) {
try {
await keycloak.updateToken(5);
} catch {
void keycloak.login();
return Promise.reject(new Error('Token refresh failed'));
}
config.headers.Authorization = `Bearer ${keycloak.token}`;
}
return config;
},
(error: unknown) =>
Promise.reject(error instanceof Error ? error : new Error('Request error')),
);
// Interceptor для обработки 401 ошибок
api.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
void keycloak.login();
}
return Promise.reject(error);
},
);

View File

@ -61,4 +61,8 @@ export const ideasApi = {
const { data } = await api.get<string[]>('/ideas/modules');
return data;
},
reorder: async (items: { id: string; order: number }[]): Promise<void> => {
await api.patch('/ideas/reorder', { items });
},
};

View File

@ -0,0 +1,9 @@
import Keycloak from 'keycloak-js';
const keycloak = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL as string,
realm: import.meta.env.VITE_KEYCLOAK_REALM as string,
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID as string,
});
export default keycloak;

View File

@ -42,7 +42,7 @@ interface IdeasStore {
}
const initialFilters: IdeasFilters = {};
const initialSorting: IdeasSorting = { sortBy: 'createdAt', sortOrder: 'DESC' };
const initialSorting: IdeasSorting = { sortBy: 'order', sortOrder: 'ASC' };
const initialPagination: IdeasPagination = { page: 1, limit: 20 };
export const useIdeasStore = create<IdeasStore>((set) => ({