Compare commits

...

16 Commits

Author SHA1 Message Date
ProstoDenya01
678cf94ad3 Merge remote-tracking branch 'refs/remotes/origin/main' into Create-Lesson
# Conflicts:
#	db/init/init.sql
2026-02-25 22:57:32 +03:00
Zuev
9776f4a56f chore: stop tracking .agent folder 2026-02-25 17:38:14 +03:00
Zuev
df6ddeecd5 feat: add subjects & teacher-subjects management tab in admin panel 2026-02-25 17:34:01 +03:00
Zuev
0ac81284fa chore: проброс порта БД для DBeaver 2026-02-24 20:14:47 +03:00
Zuev
74d937f6dc feat: complete UI redesign with glassmorphism and custom multi-select equipment dropdown 2026-02-21 01:35:56 +03:00
Zuev
eea444409e docs(rules): update database rules to match init.sql 2026-02-20 02:59:10 +03:00
Zuev
c552d14909 feat(backend): implement equipments entities and modify db 2026-02-20 02:53:47 +03:00
Zuev
be46fa2be2 fix(deploy): update workflow with escaped bash string 2026-02-20 02:52:43 +03:00
Zuev
6993ac29d5 feat(admin): add classroom edit modal 2026-02-20 02:49:51 +03:00
Zuev
07419d541e feat(db): update init.sql with subjects, lesson types, classrooms, and schedules tables 2026-02-20 01:56:05 +03:00
Zuev
86a29f6419 feat(frontend): add dynamic animations to login and admin panel 2026-02-20 00:48:03 +03:00
Zuev
e9c08b4c75 chore(git): update git-push skill and script with SSH info 2026-02-19 20:42:25 +03:00
Zuev
ed8668c599 chore: update agent rules, skills and workflows 2026-02-19 20:33:47 +03:00
EgorZuev
64d85eab55 docs(agent): add rules and skills 2026-02-18 21:56:57 +00:00
alekan
bcabb560a4 Merge remote-tracking branch 'origin/main' 2026-02-16 23:03:21 +03:00
alekan
24f8c4d518 Добавил кнопку для преподов 2026-02-16 23:02:46 +03:00
29 changed files with 2675 additions and 232 deletions

8
.gitignore vendored
View File

@@ -1,18 +1,16 @@
# Игнорируем данные БД (но не init-скрипты) # Игнорируем данные БД (но не init-скрипты)
db/data/ db/data/
postgres_data/
# Игнорируем секреты # Игнорируем секреты
.env .env
GEMINI.md !GEMINI.md
AGENTS.md !AGENTS.md
# Игнорируем системные папки IDE (если редактируете с ПК) # Игнорируем системные папки IDE (если редактируете с ПК)
.idea/ .idea/
.vscode/ .vscode/
*.DS_Store *.DS_Store
.agent .agent/
# Игнорируем временные файлы сборки (на будущее) # Игнорируем временные файлы сборки (на будущее)
backend/target/ backend/target/
backend/build/ backend/build/

View File

@@ -0,0 +1,109 @@
package com.magistr.app.controller;
import com.magistr.app.dto.ClassroomRequest;
import com.magistr.app.dto.ClassroomResponse;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Equipment;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/classrooms")
public class ClassroomController {
private final ClassroomRepository classroomRepository;
private final EquipmentRepository equipmentRepository;
public ClassroomController(ClassroomRepository classroomRepository, EquipmentRepository equipmentRepository) {
this.classroomRepository = classroomRepository;
this.equipmentRepository = equipmentRepository;
}
@GetMapping
public List<ClassroomResponse> getAllClassrooms() {
return classroomRepository.findAll().stream()
.map(this::mapToResponse)
.toList();
}
@PostMapping
public ResponseEntity<?> createClassroom(@RequestBody ClassroomRequest request) {
if (request.getName() == null || request.getName().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название аудитории обязательно"));
}
if (request.getCapacity() == null || request.getCapacity() <= 0) {
return ResponseEntity.badRequest().body(Map.of("message", "Вместимость должна быть больше нуля"));
}
if (classroomRepository.findByName(request.getName().trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Аудитория с таким названием уже существует"));
}
Classroom classroom = new Classroom();
classroom.setName(request.getName().trim());
classroom.setCapacity(request.getCapacity());
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
classroom.setEquipments(new java.util.HashSet<>(equipments));
}
classroomRepository.save(classroom);
return ResponseEntity.ok(mapToResponse(classroom));
}
@PutMapping("/{id}")
public ResponseEntity<?> updateClassroom(@PathVariable Long id, @RequestBody ClassroomRequest request) {
Optional<Classroom> opt = classroomRepository.findById(id);
if (opt.isEmpty()) {
return ResponseEntity.notFound().build();
}
Classroom classroom = opt.get();
if (request.getName() != null && !request.getName().isBlank()
&& !classroom.getName().equals(request.getName().trim())) {
if (classroomRepository.findByName(request.getName().trim()).isPresent()) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Аудитория с таким названием уже существует"));
}
classroom.setName(request.getName().trim());
}
if (request.getCapacity() != null && request.getCapacity() > 0) {
classroom.setCapacity(request.getCapacity());
}
if (request.getIsAvailable() != null) {
classroom.setIsAvailable(request.getIsAvailable());
}
if (request.getEquipmentIds() != null) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
classroom.setEquipments(new java.util.HashSet<>(equipments));
}
classroomRepository.save(classroom);
return ResponseEntity.ok(mapToResponse(classroom));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteClassroom(@PathVariable Long id) {
if (!classroomRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
classroomRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Аудитория удалена"));
}
private ClassroomResponse mapToResponse(Classroom c) {
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getIsAvailable(),
new java.util.ArrayList<>(c.getEquipments()));
}
}

View File

@@ -0,0 +1,51 @@
package com.magistr.app.controller;
import com.magistr.app.model.Equipment;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/equipments")
public class EquipmentController {
private final EquipmentRepository equipmentRepository;
public EquipmentController(EquipmentRepository equipmentRepository) {
this.equipmentRepository = equipmentRepository;
}
@GetMapping
public List<Equipment> getAllEquipments() {
return equipmentRepository.findAll();
}
@PostMapping
public ResponseEntity<?> createEquipment(@RequestBody Map<String, String> request) {
String name = request.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
}
if (equipmentRepository.findByName(name.trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Оборудование с таким названием уже существует"));
}
Equipment equipment = new Equipment();
equipment.setName(name.trim());
equipmentRepository.save(equipment);
return ResponseEntity.ok(equipment);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteEquipment(@PathVariable Long id) {
if (!equipmentRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
equipmentRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Оборудование удалено"));
}
}

View File

@@ -0,0 +1,51 @@
package com.magistr.app.controller;
import com.magistr.app.model.Subject;
import com.magistr.app.repository.SubjectRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/subjects")
public class SubjectController {
private final SubjectRepository subjectRepository;
public SubjectController(SubjectRepository subjectRepository) {
this.subjectRepository = subjectRepository;
}
@GetMapping
public List<Subject> getAllSubjects() {
return subjectRepository.findAll();
}
@PostMapping
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> request) {
String name = request.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
}
if (subjectRepository.findByName(name.trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует"));
}
Subject subject = new Subject();
subject.setName(name.trim());
subjectRepository.save(subject);
return ResponseEntity.ok(subject);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
if (!subjectRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
subjectRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
}
}

View File

@@ -0,0 +1,85 @@
package com.magistr.app.controller;
import com.magistr.app.dto.TeacherSubjectResponse;
import com.magistr.app.model.TeacherSubject;
import com.magistr.app.model.TeacherSubjectId;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/teacher-subjects")
public class TeacherSubjectController {
private final TeacherSubjectRepository teacherSubjectRepository;
private final UserRepository userRepository;
private final SubjectRepository subjectRepository;
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
UserRepository userRepository,
SubjectRepository subjectRepository) {
this.teacherSubjectRepository = teacherSubjectRepository;
this.userRepository = userRepository;
this.subjectRepository = subjectRepository;
}
@GetMapping
public List<TeacherSubjectResponse> getAll() {
return teacherSubjectRepository.findAll().stream()
.map(ts -> new TeacherSubjectResponse(
ts.getUserId(),
ts.getUser().getUsername(),
ts.getSubjectId(),
ts.getSubject().getName()
))
.toList();
}
@PostMapping
public ResponseEntity<?> create(@RequestBody Map<String, Long> request) {
Long userId = request.get("userId");
Long subjectId = request.get("subjectId");
if (userId == null || subjectId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны"));
}
if (!userRepository.existsById(userId)) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
}
if (!subjectRepository.existsById(subjectId)) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина не найдена"));
}
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (teacherSubjectRepository.existsById(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Привязка уже существует"));
}
TeacherSubject ts = new TeacherSubject(userId, subjectId);
teacherSubjectRepository.save(ts);
return ResponseEntity.ok(Map.of("message", "Привязка создана"));
}
@DeleteMapping
public ResponseEntity<?> delete(@RequestBody Map<String, Long> request) {
Long userId = request.get("userId");
Long subjectId = request.get("subjectId");
if (userId == null || subjectId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны"));
}
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (!teacherSubjectRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
teacherSubjectRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Привязка удалена"));
}
}

View File

@@ -31,6 +31,13 @@ public class UserController {
.toList(); .toList();
} }
@GetMapping("/teachers")
public List<UserResponse> getTeachers() {
return userRepository.findByRole(Role.TEACHER).stream()
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
.toList();
}
@PostMapping @PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) { public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
if (request.getUsername() == null || request.getUsername().isBlank()) { if (request.getUsername() == null || request.getUsername().isBlank()) {

View File

@@ -0,0 +1,42 @@
package com.magistr.app.dto;
import java.util.List;
public class ClassroomRequest {
private String name;
private Integer capacity;
private Boolean isAvailable;
private List<Long> equipmentIds;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getCapacity() {
return capacity;
}
public void setCapacity(Integer capacity) {
this.capacity = capacity;
}
public Boolean getIsAvailable() {
return isAvailable;
}
public void setIsAvailable(Boolean isAvailable) {
this.isAvailable = isAvailable;
}
public List<Long> getEquipmentIds() {
return equipmentIds;
}
public void setEquipmentIds(List<Long> equipmentIds) {
this.equipmentIds = equipmentIds;
}
}

View File

@@ -0,0 +1,63 @@
package com.magistr.app.dto;
import com.magistr.app.model.Equipment;
import java.util.List;
public class ClassroomResponse {
private Long id;
private String name;
private Integer capacity;
private Boolean isAvailable;
private List<Equipment> equipments;
public ClassroomResponse() {
}
public ClassroomResponse(Long id, String name, Integer capacity, Boolean isAvailable, List<Equipment> equipments) {
this.id = id;
this.name = name;
this.capacity = capacity;
this.isAvailable = isAvailable;
this.equipments = equipments;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getCapacity() {
return capacity;
}
public void setCapacity(Integer capacity) {
this.capacity = capacity;
}
public Boolean getIsAvailable() {
return isAvailable;
}
public void setIsAvailable(Boolean isAvailable) {
this.isAvailable = isAvailable;
}
public List<Equipment> getEquipments() {
return equipments;
}
public void setEquipments(List<Equipment> equipments) {
this.equipments = equipments;
}
}

View File

@@ -0,0 +1,32 @@
package com.magistr.app.dto;
public class TeacherSubjectResponse {
private Long userId;
private String username;
private Long subjectId;
private String subjectName;
public TeacherSubjectResponse(Long userId, String username, Long subjectId, String subjectName) {
this.userId = userId;
this.username = username;
this.subjectId = subjectId;
this.subjectName = subjectName;
}
public Long getUserId() {
return userId;
}
public String getUsername() {
return username;
}
public Long getSubjectId() {
return subjectId;
}
public String getSubjectName() {
return subjectName;
}
}

View File

@@ -0,0 +1,70 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "classrooms")
public class Classroom {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 50)
private String name;
@Column(nullable = false)
private Integer capacity;
@Column(name = "is_available", nullable = false)
private Boolean isAvailable = true;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "classroom_equipments", joinColumns = @JoinColumn(name = "classroom_id"), inverseJoinColumns = @JoinColumn(name = "equipment_id"))
private Set<Equipment> equipments = new HashSet<>();
public Classroom() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getCapacity() {
return capacity;
}
public void setCapacity(Integer capacity) {
this.capacity = capacity;
}
public Boolean getIsAvailable() {
return isAvailable;
}
public void setIsAvailable(Boolean isAvailable) {
this.isAvailable = isAvailable;
}
public Set<Equipment> getEquipments() {
return equipments;
}
public void setEquipments(Set<Equipment> equipments) {
this.equipments = equipments;
}
}

View File

@@ -0,0 +1,39 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "equipments")
public class Equipment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 50)
private String name;
public Equipment() {
}
public Equipment(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,39 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "subjects")
public class Subject {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 200)
private String name;
public Subject() {
}
public Subject(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,57 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "teacher_subjects")
@IdClass(TeacherSubjectId.class)
public class TeacherSubject {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "subject_id")
private Long subjectId;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", insertable = false, updatable = false)
private User user;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "subject_id", insertable = false, updatable = false)
private Subject subject;
public TeacherSubject() {
}
public TeacherSubject(Long userId, Long subjectId) {
this.userId = userId;
this.subjectId = subjectId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getSubjectId() {
return subjectId;
}
public void setSubjectId(Long subjectId) {
this.subjectId = subjectId;
}
public User getUser() {
return user;
}
public Subject getSubject() {
return subject;
}
}

View File

@@ -0,0 +1,46 @@
package com.magistr.app.model;
import java.io.Serializable;
import java.util.Objects;
public class TeacherSubjectId implements Serializable {
private Long userId;
private Long subjectId;
public TeacherSubjectId() {
}
public TeacherSubjectId(Long userId, Long subjectId) {
this.userId = userId;
this.subjectId = subjectId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getSubjectId() {
return subjectId;
}
public void setSubjectId(Long subjectId) {
this.subjectId = subjectId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TeacherSubjectId that)) return false;
return Objects.equals(userId, that.userId) && Objects.equals(subjectId, that.subjectId);
}
@Override
public int hashCode() {
return Objects.hash(userId, subjectId);
}
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.repository;
import com.magistr.app.model.Classroom;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ClassroomRepository extends JpaRepository<Classroom, Long> {
Optional<Classroom> findByName(String name);
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.repository;
import com.magistr.app.model.Equipment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface EquipmentRepository extends JpaRepository<Equipment, Long> {
Optional<Equipment> findByName(String name);
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.repository;
import com.magistr.app.model.Subject;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SubjectRepository extends JpaRepository<Subject, Long> {
Optional<Subject> findByName(String name);
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherSubject;
import com.magistr.app.model.TeacherSubjectId;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject, TeacherSubjectId> {
List<TeacherSubject> findByUserId(Long userId);
List<TeacherSubject> findBySubjectId(Long subjectId);
}

View File

@@ -1,11 +1,15 @@
package com.magistr.app.repository; package com.magistr.app.repository;
import com.magistr.app.model.Role;
import com.magistr.app.model.User; import com.magistr.app.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
List<User> findByRole(Role role);
} }

View File

@@ -27,6 +27,8 @@ services:
image: postgres:alpine3.23 image: postgres:alpine3.23
container_name: db container_name: db
restart: always restart: always
ports:
- "5432:5432"
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

View File

@@ -1,8 +1,5 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- =====================================================
-- 1. Таблица users
-- =====================================================
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL, username VARCHAR(50) UNIQUE NOT NULL,
@@ -10,48 +7,135 @@ CREATE TABLE IF NOT EXISTS users (
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT' role VARCHAR(20) NOT NULL DEFAULT 'STUDENT'
); );
-- Админ по умолчанию -- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role) INSERT INTO users (username, password, role)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN') VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN')
ON CONFLICT (username) DO NOTHING; ON CONFLICT (username) DO NOTHING;
-- =====================================================
-- 2. Таблица education_forms
-- =====================================================
CREATE TABLE IF NOT EXISTS education_forms ( CREATE TABLE IF NOT EXISTS education_forms (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL name VARCHAR(100) UNIQUE NOT NULL
); );
INSERT INTO education_forms (name) INSERT INTO education_forms (name) VALUES ('Бакалавриат'), ('Магистратура'), ('Специалитет')
VALUES ('Бакалавриат'), ('Магистратура'), ('Специалитет')
ON CONFLICT (name) DO NOTHING; ON CONFLICT (name) DO NOTHING;
-- =====================================================
-- 3. Таблица student_groups
-- =====================================================
CREATE TABLE IF NOT EXISTS student_groups ( CREATE TABLE IF NOT EXISTS student_groups (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL, name VARCHAR(100) UNIQUE NOT NULL,
education_form_id BIGINT NOT NULL REFERENCES education_forms(id) education_form_id BIGINT NOT NULL REFERENCES education_forms(id)
); );
-- ==========================================
-- Справочники
-- ==========================================
-- ===================================================== -- Дисциплины
-- 4. Таблица lessons (ЭТА ЧАСТЬ БЫЛА ИСПОРЧЕНА) CREATE TABLE IF NOT EXISTS subjects (
-- =====================================================
CREATE TABLE IF NOT EXISTS lessons (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL, name VARCHAR(200) UNIQUE NOT NULL
group_id BIGINT NOT NULL,
discipline_id BIGINT NOT NULL,
day VARCHAR(255) NOT NULL,
week VARCHAR(255) NOT NULL,
time VARCHAR(255) NOT NULL
); );
-- Добавляем внешние ключи INSERT INTO subjects (name) VALUES
ALTER TABLE lessons ADD CONSTRAINT fk_lessons_teacher ('Высшая математика'),
FOREIGN KEY (teacher_id) REFERENCES users(id); ('Философия'),
('Информатика'),
('Базы данных'),
('Английский язык')
ON CONFLICT (name) DO NOTHING;
ALTER TABLE lessons ADD CONSTRAINT fk_lessons_group -- Типы занятий
FOREIGN KEY (group_id) REFERENCES student_groups(id); CREATE TABLE IF NOT EXISTS lesson_types (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
INSERT INTO lesson_types (name) VALUES
('Лекция'),
('Практика'),
('Лабораторная работа')
ON CONFLICT (name) DO NOTHING;
-- Оборудование
CREATE TABLE IF NOT EXISTS equipments (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
INSERT INTO equipments (name) VALUES
('Проектор'),
('ПК'),
('Лаборатория'),
('Интерактивная доска')
ON CONFLICT (name) DO NOTHING;
-- Аудитории
CREATE TABLE IF NOT EXISTS classrooms (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
capacity INT NOT NULL,
is_available BOOLEAN DEFAULT TRUE
);
INSERT INTO classrooms (name, capacity) VALUES
('101 Ленинская', 120),
('202 IT Lab', 20),
('303 Обычная', 30)
ON CONFLICT (name) DO NOTHING;
-- Привязка оборудования к аудиториям (Many-to-Many)
CREATE TABLE IF NOT EXISTS classroom_equipments (
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
equipment_id BIGINT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
PRIMARY KEY (classroom_id, equipment_id)
);
-- Заполнение привязок оборудования (на основе ID базовых данных)
-- '101 Ленинская' -> Проектор (1), Интерактивная доска (4)
INSERT INTO classroom_equipments (classroom_id, equipment_id) VALUES
(1, 1), (1, 4),
-- '202 IT Lab' -> ПК (2), Проектор (1), Лаборатория (3)
(2, 2), (2, 1), (2, 3)
-- '303 Обычная' -> ничего
ON CONFLICT DO NOTHING;
-- ==========================================
-- Связи для преподавателей и студентов
-- ==========================================
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
CREATE TABLE IF NOT EXISTS subgroups (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
UNIQUE(group_id, name)
);
-- Тестовая базовая группа для работы
INSERT INTO student_groups (name, education_form_id)
VALUES ('ИВТ-21-1', 1)
ON CONFLICT (name) DO NOTHING;
-- Привязка преподавателей к дисциплинам
CREATE TABLE IF NOT EXISTS teacher_subjects (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
PRIMARY KEY(user_id, subject_id)
);
-- ==========================================
-- Основная таблица Расписания (Lessons)
-- ==========================================
CREATE TABLE IF NOT EXISTS lessons (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
subject_id BIGINT NOT NULL REFERENCES subjects(id),
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
group_id BIGINT NOT NULL REFERENCES student_groups(id), -- первичная группа
subgroup_id BIGINT REFERENCES subgroups(id), -- необязательно (если делим группу)
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), -- 1=Понедельник, 7=Воскресенье
is_even_week BOOLEAN NOT NULL, -- Четная/нечетная неделя
start_time TIME NOT NULL, -- Напр. '08:00:00'
end_time TIME NOT NULL -- Напр. '09:30:00'
);

View File

@@ -8,45 +8,56 @@
} }
:root { :root {
--bg-primary: #0f0f1a; /* Deep dark premium background */
--bg-sidebar: rgba(255, 255, 255, 0.03); --bg-primary: #0a0a0f;
--bg-card: rgba(255, 255, 255, 0.05); --bg-sidebar: rgba(255, 255, 255, 0.02);
--bg-card-border: rgba(255, 255, 255, 0.08); --bg-card: rgba(255, 255, 255, 0.03);
--bg-input: rgba(255, 255, 255, 0.06); --bg-card-border: rgba(255, 255, 255, 0.05);
--bg-input-focus: rgba(255, 255, 255, 0.1); --bg-input: rgba(255, 255, 255, 0.04);
--bg-input-focus: rgba(255, 255, 255, 0.08);
--bg-hover: rgba(255, 255, 255, 0.06); --bg-hover: rgba(255, 255, 255, 0.06);
--text-primary: #f0f0f5;
--text-secondary: #9ca3af; /* Typography */
--text-placeholder: #6b7280; --text-primary: #f8fafc;
--accent: #6366f1; --text-secondary: #94a3b8;
--accent-hover: #818cf8; --text-placeholder: #475569;
--accent-glow: rgba(99, 102, 241, 0.35);
--error: #f87171; /* Vibrant Accents */
--success: #34d399; --accent: #8b5cf6;
--warning: #fbbf24; --accent-hover: #a78bfa;
--radius-sm: 8px; --accent-glow: rgba(139, 92, 246, 0.4);
--radius-md: 12px; --accent-secondary: #ec4899;
--transition: 0.2s ease;
/* Status Colors */
--error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
/* Spatial */
--radius-sm: 10px;
--radius-md: 16px;
--transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
} }
/* ===== Light Theme ===== */ /* ===== Light Theme ===== */
[data-theme="light"] { [data-theme="light"] {
--bg-primary: #e8eaef; --bg-primary: #f8fafc;
--bg-sidebar: rgba(255, 255, 255, 0.88); --bg-sidebar: rgba(255, 255, 255, 0.7);
--bg-card: rgba(255, 255, 255, 0.95); --bg-card: rgba(255, 255, 255, 0.7);
--bg-card-border: rgba(0, 0, 0, 0.22); --bg-card-border: rgba(0, 0, 0, 0.08);
--bg-input: rgba(0, 0, 0, 0.08); --bg-input: rgba(0, 0, 0, 0.03);
--bg-input-focus: rgba(0, 0, 0, 0.12); --bg-input-focus: rgba(0, 0, 0, 0.06);
--bg-hover: rgba(0, 0, 0, 0.08); --bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #374151; --text-secondary: #475569;
--text-placeholder: #6b7280; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #6366f1;
--accent-hover: #4f46e5; --accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.25); --accent-glow: rgba(99, 102, 241, 0.3);
--error: #dc2626; --accent-secondary: #d946ef;
--success: #16a34a; --error: #ef4444;
--warning: #d97706; --success: #10b981;
--warning: #f59e0b;
} }
[data-theme="light"] .form-group select option, [data-theme="light"] .form-group select option,
@@ -59,6 +70,10 @@
background: rgba(99, 102, 241, 0.18); background: rgba(99, 102, 241, 0.18);
} }
[data-theme="light"] .custom-multi-select .dropdown-menu {
background: rgba(255, 255, 255, 0.98);
}
[data-theme="light"] .form-group input, [data-theme="light"] .form-group input,
[data-theme="light"] .form-group select, [data-theme="light"] .form-group select,
[data-theme="light"] .filter-row select { [data-theme="light"] .filter-row select {
@@ -85,9 +100,11 @@ body {
/* ===== Sidebar ===== */ /* ===== Sidebar ===== */
.sidebar { .sidebar {
width: 240px; width: 260px;
min-height: 100vh; min-height: 100vh;
background: var(--bg-sidebar); background: var(--bg-sidebar);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--bg-card-border); border-right: 1px solid var(--bg-card-border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -96,7 +113,7 @@ body {
top: 0; top: 0;
bottom: 0; bottom: 0;
z-index: 10; z-index: 10;
transition: background 0.4s ease, border-color 0.4s ease; transition: background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
} }
.sidebar-header { .sidebar-header {
@@ -121,19 +138,124 @@ body {
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.75rem;
padding: 0.65rem 0.8rem; padding: 0.75rem 1rem;
margin-bottom: 0.25rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.95rem;
font-weight: 500; font-weight: 500;
transition: background var(--transition), color var(--transition); transition: all var(--transition);
position: relative;
overflow: hidden;
}
.nav-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent);
border-radius: 0 4px 4px 0;
transform: scaleY(0);
transition: transform var(--transition);
opacity: 0;
} }
.nav-item:hover { .nav-item:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
transform: translateX(4px);
}
.nav-item.active {
background: rgba(139, 92, 246, 0.12);
color: var(--accent-hover);
}
.nav-item.active::before {
transform: scaleY(1);
opacity: 1;
}
.nav-item svg {
transition: transform var(--transition);
}
.nav-item:hover svg,
.nav-item.active svg {
transform: scale(1.15) rotate(-5deg);
}
/* Checkbox list styling */
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 8px 0;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9rem;
color: var(--text-base);
cursor: pointer;
}
.checkbox-item input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
accent-color: var(--primary-color);
}
/* Classroom Status Badges */
.badge-available {
background-color: var(--success-bg);
color: var(--success-color);
}
.badge-unavailable {
background-color: var(--error-bg);
color: var(--error-color);
}
.status-cell {
display: flex;
align-items: center;
gap: 8px;
}
.btn-icon-toggle {
background: var(--bg-body);
border: 1px solid var(--border-color);
color: var(--text-muted);
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
}
.btn-icon-toggle:hover {
background: var(--card-bg);
border-color: var(--primary-color);
color: var(--primary-color);
transform: rotate(45deg);
box-shadow: 0 0 10px rgba(99, 102, 241, 0.2);
}
.btn-icon-toggle svg {
display: block;
} }
.nav-item.active { .nav-item.active {
@@ -170,7 +292,7 @@ body {
/* ===== Main ===== */ /* ===== Main ===== */
.main { .main {
flex: 1; flex: 1;
margin-left: 240px; margin-left: 260px;
min-height: 100vh; min-height: 100vh;
} }
@@ -199,12 +321,70 @@ body {
} }
/* ===== Cards ===== */ /* ===== Cards ===== */
@keyframes slideUpCard {
from {
opacity: 0;
transform: translateY(15px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card { .card {
background: var(--bg-card); background: var(--bg-card);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 1.5rem; padding: 1.75rem;
transition: background 0.4s ease, border-color 0.4s ease; position: relative;
overflow: visible;
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
animation: slideUpCard 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) both;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
opacity: 0;
transition: opacity var(--transition);
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
border-color: rgba(255, 255, 255, 0.12);
}
.card:hover::before {
opacity: 1;
}
/* Staggered cards */
.card:nth-child(1) {
animation-delay: 0.1s;
}
.card:nth-child(2) {
animation-delay: 0.2s;
}
.card:nth-child(3) {
animation-delay: 0.3s;
}
/* Specific Cards */
.create-card {
z-index: 10;
} }
.card h2 { .card h2 {
@@ -242,26 +422,43 @@ body {
.form-group input, .form-group input,
.form-group select { .form-group select {
width: 100%; width: 100%;
padding: 0.65rem 0.8rem; padding: 0.75rem 1rem;
background: var(--bg-input); background: var(--bg-input);
border: 1px solid transparent; border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-primary); color: var(--text-primary);
font-family: inherit; font-family: inherit;
font-size: 0.9rem; font-size: 0.95rem;
outline: none; outline: none;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition); transition: all var(--transition);
} }
.form-group input::placeholder { .form-group input::placeholder {
color: var(--text-placeholder); color: var(--text-placeholder);
transition: opacity var(--transition);
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus { .form-group select:focus {
background: var(--bg-input-focus); background: var(--bg-input-focus);
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow); box-shadow: 0 0 0 4px var(--accent-glow);
transform: translateY(-1px);
}
.form-group input:focus::placeholder {
opacity: 0.5;
}
/* Hide Number Arrows */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
} }
.form-group select { .form-group select {
@@ -279,23 +476,60 @@ body {
} }
.btn-create { .btn-create {
padding: 0.65rem 1.5rem; position: relative;
background: linear-gradient(135deg, var(--accent), #8b5cf6); overflow: hidden;
padding: 0.75rem 1.75rem;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: #fff; color: #fff;
font-family: inherit; font-family: inherit;
font-size: 0.9rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: transform var(--transition), box-shadow var(--transition); transition: all var(--transition);
box-shadow: 0 2px 10px var(--accent-glow); box-shadow: 0 4px 15px var(--accent-glow);
}
.btn-create::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(rgba(255, 255, 255, 0.2), transparent);
border-radius: inherit;
opacity: 0;
transition: opacity var(--transition);
} }
.btn-create:hover { .btn-create:hover {
transform: translateY(-1px); transform: translateY(-2px);
box-shadow: 0 4px 16px var(--accent-glow); box-shadow: 0 8px 25px var(--accent-glow);
}
.btn-create:hover::before {
opacity: 1;
}
.btn-create:active {
transform: translateY(1px);
box-shadow: 0 2px 10px var(--accent-glow);
}
@keyframes slideDownAlert {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.form-alert { .form-alert {
@@ -311,6 +545,7 @@ body {
background: rgba(248, 113, 113, 0.1); background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2); border: 1px solid rgba(248, 113, 113, 0.2);
color: var(--error); color: var(--error);
animation: slideDownAlert 0.3s ease-out both;
} }
.form-alert.success { .form-alert.success {
@@ -318,11 +553,12 @@ body {
background: rgba(52, 211, 153, 0.1); background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.2); border: 1px solid rgba(52, 211, 153, 0.2);
color: var(--success); color: var(--success);
animation: slideDownAlert 0.3s ease-out both;
} }
/* ===== Table ===== */ /* ===== Table ===== */
.table-wrap { .table-wrap {
overflow-x: auto; overflow-x: visible;
} }
table { table {
@@ -342,13 +578,63 @@ thead th {
} }
tbody td { tbody td {
padding: 0.7rem 0.8rem; padding: 0.85rem 1rem;
font-size: 0.9rem; font-size: 0.95rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.03); border-bottom: 1px solid var(--bg-card-border);
transition: background var(--transition);
}
@keyframes slideInRow {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRow {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
} }
tbody tr { tbody tr {
transition: background var(--transition); transition: background var(--transition);
animation: slideInRow 0.3s ease-out both;
}
tbody tr:nth-child(1) {
animation-delay: 0.05s;
}
tbody tr:nth-child(2) {
animation-delay: 0.1s;
}
tbody tr:nth-child(3) {
animation-delay: 0.15s;
}
tbody tr:nth-child(4) {
animation-delay: 0.2s;
}
tbody tr:nth-child(5) {
animation-delay: 0.25s;
}
tbody tr:nth-child(n+6) {
animation-delay: 0.3s;
} }
tbody tr:hover { tbody tr:hover {
@@ -477,11 +763,29 @@ tbody tr:hover {
font-family: inherit; font-family: inherit;
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
transition: background var(--transition); transition: background var(--transition), transform var(--transition);
} }
.btn-delete:hover { .btn-delete:hover {
background: rgba(248, 113, 113, 0.2); background: rgba(248, 113, 113, 0.2);
transform: scale(1.05);
}
/* ===== Ripple Effect ===== */
.ripple {
position: absolute;
border-radius: 50%;
transform: scale(0);
animation: admin-ripple 0.6s linear;
background-color: rgba(255, 255, 255, 0.3);
pointer-events: none;
}
@keyframes admin-ripple {
to {
transform: scale(4);
opacity: 0;
}
} }
/* ===== Mobile Menu Toggle ===== */ /* ===== Mobile Menu Toggle ===== */
@@ -512,23 +816,27 @@ tbody tr:hover {
/* ===== Theme Toggle Button ===== */ /* ===== Theme Toggle Button ===== */
.theme-toggle { .theme-toggle {
width: 38px; width: 40px;
height: 38px; height: 40px;
border: none;
border-radius: 50%; border-radius: 50%;
background: var(--bg-input); background: var(--bg-card);
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
color: var(--text-primary); color: var(--text-primary);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease; backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all var(--transition);
z-index: 100;
flex-shrink: 0; flex-shrink: 0;
} }
.theme-toggle svg { .theme-toggle svg {
width: 18px; width: 20px;
height: 18px; height: 20px;
transition: transform 0.4s ease; transition: transform 0.4s ease;
} }
@@ -541,11 +849,142 @@ tbody tr:hover {
transform: scale(0.95); transform: scale(0.95);
} }
.theme-toggle--fixed { /* ===== Custom Multi Select ===== */
.custom-multi-select {
position: relative;
width: 100%;
}
.custom-multi-select .select-box {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: all var(--transition);
}
.custom-multi-select .select-box.active {
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
}
.custom-multi-select .dropdown-icon {
transition: transform var(--transition);
}
.custom-multi-select .select-box.active .dropdown-icon {
transform: rotate(180deg);
}
.custom-multi-select .dropdown-menu {
position: absolute;
top: calc(100% + 5px);
left: 0;
width: 100%;
background: rgba(15, 15, 26, 0.98);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
padding: 0.75rem;
z-index: 9999;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all var(--transition);
max-height: 250px;
overflow-y: auto;
}
.custom-multi-select .dropdown-menu.open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.checkbox-group-vertical {
display: flex;
flex-direction: column;
gap: 8px;
}
.checkbox-group-vertical .checkbox-item {
padding: 6px 8px;
border-radius: 6px;
transition: background var(--transition);
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-group-vertical .checkbox-item:hover {
background: var(--bg-hover);
}
/* ===== Modals ===== */
.modal-overlay {
display: none;
position: fixed; position: fixed;
top: 1.25rem; inset: 0;
right: 1.25rem; background: rgba(0, 0, 0, 0.5);
z-index: 100; z-index: 1000;
backdrop-filter: blur(2px);
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal-overlay.open {
display: flex;
animation: fadeIn 0.2s ease;
}
.modal-content {
background: var(--bg-primary);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
padding: 2rem;
width: 100%;
max-width: 600px;
position: relative;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
.modal-content h2 {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary);
border-bottom: 1px solid var(--bg-card-border);
padding-bottom: 1rem;
}
.modal-close {
position: absolute;
top: 1.5rem;
right: 1.5rem;
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
transition: color var(--transition);
}
.modal-close:hover {
color: var(--error);
} }
/* ===== Responsive ===== */ /* ===== Responsive ===== */

View File

@@ -16,6 +16,30 @@
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay'); const sidebarOverlay = document.getElementById('sidebar-overlay');
// Global Ripple Effect
document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-create, .btn-delete, .btn-logout');
if (!btn) return;
const rect = btn.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const ripple = document.createElement('span');
ripple.classList.add('ripple');
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
if (getComputedStyle(btn).position === 'static') {
btn.style.position = 'relative';
}
btn.style.overflow = 'hidden';
btn.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
});
// Users // Users
const usersTbody = document.getElementById('users-tbody'); const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form'); const createForm = document.getElementById('create-form');
@@ -33,18 +57,100 @@
const createEfForm = document.getElementById('create-ef-form'); const createEfForm = document.getElementById('create-ef-form');
const createEfAlert = document.getElementById('create-ef-alert'); const createEfAlert = document.getElementById('create-ef-alert');
// Classrooms
const classroomsTbody = document.getElementById('classrooms-tbody');
const createClassroomForm = document.getElementById('create-classroom-form');
const createClassroomAlert = document.getElementById('create-classroom-alert');
const modalEditClassroom = document.getElementById('modal-edit-classroom');
const modalEditClassroomClose = document.getElementById('modal-edit-classroom-close');
const editClassroomForm = document.getElementById('edit-classroom-form');
const editClassroomAlert = document.getElementById('edit-classroom-alert');
const editEquipmentCheckboxes = document.getElementById('edit-equipment-checkboxes');
// Equipments
const equipmentsTbody = document.getElementById('equipments-tbody');
const createEquipmentForm = document.getElementById('create-equipment-form');
const createEquipmentAlert = document.getElementById('create-equipment-alert');
const equipmentCheckboxes = document.getElementById('equipment-checkboxes');
// Subjects
const subjectsTbody = document.getElementById('subjects-tbody');
const createSubjectForm = document.getElementById('create-subject-form');
const createSubjectAlert = document.getElementById('create-subject-alert');
const assignTeacherForm = document.getElementById('assign-teacher-form');
const assignTeacherAlert = document.getElementById('assign-teacher-alert');
const assignTeacherSelect = document.getElementById('assign-teacher-select');
const assignSubjectSelect = document.getElementById('assign-subject-select');
const teacherSubjectsTbody = document.getElementById('teacher-subjects-tbody');
// --- Multi-select logic ---
function updateSelectText(containerId, textId) {
const container = document.getElementById(containerId);
const textEl = document.getElementById(textId);
if (!container || !textEl) return;
const checked = Array.from(container.querySelectorAll('input:checked'));
if (checked.length === 0) {
textEl.textContent = 'Выберите оборудование...';
} else if (checked.length === 1) {
textEl.textContent = checked[0].parentElement.textContent.trim();
} else {
textEl.textContent = `Выбрано: ${checked.length}`;
}
}
function initMultiSelect(boxId, menuId, textId, checkboxContainerId) {
const box = document.getElementById(boxId);
const menu = document.getElementById(menuId);
const container = document.getElementById(checkboxContainerId);
if (!box || !menu || !container) return;
box.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = menu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(m => m.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(b => b.classList.remove('active'));
if (!isOpen) {
menu.classList.add('open');
box.classList.add('active');
}
});
menu.addEventListener('click', (e) => {
e.stopPropagation();
});
container.addEventListener('change', () => {
updateSelectText(checkboxContainerId, textId);
});
}
initMultiSelect('equipment-select-box', 'equipment-dropdown-menu', 'equipment-select-text', 'equipment-checkboxes');
initMultiSelect('edit-equipment-select-box', 'edit-equipment-dropdown-menu', 'edit-equipment-select-text', 'edit-equipment-checkboxes');
document.addEventListener('click', () => {
document.querySelectorAll('.dropdown-menu').forEach(m => m.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(b => b.classList.remove('active'));
});
// --------------------------
const navItems = document.querySelectorAll('.nav-item[data-tab]'); const navItems = document.querySelectorAll('.nav-item[data-tab]');
const tabContents = document.querySelectorAll('.tab-content'); const tabContents = document.querySelectorAll('.tab-content');
// ---- State ---- // ---- State ----
let allGroups = []; let allGroups = [];
let allEducationForms = []; let allEducationForms = [];
let allEquipments = [];
let allSubjects = [];
let allTeachers = [];
// ---- Tab Switching ---- // ---- Tab Switching ----
const TAB_TITLES = { const TAB_TITLES = {
users: 'Управление пользователями', users: 'Управление пользователями',
groups: 'Управление группами', groups: 'Управление группами',
'edu-forms': 'Формы обучения', 'edu-forms': 'Формы обучения',
equipments: 'Оборудование',
classrooms: 'Аудитории',
subjects: 'Дисциплины и преподаватели'
}; };
navItems.forEach(item => { navItems.forEach(item => {
@@ -67,6 +173,9 @@
if (tab === 'users') loadUsers(); if (tab === 'users') loadUsers();
if (tab === 'groups') { loadEducationForms().then(() => loadGroups()); } if (tab === 'groups') { loadEducationForms().then(() => loadGroups()); }
if (tab === 'edu-forms') loadEducationForms(); if (tab === 'edu-forms') loadEducationForms();
if (tab === 'equipments') loadEquipments();
if (tab === 'classrooms') { loadEquipments().then(() => loadClassrooms()); }
if (tab === 'subjects') { Promise.all([loadSubjects(), loadTeachers()]).then(() => loadTeacherSubjects()); }
sidebar.classList.remove('open'); sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open'); sidebarOverlay.classList.remove('open');
@@ -129,6 +238,7 @@
<td>${escapeHtml(u.username)}</td> <td>${escapeHtml(u.username)}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || u.role}</span></td> <td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || u.role}</span></td>
<td><button class="btn-delete" data-id="${u.id}">Удалить</button></td> <td><button class="btn-delete" data-id="${u.id}">Удалить</button></td>
<td><button class="btn-delete" data-role="${u.role}">Добавить занятие</button></td>
</tr>`).join(''); </tr>`).join('');
} }
@@ -340,6 +450,453 @@
} catch (e) { alert('Ошибка соединения'); } } catch (e) { alert('Ошибка соединения'); }
}); });
// ============================================================
// EQUIPMENTS
// ============================================================
async function loadEquipments() {
try {
const res = await fetch('/api/equipments', {
headers: { 'Authorization': 'Bearer ' + token },
});
allEquipments = await res.json();
renderEquipments(allEquipments);
renderEquipmentCheckboxes(allEquipments);
} catch (e) {
if (equipmentsTbody) equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
if (equipmentCheckboxes) equipmentCheckboxes.innerHTML = '<p class="text-error">Ошибка загрузки</p>';
}
}
function renderEquipments(equipments) {
if (!equipments.length) {
equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
equipmentsTbody.innerHTML = equipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn-delete" data-id="${eq.id}">Удалить</button></td>
</tr>`).join('');
}
function renderEquipmentCheckboxes(equipments) {
if (!equipments.length) {
equipmentCheckboxes.innerHTML = '<p class="text-muted"><small>Нет доступного оборудования</small></p>';
return;
}
equipmentCheckboxes.innerHTML = equipments.map(eq => `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}"> ${escapeHtml(eq.name)}
</label>
`).join('');
updateSelectText('equipment-checkboxes', 'equipment-select-text');
}
createEquipmentForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(createEquipmentAlert);
const name = document.getElementById('new-equipment-name').value.trim();
if (!name) { showAlert(createEquipmentAlert, 'Введите название', 'error'); return; }
try {
const res = await fetch('/api/equipments', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (res.ok) {
showAlert(createEquipmentAlert, `Оборудование "${data.name}" добавлено`, 'success');
createEquipmentForm.reset();
loadEquipments();
} else {
showAlert(createEquipmentAlert, data.message || 'Ошибка создания', 'error');
}
} catch (e) { showAlert(createEquipmentAlert, 'Ошибка соединения', 'error'); }
});
equipmentsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить оборудование?')) return;
try {
const res = await fetch('/api/equipments/' + btn.dataset.id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) {
loadEquipments();
} else {
const data = await res.json();
alert(data.message || 'Ошибка удаления');
}
} catch (e) { alert('Ошибка соединения'); }
});
// ============================================================
// CLASSROOMS
// ============================================================
async function loadClassrooms() {
try {
const res = await fetch('/api/classrooms', {
headers: { 'Authorization': 'Bearer ' + token },
});
const classrooms = await res.json();
renderClassrooms(classrooms);
} catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderClassrooms(classrooms) {
if (!classrooms.length) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет аудиторий</td></tr>';
return;
}
classroomsTbody.innerHTML = classrooms.map(c => {
const equipHtml = c.equipments && c.equipments.length
? c.equipments.map(eq => escapeHtml(eq.name)).join(', ')
: '—';
return `
<tr>
<td>${c.id}</td>
<td><strong>${escapeHtml(c.name)}</strong></td>
<td>${c.capacity} чел.</td>
<td><small>${equipHtml}</small></td>
<td>
<div class="status-cell">
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${c.isAvailable ? 'Доступна' : 'Не доступна'}
</span>
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
</button>
</div>
</td>
<td style="text-align: right;">
<button class="btn-edit-classroom" data-id="${c.id}" style="padding: 0.35rem 0.7rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.2); border-radius: var(--radius-sm); color: var(--accent-hover); cursor: pointer; margin-right: 0.5rem;">Изменить</button>
<button class="btn-delete" data-id="${c.id}">Удалить</button>
</td>
</tr>`;
}).join('');
}
createClassroomForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(createClassroomAlert);
const name = document.getElementById('new-classroom-name').value.trim();
const capacity = parseInt(document.getElementById('new-classroom-capacity').value, 10);
const checkedBoxes = Array.from(equipmentCheckboxes.querySelectorAll('input:checked'));
const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
if (!name || isNaN(capacity)) { showAlert(createClassroomAlert, 'Заполните обязательные поля', 'error'); return; }
try {
const res = await fetch('/api/classrooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ name, capacity, equipmentIds, isAvailable: true }),
});
const data = await res.json();
if (res.ok) {
showAlert(createClassroomAlert, `Аудитория "${data.name}" добавлена`, 'success');
createClassroomForm.reset();
updateSelectText('equipment-checkboxes', 'equipment-select-text');
loadClassrooms();
} else {
showAlert(createClassroomAlert, data.message || 'Ошибка создания', 'error');
}
} catch (e) { showAlert(createClassroomAlert, 'Ошибка соединения', 'error'); }
});
classroomsTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete');
const btnToggleStatus = e.target.closest('.btn-icon-toggle');
const btnEdit = e.target.closest('.btn-edit-classroom');
if (btnDelete) {
if (!confirm('Удалить аудиторию?')) return;
try {
const res = await fetch('/api/classrooms/' + btnDelete.dataset.id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) loadClassrooms();
else alert('Ошибка удаления');
} catch (err) { alert('Ошибка соединения'); }
}
if (btnToggleStatus) {
const id = btnToggleStatus.dataset.id;
const currentStatus = btnToggleStatus.dataset.currentStatus === 'true';
try {
const res = await fetch('/api/classrooms/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ isAvailable: !currentStatus }),
});
if (res.ok) loadClassrooms();
else alert('Ошибка изменения статуса');
} catch (err) { alert('Ошибка соединения'); }
}
if (btnEdit) {
const id = btnEdit.dataset.id;
openEditClassroomModal(id);
}
});
let editingClassroomData = null;
async function openEditClassroomModal(id) {
try {
const res = await fetch('/api/classrooms', { headers: { 'Authorization': 'Bearer ' + token } });
const classrooms = await res.json();
editingClassroomData = classrooms.find(c => c.id == id);
if (!editingClassroomData) return;
document.getElementById('edit-classroom-id').value = editingClassroomData.id;
document.getElementById('edit-classroom-name').value = editingClassroomData.name;
document.getElementById('edit-classroom-capacity').value = editingClassroomData.capacity;
if (allEquipments.length) {
editEquipmentCheckboxes.innerHTML = allEquipments.map(eq => {
const isChecked = editingClassroomData.equipments.some(e => e.id === eq.id) ? 'checked' : '';
return `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${isChecked}> ${escapeHtml(eq.name)}
</label>
`;
}).join('');
} else {
editEquipmentCheckboxes.innerHTML = '<p class="text-muted"><small>Нет доступного оборудования</small></p>';
}
updateSelectText('edit-equipment-checkboxes', 'edit-equipment-select-text');
hideAlert(editClassroomAlert);
modalEditClassroom.classList.add('open');
} catch (e) {
alert('Ошибка загрузки данных аудитории');
}
}
modalEditClassroomClose.addEventListener('click', () => {
modalEditClassroom.classList.remove('open');
});
modalEditClassroom.addEventListener('click', (e) => {
if (e.target === modalEditClassroom) {
modalEditClassroom.classList.remove('open');
}
});
editClassroomForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(editClassroomAlert);
const id = document.getElementById('edit-classroom-id').value;
const name = document.getElementById('edit-classroom-name').value.trim();
const capacity = parseInt(document.getElementById('edit-classroom-capacity').value, 10);
const checkedBoxes = Array.from(editEquipmentCheckboxes.querySelectorAll('input:checked'));
const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
if (!name || isNaN(capacity)) { showAlert(editClassroomAlert, 'Заполните обязательные поля', 'error'); return; }
try {
const res = await fetch('/api/classrooms/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ name, capacity, equipmentIds, isAvailable: editingClassroomData.isAvailable }),
});
const data = await res.json();
if (res.ok) {
modalEditClassroom.classList.remove('open');
showAlert(createClassroomAlert, `Аудитория "${data.name}" обновлена`, 'success');
loadClassrooms();
} else {
showAlert(editClassroomAlert, data.message || 'Ошибка обновления', 'error');
}
} catch (e) { showAlert(editClassroomAlert, 'Ошибка соединения', 'error'); }
});
// ============================================================
// SUBJECTS
// ============================================================
async function loadSubjects() {
try {
const res = await fetch('/api/subjects', {
headers: { 'Authorization': 'Bearer ' + token },
});
allSubjects = await res.json();
renderSubjects(allSubjects);
populateSubjectSelect(allSubjects);
} catch (e) {
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderSubjects(subjects) {
if (!subjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет дисциплин</td></tr>';
return;
}
subjectsTbody.innerHTML = subjects.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.name)}</td>
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td>
</tr>`).join('');
}
function populateSubjectSelect(subjects) {
if (!assignSubjectSelect) return;
const currentVal = assignSubjectSelect.value;
assignSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
if (currentVal && subjects.find(s => s.id == currentVal)) {
assignSubjectSelect.value = currentVal;
}
}
async function loadTeachers() {
try {
const res = await fetch('/api/users/teachers', {
headers: { 'Authorization': 'Bearer ' + token },
});
allTeachers = await res.json();
populateTeacherSelect(allTeachers);
} catch (e) {
if (assignTeacherSelect) assignTeacherSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
function populateTeacherSelect(teachers) {
if (!assignTeacherSelect) return;
const currentVal = assignTeacherSelect.value;
if (!teachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет преподавателей</option>';
return;
}
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
teachers.map(t => `<option value="${t.id}">${escapeHtml(t.username)}</option>`).join('');
if (currentVal && teachers.find(t => t.id == currentVal)) {
assignTeacherSelect.value = currentVal;
}
}
async function loadTeacherSubjects() {
try {
const res = await fetch('/api/teacher-subjects', {
headers: { 'Authorization': 'Bearer ' + token },
});
const tsData = await res.json();
renderTeacherSubjects(tsData);
} catch (e) {
if (teacherSubjectsTbody) teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderTeacherSubjects(tsArray) {
if (!tsArray.length) {
teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет привязок</td></tr>';
return;
}
teacherSubjectsTbody.innerHTML = tsArray.map(ts => `
<tr>
<td>${escapeHtml(ts.username)}</td>
<td>${escapeHtml(ts.subjectName)}</td>
<td><button class="btn-delete" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button></td>
</tr>`).join('');
}
createSubjectForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(createSubjectAlert);
const name = document.getElementById('new-subject-name').value.trim();
if (!name) { showAlert(createSubjectAlert, 'Введите название', 'error'); return; }
try {
const res = await fetch('/api/subjects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (res.ok) {
showAlert(createSubjectAlert, `Дисциплина "${data.name}" добавлена`, 'success');
createSubjectForm.reset();
loadSubjects();
} else {
showAlert(createSubjectAlert, data.message || 'Ошибка создания', 'error');
}
} catch (e) { showAlert(createSubjectAlert, 'Ошибка соединения', 'error'); }
});
subjectsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить дисциплину?')) return;
try {
const res = await fetch('/api/subjects/' + btn.dataset.id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) {
loadSubjects();
loadTeacherSubjects();
} else {
const data = await res.json();
alert(data.message || 'Ошибка удаления');
}
} catch (e) { alert('Ошибка соединения'); }
});
assignTeacherForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(assignTeacherAlert);
const userId = assignTeacherSelect.value;
const subjectId = assignSubjectSelect.value;
if (!userId) { showAlert(assignTeacherAlert, 'Выберите преподавателя', 'error'); return; }
if (!subjectId) { showAlert(assignTeacherAlert, 'Выберите дисциплину', 'error'); return; }
try {
const res = await fetch('/api/teacher-subjects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ userId: Number(userId), subjectId: Number(subjectId) }),
});
const data = await res.json();
if (res.ok) {
showAlert(assignTeacherAlert, 'Привязка создана', 'success');
loadTeacherSubjects();
} else {
showAlert(assignTeacherAlert, data.message || 'Ошибка привязки', 'error');
}
} catch (e) { showAlert(assignTeacherAlert, 'Ошибка соединения', 'error'); }
});
teacherSubjectsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить привязку?')) return;
try {
const res = await fetch('/api/teacher-subjects', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ userId: Number(btn.dataset.userId), subjectId: Number(btn.dataset.subjectId) }),
});
if (res.ok) loadTeacherSubjects();
else alert('Ошибка удаления');
} catch (e) { alert('Ошибка соединения'); }
});
// ============================================================ // ============================================================
// LOGOUT & INIT // LOGOUT & INIT
// ============================================================ // ============================================================

View File

@@ -59,6 +59,29 @@
</svg> </svg>
Формы обучения Формы обучения
</a> </a>
<a href="#" class="nav-item" data-tab="equipments">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
</svg>
Оборудование
</a>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
Аудитории
</a>
<a href="#" class="nav-item" data-tab="subjects">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
Дисциплины
</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="btn-logout" id="btn-logout"> <button class="btn-logout" id="btn-logout">
@@ -225,6 +248,230 @@
</div> </div>
</div> </div>
</section> </section>
<!-- ===== Equipments Tab ===== -->
<section class="content tab-content" id="tab-equipments" style="display:none;">
<div class="card create-card">
<h2>Новое оборудование</h2>
<form id="create-equipment-form">
<div class="form-row">
<div class="form-group">
<label for="new-equipment-name">Название оборудования</label>
<input type="text" id="new-equipment-name" placeholder="Проектор" required>
</div>
<button type="submit" class="btn-create">Добавить</button>
</div>
<div class="form-alert" id="create-equipment-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Справочник оборудования</h2>
<div class="table-wrap">
<table id="equipments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th></th>
</tr>
</thead>
<tbody id="equipments-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===== Classrooms Tab ===== -->
<section class="content tab-content" id="tab-classrooms" style="display:none;">
<div class="card create-card">
<h2>Новая аудитория</h2>
<form id="create-classroom-form">
<div class="form-row">
<div class="form-group">
<label for="new-classroom-name">Номер / Название</label>
<input type="text" id="new-classroom-name" placeholder="101 Ленинская" required>
</div>
<div class="form-group">
<label for="new-classroom-capacity">Вместимость (чел.)</label>
<input type="number" id="new-classroom-capacity" placeholder="30" min="1" required>
</div>
</div>
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group" style="flex: 2;">
<label>Оборудование</label>
<div class="custom-multi-select">
<div class="select-box" id="equipment-select-box">
<span class="select-text" id="equipment-select-text">Выберите оборудование...</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div class="dropdown-menu" id="equipment-dropdown-menu">
<div id="equipment-checkboxes" class="checkbox-group-vertical">
<!-- Подгружается через JS -->
<p class="text-muted"><small>Загрузка...</small></p>
</div>
</div>
</div>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn-create">Добавить</button>
</div>
</div>
<div class="form-alert" id="create-classroom-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Список аудиторий</h2>
<div class="table-wrap">
<table id="classrooms-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Вместимость</th>
<th>Оборудование</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody id="classrooms-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===== Subjects Tab ===== -->
<section class="content tab-content" id="tab-subjects" style="display:none;">
<div class="card create-card">
<h2>Новая дисциплина</h2>
<form id="create-subject-form">
<div class="form-row">
<div class="form-group">
<label for="new-subject-name">Название дисциплины</label>
<input type="text" id="new-subject-name" placeholder="Высшая математика" required>
</div>
<button type="submit" class="btn-create">Добавить</button>
</div>
<div class="form-alert" id="create-subject-alert" role="alert"></div>
</form>
</div>
<div class="card create-card">
<h2>Привязка преподавателя к дисциплине</h2>
<form id="assign-teacher-form">
<div class="form-row">
<div class="form-group">
<label for="assign-teacher-select">Преподаватель</label>
<select id="assign-teacher-select">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="assign-subject-select">Дисциплина</label>
<select id="assign-subject-select">
<option value="">Загрузка...</option>
</select>
</div>
<button type="submit" class="btn-create">Привязать</button>
</div>
<div class="form-alert" id="assign-teacher-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все дисциплины</h2>
<div class="table-wrap">
<table id="subjects-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th></th>
</tr>
</thead>
<tbody id="subjects-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>Привязки преподавателей</h2>
<div class="table-wrap">
<table id="teacher-subjects-table">
<thead>
<tr>
<th>Преподаватель</th>
<th>Дисциплина</th>
<th></th>
</tr>
</thead>
<tbody id="teacher-subjects-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===== Edit Classroom Modal ===== -->
<div class="modal-overlay" id="modal-edit-classroom">
<div class="modal-content">
<h2>Редактировать аудиторию</h2>
<button class="modal-close" id="modal-edit-classroom-close">&times;</button>
<form id="edit-classroom-form">
<input type="hidden" id="edit-classroom-id">
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group">
<label for="edit-classroom-name">Номер / Название</label>
<input type="text" id="edit-classroom-name" required>
</div>
<div class="form-group">
<label for="edit-classroom-capacity">Вместимость (чел.)</label>
<input type="number" id="edit-classroom-capacity" min="1" required>
</div>
</div>
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group" style="flex: 2;">
<label>Оборудование</label>
<div class="custom-multi-select">
<div class="select-box" id="edit-equipment-select-box">
<span class="select-text" id="edit-equipment-select-text">Выберите
оборудование...</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div class="dropdown-menu" id="edit-equipment-dropdown-menu">
<div id="edit-equipment-checkboxes" class="checkbox-group-vertical">
<!-- Подгружается через JS -->
</div>
</div>
</div>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn-create" style="width: 100%;">Сохранить</button>
</div>
</div>
<div class="form-alert" id="edit-classroom-alert" role="alert"></div>
</form>
</div>
</div>
</main> </main>
<script src="/theme-toggle.js"></script> <script src="/theme-toggle.js"></script>

View File

@@ -37,7 +37,7 @@
</div> </div>
<form id="login-form" novalidate> <form id="login-form" novalidate>
<div class="form-group"> <div class="form-group stagger-1">
<label for="username">Имя пользователя</label> <label for="username">Имя пользователя</label>
<div class="input-wrapper"> <div class="input-wrapper">
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -49,7 +49,7 @@
<span class="error-message" id="username-error"></span> <span class="error-message" id="username-error"></span>
</div> </div>
<div class="form-group"> <div class="form-group stagger-2">
<label for="password">Пароль</label> <label for="password">Пароль</label>
<div class="input-wrapper"> <div class="input-wrapper">
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -67,9 +67,9 @@
<span class="error-message" id="password-error"></span> <span class="error-message" id="password-error"></span>
</div> </div>
<div class="form-alert" id="form-alert" role="alert"></div> <div class="form-alert stagger-3" id="form-alert" role="alert"></div>
<button type="submit" class="btn-submit" id="btn-submit"> <button type="submit" class="btn-submit stagger-4" id="btn-submit">
<span class="btn-text">Войти</span> <span class="btn-text">Войти</span>
<span class="btn-loader" hidden> <span class="btn-loader" hidden>
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24"> <svg class="spinner" width="20" height="20" viewBox="0 0 24 24">

View File

@@ -12,6 +12,24 @@
const btnLoader = btnSubmit.querySelector('.btn-loader'); const btnLoader = btnSubmit.querySelector('.btn-loader');
const togglePassword = document.getElementById('toggle-password'); const togglePassword = document.getElementById('toggle-password');
// Ripple effect
btnSubmit.addEventListener('click', function(e) {
const rect = this.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const ripple = document.createElement('span');
ripple.classList.add('ripple');
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
this.appendChild(ripple);
setTimeout(() => {
ripple.remove();
}, 600);
});
togglePassword.addEventListener('click', () => { togglePassword.addEventListener('click', () => {
const isPassword = passwordInput.type === 'password'; const isPassword = passwordInput.type === 'password';
passwordInput.type = isPassword ? 'text' : 'password'; passwordInput.type = isPassword ? 'text' : 'password';
@@ -27,8 +45,14 @@
} }
function setFieldError(input, errorEl, message) { function setFieldError(input, errorEl, message) {
input.closest('.form-group').classList.add('has-error'); const group = input.closest('.form-group');
group.classList.add('has-error');
errorEl.textContent = message; errorEl.textContent = message;
// Shake animation
group.classList.remove('shake');
void group.offsetWidth; // trigger reflow
group.classList.add('shake');
} }
function showAlert(message, type) { function showAlert(message, type) {

View File

@@ -1,11 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Панель студента</title> <title>Панель студента</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -14,59 +13,168 @@
} }
:root { :root {
--bg-primary: #0f0f1a; /* Deep dark premium background */
--text-primary: #f0f0f5; --bg-primary: #0a0a0f;
--text-secondary: #9ca3af; --bg-card: rgba(255, 255, 255, 0.03);
--accent-hover: #818cf8; --bg-card-border: rgba(255, 255, 255, 0.05);
--bg-card: rgba(255, 255, 255, 0.05); /* Typography */
--bg-card-border: rgba(255, 255, 255, 0.08); --text-primary: #f8fafc;
--bg-input: rgba(255, 255, 255, 0.06); --text-secondary: #94a3b8;
--accent-glow: rgba(99, 102, 241, 0.35); /* Vibrant Accents */
--accent: #8b5cf6;
--accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899;
} }
[data-theme="light"] { [data-theme="light"] {
--bg-primary: #e8eaef; --bg-primary: #f8fafc;
--bg-card: rgba(255, 255, 255, 0.7);
--bg-card-border: rgba(0, 0, 0, 0.08);
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #374151; --text-secondary: #475569;
--accent: #6366f1;
--accent-hover: #4f46e5; --accent-hover: #4f46e5;
--bg-card: rgba(255, 255, 255, 0.95); --accent-glow: rgba(99, 102, 241, 0.3);
--bg-card-border: rgba(0, 0, 0, 0.22); --accent-secondary: #d946ef;
--bg-input: rgba(0, 0, 0, 0.08);
--accent-glow: rgba(99, 102, 241, 0.25);
} }
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
transition: background 0.4s ease, color 0.4s ease; transition: background 0.4s ease, color 0.4s ease;
} }
.placeholder { /* ===== Animated Background ===== */
.background {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
}
.shape {
position: absolute;
border-radius: 50%;
filter: blur(90px);
opacity: 0.4;
animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
transition: opacity 0.5s ease;
will-change: transform;
}
[data-theme="light"] .shape { opacity: 0.15; }
.shape-1 {
width: 600px;
height: 600px;
background: radial-gradient(circle, var(--accent), transparent 60%);
top: -20%;
left: -10%;
animation-delay: 0s;
}
.shape-2 {
width: 500px;
height: 500px;
background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
bottom: -20%;
right: -10%;
animation-delay: -5s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-30px, 30px) scale(0.95); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.9) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.placeholder-card {
position: relative;
z-index: 1;
background: var(--bg-card);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
border: 1px solid var(--bg-card-border);
border-radius: 24px;
padding: 4rem 3rem;
text-align: center; text-align: center;
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
animation: fadeInScale 0.6s cubic-bezier(0.25, 0.8, 0.25, 1) both;
max-width: 400px;
width: 90%;
}
[data-theme="light"] .placeholder-card {
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.placeholder-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
} }
.placeholder h1 { .placeholder-card .icon {
font-size: 1.5rem; display: inline-flex;
margin-bottom: 0.5rem; align-items: center;
} justify-content: center;
width: 80px;
.placeholder p { height: 80px;
color: var(--text-secondary); border-radius: 50%;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2));
color: var(--text-primary);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
box-shadow: 0 0 30px var(--accent-glow);
} }
.placeholder a { .placeholder-card h1 {
color: var(--accent-hover); font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.placeholder-card p {
color: var(--text-secondary);
font-size: 1rem;
margin-bottom: 2rem;
line-height: 1.5;
}
.btn-logout {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 2rem;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
color: #fff;
text-decoration: none; text-decoration: none;
font-weight: 600;
border-radius: 12px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px var(--accent-glow);
} }
.placeholder a:hover { .btn-logout:hover {
text-decoration: underline; transform: translateY(-2px);
box-shadow: 0 8px 25px var(--accent-glow);
} }
/* Theme Toggle */ /* Theme Toggle */
@@ -111,10 +219,21 @@
</head> </head>
<body> <body>
<div class="placeholder"> <div class="background">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
</div>
<div class="placeholder-card">
<div class="icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
</div>
<h1>Панель студента</h1> <h1>Панель студента</h1>
<p>Раздел в разработке</p> <p>Раздел в разработке.<br>Ожидайте обновлений!</p>
<a href="/" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a> <a href="/" class="btn-logout" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
</div> </div>
<script src="/theme-toggle.js"></script> <script src="/theme-toggle.js"></script>

View File

@@ -8,50 +8,64 @@
} }
:root { :root {
--bg-primary: #0f0f1a; /* Deep dark premium background */
--bg-card: rgba(255, 255, 255, 0.05); --bg-primary: #0a0a0f;
--bg-card-border: rgba(255, 255, 255, 0.08); --bg-card: rgba(255, 255, 255, 0.03);
--bg-input: rgba(255, 255, 255, 0.06); --bg-card-border: rgba(255, 255, 255, 0.05);
--bg-input-focus: rgba(255, 255, 255, 0.1); --bg-input: rgba(255, 255, 255, 0.04);
--text-primary: #f0f0f5; --bg-input-focus: rgba(255, 255, 255, 0.08);
--text-secondary: #9ca3af;
--text-placeholder: #6b7280; /* Typography */
--accent: #6366f1; --text-primary: #f8fafc;
--accent-hover: #818cf8; --text-secondary: #94a3b8;
--accent-glow: rgba(99, 102, 241, 0.35); --text-placeholder: #475569;
--error: #f87171;
--success: #34d399; /* Vibrant Accents */
--radius-sm: 8px; --accent: #8b5cf6;
--radius-md: 14px; --accent-hover: #a78bfa;
--radius-lg: 20px; --accent-glow: rgba(139, 92, 246, 0.4);
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1); --accent-secondary: #ec4899;
/* Status Colors */
--error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
/* Spatial */
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 24px;
--transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
} }
/* ===== Light Theme ===== */ /* ===== Light Theme ===== */
[data-theme="light"] { [data-theme="light"] {
--bg-primary: #e8eaef; --bg-primary: #f8fafc;
--bg-card: rgba(255, 255, 255, 0.95); --bg-card: rgba(255, 255, 255, 0.7);
--bg-card-border: rgba(0, 0, 0, 0.22); --bg-card-border: rgba(0, 0, 0, 0.08);
--bg-input: rgba(0, 0, 0, 0.08); --bg-input: rgba(0, 0, 0, 0.03);
--bg-input-focus: rgba(0, 0, 0, 0.12); --bg-input-focus: rgba(0, 0, 0, 0.06);
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #374151; --text-secondary: #475569;
--text-placeholder: #6b7280; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #6366f1;
--accent-hover: #4f46e5; --accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.25); --accent-glow: rgba(99, 102, 241, 0.3);
--error: #dc2626; --accent-secondary: #d946ef;
--success: #16a34a; --error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
} }
[data-theme="light"] .shape { [data-theme="light"] .shape {
opacity: 0.25; opacity: 0.15;
} }
[data-theme="light"] .login-card { [data-theme="light"] .login-card {
box-shadow: box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08), 0 20px 40px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.6); inset 0 1px 0 rgba(255, 255, 255, 0.8),
inset 0 0 20px rgba(255, 255, 255, 0.5);
} }
html { html {
@@ -83,38 +97,39 @@ body {
.shape { .shape {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
filter: blur(80px); filter: blur(90px);
opacity: 0.5; opacity: 0.4;
animation: float 20s ease-in-out infinite; animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
transition: opacity 0.4s ease; transition: opacity 0.5s ease;
will-change: transform;
} }
.shape-1 { .shape-1 {
width: 500px; width: 600px;
height: 500px; height: 600px;
background: radial-gradient(circle, #6366f1, transparent 70%); background: radial-gradient(circle, var(--accent), transparent 60%);
top: -15%; top: -20%;
left: -10%; left: -10%;
animation-delay: 0s; animation-delay: 0s;
} }
.shape-2 { .shape-2 {
width: 400px; width: 500px;
height: 400px; height: 500px;
background: radial-gradient(circle, #8b5cf6, transparent 70%); background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
bottom: -10%; bottom: -20%;
right: -10%; right: -10%;
animation-delay: -7s; animation-delay: -5s;
} }
.shape-3 { .shape-3 {
width: 300px; width: 400px;
height: 300px; height: 400px;
background: radial-gradient(circle, #a78bfa, transparent 70%); background: radial-gradient(circle, #3b82f6, transparent 60%);
top: 50%; top: 40%;
left: 50%; left: 60%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
animation-delay: -14s; animation-delay: -10s;
} }
@keyframes float { @keyframes float {
@@ -162,15 +177,29 @@ body {
/* ===== Login Card (Glassmorphism) ===== */ /* ===== Login Card (Glassmorphism) ===== */
.login-card { .login-card {
background: var(--bg-card); background: var(--bg-card);
backdrop-filter: blur(24px); backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(32px);
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 2.5rem 2rem 2rem; padding: 3rem 2.5rem 2.5rem;
box-shadow: box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3), 0 24px 48px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.05); inset 0 1px 0 rgba(255, 255, 255, 0.1),
transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease; inset 0 0 32px rgba(255, 255, 255, 0.02);
transition: all 0.4s ease;
position: relative;
overflow: hidden;
}
.login-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
z-index: 1;
} }
/* ===== Header ===== */ /* ===== Header ===== */
@@ -241,9 +270,9 @@ body {
.input-wrapper input { .input-wrapper input {
width: 100%; width: 100%;
padding: 0.8rem 0.8rem 0.8rem 2.75rem; padding: 0.9rem 1rem 0.9rem 2.8rem;
background: var(--bg-input); background: var(--bg-input);
border: 1px solid transparent; border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-primary); color: var(--text-primary);
font-family: inherit; font-family: inherit;
@@ -252,17 +281,24 @@ body {
transition: transition:
background var(--transition), background var(--transition),
border-color var(--transition), border-color var(--transition),
box-shadow var(--transition); box-shadow var(--transition),
transform var(--transition);
} }
.input-wrapper input::placeholder { .input-wrapper input::placeholder {
color: var(--text-placeholder); color: var(--text-placeholder);
transition: opacity var(--transition);
} }
.input-wrapper input:focus { .input-wrapper input:focus {
background: var(--bg-input-focus); background: var(--bg-input-focus);
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow); box-shadow: 0 0 0 4px var(--accent-glow);
transform: translateY(-1px);
}
.input-wrapper input:focus::placeholder {
opacity: 0.5;
} }
.input-wrapper input:focus~.input-icon, .input-wrapper input:focus~.input-icon,
@@ -303,6 +339,28 @@ body {
transition: opacity var(--transition); transition: opacity var(--transition);
} }
/* ===== Animations ===== */
.stagger-1 { animation: fadeInUp 0.5s ease-out 0.1s both; }
.stagger-2 { animation: fadeInUp 0.5s ease-out 0.2s both; }
.stagger-3 { animation: fadeInUp 0.5s ease-out 0.3s both; }
.stagger-4 { animation: fadeInUp 0.5s ease-out 0.4s both; }
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
50% { transform: translateX(6px); }
75% { transform: translateX(-6px); }
}
.shake {
animation: shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
@keyframes slideDownAlert {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===== Form Alert ===== */ /* ===== Form Alert ===== */
.form-alert { .form-alert {
display: none; display: none;
@@ -318,6 +376,7 @@ body {
background: rgba(248, 113, 113, 0.1); background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2); border: 1px solid rgba(248, 113, 113, 0.2);
color: var(--error); color: var(--error);
animation: slideDownAlert 0.3s ease-out both;
} }
.form-alert.success { .form-alert.success {
@@ -325,19 +384,23 @@ body {
background: rgba(52, 211, 153, 0.1); background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.2); border: 1px solid rgba(52, 211, 153, 0.2);
color: var(--success); color: var(--success);
animation: slideDownAlert 0.3s ease-out both;
} }
/* ===== Submit Button ===== */ /* ===== Submit Button ===== */
.btn-submit { .btn-submit {
position: relative;
overflow: hidden;
width: 100%; width: 100%;
padding: 0.85rem; padding: 0.95rem;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), #8b5cf6); background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
color: #fff; color: #fff;
font-family: inherit; font-family: inherit;
font-size: 0.95rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -347,22 +410,55 @@ body {
transform var(--transition), transform var(--transition),
box-shadow var(--transition), box-shadow var(--transition),
opacity var(--transition); opacity var(--transition);
box-shadow: 0 4px 16px var(--accent-glow); box-shadow: 0 4px 15px var(--accent-glow);
}
.btn-submit::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(rgba(255,255,255,0.2), transparent);
border-radius: inherit;
opacity: 0;
transition: opacity var(--transition);
} }
.btn-submit:hover { .btn-submit:hover {
transform: translateY(-1px); transform: translateY(-2px);
box-shadow: 0 6px 24px var(--accent-glow); box-shadow: 0 8px 25px var(--accent-glow);
}
.btn-submit:hover::before {
opacity: 1;
} }
.btn-submit:active { .btn-submit:active {
transform: translateY(0); transform: translateY(1px);
box-shadow: 0 2px 10px var(--accent-glow);
} }
.btn-submit:disabled { .btn-submit:disabled {
opacity: 0.7; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
box-shadow: none;
}
/* ===== Ripple Effect ===== */
.ripple {
position: absolute;
border-radius: 50%;
transform: scale(0);
animation: ripple 0.6s linear;
background-color: rgba(255, 255, 255, 0.3);
pointer-events: none;
}
@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
} }
/* ===== Spinner ===== */ /* ===== Spinner ===== */

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Панель преподавателя</title> <title>Панель преподавателя</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -14,59 +14,186 @@
} }
:root { :root {
--bg-primary: #0f0f1a; /* Deep dark premium background */
--text-primary: #f0f0f5; --bg-primary: #0a0a0f;
--text-secondary: #9ca3af; --bg-card: rgba(255, 255, 255, 0.03);
--accent-hover: #818cf8; --bg-card-border: rgba(255, 255, 255, 0.05);
--bg-card: rgba(255, 255, 255, 0.05); /* Typography */
--bg-card-border: rgba(255, 255, 255, 0.08); --text-primary: #f8fafc;
--bg-input: rgba(255, 255, 255, 0.06); --text-secondary: #94a3b8;
--accent-glow: rgba(99, 102, 241, 0.35); /* Vibrant Accents */
--accent: #8b5cf6;
--accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899;
} }
[data-theme="light"] { [data-theme="light"] {
--bg-primary: #e8eaef; --bg-primary: #f8fafc;
--bg-card: rgba(255, 255, 255, 0.7);
--bg-card-border: rgba(0, 0, 0, 0.08);
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #374151; --text-secondary: #475569;
--accent: #6366f1;
--accent-hover: #4f46e5; --accent-hover: #4f46e5;
--bg-card: rgba(255, 255, 255, 0.95); --accent-glow: rgba(99, 102, 241, 0.3);
--bg-card-border: rgba(0, 0, 0, 0.22); --accent-secondary: #d946ef;
--bg-input: rgba(0, 0, 0, 0.08);
--accent-glow: rgba(99, 102, 241, 0.25);
} }
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
transition: background 0.4s ease, color 0.4s ease; transition: background 0.4s ease, color 0.4s ease;
} }
.placeholder { /* ===== Animated Background ===== */
.background {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
}
.shape {
position: absolute;
border-radius: 50%;
filter: blur(90px);
opacity: 0.4;
animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
transition: opacity 0.5s ease;
will-change: transform;
}
[data-theme="light"] .shape {
opacity: 0.15;
}
.shape-1 {
width: 600px;
height: 600px;
background: radial-gradient(circle, var(--accent), transparent 60%);
top: -20%;
left: -10%;
animation-delay: 0s;
}
.shape-2 {
width: 500px;
height: 500px;
background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
bottom: -20%;
right: -10%;
animation-delay: -5s;
}
@keyframes float {
0%,
100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-30px, 30px) scale(0.95);
}
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.placeholder-card {
position: relative;
z-index: 1;
background: var(--bg-card);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
border: 1px solid var(--bg-card-border);
border-radius: 24px;
padding: 4rem 3rem;
text-align: center; text-align: center;
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
animation: fadeInScale 0.6s cubic-bezier(0.25, 0.8, 0.25, 1) both;
max-width: 400px;
width: 90%;
} }
.placeholder h1 { [data-theme="light"] .placeholder-card {
font-size: 1.5rem; box-shadow:
margin-bottom: 0.5rem; 0 20px 40px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
} }
.placeholder p { .placeholder-card::before {
color: var(--text-secondary); content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
}
.placeholder-card .icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2));
color: var(--text-primary);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
box-shadow: 0 0 30px var(--accent-glow);
} }
.placeholder a { .placeholder-card h1 {
color: var(--accent-hover); font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.placeholder-card p {
color: var(--text-secondary);
font-size: 1rem;
margin-bottom: 2rem;
line-height: 1.5;
}
.btn-logout {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 2rem;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
color: #fff;
text-decoration: none; text-decoration: none;
font-weight: 600;
border-radius: 12px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px var(--accent-glow);
} }
.placeholder a:hover { .btn-logout:hover {
text-decoration: underline; transform: translateY(-2px);
box-shadow: 0 8px 25px var(--accent-glow);
} }
/* Theme Toggle */ /* Theme Toggle */
@@ -111,10 +238,23 @@
</head> </head>
<body> <body>
<div class="placeholder"> <div class="background">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
</div>
<div class="placeholder-card">
<div class="icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c3 3 9 3 12 0v-5" />
</svg>
</div>
<h1>Панель преподавателя</h1> <h1>Панель преподавателя</h1>
<p>Раздел в разработке</p> <p>Раздел в разработке.<br>Ожидайте обновлений!</p>
<a href="/" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a> <a href="/" class="btn-logout"
onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
</div> </div>
<script src="/theme-toggle.js"></script> <script src="/theme-toggle.js"></script>