fix
Deploy to Production / deploy-production (push) Successful in 26s
Details
Deploy to Production / deploy-production (push) Successful in 26s
Details
This commit is contained in:
parent
835bd76479
commit
47e134a857
|
|
@ -90,8 +90,36 @@ def create_livekit_room(request):
|
|||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Проверяем, есть ли LiveKit комната для этого занятия
|
||||
# Если LiveKit комната не создана — создаём «на лету» (fallback при сбое при создании урока)
|
||||
if not lesson.livekit_room_name:
|
||||
try:
|
||||
try:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ from datetime import timedelta
|
|||
# ==============================================
|
||||
|
||||
SIMPLE_JWT = {
|
||||
# Время жизни access токена (15 минут)
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
|
||||
# Время жизни access токена (3 часа - достаточно для длинных уроков)
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(hours=3),
|
||||
|
||||
# Время жизни refresh токена (7 дней)
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
|
|
|
|||
|
|
@ -403,7 +403,7 @@ REST_FRAMEWORK = {
|
|||
# ==============================================
|
||||
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(hours=1),
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(hours=3), # 3 часа - достаточно для длинных уроков
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
|
|
|
|||
|
|
@ -12,17 +12,7 @@ export default function DashboardPage() {
|
|||
const { selectedChild, loading: childLoading, childrenList } = useSelectedChild();
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '50vh',
|
||||
background: 'var(--md-sys-color-background)'
|
||||
}}>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingSpinner size="large" fullPage />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
|
|
@ -38,17 +28,7 @@ export default function DashboardPage() {
|
|||
// Родитель: те же страницы, что и студент — показываем дашборд выбранного ребёнка
|
||||
if (user.role === 'parent') {
|
||||
if (childLoading && childrenList.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '50vh',
|
||||
background: 'var(--md-sys-color-background)'
|
||||
}}>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingSpinner size="large" fullPage />;
|
||||
}
|
||||
if (childrenList.length === 0) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -76,41 +76,57 @@ export default function ProtectedLayout({
|
|||
|
||||
useEffect(() => {
|
||||
// Проверяем токен в 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) {
|
||||
console.log('[ProtectedLayout] No user found, redirecting to login');
|
||||
// Если есть refresh токен, пробуем обновить сессию вместо редиректа
|
||||
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
|
||||
}, [user, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
// Стабильный loading layout - предотвращает дёрганье
|
||||
const loadingLayout = (
|
||||
<div className="protected-layout-root">
|
||||
<main className="protected-main" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<LoadingSpinner size="large" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return loadingLayout;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return loadingLayout;
|
||||
}
|
||||
|
||||
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
|
||||
|
|
|
|||
|
|
@ -9,10 +9,18 @@ const sizeMap = {
|
|||
large: '64px',
|
||||
} as const;
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
inline?: boolean;
|
||||
/** Занимает всё доступное пространство страницы - предотвращает layout shift */
|
||||
fullPage?: boolean;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({
|
||||
size = 'medium',
|
||||
inline = false,
|
||||
}: { size?: 'small' | 'medium' | 'large'; inline?: boolean }) {
|
||||
fullPage = false,
|
||||
}: LoadingSpinnerProps) {
|
||||
const [mounted, setMounted] = 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 (inline) {
|
||||
return (
|
||||
|
|
@ -34,6 +54,18 @@ export function LoadingSpinner({
|
|||
</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>;
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +75,7 @@ export function LoadingSpinner({
|
|||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
...(fullPage ? fullPageStyle : {}),
|
||||
...(inline ? { padding: 0, width: sizeMap[size], height: sizeMap[size] } : { padding: '20px' }),
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -41,10 +41,11 @@ export const LessonCard: React.FC<LessonCardProps> = ({
|
|||
const [connectLoading, setConnectLoading] = useState(false);
|
||||
const [canJoin, setCanJoin] = useState(false);
|
||||
|
||||
// Проверяем каждые 10 секунд, чтобы кнопка появилась вовремя (решение проблемы с кэшированием)
|
||||
useEffect(() => {
|
||||
const check = () => setCanJoin(canJoinLesson(lesson));
|
||||
check();
|
||||
const interval = setInterval(check, 60000);
|
||||
const interval = setInterval(check, 10000); // 10 секунд
|
||||
return () => clearInterval(interval);
|
||||
}, [lesson.start_time, lesson.end_time, lesson.status]);
|
||||
|
||||
|
|
|
|||
|
|
@ -342,6 +342,47 @@ img {
|
|||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-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, контенту отступ снизу */
|
||||
|
|
|
|||
Loading…
Reference in New Issue