add files

This commit is contained in:
Andrey Kuvshinov
2025-12-11 01:12:45 +03:00
commit 22358272c6
31 changed files with 7392 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View 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
View 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

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View File

View 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>

View 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
View 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 || [];
}

View 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
View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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"]
}