add files
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
43
README.md
Normal file
43
README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
11
astro.config.mjs
Normal file
11
astro.config.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import node from '@astrojs/node';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
adapter: node({
|
||||
mode: 'standalone'
|
||||
})
|
||||
});
|
||||
5271
package-lock.json
generated
Normal file
5271
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "app",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.5.1",
|
||||
"astro": "^5.16.3"
|
||||
}
|
||||
}
|
||||
9
public/favicon.svg
Normal file
9
public/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
117
src/components/ContentGrid.astro
Normal file
117
src/components/ContentGrid.astro
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
export interface Props {
|
||||
items: any[];
|
||||
showCount?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
items = [],
|
||||
showCount = false,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section class="posts-section" id="posts-section">
|
||||
<h2>
|
||||
{showCount && items.length > 0 && (
|
||||
<span id="posts-count"> ({items.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<div id="posts-grid" class="posts-grid">
|
||||
{items.map((item, index) => {
|
||||
const postUrl = item.uri || `/blog/${item.databaseId}`;
|
||||
const postDate = new Date(item.date);
|
||||
|
||||
// Логика для больших плиток на десктопе
|
||||
let isLarge = false;
|
||||
let largePosition = '';
|
||||
|
||||
const rowNumber = Math.floor(index / 4) + 1;
|
||||
const positionInRow = index % 4;
|
||||
|
||||
if (index >= 8) {
|
||||
const largeRowStart = (rowNumber - 3) % 3 === 0 && rowNumber >= 3;
|
||||
|
||||
if (largeRowStart && positionInRow === 0) {
|
||||
isLarge = true;
|
||||
largePosition = 'first';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
class={`post-card ${isLarge ? 'post-card-large' : ''}`}
|
||||
data-large-position={isLarge ? largePosition : ''}
|
||||
data-index={index}
|
||||
itemscope
|
||||
itemtype="https://schema.org/BlogPosting"
|
||||
>
|
||||
<a href={postUrl} class="post-card-link">
|
||||
<div class="post-image-container">
|
||||
{item.featuredImage?.node?.sourceUrl ? (
|
||||
<img loading="lazy"
|
||||
src={item.featuredImage.node.sourceUrl}
|
||||
alt={item.featuredImage.node.altText || item.title}
|
||||
width="400"
|
||||
height="400"
|
||||
loading="lazy"
|
||||
class="post-image"
|
||||
itemprop="image"
|
||||
/>
|
||||
) : (
|
||||
<div class="post-image-placeholder"></div>
|
||||
)}
|
||||
|
||||
{/* Рубрика в верхнем правом углу */}
|
||||
{item.categories?.nodes?.[0]?.name && (
|
||||
<div class="post-category-badge">
|
||||
{item.categories.nodes[0].name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Оверлей с контентом */}
|
||||
<div class="post-content-overlay">
|
||||
<div class="post-meta-overlay">
|
||||
<time
|
||||
datetime={item.date}
|
||||
class="post-date-overlay"
|
||||
itemprop="datePublished"
|
||||
>
|
||||
{postDate.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).replace(' г.', '')}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h3 class="post-title-overlay" itemprop="headline">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
{item.author?.node?.name && (
|
||||
<div class="author-name" itemprop="author">
|
||||
{item.author.node.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Скринридеру и SEO */}
|
||||
<div class="sr-only">
|
||||
<h3 itemprop="headline">
|
||||
<a href={postUrl} itemprop="url">{item.title}</a>
|
||||
</h3>
|
||||
<time
|
||||
datetime={item.date}
|
||||
itemprop="datePublished"
|
||||
>
|
||||
{postDate.toLocaleDateString('ru-RU')}
|
||||
</time>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
275
src/components/EndnewsList.astro
Normal file
275
src/components/EndnewsList.astro
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
import { getLatestAnews } from '../lib/api/posts.js';
|
||||
|
||||
// Функции для работы с датами
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return 'Сегодня';
|
||||
}
|
||||
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return 'Вчера';
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function groupByDate(posts: Array<{ date: string }>) {
|
||||
const grouped: Record<string, Array<any>> = {};
|
||||
|
||||
posts.forEach(post => {
|
||||
const date = new Date(post.date);
|
||||
const dateKey = date.toISOString().split('T')[0];
|
||||
|
||||
if (!grouped[dateKey]) {
|
||||
grouped[dateKey] = [];
|
||||
}
|
||||
|
||||
grouped[dateKey].push(post);
|
||||
});
|
||||
|
||||
const sortedEntries = Object.entries(grouped).sort((a, b) =>
|
||||
b[0].localeCompare(a[0])
|
||||
);
|
||||
|
||||
return Object.fromEntries(sortedEntries);
|
||||
}
|
||||
|
||||
// Получаем данные при сборке (используем правильное имя функции)
|
||||
const posts = await getLatestAnews(20);
|
||||
const groupedPosts = groupByDate(posts);
|
||||
const totalPosts = posts.length;
|
||||
---
|
||||
|
||||
<!-- HTML разметка -->
|
||||
<div class="endnews-container">
|
||||
<!-- Шапка с заголовком и счетчиком -->
|
||||
<div class="endnews-header">
|
||||
<h4 class="endnews-title">Новости</h4>
|
||||
</div>
|
||||
|
||||
<!-- Список новостей с группировкой по датам -->
|
||||
<div class="latestnews-list">
|
||||
{Object.entries(groupedPosts).map(([dateKey, datePosts]) => {
|
||||
const dateStr = new Date(dateKey).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="latestnews-date-group" key={dateKey}>
|
||||
|
||||
<div class="latestnews-date">{formatDate(dateKey + 'T00:00:00')}</div>
|
||||
|
||||
|
||||
<!-- Список новостей за эту дату -->
|
||||
{datePosts.map((post, index) => {
|
||||
const postNumber = index + 1;
|
||||
const isLastInGroup = postNumber === datePosts.length;
|
||||
|
||||
return (
|
||||
<article class="lastnews-item" key={post.uri}>
|
||||
|
||||
<div class="lastnews-time">
|
||||
{formatTime(post.date)}
|
||||
</div>
|
||||
|
||||
<!-- Заголовок новости -->
|
||||
<div class="lastnews-content">
|
||||
<a
|
||||
href={post.uri || '#'}
|
||||
class="endnews-link"
|
||||
>
|
||||
{post.title}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
/* Базовые стили */
|
||||
.endnews-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.endnews-header {
|
||||
padding: 12px 16px;
|
||||
background: #B61D1D;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.endnews-title {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.latestnews-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #ECECEC;
|
||||
border-top: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.latestnews-date-group {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.latestnews-date-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.latestnews-date {
|
||||
font-size: 0.625rem;
|
||||
line-height: 1.1;
|
||||
color: #505258;
|
||||
margin: 0 0 0.4375rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.lastnews-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
contain: content;
|
||||
}
|
||||
|
||||
.lastnews-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.lastnews-time {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #B61D1D;
|
||||
min-width: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.lastnews-content {
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
color: #000;
|
||||
transition: color 0.2s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.endnews-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.endnews-link:hover {
|
||||
color: #B61D1D;
|
||||
}
|
||||
|
||||
/* Десктоп: фиксированная высота со скроллом */
|
||||
@media (min-width: 1024px) {
|
||||
.endnews-container {
|
||||
height: 500px; /* Фиксированная высота */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.latestnews-list {
|
||||
flex: 1;
|
||||
overflow-y: auto; /* Вертикальный скролл */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Кастомный скроллбар для красоты */
|
||||
.latestnews-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.latestnews-list::-webkit-scrollbar-track {
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.latestnews-list::-webkit-scrollbar-thumb {
|
||||
background: #d0d0d0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.latestnews-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #b0b0b0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Мобильные устройства: автоматическая высота */
|
||||
@media (max-width: 1023px) {
|
||||
.endnews-container {
|
||||
height: auto; /* Автоматическая высота */
|
||||
}
|
||||
|
||||
.latestnews-list {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Планшеты (опционально) */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.endnews-container {
|
||||
height: auto;
|
||||
/* или можно задать другую фиксированную высоту для планшетов */
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимация появления скролла (опционально) */
|
||||
.latestnews-list {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Улучшение для touch-устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.endnews-link {
|
||||
padding: 8px 0; /* Увеличение touch-зоны */
|
||||
}
|
||||
|
||||
.lastnews-item {
|
||||
margin: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
365
src/components/Footer.astro
Normal file
365
src/components/Footer.astro
Normal file
@@ -0,0 +1,365 @@
|
||||
---
|
||||
interface Props {
|
||||
publicationName: string;
|
||||
organization: string;
|
||||
menuItems: Array<{
|
||||
text: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const { publicationName, organization, menuItems } = Astro.props;
|
||||
|
||||
// Получаем текущий год автоматически
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const footerId = `footer-profile`;
|
||||
---
|
||||
|
||||
<footer id={footerId} class="footer">
|
||||
<!-- Свернутое состояние -->
|
||||
<div class="footer__collapsed">
|
||||
<button
|
||||
class="footer__toggle"
|
||||
onclick="toggleFooter(this)"
|
||||
aria-label="Развернуть футер"
|
||||
aria-expanded="false"
|
||||
aria-controls="footer-expanded-${footerId}"
|
||||
>
|
||||
<span class="footer__publication-name">
|
||||
<a class="footer__logo" href="/">
|
||||
<img loading="lazy" class="footer-logo" src="https://cdn.profile.ru/wp-content/themes/profile/assets/img/profil-logo-footer.png" width="136" height="30" alt="">
|
||||
</a>
|
||||
</span>
|
||||
<svg
|
||||
class="footer__arrow"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Стрелка вверх (направлена вверх в свернутом состоянии) -->
|
||||
<path
|
||||
d="M5 12.5L10 7.5L15 12.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<span class="footer__copyright-collapsed">
|
||||
© {currentYear}, {organization}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Раскрытое состояние -->
|
||||
<div
|
||||
id="footer-expanded-${footerId}"
|
||||
class="footer__expanded"
|
||||
hidden
|
||||
>
|
||||
<!-- Меню -->
|
||||
<nav class="footer__menu" aria-label="Дополнительная навигация">
|
||||
<ul class="footer__menu-list">
|
||||
{menuItems.map((item) => (
|
||||
<li class="footer__menu-item" key={item.url}>
|
||||
<a
|
||||
href={item.url}
|
||||
class="footer__menu-link"
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<span class="footer__age">16+</span>
|
||||
|
||||
<!-- Полный копирайт -->
|
||||
<div class="footer__copyright-expanded">
|
||||
|
||||
<p>
|
||||
© {currentYear} {organization}. Все права защищены.
|
||||
Информационное агентство "Деловой журнал "Профиль" зарегистрировано в Федеральной службе по надзору в сфере связи, информационных технологий и массовых коммуникаций. Свидетельство о государственной регистрации серии ИА № ФС 77 - 89668 от 23.06.2025
|
||||
</p>
|
||||
|
||||
<ul class="menu-conf-docs">
|
||||
<li><a href="">Положение об обработке и защите персональных данных</a></li>
|
||||
<li><a href="">Политика конфиденциальности</a></li>
|
||||
<li><a href="">Правила применения рекомендательных технологий</a></li>
|
||||
</ul>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Кнопка сворачивания -->
|
||||
<div class="footer__expanded-bottom">
|
||||
<button
|
||||
class="footer__collapse-btn"
|
||||
onclick="toggleFooter(document.querySelector('#${footerId} .footer__toggle'))"
|
||||
aria-label="Свернуть футер"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Стрелка вниз (направлена вниз в развернутом состоянии) -->
|
||||
<path
|
||||
d="M5 7.5L10 12.5L15 7.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<span>Свернуть</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style is:global>
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #303030;
|
||||
border-top: 2px solid #404040;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -2px 15px rgba(0, 0, 0, 0.3);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Свернутое состояние */
|
||||
.footer__collapsed {
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.footer__toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.footer__publication-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.footer__arrow {
|
||||
transition: transform 0.3s ease;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
/* При развернутом состоянии стрелка поворачивается на 180° (вниз) */
|
||||
.footer__toggle[aria-expanded="true"] .footer__arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.footer__copyright-collapsed {
|
||||
font-size: 0.9rem;
|
||||
color: #cccccc;
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
/* Раскрытое состояние */
|
||||
.footer__expanded {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0 20px;
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.footer__expanded:not([hidden]) {
|
||||
max-height: 400px;
|
||||
opacity: 1;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #404040;
|
||||
}
|
||||
|
||||
.footer__menu {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.footer__menu-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer__menu-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer__menu-link {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.footer__menu-link:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.footer__copyright-expanded {
|
||||
color: #cccccc;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 20px;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.footer__copyright-expanded p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer__expanded-bottom {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer__collapse-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: #ffffff;
|
||||
transition: all 0.2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.footer__collapse-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.menu-conf-docs{
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.footer__age {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border: 2px solid #404040;
|
||||
margin-left: 10px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.footer__toggle {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer__publication-name,
|
||||
.footer__copyright-collapsed {
|
||||
margin: 4px 0;
|
||||
text-align: center;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer__menu-list {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footer__copyright-expanded {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимация для стрелки */
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.footer__toggle:hover .footer__arrow {
|
||||
animation: bounce 0.5s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
function toggleFooter(button) {
|
||||
const footer = button.closest('.footer');
|
||||
const expandedSection = footer.querySelector('.footer__expanded');
|
||||
const isExpanded = button.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
// Обновляем состояние кнопки
|
||||
button.setAttribute('aria-expanded', !isExpanded);
|
||||
button.setAttribute('aria-label', isExpanded ? 'Развернуть футер' : 'Свернуть футер');
|
||||
|
||||
// Показываем/скрываем контент
|
||||
if (isExpanded) {
|
||||
expandedSection.setAttribute('hidden', '');
|
||||
} else {
|
||||
expandedSection.removeAttribute('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
53
src/components/Header/CurrentDate.astro
Normal file
53
src/components/Header/CurrentDate.astro
Normal file
@@ -0,0 +1,53 @@
|
||||
<div class="current-date" id="current-date">
|
||||
<!-- Дата будет обновляться в полночь -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateDate() {
|
||||
const element = document.getElementById('current-date');
|
||||
if (!element) return;
|
||||
|
||||
const today = new Date();
|
||||
const dateStr = today.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
element.textContent = dateStr;
|
||||
}
|
||||
|
||||
// Функция для расчета времени до следующей полночи
|
||||
function getTimeUntilMidnight() {
|
||||
const now = new Date();
|
||||
const midnight = new Date();
|
||||
midnight.setHours(24, 0, 0, 0); // Следующая полночь
|
||||
|
||||
return midnight.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
// Обновляем дату сразу
|
||||
updateDate();
|
||||
|
||||
// Устанавливаем таймер на обновление в полночь
|
||||
const timeUntilMidnight = getTimeUntilMidnight();
|
||||
setTimeout(function() {
|
||||
updateDate();
|
||||
// После первой полночи ставим ежедневное обновление
|
||||
setInterval(updateDate, 24 * 60 * 60 * 1000);
|
||||
}, timeUntilMidnight);
|
||||
|
||||
// Дополнительно: обновляем при возвращении на вкладку после полуночи
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (!document.hidden) {
|
||||
const now = new Date();
|
||||
const lastUpdate = new Date(localStorage.getItem('dateLastUpdate') || 0);
|
||||
|
||||
// Если прошло больше 12 часов с последнего обновления
|
||||
if (now - lastUpdate > 12 * 60 * 60 * 1000) {
|
||||
updateDate();
|
||||
localStorage.setItem('dateLastUpdate', now.toISOString());
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
27
src/components/Header/Header.astro
Normal file
27
src/components/Header/Header.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
|
||||
import Stores from './LazyStores.astro';
|
||||
|
||||
const MENU_ID = 103245;
|
||||
let menuItems = [];
|
||||
|
||||
---
|
||||
|
||||
<header class="header" itemscope itemtype="https://schema.org/WPHeader">
|
||||
|
||||
<div class="top-bar">
|
||||
<img alt="Профиль" width="249" height="55" src="https://cdn.profile.ru/wp-content/themes/profile/assets/img/profile-logo-delovoy.svg">
|
||||
<Stores />
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
|
||||
<style>
|
||||
|
||||
.top-bar{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
</style>
|
||||
76
src/components/Header/LazyStores.astro
Normal file
76
src/components/Header/LazyStores.astro
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
// Иконки загрузятся после DOM
|
||||
---
|
||||
|
||||
<div class="header-stores" id="stores-container">
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
// Ждем полной загрузки DOM
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const container = document.getElementById('stores-container');
|
||||
if (!container) return;
|
||||
|
||||
// Создаем HTML с иконками
|
||||
const storesHTML = `
|
||||
<a class="float-xs-none float-md-left header__store"
|
||||
href="https://apps.apple.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<img loading="lazy"
|
||||
src="https://cdn.profile.ru/wp-content/themes/profile/assets/img/appstore.svg"
|
||||
width="103" height="32"
|
||||
alt="AppStore">
|
||||
</a>
|
||||
<a class="float-xs-none float-md-left header__store"
|
||||
href="https://play.google.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<img loading="lazy"
|
||||
src="https://cdn.profile.ru/wp-content/themes/profile/assets/img/googleplay.png"
|
||||
width="115" height="32"
|
||||
alt="Google Play">
|
||||
</a>
|
||||
<a class="float-xs-none float-md-left header__store"
|
||||
href="https://apps.rustore.ru"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<img loading="lazy"
|
||||
src="https://cdn.profile.ru/wp-content/themes/profile/assets/img/rustore.svg"
|
||||
width="98" height="32"
|
||||
alt="RuStore">
|
||||
</a>
|
||||
`;
|
||||
|
||||
// Вставляем иконки
|
||||
container.innerHTML = storesHTML;
|
||||
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.header-stores {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.header__store {
|
||||
display: inline-block;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.header__store:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.header__store img {
|
||||
display: block;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
0
src/components/Header/MenuBar.astro
Normal file
0
src/components/Header/MenuBar.astro
Normal file
21
src/components/Header/Stores.astro
Normal file
21
src/components/Header/Stores.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="header__social clearfix">
|
||||
<div class="header-stores">
|
||||
<a class="float-xs-none float-md-left header__store" href="https://apps.apple.com/ru/app/id6476872853" target="_blank">
|
||||
<img loading="lazy" src="https://cdn.profile.ru/wp-content/themes/profile/assets/img/appstore.svg" width="103" height="32" alt="AppStore">
|
||||
</a>
|
||||
<a class="float-xs-none float-md-left header__store" href="https://play.google.com/store/apps/details?id=com.profile.magazine" target="_blank">
|
||||
<img loading="lazy" src="https://cdn.profile.ru/wp-content/themes/profile/assets/img/googleplay.png" width="115" height="32" alt="Google Play">
|
||||
</a>
|
||||
<a class="float-xs-none float-md-left header__store" href="https://apps.rustore.ru/app/com.profile.magazine" target="_blank">
|
||||
<img loading="lazy" src="https://cdn.profile.ru/wp-content/themes/profile/assets/img/rustore.svg" width="98" height="32" alt="Google Play">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header-stores{
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
58
src/layouts/MainLayout.astro
Normal file
58
src/layouts/MainLayout.astro
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
|
||||
import Header from '../components/Header/Header.astro';
|
||||
import CurrentDate from '../components/Header/CurrentDate.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
import '../styles/global.css';
|
||||
|
||||
---
|
||||
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>{`${title}`}</title>
|
||||
<meta name="description" content={description}>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header-info">
|
||||
<div class="header__widgets">
|
||||
<CurrentDate />
|
||||
<div class="header__currency">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<Header />
|
||||
<main>
|
||||
<hr>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Footer
|
||||
publicationName="ТехноВестник"
|
||||
organization="Учредитель: ИДР. Все права защищены."
|
||||
menuItems={[
|
||||
{ text: "О нас", url: "/about" },
|
||||
{ text: "Контакты", url: "/contacts" },
|
||||
{ text: "Политика конфиденциальности", url: "/privacy" },
|
||||
{ text: "Архив", url: "/archive" },
|
||||
]}
|
||||
/>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
|
||||
main {
|
||||
width:100%;
|
||||
max-width: 1024px;
|
||||
margin:auto;
|
||||
}
|
||||
</style>
|
||||
24
src/lib/api/endnews.ts
Normal file
24
src/lib/api/endnews.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { fetchGraphQL } from './graphql-client.js';
|
||||
|
||||
interface EndnewsPost {
|
||||
title: string;
|
||||
uri: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export async function getLatestEndnews(count = 12): Promise<EndnewsPost[]> {
|
||||
const query = `
|
||||
query GetEndnews($count: Int!) {
|
||||
endnews(first: $count, where: {orderby: {field: DATE, order: DESC}}) {
|
||||
nodes {
|
||||
title
|
||||
uri
|
||||
date
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchGraphQL(query, { count });
|
||||
return data.endnews?.nodes || [];
|
||||
}
|
||||
42
src/lib/api/graphql-client.ts
Normal file
42
src/lib/api/graphql-client.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// src/lib/api/graphql-client.ts
|
||||
|
||||
export async function fetchGraphQL<T = any>(
|
||||
query: string,
|
||||
variables: Record<string, any> = {}
|
||||
): Promise<T> {
|
||||
const endpoint = import.meta.env.WP_GRAPHQL_ENDPOINT;
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error("WP_GRAPHQL_ENDPOINT is not defined in environment variables");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
const { data, errors } = await response.json();
|
||||
|
||||
if (errors) {
|
||||
console.error("GraphQL Errors:", errors);
|
||||
throw new Error(errors[0]?.message || "GraphQL query failed");
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error("GraphQL request failed:", error.message);
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Unknown error in GraphQL request");
|
||||
}
|
||||
}
|
||||
49
src/lib/api/load-posts.ts
Normal file
49
src/lib/api/load-posts.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/pages/api/load-posts/index.ts
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { after = null, first = 8 } = body;
|
||||
|
||||
// Динамический импорт
|
||||
const { getLatestPosts } = await import('../../../../lib/api/posts.js');
|
||||
|
||||
const { posts, pageInfo } = await getLatestPosts(first, after);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
posts,
|
||||
pageInfo
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading posts:', error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to load posts',
|
||||
posts: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null }
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const prerender = false;
|
||||
49
src/lib/api/load-posts/index.ts
Normal file
49
src/lib/api/load-posts/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/pages/api/load-posts/index.ts
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { after = null, first = 8 } = body;
|
||||
|
||||
// Динамический импорт
|
||||
const { getLatestPosts } = await import('../../../../lib/api/posts.js');
|
||||
|
||||
const { posts, pageInfo } = await getLatestPosts(first, after);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
posts,
|
||||
pageInfo
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading posts:', error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to load posts',
|
||||
posts: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null }
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const prerender = false;
|
||||
50
src/lib/api/menu.ts
Normal file
50
src/lib/api/menu.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { fetchGraphQL } from './graphql-client.js';
|
||||
import type { MenuItem } from '../types/graphql.js';
|
||||
|
||||
|
||||
|
||||
|
||||
// Функция ДЛЯ ГЛАВНОГО МЕНЮ (максимум 5 пунктов)
|
||||
export async function getMainHeaderMenu(): Promise<MenuItem[]> {
|
||||
const query = `
|
||||
query GetMainHeaderMenu {
|
||||
menu(id: "103245", idType: DATABASE_ID) {
|
||||
menuItems(
|
||||
first: 5, # Берем только 5
|
||||
where: {parentId: null} # Только верхний уровень
|
||||
) {
|
||||
nodes {
|
||||
id
|
||||
label
|
||||
url
|
||||
target
|
||||
order
|
||||
cssClasses
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const data = await fetchGraphQL(query);
|
||||
const items = data.menu?.menuItems?.nodes || [];
|
||||
|
||||
// Сортируем по order и гарантируем максимум 5
|
||||
return items
|
||||
.sort((a: MenuItem, b: MenuItem) => a.order - b.order)
|
||||
.slice(0, 5);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки главного меню:', error);
|
||||
|
||||
// Запасной вариант на случай ошибки (ровно 5 пунктов)
|
||||
return [
|
||||
{ id: '1', label: 'Главная', url: '/', order: 0 },
|
||||
{ id: '2', label: 'Каталог', url: '/catalog', order: 1 },
|
||||
{ id: '3', label: 'О нас', url: '/about', order: 2 },
|
||||
{ id: '4', label: 'Контакты', url: '/contacts', order: 3 },
|
||||
{ id: '5', label: 'Блог', url: '/blog', order: 4 }
|
||||
];
|
||||
}
|
||||
}
|
||||
99
src/lib/api/posts.ts
Normal file
99
src/lib/api/posts.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { fetchGraphQL } from './graphql-client.js';
|
||||
import type { ProfileArticle } from '../types/graphql.js';
|
||||
|
||||
// lib/api/posts.js
|
||||
export async function getLatestPosts(first = 12, after = null) {
|
||||
const query = `
|
||||
query GetLatestProfileArticles($first: Int!, $after: String) {
|
||||
profileArticles(
|
||||
first: $first,
|
||||
after: $after,
|
||||
where: {orderby: { field: DATE, order: DESC }}
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
databaseId
|
||||
title
|
||||
uri
|
||||
date
|
||||
featuredImage {
|
||||
node {
|
||||
sourceUrl(size: LARGE)
|
||||
altText
|
||||
}
|
||||
}
|
||||
author {
|
||||
node {
|
||||
id
|
||||
name
|
||||
firstName
|
||||
lastName
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
uri
|
||||
}
|
||||
}
|
||||
categories {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
slug
|
||||
uri
|
||||
databaseId
|
||||
}
|
||||
}
|
||||
tags {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
slug
|
||||
uri
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchGraphQL(query, { first, after });
|
||||
|
||||
// Преобразуем edges в nodes
|
||||
const posts = data.profileArticles?.edges?.map(edge => edge.node) || [];
|
||||
|
||||
return {
|
||||
posts,
|
||||
pageInfo: data.profileArticles?.pageInfo || { hasNextPage: false, endCursor: null }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export interface AnewsPost {
|
||||
title: string;
|
||||
uri: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export async function getLatestAnews(count = 12): Promise<AnewsPost[]> {
|
||||
const query = `
|
||||
query GetAnews($count: Int!) {
|
||||
aNews(first: $count, where: {orderby: {field: DATE, order: DESC}}) {
|
||||
nodes {
|
||||
title
|
||||
uri
|
||||
date
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchGraphQL(query, { count });
|
||||
return data.aNews?.nodes || []; // Исправлено: aNews вместо anews
|
||||
}
|
||||
22
src/lib/graphql-client.js
Normal file
22
src/lib/graphql-client.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export async function fetchGraphQL(query, variables = {}) {
|
||||
const endpoint = import.meta.env.WP_GRAPHQL_ENDPOINT;
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error("WP_GRAPHQL_ENDPOINT is not defined in .env");
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.errors) {
|
||||
console.error("GraphQL error:", json.errors);
|
||||
throw new Error("GraphQL query failed");
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
62
src/lib/types/graphql.ts
Normal file
62
src/lib/types/graphql.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export interface FeaturedImage {
|
||||
node: {
|
||||
sourceUrl: string;
|
||||
altText: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Author {
|
||||
node: {
|
||||
id: string;
|
||||
name: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
avatar: {
|
||||
url: string;
|
||||
};
|
||||
uri: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
uri: string;
|
||||
databaseId: number;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export interface ProfileArticle {
|
||||
id: string;
|
||||
databaseId: number;
|
||||
title: string;
|
||||
uri: string;
|
||||
date: string;
|
||||
content?: string;
|
||||
excerpt?: string;
|
||||
featuredImage: FeaturedImage;
|
||||
author: Author;
|
||||
categories: {
|
||||
nodes: Category[];
|
||||
};
|
||||
tags: {
|
||||
nodes: Tag[];
|
||||
};
|
||||
}
|
||||
|
||||
// Интерфейс для пункта меню
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
target?: string;
|
||||
order: number;
|
||||
cssClasses?: string[];
|
||||
}
|
||||
48
src/lib/wp-api.js
Normal file
48
src/lib/wp-api.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { fetchGraphQL } from "./graphql-client.js";
|
||||
|
||||
export async function getSiteInfo() {
|
||||
const query = `
|
||||
query GetSiteInfo {
|
||||
generalSettings {
|
||||
title
|
||||
description
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchGraphQL(query);
|
||||
|
||||
return data.generalSettings;
|
||||
}
|
||||
|
||||
export async function getLatestPosts(limit = 10) {
|
||||
const query = `
|
||||
query GetLatestProfileArticles($limit: Int!) {
|
||||
profileArticles(
|
||||
first: $limit
|
||||
where: {
|
||||
orderby: { field: DATE, order: DESC }
|
||||
}
|
||||
) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
title
|
||||
uri
|
||||
date
|
||||
featuredImage {
|
||||
node {
|
||||
sourceUrl(size: LARGE)
|
||||
altText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchGraphQL(query, { limit });
|
||||
|
||||
// Используем profileArticles вместо posts
|
||||
return data.profileArticles.nodes;
|
||||
}
|
||||
33
src/pages/index.astro
Normal file
33
src/pages/index.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import { getSiteInfo } from "../lib/wp-api.js";
|
||||
import { getLatestPosts } from '../lib/api/posts.js';
|
||||
|
||||
const site = await getSiteInfo();
|
||||
const initialPosts = await getLatestPosts(36); // Начальная загрузка 12 постов
|
||||
|
||||
// визуальные компоненты
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import ContentGrid from '../components/ContentGrid.astro';
|
||||
import EndnewsList from '../components/EndnewsList.astro';
|
||||
|
||||
export const prerender = {
|
||||
isr: { expiration: 3 } // ISR: обновлять раз в 3 секунды
|
||||
};
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title={site.title}
|
||||
description="Информационное агентство Деловой журнал Профиль"
|
||||
>
|
||||
<h1>{site.title}</h1>
|
||||
{site.description && <p>{site.description}</p>}
|
||||
|
||||
<div class="maimnewsline">
|
||||
<EndnewsList />
|
||||
</div>
|
||||
|
||||
|
||||
<ContentGrid items={initialPosts.posts} />
|
||||
|
||||
|
||||
</MainLayout>
|
||||
370
src/styles/components/ContentGrid.css
Normal file
370
src/styles/components/ContentGrid.css
Normal file
@@ -0,0 +1,370 @@
|
||||
/* styles/components/ContentGrid.css */
|
||||
|
||||
/* Глобальные стили (только для этого компонента) */
|
||||
.posts-section {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.posts-section h2 {
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ОСНОВНОЙ СТИЛЬ: ВСЕ ПЛИТКИ КВАДРАТНЫЕ */
|
||||
.post-card {
|
||||
flex: 1 0 calc(25% - 15px); /* 4 колонки по умолчанию */
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
min-width: 0;
|
||||
aspect-ratio: 1 / 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Большие плитки на десктопе - ТОЖЕ КВАДРАТНЫЕ */
|
||||
@media (min-width: 1200px) {
|
||||
.post-card-large {
|
||||
flex: 0 0 calc(50% - 10px) !important; /* Занимает 2 колонки */
|
||||
aspect-ratio: 2 / 1 !important; /* Ширина в 2 раза больше высоты */
|
||||
}
|
||||
}
|
||||
|
||||
.post-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.post-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.post-image-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.post-card:hover .post-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.post-image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
/* Бейдж рубрики */
|
||||
.post-category-badge {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
max-width: 80%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.post-card-large .post-category-badge {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Оверлей с текстом */
|
||||
.post-content-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20px 15px 15px;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.9) 0%,
|
||||
rgba(0, 0, 0, 0.7) 50%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.post-card-large .post-content-overlay {
|
||||
padding: 25px 20px 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.post-meta-overlay {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.post-date-overlay {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
opacity: 0.9;
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.post-card-large .post-date-overlay {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.post-title-overlay {
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: white;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.post-card-large .post-title-overlay {
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-top: 3px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.post-card-large .author-name {
|
||||
font-size: 13px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Для скринридеров */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Стили для индикаторов загрузки */
|
||||
.loading-indicator {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.no-more-posts {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
border-top: 1px solid #eee;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
|
||||
/* Для ноутбуков: 3 в ряд (без больших плиток) */
|
||||
@media (min-width: 992px) and (max-width: 1199px) {
|
||||
.posts-grid {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
flex: 1 0 calc(33.333% - 14px);
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.post-card-large {
|
||||
flex: 1 0 calc(33.333% - 14px) !important;
|
||||
aspect-ratio: 1 / 1 !important;
|
||||
margin-left: 0 !important;
|
||||
order: initial !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Для планшетов: 2 в ряд */
|
||||
@media (min-width: 768px) and (max-width: 991px) {
|
||||
.posts-grid {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
flex: 1 0 calc(50% - 10px);
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.post-card-large {
|
||||
flex: 1 0 calc(50% - 10px) !important;
|
||||
aspect-ratio: 1 / 1 !important;
|
||||
margin-left: 0 !important;
|
||||
order: initial !important;
|
||||
}
|
||||
|
||||
.post-title-overlay {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Для мобильных: 1 в ряд */
|
||||
@media (max-width: 767px) {
|
||||
.posts-grid {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
flex: 1 0 100%;
|
||||
aspect-ratio: 2 / 1; /* На мобильных горизонтальные плитки */
|
||||
}
|
||||
|
||||
.post-card-large {
|
||||
flex: 1 0 100% !important;
|
||||
aspect-ratio: 2 / 1 !important;
|
||||
margin-left: 0 !important;
|
||||
order: initial !important;
|
||||
}
|
||||
|
||||
.posts-section {
|
||||
padding: 0 15px 15px;
|
||||
}
|
||||
|
||||
.posts-section h2 {
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.post-content-overlay {
|
||||
padding: 15px 12px 12px;
|
||||
}
|
||||
|
||||
.post-title-overlay {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.post-category-badge {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.no-more-posts {
|
||||
padding: 20px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Для очень маленьких экранов */
|
||||
@media (max-width: 480px) {
|
||||
.post-card {
|
||||
aspect-ratio: 3 / 2; /* Немного более вертикальные на маленьких экранах */
|
||||
}
|
||||
|
||||
.post-card-large {
|
||||
aspect-ratio: 3 / 2 !important;
|
||||
}
|
||||
|
||||
.post-content-overlay {
|
||||
padding: 12px 10px 10px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.post-title-overlay {
|
||||
font-size: 14px;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.post-category-badge {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 3px 6px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Стиль для пустого состояния */
|
||||
.no-posts {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
}
|
||||
51
src/styles/global.css
Normal file
51
src/styles/global.css
Normal file
@@ -0,0 +1,51 @@
|
||||
@import './reset.css';
|
||||
@import './components/ContentGrid.css';
|
||||
|
||||
html{
|
||||
font-family: Roboto,sans-serif;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.container{
|
||||
margin: 0 auto;
|
||||
width: 1200px;
|
||||
}
|
||||
|
||||
.header-info{
|
||||
width: 100%;
|
||||
background: #303030;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header__widgets{
|
||||
padding: 0.4375rem 0;
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.current-date{
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.maimnewsline{
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 767px) {
|
||||
|
||||
.container{
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
}
|
||||
100
src/styles/reset.css
Normal file
100
src/styles/reset.css
Normal file
@@ -0,0 +1,100 @@
|
||||
:root {
|
||||
/* CSS Custom Properties */
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'SF Mono', Monaco, 'Cascadia Mono', monospace;
|
||||
--color-bg: #ffffff;
|
||||
--color-text: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Media */
|
||||
img, picture, video, canvas, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
input, button, textarea, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
background: transparent !important;
|
||||
color: #000 !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
}
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@api/*": ["src/lib/api/*"],
|
||||
"@types/*": ["src/lib/types/*"]
|
||||
}
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user