24 Commits
main ... Lab_6

Author SHA1 Message Date
cb94a4459b Lab_2 (конец) 2025-12-15 21:04:09 +04:00
75c6762acf Lab_2 2025-11-07 01:23:52 +04:00
1ac3e5f5d3 Lab_6 2025-11-06 22:13:29 +04:00
0d292648e7 Lab_6 2025-11-06 14:47:21 +04:00
76cb103606 Lab_6 2025-11-06 13:58:48 +04:00
8058b6aef8 Lab_6 2025-11-06 13:26:38 +04:00
e2263f8856 Lab_6 2025-10-23 14:38:44 +04:00
50d2d5472a Lab_6 2025-10-23 14:19:54 +04:00
92fc66a712 Lab_6 (надеюсь все) 2025-10-23 01:54:53 +04:00
022979e322 Lab_6 2025-10-22 19:13:29 +04:00
22ae81a6eb Lab_5 (молитвами вроде работает) 2025-10-09 22:47:42 +04:00
58fa18b506 Lab_5 (явно не конец) 2025-10-09 22:08:56 +04:00
e41837a562 Lab_4 (очень надеюсь что это конец) 2025-10-09 00:31:35 +04:00
24c99f755e Lab_4 (Надеюсь конец) 2025-10-09 00:27:13 +04:00
f33c05ad5e Lab_4 2025-10-08 23:45:58 +04:00
caeac1915e Lab_4 2025-10-08 14:39:52 +04:00
abb300df17 lab_3 2025-09-25 00:16:02 +04:00
fe7f5d1791 lab_3 2025-09-24 19:45:58 +04:00
42c03b50d8 finish 2025-08-26 14:18:02 +04:00
000d7a9f67 Lab2 2025-05-24 12:17:22 +04:00
8b911b537a Lab2 2025-05-24 11:20:31 +04:00
a96c3a9400 Lab2 2025-05-24 10:54:40 +04:00
bf17f58884 ok 2025-05-21 12:54:40 +04:00
681580cd60 start 2025-05-21 12:41:46 +04:00
52 changed files with 15852 additions and 12 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

17
.eslintrc.json Normal file
View File

@@ -0,0 +1,17 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"no-unused-vars": "warn",
"no-console": "warn"
}
}

34
.gitignore vendored
View File

@@ -1,14 +1,24 @@
# ---> VisualStudioCode
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

101
db.json Normal file
View File

@@ -0,0 +1,101 @@
{
"events": [
{
"id": 2,
"title": "Cyberpunk Festival",
"description": "Грандиозный фестиваль с музыкой и цифровыми шоу.",
"imageUrl": "https://bogatyr.club/uploads/posts/2023-03/12827/thumbs/1677966670_bogatyr-club-p-kiberpank-komnata-foni-instagram-8.jpg",
"categoryId": 2,
"typeId": 1
},
{
"id": 3,
"title": "VR Rave Party",
"description": "Погрузись в виртуальную реальность с нашими эксклюзивными стримами.",
"imageUrl": "https://kartinki.pics/uploads/posts/2021-07/thumbs/1625655487_35-kartinkin-com-p-kiberpank-oboi-krasivie-38.jpg",
"categoryId": 2,
"typeId": 1
},
{
"id": 4,
"title": "Hologram Show",
"description": "Уникальное цифровое шоу с голографическими эффектами. Абсолютно новый формат!",
"imageUrl": "https://kartinki.pics/pics/uploads/posts/2022-08/1660474027_1-kartinkin-net-p-kiberpank-oboi-krasivo-1.jpg",
"categoryId": 2,
"typeId": 1
}
],
"categories": [
{
"id": 1,
"name": "Киберспорт"
},
{
"id": 2,
"name": "Электронная музыка"
},
{
"id": 3,
"name": "Цифровое искусство"
}
],
"types": [
{
"id": 1,
"name": "Трансляция"
},
{
"id": 2,
"name": "Концерт"
},
{
"id": 3,
"name": "Выставка"
}
],
"streams": [
{
"id": 1,
"title": "Neon Cyber Show",
"description": "Погружение в атмосферу неоновых ритмов с лучшими диджеями.",
"imageUrl": "https://kartinki.pics/uploads/posts/2021-07/thumbs/1625655522_52-kartinkin-com-p-kiberpank-oboi-krasivie-57.jpg"
},
{
"id": 2,
"title": "Cyberpunk Festival",
"description": "Грандиозный фестиваль с музыкой и цифровыми шоу.",
"imageUrl": "https://bogatyr.club/uploads/posts/2023-03/12827/thumbs/1677966670_bogatyr-club-p-kiberpank-komnata-foni-instagram-8.jpg"
},
{
"id": 3,
"title": "VR Adventure Live",
"description": "Эксклюзивный VR стрим с комментариями и интерактивом.",
"imageUrl": "https://img3.akspic.ru/previews/4/6/0/4/6/164064/164064-goroda_kiberpank-kiberpank_2077-kiberpank-nauchnaya_fantastika-cifrovoe_iskusstvo-550x310.jpg"
},
{
"id": 4,
"title": "Цифровой Концерт",
"description": "Виртуальный концерт с неоновыми спецэффектами и лайв-сетами.",
"imageUrl": "https://kartinki.pics/pics/uploads/posts/2022-08/thumbs/1660474007_21-kartinkin-net-p-kiberpank-oboi-krasivo-26.jpg"
}
],
"users": [
{
"id": 1,
"username": "NeonGamer",
"email": "neongamer@example.com",
"subscription": "Pro",
"registrationDate": "12.04.2024",
"lastLogin": "Сегодня, 14:35",
"avatarUrl": ""
}
],
"subscriptions": [
{
"userId": 1,
"eventId": 4,
"date": "2025-10-23T09:50:36.541Z",
"id": 2
}
]
}

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Каталог | StreamCore React</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="/src/css/custom.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

6355
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "streamcore-react-spa",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"server": "json-server --watch db.json --port 3001",
"dev:full": "concurrently \"npm run server\" \"npm run dev\""
},
"dependencies": {
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.9.4"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"concurrently": "^8.2.0",
"eslint": "^8.45.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"json-server": "^0.17.4",
"vite": "^7.1.11"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

29
src/App.jsx Normal file
View File

@@ -0,0 +1,29 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import Header from './components/Header.jsx';
import Footer from './components/Footer.jsx';
import HomePage from './pages/HomePage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import StreamsPage from './pages/StreamsPage.jsx';
import ProfilePage from './pages/ProfilePage.jsx';
import AboutPage from './pages/AboutPage.jsx';
const App = () => {
return (
<div className="app">
<Header />
<main className="page">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/catalog" element={<CatalogPage />} />
<Route path="/streams" element={<StreamsPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
</main>
<Footer />
</div>
);
};
export default App;

View File

@@ -0,0 +1,33 @@
// src/components/AvatarPlaceholder.jsx
import React from 'react';
const AvatarPlaceholder = ({ name, size = 150, backgroundColor = '#000000', textColor = '#ffffff' }) => {
// Получаем первую букву имени (или используем "A", если имя пустое)
const initial = name ? name.charAt(0).toUpperCase() : 'A';
// Стили для контейнера-заглушки
const placeholderStyle = {
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
backgroundColor: backgroundColor,
color: textColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: `${size / 3}px`, // Размер шрифта пропорционален размеру
fontWeight: 'bold',
border: '3px solid #00ffff', // Стиль рамки, как у старого аватара
boxShadow: `0 0 12px #00ffffaa`, // Тень, как у старого аватара
objectFit: 'cover', // Важно, если это img
flexShrink: 0, // Не сжимать
};
return (
<div style={placeholderStyle} className="profile-avatar-placeholder">
{initial}
</div>
);
};
export default AvatarPlaceholder;

View File

@@ -0,0 +1,58 @@
import React, { useState } from 'react';
const EventModal = ({ event, onClose }) => {
if (!event) return null;
return (
<div className="event-modal-overlay" onClick={onClose}>
<div className="event-modal-content" onClick={(e) => e.stopPropagation()}>
<button className="event-modal-close-btn" onClick={onClose}>&times;</button>
<img src={event.imageUrl} alt={event.title} className="event-modal-image" />
<h2 className="event-modal-title">{event.title}</h2>
</div>
</div>
);
};
const EventCard = ({ event, onEdit, onDelete }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleEdit = () => {
onEdit(event);
};
const handleDelete = () => {
onDelete(event.id);
};
const handleWatchClick = () => {
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
};
return (
<article className="catalog-item">
<img src={event.imageUrl} alt={event.title || "Событие"} className="catalog-item-image" />
<div className="catalog-item-content">
<h3>{event.title}</h3>
<p>{event.description}</p>
<button className="catalog-btn" onClick={handleWatchClick}>Смотреть</button>
</div>
<div className="event-actions">
<button className="btn-edit" onClick={handleEdit}>
<i className="bi bi-pencil"></i>
</button>
<button className="btn-delete" onClick={handleDelete}>
<i className="bi bi-trash"></i>
</button>
</div>
{isModalOpen && <EventModal event={event} onClose={handleCloseModal} />}
</article>
);
};
export default EventCard;

View File

@@ -0,0 +1,315 @@
import React, { useState, useEffect } from 'react';
const EventForm = ({ initialData, onSubmit, onClose }) => {
const [formData, setFormData] = useState({
title: '',
description: '',
imageUrl: '',
category: { name: '', description: '' },
type: { name: '', description: '' }
});
const [categories, setCategories] = useState([]);
const [types, setTypes] = useState([]);
const [loading, setLoading] = useState(true);
const [isNewCategory, setIsNewCategory] = useState(false);
const [isNewType, setIsNewType] = useState(false);
useEffect(() => {
const fetchCategoriesAndTypes = async () => {
try {
setLoading(true);
const [categoriesResponse, typesResponse] = await Promise.all([
fetch('http://localhost:8080/api/categories'),
fetch('http://localhost:8080/api/types')
]);
if (categoriesResponse.ok && typesResponse.ok) {
const categoriesData = await categoriesResponse.json();
const typesData = await typesResponse.json();
setCategories(categoriesData);
setTypes(typesData);
if (!initialData && categoriesData.length > 0 && typesData.length > 0) {
setFormData(prev => ({
...prev,
category: categoriesData[0],
type: typesData[0]
}));
}
}
} catch (error) {
console.error('Ошибка загрузки категорий и типов:', error);
} finally {
setLoading(false);
}
};
fetchCategoriesAndTypes();
}, [initialData]);
useEffect(() => {
if (initialData) {
setFormData({
title: initialData.title || '',
description: initialData.description || '',
imageUrl: initialData.imageUrl || '',
category: initialData.category || { name: '', description: '' },
type: initialData.type || { name: '', description: '' }
});
}
}, [initialData]);
const handleChange = (e) => {
const { name, value } = e.target;
if (name === 'categorySelect') {
if (value === 'new') {
setIsNewCategory(true);
setFormData(prev => ({
...prev,
category: { name: '', description: '' }
}));
} else {
setIsNewCategory(false);
const selectedCategory = categories.find(cat => cat.id === parseInt(value));
setFormData(prev => ({
...prev,
category: selectedCategory || { name: '', description: '' }
}));
}
} else if (name === 'typeSelect') {
if (value === 'new') {
setIsNewType(true);
setFormData(prev => ({
...prev,
type: { name: '', description: '' }
}));
} else {
setIsNewType(false);
const selectedType = types.find(t => t.id === parseInt(value));
setFormData(prev => ({
...prev,
type: selectedType || { name: '', description: '' }
}));
}
} else if (name.startsWith('category.')) {
const field = name.split('.')[1];
setFormData(prev => ({
...prev,
category: {
...prev.category,
[field]: value
}
}));
} else if (name.startsWith('type.')) {
const field = name.split('.')[1];
setFormData(prev => ({
...prev,
type: {
...prev.type,
[field]: value
}
}));
} else {
setFormData(prev => ({
...prev,
[name]: value
}));
}
};
const handleSubmit = (e) => {
e.preventDefault();
// Создаем объект для отправки с полными объектами category и type
const eventData = {
title: formData.title,
description: formData.description,
imageUrl: formData.imageUrl,
category: formData.category,
type: formData.type
};
const dataToSubmit = initialData
? { ...eventData, id: initialData.id }
: eventData;
onSubmit(dataToSubmit);
};
const handleCancel = () => {
onClose();
};
const handleOverlayClick = (e) => {
if (e.target === e.currentTarget) {
onClose();
}
};
if (loading) {
return (
<div className="event-form-overlay" onClick={handleOverlayClick}>
<div className="event-form-container" onClick={(e) => e.stopPropagation()}>
<div style={{ textAlign: 'center', padding: '20px', color: '#00ffff' }}>
<p>Загрузка категорий и типов...</p>
</div>
</div>
</div>
);
}
return (
<div className="event-form-overlay" onClick={handleOverlayClick}>
<div className="event-form-container" onClick={(e) => e.stopPropagation()}>
<h2>{initialData ? 'Редактировать событие' : 'Добавить новое событие'}</h2>
<form onSubmit={handleSubmit} className="event-form">
<div className="mb-3">
<label htmlFor="eventTitle" className="form-label">Название</label>
<input
type="text"
className="form-control"
id="eventTitle"
name="title"
value={formData.title}
onChange={handleChange}
required
placeholder="Введите название события"
/>
</div>
<div className="mb-3">
<label htmlFor="eventDescription" className="form-label">Описание</label>
<textarea
className="form-control"
id="eventDescription"
name="description"
value={formData.description}
onChange={handleChange}
rows="3"
required
placeholder="Введите описание события"
></textarea>
</div>
<div className="mb-3">
<label htmlFor="eventImage" className="form-label">URL изображения</label>
<input
type="text"
className="form-control"
id="eventImage"
name="imageUrl"
value={formData.imageUrl}
onChange={handleChange}
placeholder="https://example.com/image.jpg"
/>
</div>
<div className="mb-3">
<label htmlFor="eventCategory" className="form-label">Категория</label>
<select
className="form-control"
id="eventCategory"
name="categorySelect"
value={isNewCategory ? 'new' : (formData.category?.id || '')}
onChange={handleChange}
required
>
<option value="">Выберите категорию</option>
{categories.map(category => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
<option value="new">+ Создать новую категорию</option>
</select>
{isNewCategory && (
<div style={{ marginTop: '10px' }}>
<input
type="text"
className="form-control"
name="category.name"
value={formData.category.name}
onChange={handleChange}
placeholder="Название новой категории"
required
/>
<input
type="text"
className="form-control"
style={{ marginTop: '5px' }}
name="category.description"
value={formData.category.description}
onChange={handleChange}
placeholder="Описание категории"
/>
</div>
)}
</div>
<div className="mb-3">
<label htmlFor="eventType" className="form-label">Тип</label>
<select
className="form-control"
id="eventType"
name="typeSelect"
value={isNewType ? 'new' : (formData.type?.id || '')}
onChange={handleChange}
required
>
<option value="">Выберите тип</option>
{types.map(type => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
<option value="new">+ Создать новый тип</option>
</select>
{isNewType && (
<div style={{ marginTop: '10px' }}>
<input
type="text"
className="form-control"
name="type.name"
value={formData.type.name}
onChange={handleChange}
placeholder="Название нового типа"
required
/>
<input
type="text"
className="form-control"
style={{ marginTop: '5px' }}
name="type.description"
value={formData.type.description}
onChange={handleChange}
placeholder="Описание типа"
/>
</div>
)}
</div>
<div className="form-buttons">
<button type="submit" className="btn btn-form-submit">
{initialData ? 'Сохранить изменения' : 'Создать событие'}
</button>
<button
type="button"
className="btn btn-outline-secondary btn-form-cancel"
onClick={handleCancel}
>
Отмена
</button>
</div>
</form>
</div>
</div>
);
};
export default EventForm;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import EventCard from './EventCard';
const EventList = ({ events, onEdit, onDelete, onAddClick }) => {
if (events.length === 0) {
return <p className="text-center">Событий пока нет.</p>;
}
return (
<>
<section className="catalog-grid" id="catalogContainer">
{events.map(event => (
<EventCard
key={event.id}
event={event}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</section>
</>
);
};
export default EventList;

22
src/components/Footer.jsx Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react';
const Footer = () => {
return (
<footer className="footer">
<div className="container-fluid">
<div className="footer-info">
<p>Контакты: info@streamcore.com | +7 (999) 123-45-67</p>
<p>Часы работы: ПнВс, 10:0022:00</p>
<p>Адрес: Неон-сити, ул. Цифровая, д.42</p>
</div>
<div className="social-icons">
<a href="#" aria-label="Facebook"><i className="fab fa-facebook-f fa-lg"></i></a>
<a href="#" aria-label="ВКонтакте"><i className="fab fa-vk fa-lg"></i></a>
<a href="#" aria-label="Telegram"><i className="fab fa-telegram-plane fa-lg"></i></a>
</div>
</div>
</footer>
);
};
export default Footer;

44
src/components/Header.jsx Normal file
View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
const Header = () => {
const location = useLocation();
const isActive = (path) => location.pathname === path ? 'nav-link active' : 'nav-link';
return (
<header className="header">
<div className="container-fluid">
<div className="d-flex justify-content-between align-items-center">
<div className="logo">🎮 StreamCore React</div>
<nav className="navbar navbar-expand-lg p-0">
<button className="navbar-toggler d-lg-none" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarNav">
<ul className="navbar-nav">
<li className="nav-item">
<Link className={isActive('/')} to="/">Главная</Link>
</li>
<li className="nav-item">
<Link className={isActive('/catalog')} to="/catalog">Каталог</Link>
</li>
<li className="nav-item">
<Link className={isActive('/streams')} to="/streams">Трансляции</Link>
</li>
<li className="nav-item">
<Link className={isActive('/profile')} to="/profile">Профиль</Link>
</li>
<li className="nav-item">
<Link className={isActive('/about')} to="/about">О нас</Link>
</li>
</ul>
</div>
</nav>
</div>
</div>
</header>
);
};
export default Header;

9
src/counter.js Normal file
View File

@@ -0,0 +1,9 @@
export function setupCounter(element) {
let counter = 0
const setCounter = (count) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

922
src/css/custom.css Normal file
View File

@@ -0,0 +1,922 @@
/* --- ОБЩИЕ СТИЛИ --- */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Orbitron', sans-serif;
background-color: #0d0d0d;
color: #ddd;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* --- ШАПКА --- */
.header {
display: flex;
align-items: center;
justify-content: space-between;
background: #000000;
padding: 1rem 2rem;
border-bottom: 2px solid #ff00ff;
position: sticky;
top: 0;
z-index: 2000;
}
.logo {
font-size: 1.6rem;
color: #00ffff;
user-select: none;
}
.navbar-nav {
list-style: none;
display: flex;
gap: 2rem;
margin: 0;
padding: 0;
}
.navbar-nav .nav-link {
color: #ff00ff;
text-decoration: none;
font-weight: 600;
transition: color 0.3s ease;
user-select: none;
text-shadow: none;
padding: 0;
}
.navbar-nav .nav-link.active,
.navbar-nav .nav-link.active:hover,
.navbar-nav .nav-link.active:focus {
color: #00ffff;
}
.navbar-nav .nav-link:hover,
.navbar-nav .nav-link:focus {
color: #00ffff;
text-shadow: none;
}
/* --- ОСНОВНОЙ КОНТЕНТ --- */
.page {
padding: 4rem 2rem;
border-bottom: 1px solid #222;
}
.page h1,
.page h2 {
color: #ff00ff;
text-shadow: none;
}
.page p {
color: #ccc;
line-height: 1.6;
}
/* --- КАТАЛОГ --- */
.catalog-grid {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
margin-top: 2rem;
}
.catalog-item {
width: 100%;
background: #111;
border: 2px solid #ff00ff;
border-radius: 10px;
padding: 20px;
box-shadow: 0 0 12px #ff00ff88;
display: flex;
align-items: stretch;
gap: 20px;
position: relative;
margin-bottom: 20px;
transition: box-shadow 0.3s ease;
user-select: none;
}
.catalog-item:hover {
box-shadow: 0 0 20px #00ffffdd;
border-color: #00ffff;
}
.catalog-item img.catalog-item-image {
width: 200px;
height: 120px;
object-fit: cover;
border-radius: 5px;
flex-shrink: 0;
align-self: flex-start;
box-shadow: 0 0 8px #ff00ffaa;
}
.catalog-item-content {
flex-grow: 1;
padding-right: 140px;
display: flex;
flex-direction: column;
gap: 10px;
justify-content: space-between;
}
.catalog-item button.catalog-btn {
position: absolute;
right: 20px;
bottom: 20px;
background-color: #ff00ff;
border: none;
padding: 12px 30px;
color: #111;
font-weight: 600;
font-size: 1.1em;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
min-width: 120px;
text-align: center;
box-shadow: 0 0 12px #ff00ff;
z-index: 10;
}
.catalog-item button.catalog-btn:hover {
background-color: #00ffff;
transform: scale(1.05);
box-shadow: 0 0 15px #00ffff;
color: #000;
}
.event-actions {
position: absolute;
right: 20px;
top: 20px;
display: flex;
gap: 8px;
z-index: 100;
}
.btn-edit, .btn-delete {
background: rgba(0, 0, 0, 0.8);
border: 2px solid #ff00ff;
color: #ff00ff;
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.3s ease;
}
.btn-edit:hover {
background: #ff00ff;
color: #000;
box-shadow: 0 0 10px #ff00ff;
}
.btn-delete:hover {
background: #ff4444;
border-color: #ff4444;
color: #000;
box-shadow: 0 0 10px #ff4444;
}
/* --- КНОПКА ДОБАВЛЕНИЯ СОБЫТИЯ --- */
.btn-neon {
background-color: #ff00ff;
border: none;
color: #111;
font-weight: 700;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 0 10px #ff00ff;
transition: all 0.3s ease;
font-size: 16px;
display: inline-block;
text-decoration: none;
}
.btn-neon:hover {
background-color: #00ffff;
box-shadow: 0 0 14px #00ffff;
color: #000;
}
/* --- СТИЛИ ДЛЯ ФОРМ (включая EventForm.jsx) --- */
.form-control {
background: #222;
border: 2px solid #ff00ff;
color: #eee;
padding: 0.5rem 0.75rem;
border-radius: 6px;
box-shadow: 0 0 10px #ff00ff88 inset;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.form-control:focus {
background: #222;
border-color: #00ffff;
box-shadow: 0 0 10px #00ffff88 inset;
color: #eee;
outline: none;
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.form-select {
background-color: #222;
border: 2px solid #ff00ff;
color: #eee;
padding: 0.5rem 0.75rem;
border-radius: 6px;
box-shadow: 0 0 10px #ff00ff88 inset;
}
.form-select:focus {
background-color: #222;
border-color: #00ffff;
box-shadow: 0 0 10px #00ffff88 inset;
color: #eee;
outline: none;
}
.form-select option {
background-color: #222;
color: #eee;
}
/* --- МОДАЛЬНОЕ ОКНО ФОРМЫ (EventForm.jsx) --- */
.event-form-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.event-form-container {
background-color: #1a1a1a;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(255, 0, 255, 0.5);
width: 90%;
max-width: 500px;
border: 2px solid #ff00ff;
color: white;
}
.event-form-container h2 {
margin-bottom: 1.5rem;
color: #ff00ff;
}
.event-form .form-label {
color: #ccc;
}
.form-buttons {
text-align: right;
margin-top: 1rem;
}
.btn-form-submit {
background-color: #ff00ff;
border: none;
padding: 0.5rem 1.2rem;
color: #111;
font-weight: 700;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 0 10px #ff00ff;
transition: background-color 0.3s ease;
margin-right: 0.5rem;
}
.btn-form-submit:hover {
background-color: #00ffff;
box-shadow: 0 0 14px #00ffff;
color: #000;
}
.event-form-container .btn-outline-secondary.btn-form-cancel {
background-color: transparent;
border-color: #6c757d;
color: #6c757d;
padding: 0.5rem 1.2rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.event-form-container .btn-outline-secondary.btn-form-cancel:hover {
background-color: #6c757d;
color: white;
border-color: #6c757d;
}
/* --- МОДАЛЬНОЕ ОКНО СОБЫТИЯ (При нажатии "Смотреть") --- */
.event-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.event-modal-content {
position: relative;
background: #1a1a1a;
border: 3px solid #ff00ff;
border-radius: 12px;
box-shadow: 0 0 25px #ff00ff, 0 0 15px #00ffff;
text-align: center;
color: white;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
max-width: 95vw;
max-height: 95vh;
overflow: auto;
padding: 1.5rem;
}
.event-modal-image {
max-width: 100%;
max-height: 80vh;
border-radius: 8px;
box-shadow: 0 0 15px #ff00ffaa;
object-fit: contain;
align-self: center;
}
.event-modal-title {
color: #ff00ff;
font-size: 2rem;
margin: 0 0 1rem 0;
text-shadow: none;
align-self: center;
word-break: break-word;
}
.event-modal-close-btn {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid #ff00ff;
color: #ff00ff;
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
z-index: 1001;
}
.event-modal-close-btn:hover {
background: #ff00ff;
color: #000;
box-shadow: 0 0 10px #ff00ff;
}
/* --- ТРАНСЛЯЦИИ --- */
.streams-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.stream-item {
background: #111;
border: 2px solid #ff00ff;
border-radius: 10px;
padding: 1rem;
box-shadow: 0 0 12px #ff00ff88;
transition: box-shadow 0.3s ease;
user-select: none;
display: flex;
flex-direction: column;
height: 100%;
}
.stream-item:hover {
box-shadow: 0 0 20px #00ffffdd;
border-color: #00ffff;
}
.stream-item img {
width: 100%;
border-radius: 8px;
margin-bottom: 1rem;
box-shadow: 0 0 8px #ff00ffaa;
}
.stream-item h3 {
margin: 0 0 0.5rem;
color: #ff00ff;
text-shadow: none;
}
.stream-item p {
color: #aaa;
font-size: 0.95rem;
margin-bottom: 1rem;
flex-grow: 1;
}
.stream-item button {
background-color: #ff00ff;
border: none;
padding: 0.5rem 1.2rem;
color: #111;
font-weight: 700;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 0 10px #ff00ff;
transition: background-color 0.3s ease;
margin-top: auto;
}
.stream-item button:hover {
background-color: #00ffff;
box-shadow: 0 0 14px #00ffff;
color: #000;
}
/* --- ПРОФИЛЬ --- */
.profile-container {
background: #111;
border: 2px solid #ff00ff;
border-radius: 10px;
box-shadow: 0 0 12px #ff00ff88;
padding: 2rem;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
@media (min-width: 768px) {
.profile-container {
flex-direction: row;
align-items: flex-start;
}
}
.profile-avatar {
}
.profile-avatar img {
border-radius: 50%;
border: 3px solid #00ffff;
width: 150px;
height: 150px;
object-fit: cover;
box-shadow: 0 0 12px #00ffffaa;
}
.profile-info {
color: #eee;
text-align: center;
}
@media (min-width: 768px) {
.profile-info {
text-align: left;
}
}
.profile-info h2 {
margin: 0 0 1rem 0;
color: #ff00ff;
text-shadow: none;
}
.profile-info h2 span {
color: #00ffff;
text-shadow: none;
}
.profile-info p {
font-size: 1.1rem;
margin: 0.5rem 0;
color: #eee;
}
.btn-edit-profile {
margin-top: 1.5rem;
background-color: #ff00ff;
border: none;
padding: 0.7rem 1.4rem;
font-weight: 700;
color: #111;
border-radius: 8px;
cursor: pointer;
box-shadow: 0 0 12px #ff00ff;
transition: background-color 0.3s ease;
display: block;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
@media (min-width: 768px) {
.btn-edit-profile {
margin-left: 0;
}
}
.btn-edit-profile:hover {
background-color: #00ffff;
box-shadow: 0 0 16px #00ffff;
color: #000;
}
/* --- О СЕРВИСЕ --- */
.about-page {
max-width: 900px;
margin: 3rem auto;
padding: 0 1rem;
color: #eee;
}
.about-page h1 {
font-size: 3rem;
color: #ff00ff;
text-align: center;
text-shadow: none;
margin-bottom: 2rem;
}
.about-section {
background: #111;
border: 2px solid #ff00ff;
border-radius: 10px;
padding: 2rem;
box-shadow: 0 0 12px #ff00ff88;
transition: box-shadow 0.3s ease;
user-select: none;
margin: 2rem 0;
}
.about-section h2 {
font-size: 2rem;
margin-bottom: 1rem;
color: #00ffff;
text-shadow: none;
}
.about-section p,
.about-section ul {
font-size: 1.15rem;
line-height: 1.6;
}
.about-section ul {
padding-left: 1.3rem;
list-style-type: square;
}
.about-section ul li {
margin-bottom: 0.7rem;
}
/* --- ГЛАВНАЯ СТРАНИЦА --- */
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-top: 30px;
}
.feature-item {
background: #111;
border: 2px solid #ff00ff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 0 8px #ff00ff;
transition: box-shadow 0.3s ease;
display: flex;
flex-direction: column;
height: 100%;
}
.feature-item:hover {
box-shadow: 0 0 15px #ff00ff, 0 0 25px #00fff7;
}
.feature-item h3 {
margin-top: 0;
margin-bottom: 10px;
font-weight: 700;
color: #ff00ff;
text-shadow: none;
}
.feature-item p {
margin-bottom: 15px;
color: #ccc;
flex-grow: 1;
}
.feature-item button {
background: #ff00ff;
border: none;
border-radius: 4px;
padding: 10px 18px;
color: #111;
font-weight: 600;
cursor: pointer;
box-shadow: 0 0 10px #ff00ff;
transition: all 0.3s ease;
width: 100%;
margin-top: auto;
text-align: center;
min-height: 42px;
}
.feature-item button:hover {
background: #00ffff;
box-shadow: 0 0 15px #00ffff;
color: #000;
}
/* --- ФУТЕР --- */
.footer {
background: #111;
border-top: 3px solid #00ffff;
padding: 2rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
color: #00ffff;
text-shadow: none;
}
.footer-info p {
margin: 0.4rem 0;
font-weight: 600;
}
.social-icons {
margin-top: 1rem;
display: flex;
justify-content: center;
gap: 1.5rem;
}
.social-icons a i {
color: #ff00ff;
transition: color 0.3s ease;
}
/* --- АДАПТИВНОСТЬ --- */
@media (max-width: 768px) {
.navbar-nav {
position: fixed;
top: 60px;
right: 0;
background: #111;
width: 100%;
max-height: 0;
overflow: hidden;
flex-direction: column;
padding: 0 2rem;
transition: max-height 0.35s ease;
box-shadow: 0 10px 30px #ff00ffaa;
border-bottom: 2px solid #ff00ff;
z-index: 2000;
user-select: none;
border-radius: 0 0 10px 10px;
}
.navbar-collapse.show .navbar-nav {
max-height: 300px;
}
.navbar-nav .nav-link {
color: #ff66ff;
padding: 1rem 0;
border-bottom: 1px solid #ff00ff33;
text-shadow: none;
}
.navbar-nav .nav-link:hover,
.navbar-nav .nav-link:focus {
color: #33ffff;
text-shadow: none;
}
.catalog-item {
flex-direction: column;
align-items: flex-start;
padding-bottom: 70px;
}
.catalog-item img.catalog-item-image {
width: 100%;
height: 150px;
}
.catalog-item-content {
padding-right: 0;
width: 100%;
}
.catalog-item button.catalog-btn {
position: absolute;
right: 20px;
bottom: 20px;
left: 20px;
width: auto;
padding: 14px 30px;
font-size: 1.2em;
}
.event-actions {
right: 15px;
top: 15px;
}
.profile-container {
flex-direction: column;
align-items: center;
padding: 1.5rem;
}
.profile-container .profile-avatar {
margin-bottom: 1rem;
}
.profile-container .profile-info {
text-align: center;
}
.profile-container .btn-edit-profile {
margin-left: auto;
margin-right: auto;
}
.about-page {
margin: 1.5rem 1rem;
}
.about-page h1 {
font-size: 2.5rem;
}
.about-section h2 {
font-size: 1.6rem;
}
}
.btn-sort {
border: none;
background-color: #ff00ff;
color: #111;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
box-shadow: 0 0 10px #ff00ff88;
transition: all 0.3s ease;
padding: 0.5rem 1rem;
margin-right: 0.5rem;
text-align: center;
outline: none;
}
.btn-sort-active {
background-color: #00ffff;
box-shadow: 0 0 14px #00ffff;
color: #000;
transform: scale(1.05);
}
.btn-sort-inactive {
background-color: #ff00ff;
box-shadow: 0 0 10px #ff00ff88;
color: #111;
transform: none;
}
.btn-sort-inactive:hover {
background-color: #00ffff;
box-shadow: 0 0 14px #00ffff;
color: #000;
transform: scale(1.05);
}
.profile-info input[type="text"],
.profile-info input[type="email"] {
background-color: #000000;
color: #eee;
border: 2px solid #ff00ff;
border-radius: 6px;
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-family: inherit;
outline: none;
box-shadow: 0 0 8px #ff00ff88 inset;
width: 100%;
appearance: none;
}
.profile-info input[type="text"]:focus,
.profile-info input[type="email"]:focus {
background-color: #000000;
color: #eee;
box-shadow: 0 0 12px #00ffff inset;
}
.profile-info input[type="text"],
.profile-info input[type="email"] {
background-color: #000000; /* Чёрный фон */
color: #eee; /* Светлый текст для контраста */
border: 2px solid #ff00ff; /* Пурпурная граница */
border-radius: 6px; /* Скругление */
padding: 0.375rem 0.75rem; /* Стандартный отступ */
font-size: 1rem; /* Стандартный размер шрифта */
font-family: inherit; /* Наследуем шрифт */
outline: none; /* Убираем стандартную тень outline */
box-shadow: 0 0 8px #ff00ff88 inset; /* Внутренняя тень */
width: 100%; /* Позволяем элементу растягиваться */
appearance: none; /* Убираем возможные стандартные стили */
}
/* Опционально: стили при фокусе */
.profile-info input[type="text"]:focus,
.profile-info input[type="email"]:focus {
background-color: #000000;
color: #eee;
box-shadow: 0 0 12px #00ffff inset; /* Внутренняя неоновая тень при фокусе */
}
/* --- СТИЛИ ДЛЯ КНОПКИ СБРОСА АВАТАРА --- */
.profile-info input[type="text"],
.profile-info input[type="email"] {
background-color: #000000;
color: #eee;
border: 2px solid #ff00ff;
border-radius: 6px;
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-family: inherit;
outline: none;
box-shadow: 0 0 8px #ff00ff88 inset;
width: 100%;
appearance: none;
}
.profile-info input[type="text"]:focus,
.profile-info input[type="email"]:focus {
background-color: #000000;
color: #eee;
box-shadow: 0 0 12px #00ffff inset;
}
/* --- СТИЛИ ДЛЯ КНОПКИ СБРОСА АВАТАРА --- */
.profile-info .btn-reset-avatar-text {
background-color: #ff00ff;
border: none;
padding: 0.5rem 1rem;
font-weight: 600;
color: #111;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 0 10px #ff00ff;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
display: inline-block;
text-decoration: none;
outline: none;
}

90
src/hooks/useEvents.js Normal file
View File

@@ -0,0 +1,90 @@
import { useState, useEffect } from 'react';
const API_URL = 'http://localhost:3001/events';
const useEvents = () => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchEvents = async () => {
try {
setLoading(true);
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`Ошибка HTTP: ${response.status}`);
}
const data = await response.json();
setEvents(data);
} catch (err) {
setError(err.message);
console.error('Ошибка загрузки событий:', err);
} finally {
setLoading(false);
}
};
fetchEvents();
}, []);
const addEvent = async (newEventData) => {
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newEventData),
});
if (!response.ok) {
throw new Error(`Ошибка создания: ${response.status}`);
}
const createdEvent = await response.json();
setEvents(prev => [...prev, createdEvent]);
return createdEvent;
} catch (err) {
console.error('Ошибка при добавлении события:', err);
throw err;
}
};
const updateEvent = async (id, updatedEventData) => {
try {
const response = await fetch(`${API_URL}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedEventData),
});
if (!response.ok) {
throw new Error(`Ошибка обновления: ${response.status}`);
}
const updatedEvent = await response.json();
setEvents(prev => prev.map(e => e.id === id ? updatedEvent : e));
return updatedEvent;
} catch (err) {
console.error('Ошибка при обновлении события:', err);
throw err;
}
};
const deleteEvent = async (id) => {
try {
const response = await fetch(`${API_URL}/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Ошибка удаления: ${response.status}`);
}
setEvents(prev => prev.filter(e => e.id !== id));
} catch (err) {
console.error('Ошибка при удалении события:', err);
throw err;
}
};
return { events, loading, error, addEvent, updateEvent, deleteEvent };
};
export default useEvents;

38
src/hooks/useFormState.js Normal file
View File

@@ -0,0 +1,38 @@
import { useState } from 'react';
const useFormState = (initialData = null) => {
const [formData, setFormData] = useState(initialData || { title: '', description: '', imageUrl: '' });
const [currentEvent, setCurrentEvent] = useState(initialData);
const [isFormOpen, setIsFormOpen] = useState(false);
const openForm = (eventData = null) => {
setCurrentEvent(eventData);
setFormData(eventData || { title: '', description: '', imageUrl: '' });
setIsFormOpen(true);
};
const closeForm = () => {
setIsFormOpen(false);
setCurrentEvent(null);
setFormData({ title: '', description: '', imageUrl: '' });
};
const handleFormChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return {
formData,
currentEvent,
isFormOpen,
openForm,
closeForm,
handleFormChange
};
};
export default useFormState;

63
src/hooks/useProfile.js Normal file
View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
const getCurrentUserId = () => {
return localStorage.getItem('userId') || 1;
};
const API_URL = 'http://localhost:3001/users';
const useProfile = () => {
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchProfile = async () => {
try {
setLoading(true);
const userId = getCurrentUserId();
const response = await fetch(`${API_URL}/${userId}`);
if (!response.ok) {
throw new Error(`Ошибка HTTP: ${response.status}`);
}
const data = await response.json();
setProfile(data);
} catch (err) {
setError(err.message);
console.error('Ошибка загрузки профиля:', err);
} finally {
setLoading(false);
}
};
fetchProfile();
}, []);
const updateProfile = async (updatedData) => {
try {
const userId = getCurrentUserId();
const response = await fetch(`${API_URL}/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedData),
});
if (!response.ok) {
throw new Error(`Ошибка обновления: ${response.status}`);
}
const updatedProfile = await response.json();
setProfile(updatedProfile);
localStorage.setItem('userProfile', JSON.stringify(updatedProfile));
return updatedProfile;
} catch (err) {
console.error('Ошибка при обновлении профиля:', err);
throw err;
}
};
return { profile, loading, error, updateProfile };
};
export default useProfile;

33
src/hooks/useStreams.js Normal file
View File

@@ -0,0 +1,33 @@
import { useState, useEffect } from 'react';
const API_URL = 'http://localhost:3001/streams';
const useStreams = () => {
const [streams, setStreams] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchStreams = async () => {
try {
setLoading(true);
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`Ошибка HTTP: ${response.status}`);
}
const data = await response.json();
setStreams(data);
} catch (err) {
setError(err.message);
console.error('Ошибка загрузки трансляций:', err);
} finally {
setLoading(false);
}
};
fetchStreams();
}, []);
return { streams, loading, error };
};
export default useStreams;

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react';
const getCurrentUserId = () => {
return localStorage.getItem('userId') || 1;
};
const API_URL_SUBSCRIPTIONS = 'http://localhost:3001/subscriptions';
const API_URL_EVENTS = 'http://localhost:3001/events';
const useSubscriptions = () => {
const [subscriptions, setSubscriptions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchSubscriptions = async () => {
try {
setLoading(true);
const userId = getCurrentUserId();
const response = await fetch(`${API_URL_SUBSCRIPTIONS}?userId=${userId}`);
if (!response.ok) {
throw new Error(`Ошибка HTTP: ${response.status}`);
}
const data = await response.json();
const eventsResponse = await fetch(API_URL_EVENTS);
if (!eventsResponse.ok) {
throw new Error(`Ошибка загрузки событий: ${eventsResponse.status}`);
}
const events = await eventsResponse.json();
const subscriptionsWithEvents = data.map(sub => ({
...sub,
event: events.find(e => e.id === sub.eventId) || null
}));
setSubscriptions(subscriptionsWithEvents);
} catch (err) {
setError(err.message);
console.error('Ошибка загрузки подписок:', err);
} finally {
setLoading(false);
}
};
fetchSubscriptions();
}, []);
const addSubscription = async (eventId) => {
try {
const userId = getCurrentUserId();
const newSubscription = { userId, eventId, date: new Date().toISOString() };
const response = await fetch(API_URL_SUBSCRIPTIONS, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newSubscription),
});
if (!response.ok) {
throw new Error(`Ошибка создания подписки: ${response.status}`);
}
const createdSubscription = await response.json();
setSubscriptions(prev => [...prev, { ...createdSubscription, event: null }]);
} catch (err) {
console.error('Ошибка при добавлении подписки:', err);
throw err;
}
};
const removeSubscription = async (subId) => {
try {
const response = await fetch(`${API_URL_SUBSCRIPTIONS}/${subId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Ошибка удаления подписки: ${response.status}`);
}
setSubscriptions(prev => prev.filter(sub => sub.id !== subId));
} catch (err) {
console.error('Ошибка при удалении подписки:', err);
throw err;
}
};
return { subscriptions, loading, error, addSubscription, removeSubscription };
};
export default useSubscriptions;

1
src/javascript.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

View File

@@ -0,0 +1,335 @@
console.log('🔧 catalog-mvc-bundle.js загружен');
// EventModel
class EventModel {
constructor() {
this.apiUrl = 'http://localhost:3001';
console.log('📊 Model создана');
}
async getAllEvents() {
try {
console.log('📊 Запрос событий к API...');
const response = await fetch(`${this.apiUrl}/events`);
const events = await response.json();
console.log('✅ События получены:', events.length);
return events;
} catch (error) {
console.error('❌ Ошибка получения событий:', error);
return [];
}
}
async createEvent(eventData) {
try {
console.log('📤 Создание события:', eventData);
const response = await fetch(`${this.apiUrl}/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(eventData)
});
const newEvent = await response.json();
console.log('✅ Событие создано:', newEvent);
return newEvent;
} catch (error) {
console.error('❌ Ошибка создания события:', error);
throw error;
}
}
async deleteEvent(id) {
try {
console.log('🗑️ Удаление события:', id);
const response = await fetch(`${this.apiUrl}/events/${id}`, {
method: 'DELETE'
});
console.log('✅ Событие удалено');
return response.ok;
} catch (error) {
console.error('❌ Ошибка удаления:', error);
throw error;
}
}
}
// EventView
class EventView {
constructor() {
this.container = null;
console.log('🎨 View создана');
}
initialize(containerId) {
this.container = document.getElementById(containerId);
if (!this.container) {
console.error('❌ Контейнер не найден:', containerId);
return;
}
console.log('✅ View инициализирована с контейнером:', containerId);
this.addControlsToExistingCards();
}
addControlsToExistingCards() {
const existingCards = this.container.querySelectorAll('.catalog-item');
console.log('🎯 Найдено карточек для добавления кнопок:', existingCards.length);
existingCards.forEach((card, index) => {
const oldActions = card.querySelector('.event-actions');
if (oldActions) oldActions.remove();
const actionsDiv = document.createElement('div');
actionsDiv.className = 'event-actions';
actionsDiv.innerHTML = `
<button class="btn-edit" data-id="static-${index + 1}">
<i class="bi bi-pencil"></i>
</button>
<button class="btn-delete" data-id="static-${index + 1}">
<i class="bi bi-trash"></i>
</button>
`;
card.appendChild(actionsDiv);
});
}
renderEventList(events) {
console.log('🎨 Отрисовываем события из API:', events.length);
events.forEach(event => {
const eventElement = this.createEventElement(event);
this.container.appendChild(eventElement);
});
}
createEventElement(event) {
const eventDiv = document.createElement('article');
eventDiv.className = 'catalog-item';
eventDiv.dataset.id = event.id;
eventDiv.innerHTML = `
<img src="${event.imageUrl}" alt="${event.title}" />
<div class="catalog-item-content">
<h3>${event.title}</h3>
<p>${event.description}</p>
<small class="text-muted">ID: ${event.id}</small>
</div>
<button class="catalog-btn">Смотреть</button>
<div class="event-actions">
<button class="btn-edit" data-id="${event.id}">
<i class="bi bi-pencil"></i>
</button>
<button class="btn-delete" data-id="${event.id}">
<i class="bi bi-trash"></i>
</button>
</div>
`;
return eventDiv;
}
renderEventForm() {
console.log('📋 Рендерим форму добавления события');
const formHTML = `
<div class="event-form-overlay">
<div class="event-form-modal">
<h3>Добавить событие</h3>
<form id="eventForm">
<div class="form-group">
<label for="eventTitle">Название события</label>
<input type="text" id="eventTitle" class="form-control"
placeholder="Введите название" minlength="1" maxlength="50" required>
<small class="text-muted">От 1 до 50 символов</small>
</div>
<div class="form-group">
<label for="eventDescription">Описание</label>
<textarea id="eventDescription" class="form-control" rows="3"
placeholder="Введите описание" required></textarea>
</div>
<div class="form-group">
<label for="eventImage">Ссылка на изображение</label>
<input type="url" id="eventImage" class="form-control"
placeholder="https://example.com/image.jpg" required>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelBtn">Отмена</button>
<button type="submit" class="btn btn-primary">Создать</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', formHTML);
return document.getElementById('eventForm');
}
removeEventForm() {
const overlay = document.querySelector('.event-form-overlay');
if (overlay) overlay.remove();
}
bindEditEvent(handler) {
document.addEventListener('click', (e) => {
if (e.target.closest('.btn-edit')) {
const id = e.target.closest('.btn-edit').dataset.id;
console.log('✏️ Редактирование:', id);
handler(id);
}
});
}
bindDeleteEvent(handler) {
document.addEventListener('click', (e) => {
if (e.target.closest('.btn-delete')) {
const id = e.target.closest('.btn-delete').dataset.id;
console.log('🗑️ Удаление:', id);
handler(id);
}
});
}
}
class EventController {
constructor(containerId) {
this.model = new EventModel();
this.view = new EventView();
this.containerId = containerId;
console.log('🎯 Controller создан');
}
async initialize() {
console.log('🎯 Инициализация MVC компонента...');
try {
this.view.initialize(this.containerId);
console.log('✅ View инициализирована');
await this.loadEvents();
console.log('✅ События загружены');
this.bindEvents();
console.log('✅ Обработчики настроены');
console.log('🎉 MVC компонент полностью инициализирован');
} catch (error) {
console.error('💥 Ошибка инициализации:', error);
}
}
async loadEvents() {
const events = await this.model.getAllEvents();
this.view.renderEventList(events);
}
bindEvents() {
const addBtn = document.getElementById('addEventBtn');
console.log('🔍 Кнопка добавления:', !!addBtn);
if (addBtn) {
addBtn.addEventListener('click', () => {
console.log('🎯 Кнопка "Добавить событие" нажата!');
this.showEventForm();
});
}
this.view.bindEditEvent((id) => {
if (id.startsWith('static-')) {
alert(`Редактирование статического события ${id} (заглушка)`);
} else {
alert(`Редактирование события ${id} (заглушка)`);
}
});
this.view.bindDeleteEvent((id) => {
if (id.startsWith('static-')) {
if (confirm(`Удалить статическое событие ${id}?`)) {
alert(`Статическое событие ${id} удалено (заглушка)`);
}
} else {
if (confirm(`Удалить событие ${id}?`)) {
this.deleteEvent(id);
}
}
});
}
async showEventForm() {
const form = this.view.renderEventForm();
this.bindFormEvents(form);
}
bindFormEvents(form) {
document.getElementById('cancelBtn').addEventListener('click', () => {
this.view.removeEventForm();
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
await this.handleFormSubmit();
});
document.querySelector('.event-form-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) {
this.view.removeEventForm();
}
});
}
async handleFormSubmit() {
const formData = {
title: document.getElementById('eventTitle').value,
description: document.getElementById('eventDescription').value,
imageUrl: document.getElementById('eventImage').value,
categoryId: 1,
typeId: 1
};
console.log('📤 Отправляем данные:', formData);
try {
await this.model.createEvent(formData);
alert('Событие успешно создано!');
this.view.removeEventForm();
await this.loadEvents();
} catch (error) {
console.error('Ошибка сохранения:', error);
alert('Ошибка при создании события');
}
}
async deleteEvent(id) {
try {
await this.model.deleteEvent(id);
alert('Событие успешно удалено!');
await this.loadEvents();
} catch (error) {
console.error('Ошибка удаления:', error);
alert('Ошибка при удалении события');
}
}
}
// Инициализация
document.addEventListener('DOMContentLoaded', async () => {
console.log('✅ DOM загружен');
try {
console.log('🚀 Запускаем EventController...');
const controller = new EventController('eventsContainer');
await controller.initialize();
console.log('🎉 MVC компонент успешно инициализирован');
} catch (error) {
console.error('💥 Ошибка инициализации MVC компонента:', error);
}
});

38
src/js/catalog.js Normal file
View File

@@ -0,0 +1,38 @@
function addCatalogItem(title, description, imageUrl, type) {
const container = document.getElementById('catalogContainer');
const newItem = document.createElement('article');
newItem.className = 'catalog-item';
newItem.innerHTML = `
<img src="${imageUrl}" alt="${title}" />
<div class="catalog-item-content">
<h3>${title}</h3>
<p>${description}</p>
</div>
<button>Узнать больше</button>
`;
container.appendChild(newItem);
}
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('addCatalogForm');
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
const inputs = this.querySelectorAll('input, select');
const title = inputs[0].value;
const description = inputs[1].value;
const imageUrl = inputs[2].value;
const type = inputs[3].value;
if (title && description && imageUrl && type) {
addCatalogItem(title, description, imageUrl, type);
this.reset();
alert('Событие добавлено в каталог!');
}
});
}
});

16
src/main.jsx Normal file
View File

@@ -0,0 +1,16 @@
// src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.css';
import './css/custom.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

40
src/pages/AboutPage.jsx Normal file
View File

@@ -0,0 +1,40 @@
import React from 'react';
const AboutPage = () => {
return (
<div className="page about-page">
<div className="container-fluid">
<h1>О сервисе StreamCore</h1>
<section className="about-section">
<h2><i className="bi bi-rocket-takeoff me-2"></i>Наша миссия</h2>
<p>
StreamCore это инновационная платформа для стриминга, которая объединяет лучшие элементы киберпанка и современных технологий.
Мы стремимся предоставить уникальный опыт для зрителей и стримеров, создавая атмосферу неона и цифрового будущего.
</p>
</section>
<section className="about-section">
<h2><i className="bi bi-gift me-2"></i>Что мы предлагаем</h2>
<ul>
<li>Множество трансляций в режиме реального времени с профессиональными и начинающими стримерами.</li>
<li>Каталог эксклюзивных цифровых товаров и мерча в стиле киберпанк.</li>
<li>Интерактивное сообщество с возможностями для общения и обмена опытом.</li>
<li>Продвинутые настройки профиля и персонализация интерфейса.</li>
<li>Стабильную работу и удобный дизайн на всех устройствах.</li>
</ul>
</section>
<section className="about-section">
<h2><i className="bi bi-people me-2"></i>Команда</h2>
<p>
Наша команда состоит из энтузиастов и профессионалов в области технологий, дизайна и цифрового искусства, объединённых идеей создания лучшего стримингового сервиса в киберпанковской эстетике.
</p>
</section>
</div>
</div>
);
};
export default AboutPage;

192
src/pages/CatalogPage.jsx Normal file
View File

@@ -0,0 +1,192 @@
import React, { useState, useEffect } from 'react';
import EventList from '../components/EventList';
import EventForm from '../components/EventForm';
const API_URL = 'http://localhost:8080/api/events';
const CatalogPage = () => {
const [events, setEvents] = useState([]);
const [currentEvent, setCurrentEvent] = useState(null);
const [isFormOpen, setIsFormOpen] = useState(false);
const [sortOrder, setSortOrder] = useState('asc');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => {
try {
setLoading(true);
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`Ошибка HTTP: ${response.status}`);
}
const data = await response.json();
setEvents(data);
setError(null);
} catch (error) {
console.error('Ошибка при загрузке событий:', error);
setError('Не удалось загрузить события');
} finally {
setLoading(false);
}
};
const handleAddClick = () => {
setCurrentEvent(null);
setIsFormOpen(true);
};
const handleEditClick = (event) => {
setCurrentEvent(event);
setIsFormOpen(true);
};
const handleFormSubmit = async (eventData) => {
try {
console.log('Отправляемые данные:', eventData); // для отладки
// ПРАВИЛЬНОЕ ФОРМИРОВАНИЕ ДАННЫХ
const requestData = {
title: eventData.title,
description: eventData.description,
imageUrl: eventData.imageUrl,
categoryId: eventData.category.id || eventData.categoryId,
typeId: eventData.type.id || eventData.typeId
};
const url = currentEvent ? `${API_URL}/${currentEvent.id}` : API_URL;
const method = currentEvent ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ошибка ${response.status}: ${errorText}`);
}
const result = await response.json();
console.log('Ответ от сервера:', result); // для отладки
if (currentEvent) {
setEvents(events.map(e => e.id === currentEvent.id ? result : e));
} else {
setEvents([...events, result]);
}
setIsFormOpen(false);
setCurrentEvent(null);
} catch (error) {
console.error('Ошибка при сохранении события:', error);
alert('Ошибка при сохранении события: ' + error.message);
}
};
const handleDeleteClick = async (id) => {
if (window.confirm('Вы уверены, что хотите удалить это событие?')) {
try {
const response = await fetch(`${API_URL}/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Ошибка удаления: ${response.status}`);
}
setEvents(events.filter(e => e.id !== id));
} catch (error) {
console.error('Ошибка при удалении события:', error);
alert('Ошибка при удалении события: ' + error.message);
}
}
};
const handleFormClose = () => {
setIsFormOpen(false);
setCurrentEvent(null);
};
const handleSortAsc = () => setSortOrder('asc');
const handleSortDesc = () => setSortOrder('desc');
const getSortedEvents = (eventsList, order) => {
if (!eventsList || !Array.isArray(eventsList)) return [];
return [...eventsList].sort((a, b) => {
const titleA = a.title ? a.title.toLowerCase() : '';
const titleB = b.title ? b.title.toLowerCase() : '';
if (order === 'asc') {
return titleA < titleB ? -1 : titleA > titleB ? 1 : 0;
} else {
return titleA > titleB ? -1 : titleA < titleB ? 1 : 0;
}
});
};
const sortedEvents = getSortedEvents(events, sortOrder);
if (loading) {
return <div className="container-fluid">Загрузка событий...</div>;
}
if (error) {
return (
<div className="container-fluid">
<div style={{ color: '#ff4444', padding: '20px', textAlign: 'center' }}>
<h3>Ошибка подключения</h3>
<p>{error}</p>
<button className="btn btn-neon" onClick={fetchEvents}>
Попробовать снова
</button>
</div>
</div>
);
}
return (
<div className="container-fluid">
<h1>Каталог стримов и событий</h1>
<p>Выбирай из нашего ассортимента уникальных стримов, концертов и кибермероприятий.</p>
<div className="mb-3">
<button className={`btn btn-sort ${sortOrder === 'asc' ? 'btn-sort-active' : 'btn-sort-inactive'}`}
onClick={handleSortAsc}>
Сортировать А-Я
</button>
<button className={`btn btn-sort ${sortOrder === 'desc' ? 'btn-sort-active' : 'btn-sort-inactive'}`}
onClick={handleSortDesc}>
Сортировать Я-А
</button>
</div>
<EventList
events={sortedEvents}
onEdit={handleEditClick}
onDelete={handleDeleteClick}
/>
<div className="text-center mt-4">
<button className="btn btn-neon btn-lg" onClick={handleAddClick}>
<i className="bi bi-plus-circle me-2"></i>Добавить новое событие
</button>
</div>
{isFormOpen && (
<EventForm
initialData={currentEvent}
onSubmit={handleFormSubmit}
onClose={handleFormClose}
/>
)}
</div>
);
};
export default CatalogPage;

31
src/pages/HomePage.jsx Normal file
View File

@@ -0,0 +1,31 @@
import React from 'react';
const HomePage = () => {
return (
<div className="container-fluid">
<h1>Добро пожаловать в StreamCore</h1>
<p>Твой портал в мир киберспорта, электронной музыки и цифрового искусства. Следи за новыми трансляциями, знакомься с артистами и участвуй в событиях!</p>
<section className="features-grid" id="featuresContainer">
<article className="feature-item">
<h3><i className="bi bi-trophy me-2"></i>Киберспорт</h3>
<p>Самые масштабные турниры и захватывающие матчи в прямом эфире.</p>
<button onClick={() => window.location.hash = '#/streams'}>Смотреть трансляции</button>
</article>
<article className="feature-item">
<h3><i className="bi bi-music-note-beamed me-2"></i>Электронная музыка</h3>
<p>Эксклюзивные лайв-сеты от лучших диджеев и музыкантов.</p>
<button onClick={() => window.location.hash = '#/catalog'}>Открыть каталог</button>
</article>
<article className="feature-item">
<h3><i className="bi bi-palette me-2"></i>Цифровое искусство</h3>
<p>Выставки, NFT-галереи и творческие проекты от художников.</p>
<button onClick={() => window.location.hash = '#/catalog#art'}>Посмотреть работы</button>
</article>
</section>
</div>
);
};
export default HomePage;

153
src/pages/ProfilePage.jsx Normal file
View File

@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import useProfile from '../hooks/useProfile';
import useSubscriptions from '../hooks/useSubscriptions';
import AvatarPlaceholder from '../components/AvatarPlaceholder';
const ProfilePage = () => {
const { profile, loading: profileLoading, error: profileError, updateProfile } = useProfile();
const { subscriptions, loading: subsLoading, error: subsError, addSubscription, removeSubscription } = useSubscriptions();
const [isEditing, setIsEditing] = useState(false);
const [editAvatarUrl, setEditAvatarUrl] = useState(profile?.avatarUrl || '');
const displayAvatarUrl = isEditing ? editAvatarUrl : profile?.avatarUrl;
const [editData, setEditData] = useState({});
if (profileLoading || subsLoading) return <p className="text-center">Загрузка профиля...</p>;
if (profileError) return <p className="text-center text-danger">Ошибка профиля: {profileError}</p>;
if (subsError) return <p className="text-center text-danger">Ошибка подписок: {subsError}</p>;
const handleEditClick = () => {
if (profile) {
setEditData({ ...profile });
setEditAvatarUrl(profile.avatarUrl || '');
setIsEditing(true);
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setEditData(prev => ({
...prev,
[name]: value
}));
};
const handleSaveClick = async () => {
try {
const profileDataToUpdate = { ...editData, avatarUrl: editAvatarUrl };
await updateProfile(profileDataToUpdate);
setIsEditing(false);
} catch (err) {
console.error('Ошибка сохранения профиля:', err);
}
};
const handleCancelClick = () => {
setIsEditing(false);
if (profile) {
setEditData({ ...profile });
setEditAvatarUrl(profile.avatarUrl || '');
}
};
const handleResetAvatarClick = () => {
setEditAvatarUrl('');
};
const handleSubscribeClick = async (eventId) => {
try {
await addSubscription(eventId);
} catch (err) {
console.error('Ошибка подписки:', err);
}
};
const handleUnsubscribeClick = async (subId) => {
try {
await removeSubscription(subId);
} catch (err) {
console.error('Ошибка отписки:', err);
}
};
return (
<div className="container-fluid">
<h1>Профиль пользователя</h1>
<section className="profile-container">
<div className="d-flex flex-column flex-md-row align-items-center gap-4">
<div className="profile-avatar">
{isEditing ? (
<>
{displayAvatarUrl ? (
<img src={displayAvatarUrl} alt="Предварительный просмотр аватара" />
) : (
<AvatarPlaceholder name={editData.username || profile?.username || 'A'} />
)}
</>
) : (
profile?.avatarUrl ? (
<img src={profile.avatarUrl} alt="Аватар пользователя" />
) : (
<AvatarPlaceholder name={profile?.username || 'A'} />
)
)}
</div>
<div className="profile-info">
{isEditing ? (
<>
<h2>Имя пользователя: <input type="text" name="username" value={editData.username || ''} onChange={handleInputChange} /></h2>
<p><strong>Email:</strong> <input type="email" name="email" value={editData.email || ''} onChange={handleInputChange} /></p>
<p><strong>Подписка:</strong> {profile?.subscription}</p>
<p><strong>Дата регистрации:</strong> {profile?.registrationDate}</p>
<p><strong>Последний вход:</strong> {profile?.lastLogin}</p>
{/* --- Обновлённая кнопка сброса аватара с текстом --- */}
<div>
{/* Кнопка теперь с текстом и использует новый класс */}
<button className="btn btn-reset-avatar-text me-2" type="button" onClick={handleResetAvatarClick}>
Сбросить аватар
</button>
<button className="btn-edit-profile me-2" onClick={handleSaveClick}>Сохранить</button>
<button className="btn btn-outline-secondary" onClick={handleCancelClick}>Отмена</button>
</div>
{/* --- Конец обновления --- */}
</>
) : (
<>
<h2>Имя пользователя: <span>{profile?.username}</span></h2>
<p><strong>Email:</strong> {profile?.email}</p>
<p><strong>Подписка:</strong> {profile?.subscription}</p>
<p><strong>Дата регистрации:</strong> {profile?.registrationDate}</p>
<p><strong>Последний вход:</strong> {profile?.lastLogin}</p>
<button className="btn-edit-profile" onClick={handleEditClick}>Редактировать профиль</button>
</>
)}
</div>
</div>
</section>
<section className="subscriptions-section mt-4">
<h2>Мои подписки</h2>
{subscriptions.length === 0 ? (
<p>Вы пока не подписаны ни на какие события.</p>
) : (
<div className="streams-grid">
{subscriptions.map(sub => (
<div className="stream-item" key={sub.id}>
<div>
<div>
<h5>{sub.event?.title || `Событие ${sub.eventId}`}</h5>
<p>{sub.event?.description || "Информация о событии недоступна."}</p>
<button className="btn btn-danger btn-sm" onClick={() => handleUnsubscribeClick(sub.id)}>Отписаться</button>
</div>
</div>
</div>
))}
</div>
)}
</section>
</div>
);
};
export default ProfilePage;

33
src/pages/StreamsPage.jsx Normal file
View File

@@ -0,0 +1,33 @@
import React from 'react';
import useStreams from '../hooks/useStreams';
const StreamsPage = () => {
const { streams, loading, error } = useStreams();
if (loading) return <p className="text-center">Загрузка трансляций...</p>;
if (error) return <p className="text-center text-danger">Ошибка: {error}</p>;
return (
<div className="container-fluid">
<h1>Текущие и предстоящие трансляции</h1>
<p>Выбирай любимые трансляции и присоединяйся к просмотру в реальном времени!</p>
<section className="streams-grid" id="streamsContainer">
{streams.length === 0 ? (
<p className="text-center">Трансляций пока нет.</p>
) : (
streams.map(stream => (
<article className="stream-item" key={stream.id}>
<img src={stream.imageUrl} alt={stream.title} />
<h3>{stream.title}</h3>
<p>{stream.description}</p>
<button>Смотреть сейчас</button>
</article>
))
)}
</section>
</div>
);
};
export default StreamsPage;

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,11 @@
import js from "@eslint/js";
import globals from "globals";
import pluginReact from "eslint-plugin-react";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs,jsx}"], plugins: { js }, extends: ["js/recommended"] },
{ files: ["**/*.{js,mjs,cjs,jsx}"], languageOptions: { globals: globals.browser } },
pluginReact.configs.flat.recommended,
]);

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "my-bootstrap-site",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"eslint": "^9.27.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.1.0",
"vite": "^6.3.5"
},
"dependencies": {
"bootstrap": "^5.3.6",
"bootstrap-icons": "^1.13.1"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,9 @@
export function setupCounter(element) {
let counter = 0
const setCounter = (count) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

View File

@@ -0,0 +1,24 @@
import './style.css'
import javascriptLogo from './javascript.svg'
import viteLogo from '/vite.svg'
import { setupCounter } from './counter.js'
document.querySelector('#app').innerHTML = `
<div>
<a href="https://vite.dev" target="_blank">
<img src="${viteLogo}" class="logo" alt="Vite logo" />
</a>
<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript" target="_blank">
<img src="${javascriptLogo}" class="logo vanilla" alt="JavaScript logo" />
</a>
<h1>Hello Vite!</h1>
<div class="card">
<button id="counter" type="button"></button>
</div>
<p class="read-the-docs">
Click on the Vite logo to learn more
</p>
</div>
`
setupCounter(document.querySelector('#counter'))

View File

@@ -0,0 +1,96 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #f7df1eaa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "streamcore-bootstrap",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.0",
"prettier": "^3.5.3",
"vite": "^6.3.5"
},
"dependencies": {
"bootstrap": "^5.3.6",
"bootstrap-icons": "^1.13.1"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,9 @@
export function setupCounter(element) {
let counter = 0
const setCounter = (count) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

View File

@@ -0,0 +1,24 @@
import './style.css'
import javascriptLogo from './javascript.svg'
import viteLogo from '/vite.svg'
import { setupCounter } from './counter.js'
document.querySelector('#app').innerHTML = `
<div>
<a href="https://vite.dev" target="_blank">
<img src="${viteLogo}" class="logo" alt="Vite logo" />
</a>
<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript" target="_blank">
<img src="${javascriptLogo}" class="logo vanilla" alt="JavaScript logo" />
</a>
<h1>Hello Vite!</h1>
<div class="card">
<button id="counter" type="button"></button>
</div>
<p class="read-the-docs">
Click on the Vite logo to learn more
</p>
</div>
`
setupCounter(document.querySelector('#counter'))

View File

@@ -0,0 +1,96 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #f7df1eaa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

18
vite.config.js Normal file
View File

@@ -0,0 +1,18 @@
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// Можно добавить алиасы для удобства импорта
// '@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
open: true,
},
})