fix
Deploy to Production / deploy-production (push) Successful in 26s Details

This commit is contained in:
root 2026-02-28 21:55:33 +03:00
parent 835bd76479
commit 47e134a857
22 changed files with 2612 additions and 2513 deletions

View File

@ -90,12 +90,40 @@ def create_livekit_room(request):
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
# Проверяем, есть ли LiveKit комната для этого занятия # Если LiveKit комната не создана — создаём «на лету» (fallback при сбое при создании урока)
if not lesson.livekit_room_name: if not lesson.livekit_room_name:
return Response( try:
{'error': 'LiveKit комната не создана для этого урока. Обратитесь к администратору.'}, try:
status=status.HTTP_500_INTERNAL_SERVER_ERROR existing = VideoRoom.objects.get(lesson=lesson)
) room_name = str(existing.room_id)
except VideoRoom.DoesNotExist:
room_name = LiveKitService.generate_room_name()
client_user = lesson.client.user if hasattr(lesson.client, 'user') else lesson.client
VideoRoom.objects.create(
lesson=lesson,
mentor=lesson.mentor,
client=client_user,
room_id=room_name,
is_recording=True,
max_participants=10 if lesson.group else 2,
)
mentor_token = LiveKitService.generate_access_token(
room_name=room_name,
participant_name=lesson.mentor.get_full_name(),
participant_identity=str(lesson.mentor.pk),
is_admin=True,
expires_in_minutes=1440,
)
lesson.livekit_room_name = room_name
lesson.livekit_access_token = mentor_token
lesson.save(update_fields=['livekit_room_name', 'livekit_access_token'])
logger.info(f'LiveKit room created on-demand for lesson {lesson.id}')
except Exception:
logger.exception(f'Failed to create LiveKit room for lesson {lesson.id}')
return Response(
{'error': 'LiveKit комната не создана для этого урока. Обратитесь к администратору.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
room_name = lesson.livekit_room_name room_name = lesson.livekit_room_name

View File

@ -9,8 +9,8 @@ from datetime import timedelta
# ============================================== # ==============================================
SIMPLE_JWT = { SIMPLE_JWT = {
# Время жизни access токена (15 минут) # Время жизни access токена (3 часа - достаточно для длинных уроков)
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), 'ACCESS_TOKEN_LIFETIME': timedelta(hours=3),
# Время жизни refresh токена (7 дней) # Время жизни refresh токена (7 дней)
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7),

View File

@ -403,7 +403,7 @@ REST_FRAMEWORK = {
# ============================================== # ==============================================
SIMPLE_JWT = { SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), 'ACCESS_TOKEN_LIFETIME': timedelta(hours=3), # 3 часа - достаточно для длинных уроков
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True, 'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True, 'BLACKLIST_AFTER_ROTATION': True,

View File

@ -12,17 +12,7 @@ export default function DashboardPage() {
const { selectedChild, loading: childLoading, childrenList } = useSelectedChild(); const { selectedChild, loading: childLoading, childrenList } = useSelectedChild();
if (authLoading) { if (authLoading) {
return ( return <LoadingSpinner size="large" fullPage />;
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '50vh',
background: 'var(--md-sys-color-background)'
}}>
<LoadingSpinner size="large" />
</div>
);
} }
if (!user) { if (!user) {
@ -38,17 +28,7 @@ export default function DashboardPage() {
// Родитель: те же страницы, что и студент — показываем дашборд выбранного ребёнка // Родитель: те же страницы, что и студент — показываем дашборд выбранного ребёнка
if (user.role === 'parent') { if (user.role === 'parent') {
if (childLoading && childrenList.length === 0) { if (childLoading && childrenList.length === 0) {
return ( return <LoadingSpinner size="large" fullPage />;
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '50vh',
background: 'var(--md-sys-color-background)'
}}>
<LoadingSpinner size="large" />
</div>
);
} }
if (childrenList.length === 0) { if (childrenList.length === 0) {
return ( return (

View File

@ -76,41 +76,57 @@ export default function ProtectedLayout({
useEffect(() => { useEffect(() => {
// Проверяем токен в localStorage напрямую, чтобы избежать race condition // Проверяем токен в localStorage напрямую, чтобы избежать race condition
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; const accessToken = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refresh_token') : null;
console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname }); console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasAccessToken: !!accessToken, hasRefreshToken: !!refreshToken, pathname });
if (!loading && !user) { if (!loading && !user) {
console.log('[ProtectedLayout] No user found, redirecting to login'); // Если есть refresh токен, пробуем обновить сессию вместо редиректа
router.replace('/login'); if (refreshToken) {
console.log('[ProtectedLayout] User lost but refresh token exists, trying to restore session...');
import('@/api/auth').then(({ refreshToken: doRefresh }) => {
doRefresh(refreshToken)
.then(({ access }) => {
if (access) {
localStorage.setItem('access_token', access);
console.log('[ProtectedLayout] Session restored, reloading user...');
window.location.reload();
} else {
console.log('[ProtectedLayout] Refresh failed, redirecting to login');
router.replace('/login');
}
})
.catch(() => {
console.log('[ProtectedLayout] Refresh error, redirecting to login');
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
router.replace('/login');
});
});
} else {
console.log('[ProtectedLayout] No user and no refresh token, redirecting to login');
router.replace('/login');
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, loading]); }, [user, loading]);
if (loading) { // Стабильный loading layout - предотвращает дёрганье
return ( const loadingLayout = (
<div <div className="protected-layout-root">
style={{ <main className="protected-main" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<LoadingSpinner size="large" /> <LoadingSpinner size="large" />
</div> </main>
); </div>
);
if (loading) {
return loadingLayout;
} }
if (!user) { if (!user) {
return ( return loadingLayout;
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: 'var(--md-sys-color-background)' }}>
<div style={{ textAlign: 'center', color: 'var(--md-sys-color-on-surface)' }}>
<LoadingSpinner size="large" />
<p style={{ marginTop: '16px', fontSize: '14px', opacity: 0.8 }}>Проверка авторизации...</p>
</div>
</div>
);
} }
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом // Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом

View File

@ -9,10 +9,18 @@ const sizeMap = {
large: '64px', large: '64px',
} as const; } as const;
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large';
inline?: boolean;
/** Занимает всё доступное пространство страницы - предотвращает layout shift */
fullPage?: boolean;
}
export function LoadingSpinner({ export function LoadingSpinner({
size = 'medium', size = 'medium',
inline = false, inline = false,
}: { size?: 'small' | 'medium' | 'large'; inline?: boolean }) { fullPage = false,
}: LoadingSpinnerProps) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [componentsLoaded, setComponentsLoaded] = useState(false); const [componentsLoaded, setComponentsLoaded] = useState(false);
@ -23,6 +31,18 @@ export function LoadingSpinner({
}); });
}, []); }, []);
// Стили для fullPage режима - занимает всё пространство, предотвращает дёрганье
const fullPageStyle: React.CSSProperties = fullPage ? {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
minHeight: '100%',
flex: 1,
background: 'var(--md-sys-color-background)',
} : {};
if (!mounted || !componentsLoaded) { if (!mounted || !componentsLoaded) {
if (inline) { if (inline) {
return ( return (
@ -34,6 +54,18 @@ export function LoadingSpinner({
</span> </span>
); );
} }
if (fullPage) {
return (
<div style={fullPageStyle}>
<span
className="material-symbols-outlined"
style={{ fontSize: sizeMap[size], animation: 'lk-spin 1s linear infinite', color: 'var(--md-sys-color-primary)' }}
>
progress_activity
</span>
</div>
);
}
return <div>Загрузка...</div>; return <div>Загрузка...</div>;
} }
@ -43,6 +75,7 @@ export function LoadingSpinner({
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
...(fullPage ? fullPageStyle : {}),
...(inline ? { padding: 0, width: sizeMap[size], height: sizeMap[size] } : { padding: '20px' }), ...(inline ? { padding: 0, width: sizeMap[size], height: sizeMap[size] } : { padding: '20px' }),
}} }}
> >

View File

@ -41,10 +41,11 @@ export const LessonCard: React.FC<LessonCardProps> = ({
const [connectLoading, setConnectLoading] = useState(false); const [connectLoading, setConnectLoading] = useState(false);
const [canJoin, setCanJoin] = useState(false); const [canJoin, setCanJoin] = useState(false);
// Проверяем каждые 10 секунд, чтобы кнопка появилась вовремя (решение проблемы с кэшированием)
useEffect(() => { useEffect(() => {
const check = () => setCanJoin(canJoinLesson(lesson)); const check = () => setCanJoin(canJoinLesson(lesson));
check(); check();
const interval = setInterval(check, 60000); const interval = setInterval(check, 10000); // 10 секунд
return () => clearInterval(interval); return () => clearInterval(interval);
}, [lesson.start_time, lesson.end_time, lesson.status]); }, [lesson.start_time, lesson.end_time, lesson.status]);

View File

@ -342,6 +342,47 @@ img {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
/* Предотвращение layout shift при переходах */
contain: layout style;
}
/* Плавные переходы между страницами - предотвращение дёрганья */
.protected-layout-root .protected-main > * {
animation: pageContentFadeIn 0.2s ease-out;
will-change: opacity;
}
@keyframes pageContentFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Стабилизация layout при загрузке - контент сразу занимает всё пространство */
.ios26-dashboard,
.ios26-schedule-page,
.ios26-students-page,
.ios26-chat-page,
.ios26-materials-page,
.ios26-profile-page,
.ios26-homework-page,
.ios26-analytics-page,
.ios26-payment-page {
min-height: 100%;
width: 100%;
contain: layout style;
}
/* Отключаем анимацию для уменьшения motion (accessibility) */
@media (prefers-reduced-motion: reduce) {
.protected-layout-root .protected-main > * {
animation: none;
}
} }
/* Ноутбук и выше (768px+): нижний бар fixed, bottom 20px, контенту отступ снизу */ /* Ноутбук и выше (768px+): нижний бар fixed, bottom 20px, контенту отступ снизу */