feat(ui): Solve issues 4-10 and polish UI

- Added top padding to all pages (Fix #4)
- Implemented smooth accordion animations for navigation and messages (Fix #5, #9)
- Fixed double selection highlight in navigation bar (Fix #6)
- Enabled nested schedule routing for 'Session' view (Fix #7)
- Softened switching animations for message types (Fix #8)
- Reworked messages to use accordion expansion and removed history (Fix #9)
- Moved Support section into Settings page (Fix #10)
- Cleaned up unused imports and refined layouts

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-11 02:17:12 +03:00
parent 741e4f7db7
commit 24fb28f70a
6 changed files with 285 additions and 200 deletions

View File

@@ -1,133 +1,62 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { MessageListItem, MessageDetails } from '../types/api';
import { Mail, Send, Paperclip, ChevronRight, Inbox, MessageSquareText, ArrowLeft, Download, Reply } from 'lucide-react';
import { Mail, Send, Paperclip, ChevronDown, Inbox, MessageSquareText, Download, Reply, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
export const Messages: React.FC = () => {
const [messages, setMessages] = useState<MessageListItem[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [details, setDetails] = useState<MessageDetails | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [details, setDetails] = useState<Record<string, MessageDetails>>({});
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
const [type, setType] = useState<'in' | 'out'>('in');
const [loading, setLoading] = useState(true);
const [detailsLoading, setDetailsLoading] = useState(false);
const [listLoading, setListLoading] = useState(true);
useEffect(() => {
const fetchMessages = async () => {
setLoading(true);
setListLoading(true);
setExpandedId(null);
try {
const data = await api.getMessages(type);
setMessages(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
setListLoading(false);
}
};
fetchMessages();
}, [type]);
const handleSelectMessage = async (id: string) => {
setSelectedId(id);
setDetailsLoading(true);
try {
const data = await api.getMessageDetails(id);
setDetails(data);
} catch (err) {
console.error(err);
} finally {
setDetailsLoading(false);
const toggleExpand = async (id: string) => {
if (expandedId === id) {
setExpandedId(null);
return;
}
setExpandedId(id);
if (!details[id]) {
setLoadingIds(prev => new Set(prev).add(id));
try {
const data = await api.getMessageDetails(id);
setDetails(prev => ({ ...prev, [id]: data }));
} catch (err) {
console.error(err);
} finally {
setLoadingIds(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
}
};
const handleBack = () => {
setSelectedId(null);
setDetails(null);
};
if (selectedId) {
return (
<div className="page-enter max-w-4xl mx-auto space-y-6">
<button
onClick={handleBack}
className="flex items-center gap-2 text-primary font-medium hover:opacity-70 transition-opacity"
>
<ArrowLeft size={20} />
Назад к списку
</button>
{detailsLoading ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-4">
<div className="w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
<p className="text-sm font-medium">Загрузка содержания...</p>
</div>
) : details ? (
<div className="space-y-6">
<section className="surface-card p-8">
<div className="flex justify-between items-start gap-4 mb-8">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{messages.find(m => m.id === selectedId)?.subject}</h1>
<p className="text-sm text-muted-foreground">
От: <span className="font-semibold text-primary">{messages.find(m => m.id === selectedId)?.senderOrRecipient}</span> {messages.find(m => m.id === selectedId)?.date}
</p>
</div>
<Button variant="outline" size="sm" className="gap-2">
<Reply size={16} />
Ответить
</Button>
</div>
<div className="prose dark:prose-invert max-w-none text-foreground/90 leading-relaxed whitespace-pre-wrap">
{details.text}
</div>
{details.files && details.files.length > 0 && (
<div className="mt-10 pt-6 border-t border-white/5">
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-4">Вложения ({details.files.length})</h3>
<div className="flex flex-wrap gap-3">
{details.files.map((file, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl bg-muted/30 border border-white/5 hover:bg-muted/50 transition-colors cursor-pointer group">
<div className="p-2 rounded-lg bg-primary/10 text-primary group-hover:scale-110 transition-transform">
<Paperclip size={16} />
</div>
<span className="text-sm font-medium">{file}</span>
<Download size={14} className="ml-2 opacity-40 group-hover:opacity-100" />
</div>
))}
</div>
</div>
)}
</section>
{details.history && details.history.length > 0 && (
<section className="space-y-4">
<h2 className="text-lg font-bold px-2">История переписки</h2>
{details.history.map((h, i) => (
<div key={i} className="surface-card p-5 bg-card/20 space-y-2 border-l-4 border-primary/30">
<div className="flex justify-between text-xs font-bold uppercase tracking-wider text-muted-foreground">
<span>{h.author}</span>
<span>{h.date}</span>
</div>
<p className="text-sm">{h.text}</p>
</div>
))}
</section>
)}
</div>
) : (
<div className="surface-card text-center py-20 text-muted-foreground">
Не удалось загрузить сообщение
</div>
)}
</div>
);
}
return (
<div className="page-enter max-w-4xl mx-auto space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-2">
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-2 animate-in fade-in slide-in-from-top-2 duration-500">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-primary/10 text-primary">
<MessageSquareText size={24} />
@@ -138,7 +67,7 @@ export const Messages: React.FC = () => {
<div className="flex p-1 rounded-2xl bg-muted/30 border border-white/5 w-fit">
<button
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all",
"flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200",
type === 'in' ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setType('in')}
@@ -148,7 +77,7 @@ export const Messages: React.FC = () => {
</button>
<button
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all",
"flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200",
type === 'out' ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setType('out')}
@@ -159,57 +88,126 @@ export const Messages: React.FC = () => {
</div>
</div>
{loading ? (
{listLoading ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-4">
<div className="w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
<p className="text-sm font-medium">Загрузка сообщений...</p>
<Loader2 className="w-8 h-8 text-primary/40 animate-spin" />
<p className="text-sm font-medium italic opacity-60">Загрузка сообщений...</p>
</div>
) : (
<div className="space-y-3">
{messages.length === 0 ? (
<div className="surface-card flex flex-col items-center justify-center py-20 text-muted-foreground gap-3">
<Mail size={40} className="opacity-20" />
<p className="text-sm">Нет сообщений в данной категории</p>
<div className="surface-card animate-in fade-in zoom-in-95 duration-300 flex flex-col items-center justify-center py-20 text-muted-foreground gap-3">
<Mail size={40} className="opacity-10" />
<p className="text-sm italic">Нет сообщений в данной категории</p>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
onClick={() => handleSelectMessage(msg.id)}
className="surface-card group hover:bg-card/60 cursor-pointer flex items-center gap-4 p-4"
>
<div className="hidden sm:flex w-12 h-12 rounded-2xl bg-primary/10 text-primary items-center justify-center shrink-0 group-hover:scale-110 transition-transform">
{type === 'in' ? <Mail size={22} /> : <Send size={22} />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-bold uppercase tracking-wider text-primary truncate">
{msg.senderOrRecipient}
</span>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{msg.date}
</span>
messages.map((msg, index) => {
const isExpanded = expandedId === msg.id;
const isLoading = loadingIds.has(msg.id);
const msgDetails = details[msg.id];
return (
<div
key={msg.id}
className={cn(
"surface-card flex flex-col p-0 overflow-hidden transition-all duration-300",
isExpanded ? "ring-2 ring-primary/20 bg-card/80" : "animate-in fade-in slide-in-from-bottom-2"
)}
style={{ animationDelay: `${index * 50}ms`, animationFillMode: 'both' }}
>
<div
onClick={() => toggleExpand(msg.id)}
className="flex items-center gap-4 p-4 sm:p-5 cursor-pointer hover:bg-white/5 transition-colors"
>
<div className={cn(
"hidden sm:flex w-12 h-12 rounded-2xl items-center justify-center shrink-0 transition-all duration-300",
isExpanded ? "bg-primary text-primary-foreground scale-90" : "bg-primary/10 text-primary"
)}>
{type === 'in' ? <Mail size={22} /> : <Send size={22} />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<span className={cn(
"text-[10px] font-bold uppercase tracking-widest truncate transition-colors",
isExpanded ? "text-primary" : "text-muted-foreground opacity-80"
)}>
{msg.senderOrRecipient}
</span>
<span className="text-[11px] text-muted-foreground whitespace-nowrap opacity-60">
{msg.date}
</span>
</div>
<h3 className={cn(
"text-base font-semibold truncate transition-colors",
isExpanded && "text-primary whitespace-normal"
)}>
{msg.subject}
</h3>
{!isExpanded && msg.hasFiles && (
<div className="flex items-center gap-1.5 mt-1.5 text-primary opacity-80">
<Paperclip size={12} />
<span className="text-[10px] font-bold uppercase">Вложения</span>
</div>
)}
</div>
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-muted-foreground transition-transform duration-300",
isExpanded && "rotate-180 text-primary"
)}>
<ChevronDown size={20} />
</div>
</div>
<h3 className="text-base font-semibold truncate group-hover:text-primary transition-colors">
{msg.subject}
</h3>
<div className="flex items-center gap-3 mt-2">
{msg.hasFiles && (
<Badge variant="secondary" className="h-5 text-[10px] px-2 bg-primary/5 text-primary border-primary/10">
<Paperclip size={10} className="mr-1" />
Вложения
</Badge>
)}
<span className="text-xs text-muted-foreground truncate opacity-60">
Нажмите, чтобы прочитать полностью...
</span>
<div className={cn(
"overflow-hidden transition-all duration-300 ease-in-out",
isExpanded ? "max-h-[1000px] border-t border-white/5" : "max-h-0"
)}>
<div className="p-5 sm:p-8 space-y-6">
{isLoading ? (
<div className="flex items-center justify-center py-8 gap-3 text-muted-foreground">
<Loader2 size={18} className="animate-spin text-primary" />
<span className="text-sm italic">Загрузка содержания...</span>
</div>
) : msgDetails ? (
<>
<div className="flex justify-end mb-2">
<Button variant="outline" size="xs" className="rounded-xl h-8 px-3 gap-2 border-white/10">
<Reply size={14} />
Ответить
</Button>
</div>
<div className="prose dark:prose-invert max-w-none text-foreground/90 leading-relaxed whitespace-pre-wrap text-sm sm:text-base">
{msgDetails.text}
</div>
{msgDetails.files && msgDetails.files.length > 0 && (
<div className="pt-6 mt-6 border-t border-white/5 space-y-3">
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground">Вложения</h4>
<div className="flex flex-wrap gap-2">
{msgDetails.files.map((file, i) => (
<div key={i} className="flex items-center gap-2.5 p-2.5 rounded-xl bg-muted/30 border border-white/5 hover:bg-primary/5 hover:border-primary/20 transition-all cursor-pointer group">
<Paperclip size={14} className="text-primary/60 group-hover:text-primary transition-colors" />
<span className="text-xs font-medium truncate max-w-[150px]">{file}</span>
<Download size={12} className="ml-1 opacity-0 group-hover:opacity-60 transition-opacity" />
</div>
))}
</div>
</div>
)}
</>
) : (
<p className="text-center py-4 text-destructive text-sm font-medium">
Не удалось загрузить текст сообщения
</p>
)}
</div>
</div>
</div>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary transition-colors">
<ChevronRight size={18} />
</div>
</div>
))
);
})
)}
</div>
)}

View File

@@ -1,14 +1,18 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { Schedule as ScheduleType } from '../types/api';
import { Clock, User, MapPin, CalendarDays } from 'lucide-react';
import { Clock, User, MapPin, CalendarDays, Loader2 } from 'lucide-react';
import { useLocation } from 'react-router-dom';
export const Schedule: React.FC = () => {
const [schedule, setSchedule] = useState<ScheduleType | null>(null);
const [loading, setLoading] = useState(true);
const location = useLocation();
const isSession = location.pathname.includes('/session');
useEffect(() => {
const fetchSchedule = async () => {
setLoading(true);
try {
const data = await api.getSchedule();
setSchedule(data);
@@ -19,35 +23,37 @@ export const Schedule: React.FC = () => {
}
};
fetchSchedule();
}, []);
}, [isSession]);
if (loading) return (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-4">
<div className="w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
<p className="text-sm font-medium">Загрузка расписания...</p>
<Loader2 className="w-8 h-8 text-primary/40 animate-spin" />
<p className="text-sm font-medium italic opacity-60">Загрузка расписания...</p>
</div>
);
return (
<div className="page-enter max-w-4xl mx-auto space-y-8">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-3 mb-2 animate-in fade-in slide-in-from-top-2 duration-500">
<div className="p-2 rounded-xl bg-primary/10 text-primary">
<CalendarDays size={24} />
</div>
<h1 className="text-2xl font-bold tracking-tight">Расписание</h1>
<h1 className="text-2xl font-bold tracking-tight">
{isSession ? 'Расписание сессии' : 'Расписание занятий'}
</h1>
</div>
{!schedule || schedule.days.length === 0 ? (
<div className="surface-card text-center py-12 text-muted-foreground">
<div className="surface-card text-center py-12 text-muted-foreground italic">
Нет данных о расписании
</div>
) : (
<div className="space-y-10">
{schedule.days.map((day, idx) => (
<section key={idx} className="space-y-4">
<section key={idx} className="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-500" style={{ animationDelay: `${idx * 100}ms`, animationFillMode: 'both' }}>
<div className="flex items-center gap-4">
<div className="h-px flex-1 bg-white/5" />
<h2 className="text-[11px] font-bold text-primary uppercase tracking-[0.2em]">
<h2 className="text-[11px] font-bold text-primary uppercase tracking-[0.2em] bg-primary/5 px-3 py-1 rounded-full border border-primary/10">
{day.date}
</h2>
<div className="h-px flex-1 bg-white/5" />
@@ -57,7 +63,7 @@ export const Schedule: React.FC = () => {
{day.lessons.map((lesson, lIdx) => (
<div
key={lIdx}
className="surface-card flex flex-col sm:flex-row gap-4 sm:items-center p-5 hover:bg-card/60 transition-colors"
className="surface-card flex flex-col sm:flex-row gap-4 sm:items-center p-5 hover:bg-card/60 transition-all active:scale-[0.99]"
>
<div className="flex sm:flex-col items-center sm:items-start gap-3 sm:gap-1 sm:min-w-[110px] border-b sm:border-b-0 sm:border-r border-white/5 pb-3 sm:pb-0 sm:pr-4">
<div className="flex items-center gap-2 text-primary font-bold">
@@ -71,10 +77,10 @@ export const Schedule: React.FC = () => {
<div className="flex-1 space-y-3">
<div>
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-1">
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-1 opacity-80">
{lesson.type || 'Занятие'}
</div>
<h3 className="text-lg font-bold leading-tight">{lesson.subject}</h3>
<h3 className="text-lg font-bold leading-tight group-hover:text-primary transition-colors">{lesson.subject}</h3>
</div>
<div className="flex flex-wrap gap-4">

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useStore } from '../store/useStore';
import { Palette, Moon, Sun, Monitor, LogOut, Settings2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Palette, Moon, Sun, Monitor, LogOut, Settings2, LifeBuoy, ChevronRight, HelpCircle, MessageCircle } from 'lucide-react';
import { useNavigate, Link } from 'react-router-dom';
import { TextField } from '../components/TextField';
import { Button } from '../components/ui/button';
import { cn } from '@/lib/utils';
@@ -32,7 +32,7 @@ export const Settings: React.FC = () => {
};
return (
<div className="page-enter max-w-3xl mx-auto space-y-8">
<div className="page-enter max-w-3xl mx-auto space-y-8 pb-12">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-xl bg-primary/10 text-primary">
<Settings2 size={24} />
@@ -137,6 +137,45 @@ export const Settings: React.FC = () => {
</div>
</section>
{/* Support Section Moved Here - Issue #10 */}
<section className="surface-card space-y-6">
<div className="space-y-1">
<h2 className="text-lg font-bold flex items-center gap-2">
<LifeBuoy size={20} className="text-primary" />
Поддержка
</h2>
<p className="text-muted-foreground text-sm">Помощь и обратная связь</p>
</div>
<div className="grid gap-3">
<Link to="/support" className="flex items-center justify-between p-4 rounded-2xl bg-muted/30 border border-transparent hover:border-primary/20 hover:bg-primary/5 transition-all group">
<div className="flex items-center gap-4">
<div className="p-2 rounded-xl bg-primary/10 text-primary">
<MessageCircle size={20} />
</div>
<div className="text-left">
<div className="text-sm font-bold">Написать в поддержку</div>
<div className="text-xs text-muted-foreground">Создать новое обращение</div>
</div>
</div>
<ChevronRight size={18} className="text-muted-foreground group-hover:text-primary transition-colors" />
</Link>
<button className="flex items-center justify-between p-4 rounded-2xl bg-muted/30 border border-transparent hover:border-primary/20 hover:bg-primary/5 transition-all group">
<div className="flex items-center gap-4">
<div className="p-2 rounded-xl bg-primary/10 text-primary">
<HelpCircle size={20} />
</div>
<div className="text-left">
<div className="text-sm font-bold">Частые вопросы (FAQ)</div>
<div className="text-xs text-muted-foreground">База знаний для студентов</div>
</div>
</div>
<ChevronRight size={18} className="text-muted-foreground group-hover:text-primary transition-colors" />
</button>
</div>
</section>
<section className="pt-4">
<Button
variant="outline"