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

This commit is contained in:
root 2026-02-18 01:15:06 +03:00
parent b4b99491ae
commit d4c4dbb087
37 changed files with 11862 additions and 11180 deletions

View File

@ -99,9 +99,19 @@ class UpdateLastActivityMiddleware:
cache.set(cache_key, now, timeout=self.UPDATE_INTERVAL * 2) cache.set(cache_key, now, timeout=self.UPDATE_INTERVAL * 2)
# Обновляем объект пользователя в запросе для текущего запроса # Обновляем объект пользователя в запросе для текущего запроса
# Это позволяет использовать обновленное значение в текущем запросе
user.last_activity = now user.last_activity = now
# Учёт дня активности для реферальной программы (не чаще 1 раза в день на пользователя)
today = now.date()
day_cache_key = f'referral_activity_day:{user.id}:{today}'
if not cache.get(day_cache_key):
try:
from apps.referrals.models import UserActivityDay
UserActivityDay.objects.get_or_create(user=user, date=today)
cache.set(day_cache_key, 1, timeout=86400 * 2)
except Exception:
pass
except Exception as e: except Exception as e:
# Логируем ошибку, но не прерываем выполнение запроса # Логируем ошибку, но не прерываем выполнение запроса
logger.error(f"Ошибка при обновлении last_activity для пользователя {user.id}: {e}", exc_info=True) logger.error(f"Ошибка при обновлении last_activity для пользователя {user.id}: {e}", exc_info=True)

View File

@ -309,9 +309,37 @@ class User(AbstractUser):
def __str__(self): def __str__(self):
return f"{self.get_full_name()} ({self.email})" return f"{self.get_full_name()} ({self.email})"
def _generate_universal_code(self):
"""Генерация уникального 8-символьного кода (цифры + латинские буквы AZ)."""
alphabet = string.ascii_uppercase + string.digits
for _ in range(100):
code = ''.join(random.choices(alphabet, k=8))
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
return code
raise ValueError('Не удалось сгенерировать уникальный universal_code')
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.phone: if self.phone:
self.phone = normalize_phone(self.phone) self.phone = normalize_phone(self.phone)
# Автоматическая генерация username из email, если не задан
if not self.username and self.email:
self.username = self.email.split('@')[0]
# Добавляем цифры, если username уже существует
counter = 1
original_username = self.username
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
self.username = f"{original_username}{counter}"
counter += 1
# Гарантируем 8-символьный код (universal_code)
if not self.universal_code:
try:
self.universal_code = self._generate_universal_code()
except Exception:
# Если не удалось сгенерировать, не прерываем сохранение
pass
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -365,35 +393,7 @@ class Mentor(User):
"""Может ли пользователь получить доступ к админ-панели.""" """Может ли пользователь получить доступ к админ-панели."""
return self.is_staff or self.is_superuser or self.role == 'admin' return self.is_staff or self.is_superuser or self.role == 'admin'
def _generate_universal_code(self): # Мы удалили Mentor.save и _generate_universal_code, так как они теперь в User
"""Генерация уникального 8-символьного кода (цифры + латинские буквы AZ)."""
alphabet = string.ascii_uppercase + string.digits
for _ in range(100):
code = ''.join(random.choices(alphabet, k=8))
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
return code
raise ValueError('Не удалось сгенерировать уникальный universal_code')
def save(self, *args, **kwargs):
"""Переопределение save для автоматической генерации username и universal_code."""
if not self.username:
# Генерируем username из email, если не задан
self.username = self.email.split('@')[0]
# Добавляем цифры, если username уже существует
counter = 1
original_username = self.username
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
self.username = f"{original_username}{counter}"
counter += 1
if not self.universal_code:
try:
self.universal_code = self._generate_universal_code()
except Exception:
# Если не удалось сгенерировать, не прерываем сохранение
# Код будет сгенерирован при следующем запросе профиля или в RegisterView
pass
super().save(*args, **kwargs)
class Client(models.Model): class Client(models.Model):

View File

@ -1505,6 +1505,27 @@ class InvitationViewSet(viewsets.ViewSet):
city=city city=city
) )
# Гарантируем 8-символьный код для приглашений (ментор/студент)
if not student_user.universal_code or len(str(student_user.universal_code or '').strip()) != 8:
try:
# Теперь метод _generate_universal_code определен в базовой модели User
student_user.universal_code = student_user._generate_universal_code()
student_user.save(update_fields=['universal_code'])
except Exception:
# Fallback на случай ошибок генерации
import string
import random
try:
alphabet = string.ascii_uppercase + string.digits
for _ in range(500):
code = ''.join(random.choices(alphabet, k=8))
if not User.objects.filter(universal_code=code).exclude(pk=student_user.pk).exists():
student_user.universal_code = code
student_user.save(update_fields=['universal_code'])
break
except Exception:
pass
# Генерируем персональный токен для входа # Генерируем персональный токен для входа
student_user.login_token = secrets.token_urlsafe(32) student_user.login_token = secrets.token_urlsafe(32)
student_user.save(update_fields=['login_token']) student_user.save(update_fields=['login_token'])
@ -1538,6 +1559,8 @@ class InvitationViewSet(viewsets.ViewSet):
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(student_user) refresh = RefreshToken.for_user(student_user)
# Обновляем из БД, чтобы в ответе был актуальный universal_code
student_user.refresh_from_db()
return Response({ return Response({
'refresh': str(refresh), 'refresh': str(refresh),
'access': str(refresh.access_token), 'access': str(refresh.access_token),

View File

@ -391,10 +391,10 @@ REST_FRAMEWORK = {
'rest_framework.throttling.UserRateThrottle', 'rest_framework.throttling.UserRateThrottle',
], ],
'DEFAULT_THROTTLE_RATES': { 'DEFAULT_THROTTLE_RATES': {
'anon': '100/hour', # Для неавторизованных пользователей 'anon': '200/hour', # Для неавторизованных пользователей
'user': '1000/hour', # Для авторизованных пользователей 'user': '5000/hour', # Для авторизованных пользователей
'burst': '60/minute', # Для критичных endpoints (login, register) 'burst': '60/minute', # Для критичных endpoints (login, register)
'upload': '20/hour', # Для загрузки файлов 'upload': '60/hour', # Для загрузки файлов
}, },
} }

View File

@ -84,28 +84,41 @@ RUN mkdir -p public
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm run build RUN npm run build
# Проверяем, что standalone создался (выводим структуру для отладки)
RUN echo "=== Checking standalone output ===" && \
ls -la /app/.next/ || echo "No .next directory" && \
ls -la /app/.next/standalone/ 2>/dev/null || echo "No standalone directory" && \
test -f /app/.next/standalone/server.js || (echo "ERROR: server.js not found in standalone" && ls -la /app/.next/standalone/ && exit 1)
# Production stage # Production stage
FROM node:20-alpine AS production FROM node:20-alpine AS production
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Копируем собранное приложение (standalone mode) # Копируем собранное приложение (standalone mode)
COPY --from=production-build /app/.next/standalone ./ # В Next.js standalone структура: /app/.next/standalone/ содержит server.js, .next/server/, node_modules/
COPY --from=production-build /app/.next/static ./.next/static # Копируем всё содержимое standalone в корень /app
COPY --from=production-build /app/public ./public COPY --from=production-build --chown=nextjs:nodejs /app/.next/standalone ./
# Статические файлы (standalone их не включает автоматически)
COPY --from=production-build --chown=nextjs:nodejs /app/.next/static ./.next/static
# Public файлы (standalone их не включает автоматически)
COPY --from=production-build --chown=nextjs:nodejs /app/public ./public
# Проверяем, что server.js существует
RUN test -f /app/server.js || (echo "ERROR: server.js not found after copy" && ls -la /app/ && exit 1)
# Создаем непривилегированного пользователя # Создаем непривилегированного пользователя
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs && \
RUN adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
RUN chown -R nextjs:nodejs /app
USER nextjs USER nextjs
# Открываем порт # Открываем порт
EXPOSE 3000 EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Запускаем production server # Запускаем production server
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@ -8,6 +8,7 @@ export default function AuthLayout({
return ( return (
<AuthRedirect> <AuthRedirect>
<div <div
className="auth-layout"
data-no-nav data-no-nav
style={{ style={{
minHeight: '100vh', minHeight: '100vh',
@ -17,6 +18,7 @@ export default function AuthLayout({
> >
{/* Левая колонка — пустая, фон как у body */} {/* Левая колонка — пустая, фон как у body */}
<div <div
className="auth-layout-logo"
style={{ style={{
minHeight: '100vh', minHeight: '100vh',
display: 'flex', display: 'flex',
@ -38,6 +40,7 @@ export default function AuthLayout({
{/* Правая колонка — форма на белом фоне */} {/* Правая колонка — форма на белом фоне */}
<div <div
className="auth-layout-form"
style={{ style={{
minHeight: '100vh', minHeight: '100vh',
background: '#fff', background: '#fff',

View File

@ -234,6 +234,7 @@ export default function RegisterPage() {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div <div
className="auth-name-grid"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '1fr 1fr', gridTemplateColumns: '1fr 1fr',

View File

@ -10,6 +10,7 @@ import { ChatWindow } from '@/components/chat/ChatWindow';
import { usePresenceWebSocket } from '@/hooks/usePresenceWebSocket'; import { usePresenceWebSocket } from '@/hooks/usePresenceWebSocket';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext'; import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
import { useIsMobile } from '@/hooks/useIsMobile';
export default function ChatPage() { export default function ChatPage() {
const { user } = useAuth(); const { user } = useAuth();
@ -17,6 +18,7 @@ export default function ChatPage() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const uuidFromUrl = searchParams.get('uuid'); const uuidFromUrl = searchParams.get('uuid');
const isMobile = useIsMobile();
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [chats, setChats] = React.useState<Chat[]>([]); const [chats, setChats] = React.useState<Chat[]>([]);
@ -166,70 +168,102 @@ export default function ChatPage() {
} }
}, [normalizeChat, refreshNavBadges]); }, [normalizeChat, refreshNavBadges]);
const handleBackToList = React.useCallback(() => {
setSelected(null);
router.replace(pathname ?? '/chat');
}, [router, pathname]);
// Mobile: show only list or only chat
const mobileShowChat = isMobile && selected != null;
// Hide bottom navigation when a chat is open on mobile
React.useEffect(() => {
if (mobileShowChat) {
document.documentElement.classList.add('mobile-chat-open');
} else {
document.documentElement.classList.remove('mobile-chat-open');
}
return () => {
document.documentElement.classList.remove('mobile-chat-open');
};
}, [mobileShowChat]);
return ( return (
<div className="ios26-dashboard ios26-chat-page" style={{ padding: '16px' }}> <div className="ios26-dashboard ios26-chat-page" style={{ padding: isMobile ? '8px' : '16px' }}>
<Box <Box
className="ios26-chat-layout" className="ios26-chat-layout"
sx={{ sx={{
display: 'grid', display: isMobile ? 'flex' : 'grid',
gridTemplateColumns: '320px 1fr', gridTemplateColumns: isMobile ? undefined : '320px 1fr',
flexDirection: isMobile ? 'column' : undefined,
gap: 'var(--ios26-spacing)', gap: 'var(--ios26-spacing)',
alignItems: 'stretch', alignItems: 'stretch',
height: 'calc(90vh - 32px)', height: isMobile ? '100%' : 'calc(90vh - 32px)',
maxHeight: 'calc(90vh - 32px)', maxHeight: isMobile ? undefined : 'calc(90vh - 32px)',
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{loading ? ( {/* Chat list: hidden on mobile when a chat is selected */}
<Box {!mobileShowChat && (
className="ios-glass-panel" <>
sx={{ {loading ? (
borderRadius: '20px', <Box
p: 2, className="ios-glass-panel"
display: 'flex', sx={{
alignItems: 'center', borderRadius: '20px',
justifyContent: 'center', p: 2,
color: 'var(--md-sys-color-on-surface-variant)', display: 'flex',
background: 'var(--ios26-glass)', flex: isMobile ? 1 : undefined,
border: '1px solid var(--ios26-glass-border)', alignItems: 'center',
backdropFilter: 'var(--ios26-blur)', justifyContent: 'center',
}} color: 'var(--md-sys-color-on-surface-variant)',
> background: 'var(--ios26-glass)',
<Typography>Загрузка</Typography> border: '1px solid var(--ios26-glass-border)',
</Box> backdropFilter: 'var(--ios26-blur)',
) : error ? ( }}
<Box >
className="ios-glass-panel" <Typography>Загрузка</Typography>
sx={{ </Box>
borderRadius: '20px', ) : error ? (
p: 2, <Box
display: 'flex', className="ios-glass-panel"
alignItems: 'center', sx={{
justifyContent: 'center', borderRadius: '20px',
color: 'var(--md-sys-color-on-surface-variant)', p: 2,
background: 'var(--ios26-glass)', display: 'flex',
border: '1px solid var(--ios26-glass-border)', flex: isMobile ? 1 : undefined,
backdropFilter: 'var(--ios26-blur)', alignItems: 'center',
}} justifyContent: 'center',
> color: 'var(--md-sys-color-on-surface-variant)',
<Typography>{error}</Typography> background: 'var(--ios26-glass)',
</Box> border: '1px solid var(--ios26-glass-border)',
) : ( backdropFilter: 'var(--ios26-blur)',
<ChatList }}
chats={chats} >
selectedChatUuid={selected?.uuid ?? (selected as any)?.uuid ?? null} <Typography>{error}</Typography>
onSelect={handleSelectChat} </Box>
hasMore={hasMore} ) : (
loadingMore={loadingMore} <ChatList
onLoadMore={loadMore} chats={chats}
/> selectedChatUuid={selected?.uuid ?? (selected as any)?.uuid ?? null}
onSelect={handleSelectChat}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadMore}
/>
)}
</>
)} )}
<ChatWindow {/* Chat window: on mobile only visible when a chat is selected */}
chat={selected} {(!isMobile || mobileShowChat) && (
currentUserId={user?.id ?? null} <ChatWindow
onMessagesMarkedAsRead={refreshChatListUnread} chat={selected}
/> currentUserId={user?.id ?? null}
onBack={isMobile ? handleBackToList : undefined}
onMessagesMarkedAsRead={refreshChatListUnread}
/>
)}
</Box> </Box>
</div> </div>
); );

View File

@ -1,19 +1,7 @@
'use client'; 'use client';
import { useEffect, useState, useCallback, Suspense } from 'react'; import { useEffect, useState, useCallback, Suspense } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
const MOBILE_BREAKPOINT = 767;
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
setIsMobile(mq.matches);
const listener = () => setIsMobile(mq.matches);
mq.addEventListener('change', listener);
return () => mq.removeEventListener('change', listener);
}, []);
return isMobile;
}
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar'; import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
import { TopNavigationBar } from '@/components/navigation/TopNavigationBar'; import { TopNavigationBar } from '@/components/navigation/TopNavigationBar';
@ -91,9 +79,9 @@ export default function ProtectedLayout({
console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname }); console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname });
if (!loading && !user && !token) { if (!loading && !user) {
console.log('[ProtectedLayout] Redirecting to login'); console.log('[ProtectedLayout] No user found, redirecting to login');
router.push('/login'); router.replace('/login');
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, loading]); }, [user, loading]);
@ -114,7 +102,14 @@ export default function ProtectedLayout({
} }
if (!user) { if (!user) {
return null; 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>
);
} }
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом // Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
@ -153,24 +148,13 @@ export default function ProtectedLayout({
return ( return (
<NavBadgesProvider refreshNavBadges={refreshNavBadges}> <NavBadgesProvider refreshNavBadges={refreshNavBadges}>
<SelectedChildProvider> <SelectedChildProvider>
<div <div className="protected-layout-root">
className="protected-layout-root"
style={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
height: '100vh',
}}
>
{!isFullWidthPage && <TopNavigationBar user={user} />} {!isFullWidthPage && <TopNavigationBar user={user} />}
<main <main
className="protected-main" className="protected-main"
data-no-nav={isLiveKit ? true : undefined} data-no-nav={isLiveKit ? true : undefined}
data-full-width={isFullWidthPage ? true : undefined} data-full-width={isFullWidthPage ? true : undefined}
style={{ style={{
flex: 1,
minHeight: 0,
overflow: 'auto',
padding: isFullWidthPage ? '0' : '16px', padding: isFullWidthPage ? '0' : '16px',
maxWidth: isFullWidthPage ? '100%' : '1200px', maxWidth: isFullWidthPage ? '100%' : '1200px',
margin: isFullWidthPage ? '0' : '0 auto', margin: isFullWidthPage ? '0' : '0 auto',

View File

@ -573,7 +573,7 @@ export default function MaterialsPage() {
} }
return ( return (
<div style={{ padding: '24px', minHeight: '100vh' }}> <div style={{ padding: '24px' }}>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@ -796,9 +796,10 @@ export default function MaterialsPage() {
</md-elevated-card> </md-elevated-card>
) : ( ) : (
<div <div
className="materials-cards-grid"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gridTemplateColumns: 'repeat(auto-fill, minmax(min(340px, 100%), 1fr))',
gap: '24px', gap: '24px',
opacity: materialsLoading ? 0.7 : 1, opacity: materialsLoading ? 0.7 : 1,
transition: 'opacity 0.2s ease', transition: 'opacity 0.2s ease',
@ -1482,13 +1483,14 @@ export default function MaterialsPage() {
{/* Sidebar */} {/* Sidebar */}
<div <div
className="students-side-panel"
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
width: '100%', width: '100%',
maxWidth: '400px', maxWidth: 'min(400px, 100vw)',
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.15)', boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.15)',
zIndex: 101, zIndex: 101,
@ -1905,6 +1907,7 @@ export default function MaterialsPage() {
}} }}
/> />
<div <div
className="students-side-panel"
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, top: 0,

View File

@ -287,7 +287,7 @@ export default function MyProgressPage() {
}; };
return ( return (
<div style={{ width: '100%', minHeight: '100vh' }}> <div style={{ width: '100%' }}>
<DashboardLayout className="ios26-dashboard-grid"> <DashboardLayout className="ios26-dashboard-grid">
{/* Ячейка 1: Общая статистика за период + выбор предмета и даты */} {/* Ячейка 1: Общая статистика за период + выбор предмета и даты */}
<Panel padding="md"> <Panel padding="md">
@ -325,7 +325,7 @@ export default function MyProgressPage() {
<LoadingSpinner size="medium" /> <LoadingSpinner size="medium" />
</div> </div>
) : ( ) : (
<div className="ios26-stat-grid" style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}> <div className="ios26-stat-grid my-progress-grid" style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>
<div className="ios26-stat-tile"> <div className="ios26-stat-tile">
<div className="ios26-stat-label">Занятий проведено</div> <div className="ios26-stat-label">Занятий проведено</div>
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.completedLessons}</div> <div className="ios26-stat-value ios26-stat-value--primary">{periodStats.completedLessons}</div>

View File

@ -383,7 +383,6 @@ function ProfilePage() {
<div <div
className="page-profile" className="page-profile"
style={{ style={{
minHeight: '100vh',
padding: 24, padding: 24,
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',

View File

@ -5,13 +5,14 @@ import { ReferralsPageContent } from '@/components/referrals/ReferralsPageConten
export default function ReferralsPage() { export default function ReferralsPage() {
return ( return (
<div <div
className="page-referrals"
style={{ style={{
minHeight: '100vh',
padding: 24, padding: 24,
background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)', background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)',
}} }}
> >
<div <div
className="page-referrals-card"
style={{ style={{
background: '#fff', background: '#fff',
borderRadius: 20, borderRadius: 20,

View File

@ -121,7 +121,6 @@ export default function RequestMentorPage() {
<div <div
style={{ style={{
padding: '24px', padding: '24px',
minHeight: '100vh',
}} }}
> >
{/* Табы всегда видны — Менторы | Ожидают ответа (ваши запросы) | Входящие приглашения (от менторов) */} {/* Табы всегда видны — Менторы | Ожидают ответа (ваши запросы) | Входящие приглашения (от менторов) */}
@ -211,6 +210,7 @@ export default function RequestMentorPage() {
</div> </div>
)} )}
<div <div
className="students-cards-grid"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
@ -364,6 +364,7 @@ export default function RequestMentorPage() {
</md-elevated-card> </md-elevated-card>
) : ( ) : (
<div <div
className="students-cards-grid"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
@ -468,6 +469,7 @@ export default function RequestMentorPage() {
) )
) : ( ) : (
<div <div
className="students-cards-grid"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
@ -734,12 +736,13 @@ export default function RequestMentorPage() {
{/* Боковая панель с формой (как на странице студентов) */} {/* Боковая панель с формой (как на странице студентов) */}
{showAddPanel && ( {showAddPanel && (
<div <div
className="students-side-panel"
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, top: 0,
right: 0, right: 0,
height: '100vh', height: '100vh',
width: '420px', width: 'min(420px, 100vw)',
padding: '20px 20px 24px', padding: '20px 20px 24px',
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
boxShadow: '0 0 24px rgba(0,0,0,0.18)', boxShadow: '0 0 24px rgba(0,0,0,0.18)',

View File

@ -415,7 +415,7 @@ export default function SchedulePage() {
alignItems: 'stretch', alignItems: 'stretch',
// стабилизируем высоту секции (без фиксированных px), // стабилизируем высоту секции (без фиксированных px),
// чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента // чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента
minHeight: 'calc(100vh - 160px)', minHeight: 'min(calc(100vh - 160px), 600px)',
}}> }}>
<div className="ios26-schedule-calendar-wrap"> <div className="ios26-schedule-calendar-wrap">
<Calendar <Calendar

View File

@ -416,18 +416,20 @@ export default function StudentsPage() {
className="page-students" className="page-students"
style={{ style={{
padding: '24px', padding: '24px',
minHeight: '100vh',
}} }}
> >
{/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */} {/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */}
{(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && ( {(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && (
<div <div
className="students-tabs"
style={{ style={{
display: 'flex', display: 'flex',
gap: 4, gap: 4,
marginBottom: 24, marginBottom: 24,
borderBottom: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))', borderBottom: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
paddingBottom: 0, paddingBottom: 0,
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}} }}
> >
<button <button
@ -1408,8 +1410,8 @@ export default function StudentsPage() {
sx: { sx: {
mt: 1, mt: 1,
borderRadius: 12, borderRadius: 12,
minWidth: 300, minWidth: { xs: 'calc(100vw - 32px)', sm: 300 },
maxWidth: 360, maxWidth: { xs: 'calc(100vw - 32px)', sm: 360 },
overflow: 'hidden', overflow: 'hidden',
}, },
}, },

View File

@ -142,7 +142,7 @@ export default function InvitationPage() {
style={{ width: '120px', height: 'auto' }} style={{ width: '120px', height: 'auto' }}
/> />
</div> </div>
<h1 style={{ fontSize: '32px', fontWeight: '700', color: 'var(--md-sys-color-on-surface)', marginBottom: '8px' }}>Присоединяйтесь!</h1> <h1 className="invite-title" style={{ fontSize: '32px', fontWeight: '700', color: 'var(--md-sys-color-on-surface)', marginBottom: '8px' }}>Присоединяйтесь!</h1>
<p style={{ fontSize: '16px', color: 'var(--md-sys-color-on-surface-variant)' }}> <p style={{ fontSize: '16px', color: 'var(--md-sys-color-on-surface-variant)' }}>
Вас пригласил ментор <span style={{ fontWeight: '600', color: 'var(--md-sys-color-primary)' }}>{mentor?.mentor_name}</span> Вас пригласил ментор <span style={{ fontWeight: '600', color: 'var(--md-sys-color-primary)' }}>{mentor?.mentor_name}</span>
</p> </p>
@ -157,9 +157,9 @@ export default function InvitationPage() {
)} )}
</div> </div>
<div style={{ padding: '32px', borderRadius: '24px', background: 'var(--md-sys-color-surface)', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}> <div className="invite-form-card" style={{ padding: '32px', borderRadius: '24px', background: 'var(--md-sys-color-surface)', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}> <div className="auth-name-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<md-filled-text-field <md-filled-text-field
label="Имя" label="Имя"
value={formData.first_name} value={formData.first_name}

View File

@ -6,12 +6,24 @@ import '@/styles/material-theme.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Uchill Platform', title: 'Uchill Platform',
description: 'Образовательная платформа', description: 'Образовательная платформа',
viewport: 'width=device-width, initial-scale=1, maximum-scale=5', viewport: 'width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover',
themeColor: '#7444FD', themeColor: '#7444FD',
manifest: '/manifest.json',
icons: { icons: {
icon: '/favicon.png', icon: '/icon.svg',
shortcut: '/favicon.png', shortcut: '/icon.svg',
apple: '/favicon.png', apple: '/icon.svg',
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Uchill',
},
formatDetection: {
telephone: false,
},
other: {
'mobile-web-app-capable': 'yes',
}, },
}; };

View File

@ -38,7 +38,21 @@ export function AuthRedirect({ children }: { children: React.ReactNode }) {
} }
if (user) { if (user) {
return null; // редирект уже идёт return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
}}
>
<div style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
Вы уже вошли. Перенаправление...
</div>
</div>
);
} }
return <>{children}</>; return <>{children}</>;

View File

@ -136,6 +136,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
return ( return (
<div <div
className="checklesson-root"
style={{ style={{
position: 'relative', position: 'relative',
width: '100%', width: '100%',
@ -385,6 +386,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
<> <>
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="checklesson-form"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '1fr 1fr', gridTemplateColumns: '1fr 1fr',

View File

@ -1,18 +1,32 @@
/** /**
* Material Design 3 Date Picker * Material Design 3 Date Picker Dialog variant.
* Opens a calendar inside a MUI Dialog (works well on mobile and inside other dialogs).
*/ */
'use client'; 'use client';
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useMemo } from 'react';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, startOfWeek, endOfWeek } from 'date-fns'; import {
format,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameDay,
isSameMonth,
addMonths,
subMonths,
startOfWeek,
endOfWeek,
} from 'date-fns';
import { ru } from 'date-fns/locale'; import { ru } from 'date-fns/locale';
import { Dialog, DialogContent, Box, Button } from '@mui/material';
interface DatePickerProps { interface DatePickerProps {
value: string; // YYYY-MM-DD format value: string; // YYYY-MM-DD format
onChange: (value: string) => void; onChange: (value: string) => void;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
label?: string;
} }
export const DatePicker: React.FC<DatePickerProps> = ({ export const DatePicker: React.FC<DatePickerProps> = ({
@ -20,66 +34,65 @@ export const DatePicker: React.FC<DatePickerProps> = ({
onChange, onChange,
disabled = false, disabled = false,
required = false, required = false,
label,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [open, setOpen] = useState(false);
const [displayMonth, setDisplayMonth] = useState(value ? new Date(value) : new Date()); const [displayMonth, setDisplayMonth] = useState(
const containerRef = useRef<HTMLDivElement>(null); value ? new Date(value + 'T00:00:00') : new Date(),
);
const selectedDate = value ? new Date(value) : null; const selectedDate = useMemo(
() => (value ? new Date(value + 'T00:00:00') : null),
[value],
);
// Закрываем picker при клике вне компонента const openPicker = () => {
useEffect(() => { if (disabled) return;
const handleClickOutside = (event: MouseEvent) => { setDisplayMonth(selectedDate ?? new Date());
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setOpen(true);
setIsOpen(false); };
}
};
if (isOpen) { const closePicker = () => setOpen(false);
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleDateSelect = (date: Date) => { const handleDateSelect = (date: Date) => {
const year = date.getFullYear(); const y = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const m = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0');
onChange(`${year}-${month}-${day}`); onChange(`${y}-${m}-${d}`);
setIsOpen(false); closePicker();
}; };
// Получаем дни для отображения в календаре const days = useMemo(() => {
const getDaysInMonth = () => {
const start = startOfMonth(displayMonth); const start = startOfMonth(displayMonth);
const end = endOfMonth(displayMonth); const end = endOfMonth(displayMonth);
const startDate = startOfWeek(start, { locale: ru }); return eachDayOfInterval({
const endDate = endOfWeek(end, { locale: ru }); start: startOfWeek(start, { locale: ru }),
end: endOfWeek(end, { locale: ru }),
});
}, [displayMonth]);
return eachDayOfInterval({ start: startDate, end: endDate });
};
const days = getDaysInMonth();
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const displayValue = selectedDate
? format(selectedDate, 'd MMMM yyyy', { locale: ru })
: label || 'Выберите дату';
return ( return (
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}> <>
{/* Input field */}
<button <button
type="button" type="button"
onClick={() => !disabled && setIsOpen(!isOpen)} onClick={openPicker}
disabled={disabled} disabled={disabled}
aria-required={required}
style={{ style={{
width: '100%', width: '100%',
padding: '12px 16px', padding: '12px 16px',
fontSize: '16px', fontSize: '16px',
color: value ? 'var(--md-sys-color-on-surface)' : 'var(--md-sys-color-on-surface-variant)', color: value
? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)',
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
border: `1px solid ${isOpen ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'}`, border: '1px solid var(--md-sys-color-outline)',
borderWidth: isOpen ? '2px' : '1px',
borderRadius: '4px', borderRadius: '4px',
fontFamily: 'inherit', fontFamily: 'inherit',
cursor: disabled ? 'not-allowed' : 'pointer', cursor: disabled ? 'not-allowed' : 'pointer',
@ -92,44 +105,46 @@ export const DatePicker: React.FC<DatePickerProps> = ({
textAlign: 'left', textAlign: 'left',
}} }}
> >
<span> <span>{displayValue}</span>
{selectedDate ? format(selectedDate, 'd MMMM yyyy', { locale: ru }) : 'Выберите дату'} <span
className="material-symbols-outlined"
style={{ fontSize: 20, opacity: 0.7 }}
>
calendar_today
</span> </span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</button> </button>
{/* Calendar dropdown */} <Dialog
{isOpen && ( open={open}
<div style={{ onClose={closePicker}
position: 'absolute', fullWidth
top: 'calc(100% + 4px)', maxWidth="xs"
left: 0, slotProps={{
background: 'var(--md-sys-color-surface)', paper: {
border: '1px solid var(--md-sys-color-outline-variant)', sx: {
borderRadius: '16px', borderRadius: '24px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)', overflow: 'visible',
zIndex: 1000, bgcolor: 'var(--md-sys-color-surface)',
padding: '16px', },
minWidth: '320px', },
}}> }}
{/* Header with month/year navigation */} >
<div style={{ <DialogContent sx={{ p: 2 }}>
display: 'flex', {/* Month/year header */}
justifyContent: 'space-between', <Box
alignItems: 'center', sx={{
marginBottom: '16px', display: 'flex',
}}> justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<button <button
type="button" type="button"
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))} onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
style={{ style={{
width: '32px', width: 36,
height: '32px', height: 36,
padding: 0, padding: 0,
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
@ -139,30 +154,30 @@ export const DatePicker: React.FC<DatePickerProps> = ({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: 'var(--md-sys-color-on-surface)', color: 'var(--md-sys-color-on-surface)',
transition: 'background 0.2s ease',
}} }}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <span className="material-symbols-outlined" style={{ fontSize: 22 }}>
<polyline points="15 18 9 12 15 6"></polyline> chevron_left
</svg> </span>
</button> </button>
<div style={{ <span
fontSize: '16px', style={{
fontWeight: '500', fontSize: 16,
color: 'var(--md-sys-color-on-surface)', fontWeight: 500,
}}> color: 'var(--md-sys-color-on-surface)',
textTransform: 'capitalize',
}}
>
{format(displayMonth, 'LLLL yyyy', { locale: ru })} {format(displayMonth, 'LLLL yyyy', { locale: ru })}
</div> </span>
<button <button
type="button" type="button"
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))} onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
style={{ style={{
width: '32px', width: 36,
height: '32px', height: 36,
padding: 0, padding: 0,
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
@ -172,33 +187,32 @@ export const DatePicker: React.FC<DatePickerProps> = ({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: 'var(--md-sys-color-on-surface)', color: 'var(--md-sys-color-on-surface)',
transition: 'background 0.2s ease',
}} }}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <span className="material-symbols-outlined" style={{ fontSize: 22 }}>
<polyline points="9 18 15 12 9 6"></polyline> chevron_right
</svg> </span>
</button> </button>
</div> </Box>
{/* Week days header */} {/* Weekday headers */}
<div style={{ <div
display: 'grid', style={{
gridTemplateColumns: 'repeat(7, 1fr)', display: 'grid',
gap: '4px', gridTemplateColumns: 'repeat(7, 1fr)',
marginBottom: '8px', gap: 2,
}}> marginBottom: 4,
{weekDays.map(day => ( }}
>
{weekDays.map((day) => (
<div <div
key={day} key={day}
style={{ style={{
textAlign: 'center', textAlign: 'center',
fontSize: '12px', fontSize: 12,
fontWeight: '500', fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)', color: 'var(--md-sys-color-on-surface-variant)',
padding: '8px 0', padding: '6px 0',
}} }}
> >
{day} {day}
@ -206,56 +220,51 @@ export const DatePicker: React.FC<DatePickerProps> = ({
))} ))}
</div> </div>
{/* Calendar days grid */} {/* Calendar days */}
<div style={{ <div
display: 'grid', style={{
gridTemplateColumns: 'repeat(7, 1fr)', display: 'grid',
gap: '4px', gridTemplateColumns: 'repeat(7, 1fr)',
}}> gap: 2,
{days.map((day, index) => { }}
>
{days.map((day, idx) => {
const isSelected = selectedDate && isSameDay(day, selectedDate); const isSelected = selectedDate && isSameDay(day, selectedDate);
const isCurrentMonth = isSameMonth(day, displayMonth); const isCurrent = isSameMonth(day, displayMonth);
const isToday = isSameDay(day, new Date()); const isToday = isSameDay(day, new Date());
return ( return (
<button <button
key={index} key={idx}
type="button" type="button"
onClick={() => handleDateSelect(day)} onClick={() => handleDateSelect(day)}
style={{ style={{
width: '40px', width: '100%',
height: '40px', aspectRatio: '1',
maxWidth: 40,
margin: '0 auto',
padding: 0, padding: 0,
background: isSelected background: isSelected
? 'var(--md-sys-color-primary)' ? 'var(--md-sys-color-primary)'
: 'transparent', : 'transparent',
border: isToday && !isSelected border:
? '1px solid var(--md-sys-color-primary)' isToday && !isSelected
: 'none', ? '1px solid var(--md-sys-color-primary)'
: 'none',
borderRadius: '50%', borderRadius: '50%',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '14px', fontSize: 14,
fontWeight: isSelected ? '500' : '400', fontWeight: isSelected ? 600 : 400,
color: isSelected color: isSelected
? 'var(--md-sys-color-on-primary)' ? 'var(--md-sys-color-on-primary)'
: isCurrentMonth : isCurrent
? 'var(--md-sys-color-on-surface)' ? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)', : 'var(--md-sys-color-on-surface-variant)',
opacity: isCurrentMonth ? 1 : 0.4, opacity: isCurrent ? 1 : 0.35,
transition: 'all 0.2s ease', transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.background = 'transparent';
}
}} }}
> >
{format(day, 'd')} {format(day, 'd')}
@ -264,36 +273,44 @@ export const DatePicker: React.FC<DatePickerProps> = ({
})} })}
</div> </div>
{/* Today button */} {/* Actions */}
<div style={{ <Box
marginTop: '16px', sx={{
paddingTop: '16px', display: 'flex',
borderTop: '1px solid var(--md-sys-color-outline-variant)', justifyContent: 'space-between',
display: 'flex', alignItems: 'center',
justifyContent: 'center', mt: 2,
}}> pt: 1.5,
<button borderTop: '1px solid var(--md-sys-color-outline-variant)',
type="button" }}
>
<Button
onClick={() => handleDateSelect(new Date())} onClick={() => handleDateSelect(new Date())}
style={{ variant="text"
padding: '8px 16px', sx={{
background: 'transparent',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
color: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-primary)',
transition: 'background 0.2s ease', textTransform: 'none',
fontWeight: 500,
fontSize: 14,
}} }}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-primary-container)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
> >
Сегодня Сегодня
</button> </Button>
</div> <Button
</div> onClick={closePicker}
)} variant="text"
</div> sx={{
color: 'var(--md-sys-color-on-surface-variant)',
textTransform: 'none',
fontWeight: 500,
fontSize: 14,
}}
>
Отмена
</Button>
</Box>
</DialogContent>
</Dialog>
</>
); );
}; };

View File

@ -78,7 +78,6 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
width: '100%', width: '100%',
maxWidth: '100%', maxWidth: '100%',
padding: '16px', padding: '16px',
minHeight: '100vh'
}}> }}>
{/* Статистика студента */} {/* Статистика студента */}
<div style={{ <div style={{
@ -162,9 +161,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
)} )}
{/* Домашние задания и расписание */} {/* Домашние задания и расписание */}
<div style={{ <div className="client-dashboard-grid" style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gridTemplateColumns: 'repeat(auto-fit, minmax(min(400px, 100%), 1fr))',
gap: '16px', gap: '16px',
marginBottom: '24px' marginBottom: '24px'
}}> }}>

View File

@ -11,6 +11,8 @@ import { getStudents, Student } from '@/api/students';
import { getSubjects, getMentorSubjects, createMentorSubject, Subject, MentorSubject } from '@/api/subjects'; import { getSubjects, getMentorSubjects, createMentorSubject, Subject, MentorSubject } from '@/api/subjects';
import { getCurrentUser, User } from '@/api/auth'; import { getCurrentUser, User } from '@/api/auth';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { DatePicker } from '@/components/common/DatePicker';
import { TimePicker } from '@/components/common/TimePicker';
interface CreateLessonDialogProps { interface CreateLessonDialogProps {
open: boolean; open: boolean;
@ -339,9 +341,31 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
'--md-dialog-supporting-text-color': 'var(--md-sys-color-on-surface-variant)', '--md-dialog-supporting-text-color': 'var(--md-sys-color-on-surface-variant)',
} as React.CSSProperties} } as React.CSSProperties}
> >
<div slot="headline">Создать занятие</div> <div slot="headline" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span>Создать занятие</span>
<button
type="button"
onClick={handleClose}
className="create-lesson-dialog-close"
aria-label="Закрыть"
style={{
all: 'unset',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: '50%',
color: 'var(--md-sys-color-on-surface-variant)',
flexShrink: 0,
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>close</span>
</button>
</div>
<form slot="content" method="dialog" onSubmit={(e) => { e.preventDefault(); handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: '20px', minWidth: '500px', maxWidth: '600px' }}> <form slot="content" method="dialog" onSubmit={(e) => { e.preventDefault(); handleSubmit(e); }} className="create-lesson-dialog" style={{ display: 'flex', flexDirection: 'column', gap: '20px', minWidth: 'min(500px, 90vw)', maxWidth: '600px' }}>
{error && ( {error && (
<div style={{ <div style={{
padding: '12px', padding: '12px',
@ -521,7 +545,7 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Дополнительная информация о занятии" placeholder="Дополнительная информация о занятии"
disabled={loading} disabled={loading}
rows={3} rows={2}
style={{ style={{
border: 'none', border: 'none',
outline: 'none', outline: 'none',
@ -529,7 +553,7 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
width: '100%', width: '100%',
fontSize: '16px', fontSize: '16px',
color: 'var(--md-sys-color-on-surface)', color: 'var(--md-sys-color-on-surface)',
resize: 'vertical', resize: 'none',
fontFamily: 'inherit', fontFamily: 'inherit',
}} }}
/> />
@ -547,29 +571,17 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
}}> }}>
Дата начала * Дата начала *
</label> </label>
<md-outlined-field label="Дата" style={{ width: '100%' }}> <DatePicker
<input value={formData.start_date}
slot="input" onChange={(v) => handleDateChange(v)}
type="date" disabled={loading}
value={formData.start_date} required
onChange={(e) => handleDateChange(e.target.value)} label="Дата начала"
required />
disabled={loading}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
fontFamily: 'inherit',
}}
/>
</md-outlined-field>
</div> </div>
{/* Время начала и длительность */} {/* Время начала и длительность */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}> <div className="create-lesson-time-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div> <div>
<label style={{ <label style={{
display: 'block', display: 'block',
@ -580,25 +592,12 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
}}> }}>
Время начала * Время начала *
</label> </label>
<md-outlined-field label="Время" style={{ width: '100%' }}> <TimePicker
<input value={formData.start_time}
slot="input" onChange={(v) => handleTimeChange(v)}
type="time" disabled={loading}
value={formData.start_time} required
onChange={(e) => handleTimeChange(e.target.value)} />
required
disabled={loading}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
fontFamily: 'inherit',
}}
/>
</md-outlined-field>
</div> </div>
<div> <div>
@ -702,7 +701,7 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
md-dialog { md-dialog {
--md-dialog-container-shape: 28px; --md-dialog-container-shape: 28px;
--md-dialog-container-max-width: 600px; --md-dialog-container-max-width: 600px;
--md-dialog-container-min-width: 500px; --md-dialog-container-min-width: min(500px, 90vw);
} }
md-dialog::part(container) { md-dialog::part(container) {

View File

@ -66,7 +66,6 @@ export const ParentDashboard: React.FC = () => {
maxWidth: '100%', maxWidth: '100%',
padding: '16px', padding: '16px',
background: 'var(--md-sys-color-background)', background: 'var(--md-sys-color-background)',
minHeight: '100vh'
}}> }}>
{/* Приветствие */} {/* Приветствие */}
<div style={{ <div style={{
@ -76,7 +75,7 @@ export const ParentDashboard: React.FC = () => {
marginBottom: '24px', marginBottom: '24px',
color: 'var(--md-sys-color-on-primary)' color: 'var(--md-sys-color-on-primary)'
}}> }}>
<h1 style={{ <h1 className="parent-dashboard-title" style={{
fontSize: '28px', fontSize: '28px',
fontWeight: '500', fontWeight: '500',
margin: '0 0 8px 0' margin: '0 0 8px 0'
@ -94,9 +93,9 @@ export const ParentDashboard: React.FC = () => {
{/* Статистика по детям */} {/* Статистика по детям */}
{stats?.children_stats && stats.children_stats.length > 0 && ( {stats?.children_stats && stats.children_stats.length > 0 && (
<div style={{ <div className="parent-children-grid" style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gridTemplateColumns: 'repeat(auto-fit, minmax(min(300px, 100%), 1fr))',
gap: '16px', gap: '16px',
marginBottom: '24px' marginBottom: '24px'
}}> }}>

View File

@ -59,6 +59,7 @@ export const IncomeSection: React.FC<IncomeSectionProps> = ({
period={period} period={period}
/> />
<div <div
className="income-stats-grid"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)', gridTemplateColumns: 'repeat(3, 1fr)',

View File

@ -142,6 +142,7 @@ export const RecentSubmissionsSection: React.FC<RecentSubmissionsSectionProps> =
</div> </div>
) : ( ) : (
<div <div
className="recent-submissions-grid"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',

View File

@ -124,6 +124,7 @@ export const UpcomingLessonsSection: React.FC<UpcomingLessonsSectionProps> = ({
</div> </div>
) : ( ) : (
<div <div
className="upcoming-lessons-grid"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',

View File

@ -23,10 +23,9 @@ export interface StatsGridProps {
export const StatsGrid: React.FC<StatsGridProps> = ({ items }) => { export const StatsGrid: React.FC<StatsGridProps> = ({ items }) => {
return ( return (
<div <div
className="dashboard-stats-grid"
style={{ style={{
display: 'grid', display: 'grid',
// Для дашборда ментора: 4 карточки в один ряд,
// чтобы верхняя строка занимала всю ширину.
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 'var(--ios26-spacing)', gap: 'var(--ios26-spacing)',
}} }}

View File

@ -1,9 +1,10 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import type { NavBadges } from '@/api/navBadges'; import type { NavBadges } from '@/api/navBadges';
import { ChildSelectorCompact } from '@/components/navigation/ChildSelector'; import { ChildSelectorCompact } from '@/components/navigation/ChildSelector';
import { useIsMobile } from '@/hooks/useIsMobile';
interface NavigationItem { interface NavigationItem {
label: string; label: string;
@ -67,6 +68,33 @@ export function BottomNavigationBar({ userRole, user, navBadges, notificationsSl
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const avatarUrl = getAvatarUrl(user); const avatarUrl = getAvatarUrl(user);
const isMobile = useIsMobile();
// Swipe gesture handling (secondary to "More" button)
const navContainerRef = useRef<HTMLDivElement>(null);
const touchStartY = useRef<number | null>(null);
const touchStartX = useRef<number | null>(null);
const SWIPE_THRESHOLD = 30;
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartY.current = e.touches[0].clientY;
touchStartX.current = e.touches[0].clientX;
}, []);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (touchStartY.current === null || touchStartX.current === null) return;
const deltaY = touchStartY.current - e.changedTouches[0].clientY;
const deltaX = Math.abs(touchStartX.current - e.changedTouches[0].clientX);
touchStartY.current = null;
touchStartX.current = null;
if (Math.abs(deltaY) < SWIPE_THRESHOLD || deltaX > Math.abs(deltaY)) return;
if (deltaY > 0) {
setExpanded(true);
} else {
setExpanded(false);
}
}, []);
// Определяем навигационные элементы в зависимости от роли // Определяем навигационные элементы в зависимости от роли
const navigationItems = useMemo<NavigationItem[]>(() => { const navigationItems = useMemo<NavigationItem[]>(() => {
@ -115,8 +143,15 @@ export function BottomNavigationBar({ userRole, user, navBadges, notificationsSl
return common; return common;
}, [userRole]); }, [userRole]);
const firstRowItems = navigationItems.slice(0, notificationsSlot ? 3 : 5); // Mobile: first 3 items + "More" button; Desktop: first 3/5 items + notifications
const restItems = navigationItems.slice(notificationsSlot ? 3 : 5); const MOBILE_FIRST_ROW_COUNT = 3;
const desktopFirstCount = notificationsSlot ? 3 : 5;
const firstRowItems = isMobile
? navigationItems.slice(0, MOBILE_FIRST_ROW_COUNT)
: navigationItems.slice(0, desktopFirstCount);
const restItems = isMobile
? navigationItems.slice(MOBILE_FIRST_ROW_COUNT)
: navigationItems.slice(desktopFirstCount);
const hasMore = restItems.length > 0; const hasMore = restItems.length > 0;
// Подсветка активного таба по текущему URL // Подсветка активного таба по текущему URL
@ -244,36 +279,72 @@ export function BottomNavigationBar({ userRole, user, navBadges, notificationsSl
); );
} }
// "More" button for mobile
const renderMoreButton = () => (
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className={
'ios26-bottom-nav-button' +
(expanded ? ' ios26-bottom-nav-button--active' : '')
}
>
<span style={{ position: 'relative', display: 'inline-flex' }}>
<span className="material-symbols-outlined ios26-bottom-nav-icon">
{expanded ? 'close' : 'more_horiz'}
</span>
</span>
<span className="ios26-bottom-nav-label">
{expanded ? 'Закрыть' : 'Ещё'}
</span>
</button>
);
// Index offset for rest items
const restIndexOffset = isMobile ? MOBILE_FIRST_ROW_COUNT : desktopFirstCount;
return ( return (
<div <div
ref={navContainerRef}
className={ className={
'ios26-bottom-nav-container' + 'ios26-bottom-nav-container' +
(expanded ? ' ios26-bottom-nav-container--expanded' : '') (expanded ? ' ios26-bottom-nav-container--expanded' : '')
} }
onTouchStart={hasMore ? handleTouchStart : undefined}
onTouchEnd={hasMore ? handleTouchEnd : undefined}
> >
{hasMore && ( {/* Desktop: swipe handle + arrow trigger */}
<button {hasMore && !isMobile && (
type="button" <>
className="ios26-bottom-nav-expand-trigger" <div
onClick={() => setExpanded((e) => !e)} className="ios26-bottom-nav-swipe-handle"
aria-label={expanded ? 'Свернуть' : 'Развернуть'} onClick={() => setExpanded((e) => !e)}
>
<span
className="material-symbols-outlined ios26-bottom-nav-arrow"
style={{
transform: expanded ? 'rotate(180deg)' : 'none',
}}
> >
keyboard_arrow_up <div className="ios26-bottom-nav-swipe-handle-bar" />
</span> </div>
</button> <button
type="button"
className="ios26-bottom-nav-expand-trigger"
onClick={() => setExpanded((e) => !e)}
aria-label={expanded ? 'Свернуть' : 'Развернуть'}
>
<span
className="material-symbols-outlined ios26-bottom-nav-arrow"
style={{
transform: expanded ? 'rotate(180deg)' : 'none',
}}
>
keyboard_arrow_up
</span>
</button>
</>
)} )}
<div className="ios26-bottom-nav"> <div className="ios26-bottom-nav">
<div <div
className={ className={
'ios26-bottom-nav-first-row' + 'ios26-bottom-nav-first-row' +
(userRole === 'parent' ? ' ios26-bottom-nav-first-row--with-selector' : '') + (userRole === 'parent' ? ' ios26-bottom-nav-first-row--with-selector' : '') +
(notificationsSlot ? ' ios26-bottom-nav-first-row--with-notifications' : '') (!isMobile && notificationsSlot ? ' ios26-bottom-nav-first-row--with-notifications' : '')
} }
> >
{userRole === 'parent' && <ChildSelectorCompact />} {userRole === 'parent' && <ChildSelectorCompact />}
@ -281,23 +352,29 @@ export function BottomNavigationBar({ userRole, user, navBadges, notificationsSl
<div <div
className={ className={
'ios26-bottom-nav-first-row-buttons' + 'ios26-bottom-nav-first-row-buttons' +
(notificationsSlot ? ' ios26-bottom-nav-first-row-buttons--with-notifications' : '') (!isMobile && notificationsSlot ? ' ios26-bottom-nav-first-row-buttons--with-notifications' : '')
} }
> >
{firstRowItems.map((item, i) => renderButton(item, i))} {firstRowItems.map((item, i) => renderButton(item, i))}
{notificationsSlot} {isMobile && hasMore ? renderMoreButton() : notificationsSlot}
</div> </div>
) : ( ) : (
<> <>
{firstRowItems.map((item, i) => renderButton(item, i))} {firstRowItems.map((item, i) => renderButton(item, i))}
{notificationsSlot} {isMobile && hasMore ? renderMoreButton() : notificationsSlot}
</> </>
)} )}
</div> </div>
<div <div
className={'ios26-bottom-nav-rest' + (expanded ? ' ios26-bottom-nav-rest--expanded' : '')} className={'ios26-bottom-nav-rest' + (expanded ? ' ios26-bottom-nav-rest--expanded' : '')}
> >
{restItems.map((item, i) => renderButton(item, (notificationsSlot ? 3 : 5) + i))} {restItems.map((item, i) => renderButton(item, restIndexOffset + i))}
{/* On mobile: notifications at the end of expanded section */}
{isMobile && notificationsSlot && (
<div className="ios26-bottom-nav-notifications-in-rest">
{notificationsSlot}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -189,13 +189,13 @@ export function NotificationBell({ embedded }: { embedded?: boolean }) {
{open && ( {open && (
<div <div
ref={panelRef} ref={panelRef}
className="notification-panel-enter-active" className="notification-panel-enter-active notification-panel"
style={{ style={{
position: 'absolute', position: embedded ? 'fixed' : 'absolute',
...(embedded ...(embedded
? { bottom: '100%', marginBottom: 8, left: '50%', transform: 'translateX(-50%)' } ? { bottom: 'auto', left: 8, right: 8, top: 'auto', transform: 'none' }
: { right: 52, bottom: 0 }), : { right: 52, bottom: 0 }),
width: PANEL_WIDTH, width: embedded ? 'auto' : `min(${PANEL_WIDTH}px, calc(100vw - 32px))`,
maxHeight: PANEL_MAX_HEIGHT, maxHeight: PANEL_MAX_HEIGHT,
backgroundColor: 'var(--md-sys-color-surface)', backgroundColor: 'var(--md-sys-color-surface)',
borderRadius: 'var(--ios26-radius-md, 24px)', borderRadius: 'var(--ios26-radius-md, 24px)',
@ -225,24 +225,45 @@ export function NotificationBell({ embedded }: { embedded?: boolean }) {
> >
Уведомления Уведомления
</span> </span>
{unreadCount > 0 && ( <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{unreadCount > 0 && (
<button
type="button"
onClick={markAllAsRead}
style={{
padding: '6px 12px',
fontSize: 13,
fontWeight: 500,
color: 'var(--md-sys-color-primary)',
background: 'transparent',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
}}
>
Прочитать все
</button>
)}
<button <button
type="button" type="button"
onClick={markAllAsRead} onClick={() => setOpen(false)}
aria-label="Закрыть"
style={{ style={{
padding: '6px 12px', all: 'unset',
fontSize: 13,
fontWeight: 500,
color: 'var(--md-sys-color-primary)',
background: 'transparent',
border: 'none',
borderRadius: 8,
cursor: 'pointer', cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: '50%',
color: 'var(--md-sys-color-on-surface-variant)',
flexShrink: 0,
}} }}
> >
Прочитать все <span className="material-symbols-outlined" style={{ fontSize: 20 }}>close</span>
</button> </button>
)} </div>
</div> </div>
<div <div
ref={scrollRef} ref={scrollRef}

View File

@ -68,13 +68,14 @@ export function ReferralsPageContent() {
> >
РЕФЕРАЛЬНАЯ ССЫЛКА РЕФЕРАЛЬНАЯ ССЫЛКА
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div className="referral-link-row" style={{ display: 'flex', gap: 8 }}>
<input <input
type="text" type="text"
readOnly readOnly
value={profile.referral_link || ''} value={profile.referral_link || ''}
style={{ style={{
flex: 1, flex: 1,
minWidth: 0,
padding: '12px 16px', padding: '12px 16px',
borderRadius: 12, borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)', border: '1px solid var(--md-sys-color-outline)',
@ -108,6 +109,7 @@ export function ReferralsPageContent() {
</div> </div>
{stats && ( {stats && (
<div <div
className="referral-stats-grid"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
@ -169,9 +171,9 @@ export function ReferralsPageContent() {
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}> <div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
Прямые рефералы ({referralsList.direct.length}) Прямые рефералы ({referralsList.direct.length})
</div> </div>
<ul style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}> <ul className="referral-list" style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
{referralsList.direct.map((r: MyReferralItem, i: number) => ( {referralsList.direct.map((r: MyReferralItem, i: number) => (
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}> <li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)', wordBreak: 'break-word' }}>
{r.email} {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)} {r.email} {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
</li> </li>
))} ))}
@ -183,9 +185,9 @@ export function ReferralsPageContent() {
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}> <div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
Рефералы ваших рефералов ({referralsList.indirect.length}) Рефералы ваших рефералов ({referralsList.indirect.length})
</div> </div>
<ul style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}> <ul className="referral-list" style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
{referralsList.indirect.map((r: MyReferralItem, i: number) => ( {referralsList.indirect.map((r: MyReferralItem, i: number) => (
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}> <li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)', wordBreak: 'break-word' }}>
{r.email} {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)} {r.email} {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
</li> </li>
))} ))}

View File

@ -0,0 +1,17 @@
'use client';
import { useEffect, useState } from 'react';
export const MOBILE_BREAKPOINT = 767;
export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT) {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
setIsMobile(mq.matches);
const listener = () => setIsMobile(mq.matches);
mq.addEventListener('change', listener);
return () => mq.removeEventListener('change', listener);
}, [breakpoint]);
return isMobile;
}

View File

@ -0,0 +1,4 @@
<svg width="512" height="512" viewBox="0 8 130 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="8" width="130" height="130" rx="20" fill="#7444FD"/>
<path d="M31.8908 90.7V38H46.1708V87.555C46.1708 91.8617 47.2192 95.7717 49.3158 99.285C51.4125 102.798 54.2175 105.603 57.7308 107.7C61.3008 109.74 65.2108 110.76 69.4608 110.76C73.7675 110.76 77.6492 109.74 81.1058 107.7C84.6192 105.603 87.4242 102.798 89.5208 99.285C91.6175 95.7717 92.6658 91.8617 92.6658 87.555V38H106.946L107.031 123H92.7508L92.6658 112.205C89.6625 116.172 85.8658 119.345 81.2758 121.725C76.6858 124.048 71.7275 125.21 66.4008 125.21C60.0542 125.21 54.2458 123.68 48.9758 120.62C43.7625 117.503 39.5975 113.338 36.4808 108.125C33.4208 102.912 31.8908 97.1033 31.8908 90.7Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 782 B

View File

@ -0,0 +1,19 @@
{
"name": "Uchill Platform",
"short_name": "Uchill",
"description": "Образовательная платформа",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#7444FD",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

View File

@ -169,8 +169,12 @@ body:has([data-no-nav]) {
padding-bottom: 0; padding-bottom: 0;
} }
/* Protected layout: body/html не скроллятся, скролл только внутри .protected-main */
html:has(.protected-layout-root),
body:has(.protected-layout-root) { body:has(.protected-layout-root) {
padding-bottom: 0; padding-bottom: 0;
height: 100vh;
overflow: hidden;
} }
body > * { body > * {
@ -298,18 +302,21 @@ img {
padding-bottom: env(safe-area-inset-bottom, 0); padding-bottom: env(safe-area-inset-bottom, 0);
} }
/* Protected layout: контент скроллится сверху, снизу меню. На мобильном — меню в потоке; ноутбук+ — fixed */ /* Protected layout: контент скроллится сверху, снизу меню. Скролл только в .protected-main */
.protected-layout-root { .protected-layout-root {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh;
height: 100vh; height: 100vh;
height: 100dvh; /* dynamic viewport height — учитывает адресную строку мобильного браузера */
overflow: hidden;
} }
.protected-layout-root .protected-main { .protected-layout-root .protected-main {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
} }
/* Ноутбук и выше (768px+): нижний бар fixed, bottom 20px, контенту отступ снизу */ /* Ноутбук и выше (768px+): нижний бар fixed, bottom 20px, контенту отступ снизу */
@ -328,7 +335,7 @@ img {
} }
} }
/* Мобильный: меню в потоке, на всю ширину, прижато к низу */ /* Мобильный: меню в потоке, flex-shrink: 0 — навигация НИКОГДА не исчезает и не перекрывает контент */
@media (max-width: 767px) { @media (max-width: 767px) {
.protected-layout-root .ios26-bottom-nav-container { .protected-layout-root .ios26-bottom-nav-container {
position: relative; position: relative;
@ -360,6 +367,40 @@ img {
.protected-layout-root .ios26-bottom-nav-rest { .protected-layout-root .ios26-bottom-nav-rest {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
} }
/* На мобильном отступ снизу в main не нужен — навигация в потоке */
.protected-layout-root .protected-main {
padding-bottom: 0 !important;
}
}
/* Swipe handle — visual indicator for swipe gesture (mobile only) */
.ios26-bottom-nav-swipe-handle {
display: none;
align-items: center;
justify-content: center;
padding: 6px 0 2px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.ios26-bottom-nav-swipe-handle-bar {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--md-sys-color-outline-variant, rgba(0,0,0,0.18));
transition: width 0.2s ease, background 0.2s ease;
}
.ios26-bottom-nav-container--expanded .ios26-bottom-nav-swipe-handle-bar {
width: 28px;
background: var(--md-sys-color-primary, #7444FD);
}
@media (max-width: 767px) {
/* Hide both swipe handle and arrow on mobile — "More" button replaces them */
.ios26-bottom-nav-swipe-handle,
.ios26-bottom-nav-expand-trigger {
display: none !important;
}
} }
.ios26-bottom-nav-expand-trigger { .ios26-bottom-nav-expand-trigger {
@ -438,6 +479,15 @@ img {
gap: 4px 8px; gap: 4px 8px;
} }
/* Notifications slot inside expanded section on mobile */
.ios26-bottom-nav-notifications-in-rest {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
padding: 4px 0;
}
.ios26-bottom-nav-rest { .ios26-bottom-nav-rest {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
@ -1552,8 +1602,8 @@ img {
padding: 12px !important; padding: 12px !important;
} }
.students-cards-grid { .students-cards-grid {
grid-template-columns: 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 8px;
} }
.page-profile { .page-profile {
padding: 12px !important; padding: 12px !important;
@ -1620,36 +1670,60 @@ img {
.ios26-schedule-layout { .ios26-schedule-layout {
grid-template-rows: 1fr !important; grid-template-rows: 1fr !important;
min-height: auto !important; min-height: auto !important;
height: 100% !important;
flex: 1 !important;
}
.ios26-schedule-right-wrap {
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-height: 0 !important;
height: 100% !important;
}
.checklesson-root {
min-height: auto !important;
flex: 1 !important;
height: 100% !important;
}
/* Обе стороны «куба» — полная высота */
.checklesson-root > div {
height: 100% !important;
}
.checklesson-root .ios-glass-panel {
height: 100% !important;
padding: 12px !important;
border-radius: 12px !important;
}
.ios26-schedule-page {
display: flex !important;
flex-direction: column !important;
height: 100% !important;
flex: 1 !important;
min-height: 0 !important;
} }
} }
/* Chat: список + окно чата — на планшете и телефоне одна колонка, список сверху */ /* Chat: на мобильном — только список или только окно чата (управляется из JS) */
@media (max-width: 900px) { @media (max-width: 767px) {
.ios26-chat-page { .ios26-chat-page {
padding: 10px !important; padding: 4px !important;
height: 100%;
display: flex;
flex-direction: column;
} }
.ios26-chat-layout { .ios26-chat-layout {
grid-template-columns: 1fr !important; flex: 1;
grid-template-rows: auto 1fr; min-height: 0;
height: calc(100vh - 120px) !important; height: auto !important;
max-height: none !important; max-height: none !important;
} }
.ios26-chat-layout > div:first-of-type { /* Скрыть навигацию когда открыт чат */
max-height: 38vh; html.mobile-chat-open .ios26-bottom-nav-container {
min-height: 180px; display: none !important;
overflow: auto;
} }
} /* Контент занимает всю высоту без навигации */
@media (max-width: 480px) { html.mobile-chat-open .protected-main {
.ios26-chat-page { padding-bottom: 0 !important;
padding: 8px !important;
}
.ios26-chat-layout {
height: calc(100vh - 100px) !important;
}
.ios26-chat-layout > div:first-of-type {
max-height: 35vh;
min-height: 160px;
} }
} }
@ -1685,6 +1759,354 @@ img {
} }
} }
/* ========== Адаптив: авторизация (login, register, invite) ========== */
/* Планшет: auth layout — уменьшаем колонку формы */
@media (max-width: 900px) {
.auth-layout {
grid-template-columns: 1fr minmax(0, 420px) !important;
}
.auth-layout-form {
padding: 20px 24px !important;
}
}
/* Телефон: auth layout — одна колонка, скрываем лого слева */
@media (max-width: 767px) {
.auth-layout {
grid-template-columns: 1fr !important;
}
.auth-layout-logo {
display: none !important;
}
.auth-layout-form {
min-height: 100vh;
padding: 20px 16px !important;
}
/* Имя + Фамилия в колонку на узких экранах */
.auth-name-grid {
grid-template-columns: 1fr !important;
}
/* Invite page: уменьшаем заголовок */
.invite-title {
font-size: 24px !important;
}
.invite-form-card {
padding: 20px !important;
}
}
/* Маленький телефон */
@media (max-width: 480px) {
.auth-layout-form {
padding: 16px 12px !important;
}
.auth-name-grid {
grid-template-columns: 1fr !important;
gap: 12px !important;
}
.invite-title {
font-size: 22px !important;
}
.invite-form-card {
padding: 16px !important;
}
}
/* ========== Адаптив: материалы, мой прогресс, диалоги ========== */
/* Материалы: grid minmax слишком большой для маленьких экранов */
@media (max-width: 480px) {
.materials-cards-grid {
grid-template-columns: 1fr !important;
}
}
/* Мой прогресс: 2 колонки → 1 на телефоне */
@media (max-width: 767px) {
.my-progress-grid {
grid-template-columns: 1fr !important;
}
}
/* CheckLesson форма (создание/редактирование занятия): одна колонка на мобильном */
@media (max-width: 600px) {
.checklesson-form {
grid-template-columns: 1fr !important;
gap: 8px !important;
}
.checklesson-form > div {
grid-column: 1 / -1 !important;
}
.checklesson-form > div > label {
font-size: 11px !important;
margin-bottom: 2px !important;
}
.checklesson-form h3 {
font-size: 17px !important;
}
.checklesson-form textarea {
rows: 1;
font-size: 14px !important;
padding: 8px 12px !important;
}
.checklesson-form select,
.checklesson-form input[type="number"] {
font-size: 14px !important;
padding: 8px 12px !important;
}
.checklesson-form button[type="button"],
.checklesson-form button[type="submit"] {
padding: 8px 16px !important;
font-size: 13px !important;
}
/* DatePicker / TimePicker trigger buttons */
.checklesson-form button[aria-required] {
padding: 8px 12px !important;
font-size: 14px !important;
}
/* Header row: reduce margin */
.checklesson-form > div:first-child {
margin-bottom: 0 !important;
}
/* Switch row: reduce padding */
.checklesson-form > div:has(> [class*="switch"]),
.checklesson-form > div:has(> div > input[type="checkbox"]) {
padding: 4px 0 !important;
}
/* Actions row: reduce margin */
.checklesson-form > div:last-child {
margin-top: 4px !important;
}
}
/* Диалог создания/редактирования урока: полноэкранный на мобильном */
@media (max-width: 600px) {
.create-lesson-dialog {
min-width: unset !important;
width: auto !important;
max-width: none !important;
gap: 6px !important;
}
/* Скрываем отдельные лейблы — у md-outlined-field есть свой label */
.create-lesson-dialog > div > label {
display: none !important;
}
/* Внутренний gap у предмета (select + custom input) */
.create-lesson-dialog > div > div {
gap: 6px !important;
}
/* Время + длительность в одну колонку */
.create-lesson-time-row {
grid-template-columns: 1fr !important;
gap: 6px !important;
}
/* Лейблы внутри time-row тоже скрываем */
.create-lesson-time-row label {
display: none !important;
}
/* Переключатель "постоянное занятие" компактнее */
.create-lesson-dialog > div:last-child {
gap: 8px !important;
}
.create-lesson-dialog > div:last-child label {
display: inline !important;
font-size: 13px !important;
}
md-dialog {
--md-dialog-container-shape: 0px !important;
--md-dialog-container-max-width: 100vw !important;
--md-dialog-container-min-width: 100vw !important;
max-width: 100vw !important;
width: 100vw !important;
margin: 0 !important;
}
md-dialog::part(container) {
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
height: 100dvh !important;
max-height: 100dvh !important;
border-radius: 0 !important;
margin: 0 !important;
top: 0 !important;
left: 0 !important;
transform: none !important;
display: flex !important;
flex-direction: column !important;
}
md-dialog [slot="headline"] {
padding: 10px 12px !important;
font-size: 16px !important;
flex-shrink: 0 !important;
}
md-dialog form {
padding: 8px 12px !important;
overflow-y: auto !important;
flex: 1 !important;
min-height: 0 !important;
-webkit-overflow-scrolling: touch;
}
md-dialog [slot="actions"] {
padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0)) !important;
flex-shrink: 0 !important;
}
md-dialog input,
md-dialog select,
md-dialog textarea {
font-size: 14px !important;
padding: 8px 0 !important;
}
md-dialog textarea {
min-height: 32px !important;
max-height: 48px !important;
}
}
/* ========== Адаптив: дашборд компоненты ========== */
/* StatsGrid: 4 колонки → 2 на планшете → 1 на маленьком телефоне */
@media (max-width: 1024px) {
.dashboard-stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
}
@media (max-width: 480px) {
.dashboard-stats-grid {
grid-template-columns: 1fr !important;
}
}
/* IncomeSection: 3 колонки → 1 на телефоне */
@media (max-width: 767px) {
.income-stats-grid {
grid-template-columns: 1fr !important;
}
}
/* UpcomingLessons / RecentSubmissions: 2 колонки → 1 на телефоне */
@media (max-width: 600px) {
.upcoming-lessons-grid,
.recent-submissions-grid {
grid-template-columns: 1fr !important;
}
}
/* ParentDashboard: уменьшаем заголовок на мобильном */
@media (max-width: 767px) {
.parent-dashboard-title {
font-size: 22px !important;
}
}
/* ========== Адаптив: студенты — табы и высота карточек ========== */
/* Табы студентов: горизонтальный скролл на мобильных */
.students-tabs {
scrollbar-width: none;
-ms-overflow-style: none;
}
.students-tabs::-webkit-scrollbar {
display: none;
}
.students-tabs button {
white-space: nowrap;
flex-shrink: 0;
}
/* Студенты: карточки — уменьшаем сетку и высоту фото на мобильных */
@media (max-width: 1024px) {
.students-cards-grid {
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
}
@media (max-width: 767px) {
.students-tabs button {
padding: 10px 14px !important;
font-size: 14px !important;
}
.students-cards-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 8px !important;
}
.students-cards-grid md-elevated-card {
border-radius: 16px !important;
}
.students-cards-grid md-elevated-card > div:first-child {
height: 100px !important;
}
.students-cards-grid md-elevated-card > div:last-child {
padding: 8px 10px 10px !important;
}
.students-cards-grid md-elevated-card > div:last-child > div:first-child {
font-size: 14px !important;
margin-bottom: 2px !important;
}
.students-cards-grid md-elevated-card > div:last-child > div:nth-child(2) {
font-size: 12px !important;
}
}
@media (max-width: 480px) {
.students-cards-grid md-elevated-card > div:first-child {
height: 80px !important;
}
}
/* ========== Адаптив: рефералы ========== */
@media (max-width: 767px) {
.page-referrals {
padding: 12px !important;
}
.page-referrals-card {
padding: 16px !important;
border-radius: 16px !important;
}
.referral-link-row {
flex-direction: column !important;
}
.referral-link-row input {
width: 100% !important;
}
.referral-link-row button {
width: 100% !important;
}
.referral-stats-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.referral-list {
padding-left: 16px !important;
}
.referral-list li {
font-size: 13px !important;
}
}
@media (max-width: 480px) {
.page-referrals {
padding: 8px !important;
}
.page-referrals-card {
padding: 12px !important;
border-radius: 14px !important;
}
.referral-stats-grid {
grid-template-columns: 1fr !important;
}
}
/* ========== Адаптив: уведомления — панель fixed на мобильном (embedded) ========== */
@media (max-width: 767px) {
.notification-panel {
left: 8px !important;
right: 8px !important;
bottom: 8px !important;
top: auto !important;
width: auto !important;
max-width: none !important;
max-height: 70vh !important;
transform: none !important;
}
}
/* Flip-карточка эффект */ /* Flip-карточка эффект */
.flip-card { .flip-card {
position: relative; position: relative;