Compare commits

..

20 commits

Author SHA1 Message Date
746359e93a Merge pull request 'События и первый конкурс' (#6) from feature/events into master
Some checks failed
/ build (push) Failing after 2m54s
/ deploy (push) Has been skipped
Reviewed-on: #6
2025-11-26 11:46:25 +03:00
f0e62d81cd Ладно, выложу ровно в 12 2025-11-26 09:58:35 +03:00
479908db22 Добавил ещё один мета-тег описания (так попросил Lighthouse) 2025-11-21 14:27:49 +03:00
05e6c4cd00 Обновил дату на тизере 2025-11-20 00:09:17 +03:00
528e19a012 В RSS-ленте выводятся даты начала и окончания события 2025-11-17 00:53:08 +03:00
2f255c96c1 Теперь ждём. 2025-11-17 00:28:28 +03:00
62833f1b6e Добавил ограничение частоты проверки в 12 часов на всякий случай 2025-11-17 00:28:19 +03:00
828cba41bd Актуальные события выставлены вперёд списка 2025-11-16 11:31:13 +03:00
d6c3c4990c Увеличил количество последних постов до 5 2025-11-16 10:55:58 +03:00
9b03d0c197 Поддержка событий в карточках блога 2025-11-16 10:55:18 +03:00
17db6d4f3e Добавил упоминание Телеграма на главную страницу 2025-11-16 10:19:41 +03:00
e61e48fb51 Первая версия поста (БЕЗ ДАТЫ! ПОСТАВИТЬ ПЕРЕД СЛИЯНИЕМ ВЕТКИ) 2025-11-16 07:49:44 +03:00
703695ffe5 Панель описания события на странице блога 2025-11-16 06:28:51 +03:00
a6bccea822 Хелперы для работы с датами + polyfill для свежего API 2025 года 2025-11-16 06:26:19 +03:00
b0bd04b8d6 Исправил заголовок у Последних постов 2025-11-14 05:57:26 +03:00
31f7b6498c Добавил ссылку на публичный Telegram канал 2025-11-13 23:05:53 +03:00
d23a7e6940 Переименовал файл из-за странного правила в uBlock
e0a0de9198/advblock/general_block.txt (L411)
2025-11-13 22:20:19 +03:00
db1ad3f9eb Ресурсы блог-поста с конкурсом 2025-11-13 03:51:56 +03:00
3d44c43dbd Убрал лишнее из определений типов 2025-11-13 03:51:25 +03:00
c52fb92c01 Улучшил обработку ссылок на Steam 2025-11-13 03:51:08 +03:00
22 changed files with 685 additions and 93 deletions

View file

@ -10,12 +10,12 @@ module.exports = {
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
ecmaVersion: 2024,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
es2024: true,
node: true
},
rules: {

51
package-lock.json generated
View file

@ -7,6 +7,9 @@
"": {
"name": "teasanctuary-ru",
"version": "0.0.1",
"dependencies": {
"@formatjs/intl-durationformat": "^0.7.6"
},
"devDependencies": {
"@iconify/svelte": "^4.2.0",
"@react2svelte/swipeable": "^0.1.4",
@ -619,6 +622,47 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@formatjs/ecma402-abstract": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "2.2.7",
"@formatjs/intl-localematcher": "0.6.2",
"decimal.js": "^10.4.3",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/fast-memoize": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/intl-durationformat": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.7.6.tgz",
"integrity": "sha512-jatAN3E84X6aP2UOGK1jTrwD1a7BiG3qWUSEDAhtyNd1BgYeS5wQPtXlnuGF1QRx0DjnwwNOIssyd7oQoRlQeg==",
"license": "MIT",
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/intl-localematcher": "0.6.2",
"tslib": "^2.8.0"
}
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -1960,6 +2004,12 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -4097,7 +4147,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-check": {

View file

@ -41,5 +41,8 @@
"typescript": "^5.7.3",
"vite": "^6.2.0"
},
"type": "module"
"type": "module",
"dependencies": {
"@formatjs/intl-durationformat": "^0.7.6"
}
}

20
src/app.d.ts vendored
View file

@ -1,10 +1,18 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
import type {
DurationFormatConstructor,
DurationFormatOptions as _DurationFormatOptions,
DurationInput as _DurationInput,
} from '@formatjs/intl-durationformat/src/types';
declare global {
// rndtrash: Терпим. https://github.com/microsoft/TypeScript/issues/60608
namespace Intl {
const DurationFormat: DurationFormatConstructor;
type DurationFormatOptions = _DurationFormatOptions;
type DurationInput = _DurationInput;
}
namespace App {
interface Route {
label: string;
icon: string;

141
src/blogs/tsmc_1.md Normal file
View file

@ -0,0 +1,141 @@
---
title: 'Mapping Challenge #1'
description: 'Наш первый конкурс для авторов карт под Half-Life Deathmatch!'
date: '2025-11-26 12:00:00 GMT+3'
type: 'event'
dateEventFrom: '2025-11-26 12:00:00 GMT+3'
dateEventTo: '2025-12-26 12:00:00 GMT+3'
projects: ['ts-hldm']
thumbnail: 'poster.webp'
thumbnailAlt: 'Постер TSMC #1: Модель info_player_start пробивается через стену с помощью молота в виде логотипа Valve Hammer Editor'
---
![Тизер TSMC #1: С 26 ноября по 26 декабря 2025 года, Tea Sanctuary Mapping Challenge #1](challenge_teaser.webp)
# Tea Sanctuary Mapping Challenge #1
Добро пожаловать на страницу первого конкурса по левел-дизайну от __Tea Sanctuary__! Ваша задача: за __30 дней__ создать с нуля карту для __Half-Life 1: Deathmatch__ по __зимней теме__. Авторы трёх лучших карт получат главный приз: игру в __Steam__ за __2000 рублей__!💸
На конкурс приглашаются как опытные мододелы, так и новички, желающие опробовать себя в создании карт для любимой игры. __Туториал для новичков__ по использованию редактора [TrenchBroom](https://github.com/TrenchBroom/TrenchBroom/) будет выложен в ближайшее время. Не переключайтесь!
Конкурс проводится с целью привлечения внимания к проектам Tea Sanctuary, и расширению его сообщества. Мы будем рады видеть талантливых участников в наших рядах!
Вне зависимости от успешности этого мероприятия, мы планируем и дальше радовать вас новыми событиями, так что обязательно следите за нашими новостями!📰
# Призы
Для авторов карт, прошедших начальный отбор:
- Карта вместе с ресурсами будет добавлена на наш сервер [Half-Life Deathmatch](https://hl.teasanctuary.ru) в основной и в особый, зимний пул карт.
- В [нашем Discord-сообществе](https://teasanctuary.ru/discord) и [Telegram-чате](https://t.me/tea_sanctuary) вы получите __отличительную роль__
Для авторов трёх лучших карт по выбору жюри и администрации:
- __Игры в [Steam](https://store.steampowered.com/)__ на выбор участника (не более четырёх), доступные в регионе РФ, стоимостью суммарно меньше __двух тысяч рублей__ (₽2000) в региональной цене на момент выдачи приза
- Также __отличительная роль__, но чуть круче😁
# Тема
Для первого конкурса мы взяли незамысловатую тему: __зима__.⛄
Что для вас значит это слово?
- Праздничная обстановка накануне Рождества или Нового Года?
- Зимняя сказка в тихом лесу?
- Предпраздничный аврал в исследовательском центре Чёрной Мезы?
- Тоскливые панельки в заснеженном городе?
- А может, и не заснеженном, а так, *по-южному*, весь в лужах и грязи, но с новогодними декорациями?
Мы призываем вас проявить творческий подход в осмыслении этой темы. Не ограничивайтесь устоявшимися шаблонами и существующими текстурами — удивите нас! Главное, чтобы было и глазу приятно, и игралось весело!
# Правила
<WarningBlock>
Правила снизу работают по <b>джентльменскому соглашению</b>: если вы не согласны с правилами или с решениями судей и администрации &mdash; то вы можете отказаться от участия в конкурсе, а мы не будем пользоваться вашими трудами, и разойдёмся мы как в море корабли.
</WarningBlock>
## Краткие правила
1. Работа над непосредственно картой должна проводиться с __26 ноября 2025 года в 12:00 по Москве__ вплоть до __26 декабря 2025 года в 12:00 по Москве__. Можно дорабатывать карту и дальше, но оценивать мы будем только то, что получили в определённый срок
2. Можно использовать использовать сторонние ресурсы (текстуры, звуки, модели), которыми вы обладаете. Например, купленные с лицензией, встроенные в игру __Half-Life__, либо созданные до или во время проведения конкурса
3. Все части работы, от геометрии карты и ресурсов, до названия работы и имени/псевдонима самого автора, не должны нарушать законы, действующие на территории РФ. Также убедительно просим обойти стороной аморальные темы *(надеюсь, перечислять не придётся)*
4. Работать над картой можно как в соло, так и __командой до 4-х человек__. Один участник может быть автором только одной работы, участвовать в разработке нескольких карт запрещено
5. Отказ от участия и дисквалификация участника запрещает нам, организаторам, использовать его работы в дальнейшем. Отказаться можно __до__ проведения церемонии вознаграждения. _Получил приз и запретил нам использовать свою карту? Ну ты придумал!_
6. Последнее слово всегда за администрацией и членами жюри. Ну это так, чтобы уберечь себя от инцидентов на первом же конкурсе!
------
## Формальные правила
### 1. Основные положения
1. Участие в конкурсе определяется отправкой выполненной работы организаторам конкурса в любом [канале связи](/contact) на выбор конкурсанта
2. Для участия в конкурсе не требуется заблаговременная запись: достаточно отправить работу организаторам до окончания конкурса
3. До проведения оценки работ и церемонии награждения, участник имеет право отказаться от участия в конкурсе и дальнейшего использования его работ в проектах Tea Sanctuary
4. Отправляя свою работу на конкурс, вы соглашаетесь с тем, что ваша работа будет безвозмездно использована в рамках сервера Tea Sanctuary DM и прочих проектов объединения Tea Sanctuary
5. Объединение Tea Sanctuary также имеет право удалить карту со своих ресурсов в случае обнаружения нарушения правил __после__ вознаграждения
6. Судьи и администрация ресурса имеет право отстранить участника от участия в конкурсе, не называя причину. Дисквалификация эквивалентна добровольному отказу от участия (отсутствие вознаграждения, отправленная работа не будет использоваться в проектах и т.д.)
7. Отправляя работу под псевдонимом либо реальным именем, вы разрешаете нам использовать его в наших материалах (объявление победителей и прочие упоминания).
### 2. Требования к работе
1. Работой называется архив формата `.zip` или `.7z`, содержащий результаты трудов конкурсанта, оформленные в специальном формате, либо же файл формата `.bsp`, если участник не использовал дополнительных ресурсов
2. В рамках правил "картой" называется как сам файл карты с расширением `.bsp`, так и сопутствующие ресурсы, требуемые для её работы (файлы текстур, звуки и модели)
3. Работа над картой должна начаться не раньше __26 ноября 2025 года в 12:00 по Москве__
4. Для участия в конкурсе, работа должна быть отправлена организаторам конкурса через доступные каналы связи не позднее __26 декабря 2025 года в 12:00 по Москве__. Допускается продление срока принятия работ, о чём участники будут уведомлены, а данная страница будет обновлена соответствующим образом
5. Организаторы и члены жюри должны иметь возможность загрузить работу участника бесплатно. О проблемах с доступом со стороны организаторов участник должен быть осведомлён. В случае отсутствия решения проблемы с доступом по истечению срока приёма работ, участник будет дисквалифицирован
6. Допускается работа над картой в команде составом не более четырёх (4) человек
7. В период от начала конкурса и до проведения церемонии награждения, автор(ы) обязуются не публиковать выполненную работу на другие конкурсы. Разрешается распространение работы на других ресурсах только при указании того, что она выполнена в рамках конкурса __Tea Sanctuary__. Допускается распространение фото- и видео-материалов, созданных на основе работы. _(например, видео плейтеста с друзьями или скриншот ранней версии)_ Правило не действует после проведения церемонии награждения
8. Все аспекты работы, в том числе псевдоним автора, не должны нарушать законы, действующие на территории РФ, а также не должны никого оскорблять
9. Нежелательно встраивание в работу рекламы _(в виде текста, фигур, звуков и т.д.)_. Приветствуется безвозмездные упоминания автора/ов карты, их творческих коллективов и проектов, а также [коллектива Tea Sanctuary](https://teasanctuary.ru). __Категорически запрещена__ реклама казино, проектов, связанных с криптовалютой, а также запрещённых на территории РФ сервисов. Если не уверены в уместности рекламы, обратитесь к организаторам.
### 3. Технические требования
1. Несоответствие карты техническим требованиям ведёт к дисквалификации участника
2. Непосредственно геометрия уровня (содержимое файла с расширением `.bsp`) должна быть выполнена в объявленных рамках проведения соревнования
3. Автор карты должен иметь право на использование ресурсов (текстур, моделей, звуков). При возникновении подозрений со стороны организаторов, автор обязан предоставить доказательство права на использование
4. Автор не может применять технологии генеративного искусственного интеллекта в работе (как для генерации ресурсов, так и для создания непосредственно самой карты)
5. Рекомендуется разрабатывать карту с рассчётом на то, что на карте могут играть одновременно тридцать два (32) игрока
6. Карта должна работать на последней Steam-версии __Half-Life 1__ на протяжении конкурса
7. Работа не должна вносить изменения в ресурсы игры в частности, в данные игрока в целом, а также каким-либо образом нарушать работу игры, сервера и устройства игрока _(одним словом: эксплоиты)_
8. Организаторы могут незначительно поменять название карты на своё усмотрение в случае возникновения конфликтов с названиями существующих карт
9. Если карта использует дополнительные ресурсы, то автор обязан предоставить файл формата `.res` с перечислением всех использованных файлов _(с целью прекеширования и обеспечения работы FastDL)_
10. Участник не ограничен в выборе инструментов для выполнения работы за исключением тех, что явно запрещены правилами выше.
### 4. Начальный отбор
1. Начальный отбор проводится организаторами конкурса по прошествию даты окончания конкурса
2. Организатор обязан провести начальный отбор работ в течение недели с момента начала этапа
3. В период начального отбора все участники конкурса опрашиваются на подтверждение дальнейшего участия в конкурсе
4. Для проведения отбора берётся последняя версия файлов, отправленная участником до завершения периода приёма работ
5. Начальный отбор проводится по критериям, описанным в пункте __2. Требования к работе__ и __3. Технические требования__
6. Организатор обязан сообщить участнику о факте прохождения, либо непрохождения начального отбора
7. Организатор *может, но не обязан* сообщить участнику причину непрохождения начального отбора.
<InfoBlock>
От <b>rndtrash</b>: одной из такой причин могут быть слова "экспериментальный юмор", которым трудно дать точное определение.
Скажем, это хаотичное нагромождение текстур, звуков, геометрии и мемов с отсылками, и просто троллинг,
который ну никак не клеится в весёлый геймплей.
<br><br>
При всём уважении к далее перечисленным авторам, половина их работ может послужить эталонным примером того, что мы не захотим принять:
<SocialHyperlink href="https://scmapdb.wikidot.com/mapper:keen">CryoKeen</SocialHyperlink>,
<SocialHyperlink href="https://scmapdb.wikidot.com/mapper:robootto">Robootto</SocialHyperlink>
</InfoBlock>
### 5. Оценка работ
1. Этап оценки работ начинается сразу после объявления об окончании начального отбора
2. В оценке работ не могут участвовать лица, отправившие до этого работу на данный конкурс
3. Участники по запросу _(либо в публичных каналах связи)_ могут узнать состав жюри, оценивающих работы, в течение периода оценки работ
4. Процесс оценки проводится взакрытую. Итоговый результат участник может узнать после заверешения церемонии награждения
5. Ранжирование работ и дальнейший выбор призёров проводится на основе общей суммы баллов. Ранжирование проводится до достижения полного консенсуса среди судей и организаторов
6. Критерии оценки для каждой работы следующие: __Соответствие теме__, __Геймплей__, __Визуал__, __Звуковое сопровождение__. Вес каждого критерия не является публичной информацией
7. Судьи обязаны ознакомиться с каждой работой в течение двух недель с начала данного этапа. Если судья не оценил хоть одну из присланных работ &mdash; его мнение не учитывается при оценке ни одной из работ
8. В исключительном случае отсутствия оценок от судей, оценка работ проводится непосредственно организаторами
9. Конечные результаты будут опубликованы публично по окончанию крайнего срока оценки, либо по получению оценок от всех судей.
### 6. Вознаграждение
1. На вознаграждение могут претендовать только участники, прошедшие __начальный отбор__
2. У участников после объявления организаторами призёров есть календарная неделя на то, чтобы связаться с организаторами, договориться о выбранном призе, и получить его. По истечению недели без связи приз автоматически считается вручённым
3. Призёру предлагаются на выбор не более четырёх (4) игр, доступных на площадке Steam от компании Valve. Выбранные игры должны быть покупаемыми с аккаунта, зарегистрированным в РФ, а сумма стоимости всех игр в регионе РФ на момент вручения не должна превышать __две тысячи рублей__ (₽2000)
4. Если работа над картой велась совместно несколькими участниками, то приз можно разделить между сокомандниками. Сумма стоимости игр для всех участников команды также не должна превышать __две тысячи рублей__ (₽2000)

View file

@ -10,7 +10,9 @@
import {
BLOG_POST_FRESHNESS_MILLIS,
blogPostTypeToIcon,
blogPostTypeToString
blogPostTypeToString,
EventStatus,
postEventStatus
} from '$lib/util/Blogs';
import DateWidget from '$lib/components/DateWidget.svelte';
import Icon from '@iconify/svelte';
@ -20,21 +22,26 @@
post,
size = BlogCardSize.Both,
fullHeight = false
}: { post: App.BlogPost; size: BlogCardSize; fullHeight: boolean } = $props();
}: { post: App.BlogPost; size?: BlogCardSize; fullHeight?: boolean } = $props();
const type: App.BlogPostType = post.type ?? 'article';
let isPostNew = $state(false);
let isPostUpdated = $state(false);
let isPostFresh = $derived(isPostNew || isPostUpdated);
// TODO: rndtrash: события и их актуальность
let eventStatus: EventStatus | undefined = $state(undefined);
let isPostEventOfInterest = $derived(
type === 'event' &&
(eventStatus === EventStatus.NotStarted || eventStatus === EventStatus.InProgress)
);
let eventHasStarted = $derived(eventStatus === EventStatus.InProgress);
onMount(() => {
// rndtrash: Выполняем проверки на клиенте, чтобы плашка не скомпилировалась и потом не потеряла актуальность
const dateNow = new Date().valueOf();
isPostNew = dateNow - new Date(post.date!).valueOf() <= BLOG_POST_FRESHNESS_MILLIS;
isPostUpdated =
post.dateChanged != null &&
dateNow - new Date(post.dateChanged).valueOf() <= BLOG_POST_FRESHNESS_MILLIS;
eventStatus = postEventStatus(post);
});
/**
@ -71,7 +78,15 @@
href="/blog/{post.slug}"
class="blog-card
{fullHeight ? 'min-h-full' : ''}
{isPostUpdated ? 'updated' : isPostNew ? 'new' : ''}
{isPostEventOfInterest
? eventHasStarted
? 'eventOngoing'
: 'eventPreStart'
: isPostUpdated
? 'updated'
: isPostNew
? 'new'
: ''}
{shortClass('flex-col justify-baseline')}
{fullClass('flex-row justify-stretch', 'sm:flex-row sm:justify-stretch')}"
>
@ -87,7 +102,15 @@
alt={post.thumbnailAlt ?? 'Миниатюра поста'}
/>
{/if}
{#if isPostFresh}
{#if isPostEventOfInterest}
<div class="toast {fullClass('hidden', 'sm:hidden')}">
{#if eventStatus === EventStatus.NotStarted}
СКОРО НАЧАЛО
{:else}
СОБЫТИЕ
{/if}
</div>
{:else if isPostFresh}
<div class="toast {fullClass('hidden', 'sm:hidden')}">
{#if isPostUpdated}
ОБНОВЛЕНО
@ -99,25 +122,40 @@
</div>
<div class="flex w-full flex-col justify-center p-4 break-words">
<div class="flex flex-row flex-wrap justify-start gap-4 pb-2">
<DateWidget
class={post.dateChanged ? shortClass('hidden', 'not-sm:hidden') : ''}
dateString={post.date}
type="published"
highlight={isPostNew && !isPostUpdated}
/>
{#if post.dateChanged}
{#if isPostEventOfInterest}
<DateWidget
dateString={post.dateChanged}
type="updated"
highlight={isPostUpdated}
class={eventHasStarted ? shortClass('hidden', 'not-sm:hidden') : ''}
dateString={post.dateEventFrom}
type="eventStart"
highlight={!eventHasStarted}
/>
<DateWidget
class={!eventHasStarted ? shortClass('hidden', 'not-sm:hidden') : ''}
dateString={post.dateEventTo}
type="eventFinish"
highlight={eventHasStarted}
/>
{:else}
<DateWidget
class={post.dateChanged ? shortClass('hidden', 'not-sm:hidden') : ''}
dateString={post.date}
type="published"
highlight={isPostNew && !isPostUpdated}
/>
{#if post.dateChanged}
<DateWidget
dateString={post.dateChanged}
type="updated"
highlight={isPostUpdated}
/>
{/if}
<div class="flex items-center gap-2 p-1 text-lg font-bold">
<Icon icon={blogPostTypeToIcon(type)} width={28} height={28} />
<span class={shortClass('hidden', 'not-md:hidden')}>
{blogPostTypeToString(type)}
</span>
</div>
{/if}
<div class="flex items-center gap-2 p-1 text-lg font-bold">
<Icon icon={blogPostTypeToIcon(type)} width={28} height={28} />
<span class={shortClass('hidden', 'not-md:hidden')}>
{blogPostTypeToString(type)}
</span>
</div>
</div>
<h2 class="text-3xl font-bold">{post.title}</h2>
@ -139,7 +177,7 @@
}
&.updated {
box-shadow: 0 0 0 4px var(--color-purple-600);
box-shadow: 0 0 0 calc(var(--spacing) * 1) var(--color-purple-600);
.toast {
@apply bg-purple-600;
@ -147,13 +185,29 @@
}
&.new {
box-shadow: 0 0 0 4px var(--color-amber-600);
box-shadow: 0 0 0 calc(var(--spacing) * 1) var(--color-amber-600);
.toast {
@apply bg-amber-600;
}
}
&.eventPreStart {
box-shadow: 0 0 0 calc(var(--spacing) * 1) var(--color-rose-600);
.toast {
@apply bg-rose-600;
}
}
&.eventOngoing {
box-shadow: 0 0 0 calc(var(--spacing) * 1) var(--color-teal-600);
.toast {
@apply bg-teal-600;
}
}
img.thumbnail {
@apply absolute h-full w-full object-cover transition-transform;

View file

@ -0,0 +1,99 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dateDurationLong } from '$lib/util/Dates';
const FINAL_COUNTDOWN_MILLIS = 15 * 60 * 1000;
let { deadline }: { deadline: Date } = $props();
let deadlineMillis = $derived(deadline.valueOf());
let currentTime = $state(new Date().valueOf());
let timeLeft = $derived(Math.max(deadlineMillis - currentTime, 0));
let isFinalCountdown = $state(false);
$effect(() => {
// HACK: rndtrash: если дата обратного отчёта поменялась, то сбрасываем синхронизацию анимации
if (deadline || true) {
isFinalCountdown = false;
}
});
const padNumber = (n: number) => String(n).padStart(2, '0');
let secondsLeft = $derived(padNumber(Math.floor(timeLeft / 1000) % 60));
let minutesLeft = $derived(padNumber(Math.floor(timeLeft / 1000 / 60) % 60));
let hoursLeft = $derived(padNumber(Math.floor(timeLeft / 1000 / 60 / 60) % 24));
let daysLeft = $derived(Math.floor(timeLeft / 1000 / 60 / 60 / 24));
onMount(() => {
// HACK: rndtrash: NodeJS использует другой тип данных для интервалов
let interval: ReturnType<typeof setInterval> | undefined = undefined;
function updateTime() {
currentTime = new Date().valueOf();
isFinalCountdown = timeLeft <= FINAL_COUNTDOWN_MILLIS;
}
// В случае, если часы пререндернулись сервером, обновляем время вручную.
// Только потом используем специальную функцию, в том числе активирующую анимацию.
currentTime = new Date().valueOf();
interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
});
</script>
<div
class="flex flex-row flex-wrap items-center justify-center gap-2 text-2xl sm:gap-4 sm:text-4xl {isFinalCountdown
? 'final'
: ''}"
>
{#if daysLeft > 0}
<div class="digit digitBlock">{dateDurationLong.format({ days: daysLeft })}</div>
{/if}
<div class="align-center flex flex-row flex-nowrap gap-1 align-middle">
<div class="digit digitBlock">{hoursLeft[0]}</div>
<div class="digit digitBlock">{hoursLeft[1]}</div>
<div class="digit">:</div>
<div class="digit digitBlock">{minutesLeft[0]}</div>
<div class="digit digitBlock">{minutesLeft[1]}</div>
<div class="digit">:</div>
<div class="digit digitBlock">{secondsLeft[0]}</div>
<div class="digit digitBlock">{secondsLeft[1]}</div>
</div>
</div>
<style>
@import '$src/app.css';
.digit {
@apply pt-0.5 font-bold sm:pt-1 sm:pb-0.5;
}
.digitBlock {
/* rndtrash: паддинг снизу по-меньше из-за оптического центра ьуквы */
@apply font-disket rounded-md border-2 border-slate-950 bg-slate-50 px-1.5 text-slate-950 sm:rounded-xl;
box-shadow: 0 2px var(--color-slate-950);
}
@media (prefers-reduced-motion: no-preference) {
.final {
.digitBlock {
animation: finalCountdownText 1s ease-in-out infinite;
}
}
}
@keyframes finalCountdownText {
0% {
color: var(--color-slate-950);
}
1% {
color: var(--color-red-700);
}
50%,
100% {
color: var(--color-slate-950);
}
}
</style>

View file

@ -1,32 +1,49 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { dateFormatLong, dateFormatShort } from '$lib/util/Dates';
let className: string = '';
export { className as class };
let {
dateString,
type = undefined,
highlight = false,
showTime = false,
class: className = ''
}: {
dateString?: string;
type?: 'published' | 'updated' | 'eventStart' | 'eventFinish';
highlight?: boolean;
showTime?: boolean;
class?: string;
} = $props();
export let dateString: string | undefined;
export let type: 'published' | 'updated' = 'published';
export let highlight = false;
const typeToIcon = {
published: 'material-symbols:calendar-today',
updated: 'material-symbols:update',
eventStart: 'material-symbols:rocket-launch',
eventFinish: 'material-symbols:sports-score'
};
const icon =
type == 'published' ? 'material-symbols:calendar-today' : 'material-symbols:update';
const icon = type ? typeToIcon[type] : undefined;
const highlightClasses = (classes: string) => (highlight ? classes : '');
</script>
<div
class="flex flex-nowrap items-center gap-2 p-1 text-lg font-bold {className} rounded-lg
{highlightClasses(type == 'published' ? 'bg-amber-600' : 'bg-purple-600')}
{highlightClasses(
type === 'published' ? 'bg-amber-600' : type === 'updated' ? 'bg-purple-600' : ''
)}
{highlightClasses(
type === 'eventStart' ? 'bg-rose-600' : type === 'eventFinish' ? 'bg-teal-600' : ''
)}
{highlightClasses('text-slate-50')}"
>
<Icon {icon} width={28} height={28} />
{#if icon}
<Icon {icon} width={28} height={28} />
{/if}
<span class="text-nowrap">
{dateString
? new Date(dateString).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
})
? (showTime ? dateFormatLong : dateFormatShort).format(new Date(dateString))
: 'Не опубликован!'}
</span>
</div>

View file

@ -1,20 +1,51 @@
export const THUMBNAIL_DEFAULT = "https://teasanctuary.ru/common/background-day.webp";
export const BLOG_POST_FRESHNESS_MILLIS = 3 * 24 * 60 * 60 * 1000; // 3 дня
export enum EventStatus {
NotEvent = 0,
NotStarted,
InProgress,
IsOver
}
export function postIsEventOfInterest(post: App.BlogPost): boolean {
const status = postEventStatus(post);
return status === EventStatus.NotStarted || status === EventStatus.InProgress;
}
export function postEventStatus(post: App.BlogPost): EventStatus {
if (post.type !== 'event' || post.dateEventFrom === undefined || post.dateEventTo === undefined) {
return EventStatus.NotEvent;
}
const currentTime = new Date().valueOf();
const eventStart = new Date(post.dateEventFrom).valueOf();
if (currentTime < eventStart) return EventStatus.NotStarted;
const eventEnd = new Date(post.dateEventTo).valueOf();
if (currentTime < eventEnd) return EventStatus.InProgress;
return EventStatus.IsOver;
}
export type PostComparer = (a: App.BlogPost, b: App.BlogPost) => number;
export const sortPostsByPostDate: PostComparer = (a, b) => new Date(b.date!).valueOf() - new Date(a.date!).valueOf();
function laterDate(a: string, b?: string): number {
const dateA = new Date(a).valueOf();
if (!b) return dateA;
const dateB = new Date(b).valueOf();
return Math.max(dateA, dateB);
}
export const sortPostsByPostAndUpdateDate: PostComparer = (a, b) => laterDate(b.date!, b.dateChanged) - laterDate(a.date!, a.dateChanged);
function laterDate(a: string, ...dates: (string | undefined)[]): number {
const dateA = new Date(a).valueOf();
if (dates.length <= 0) return dateA;
let max = dateA;
for (const d of dates) {
if (!d) continue;
const date = new Date(d).valueOf();
max = Math.max(max, date);
};
return max;
}
export async function fetchPostsSorted(postComparer?: PostComparer) {
const allPosts = await fetchPosts();

42
src/lib/util/Dates.ts Normal file
View file

@ -0,0 +1,42 @@
import { shouldPolyfill } from '@formatjs/intl-durationformat/should-polyfill'
if (shouldPolyfill()) {
import('@formatjs/intl-durationformat/polyfill-force');
}
export const dateFormatShort = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
});
export const dateFormatLong = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
export const dateDurationLong = new Intl.DurationFormat(undefined, {
style: "long"
});
export const dateDurationLongBackup = new Intl.DurationFormat(undefined, {
style: "long",
seconds: "long"
});
export const durationHumanReadable = (a: Date, b: Date) => durationHumanReadableMillis(b.valueOf() - a.valueOf());
export function durationHumanReadableMillis(dur: number): string {
dur = Math.max(dur, 0);
const seconds = dur / 1000;
const minutes = dur / (1000 * 60);
const hours = dur / (1000 * 60 * 60);
const days = dur / (1000 * 60 * 60 * 24);
const formatObject = { days: Math.floor(days), hours: Math.floor(hours) % 24, minutes: Math.floor(minutes) % 60, seconds: Math.floor(seconds) % 60 };
const result = dateDurationLong.format(formatObject);
// Если на выходе получаем пустую строку, то хоть выведем 0 секунд
return result === '' ? dateDurationLongBackup.format(formatObject) : result;
}

View file

@ -2,6 +2,8 @@
const icons: Record<string, string> = {
none: 'material-symbols:link',
'steamcommunity.com': 'simple-icons:steam',
'steampowered.com': 'simple-icons:steam',
't.me': 'simple-icons:telegram',
'twitter.com': 'simple-icons:x',
'x.com': 'simple-icons:x',
'github.com': 'simple-icons:github',
@ -44,7 +46,9 @@ const specialResolvers: Record<string, (url: URL) => string> = {
// Игнорируем имя пользователя
'bsky.app': (url) => 'bsky.app',
'bsky.social': (url) => 'bsky.social',
'itch.io': (url) => 'itch.io'
'itch.io': (url) => 'itch.io',
'steamcommunity.com': (url) => 'steamcommunity.com',
'steampowered.com': (url) => 'steampowered.com',
}
function getIconFromUrl(url: URL): string | undefined {

View file

@ -36,4 +36,7 @@ __Tea Sanctuary__ &mdash; это также и сообщество едином
Общие вопросы можно задавать в [сообществе Tea Sanctuary](https://teasanctuary.ru/discord).
Там же можете написать личное сообщение администраторам.
Есть и другие способы следить за нашими новостями &mdash; например, [канал в Telegram](https://t.me/tea_sanctuary),
с комментариями и отдельным чатом.
Наши соцсети и почту для более важных обращений можно найти на странице [Контакты](/contact).

View file

@ -25,6 +25,7 @@
property="og:image"
content={page.data.thumbnail ?? 'https://teasanctuary.ru/common/logo.png'}
/>
<meta name="description" content={page.data.description ?? 'Делаем вещи как можем.'} />
<meta property="og:description" content={page.data.description ?? 'Делаем вещи как можем.'} />
</svelte:head>

View file

@ -1,11 +1,24 @@
<script lang="ts">
import SocialButton from '$lib/components/SocialButton.svelte';
import { PUBLIC_TS_DISCORD } from '$env/static/public';
import BlogCard, { BlogCardSize } from '$src/lib/components/BlogCard.svelte';
import { PUBLIC_TS_DISCORD, PUBLIC_TS_TELEGRAM } from '$env/static/public';
import BlogCard, { BlogCardSize } from '$lib/components/BlogCard.svelte';
import { page } from '$app/state';
import type { PageData } from './$types';
import { onMount } from 'svelte';
import { postIsEventOfInterest } from '$lib/util/Blogs';
export let data: PageData;
let { data }: { data: PageData } = $props();
// postIsEventOfInterest(b) ? b.dateEventTo : undefined
let posts: App.BlogPost[] = $state(page.data.posts);
onMount(() => {
const dateToNum = (d: string) => new Date(d).valueOf();
posts = posts.sort(
(a, b) =>
dateToNum(postIsEventOfInterest(b) ? b.dateEventTo! : b.date!) -
dateToNum(postIsEventOfInterest(a) ? a.dateEventTo! : a.date!)
);
});
</script>
<svelte:head>
@ -67,6 +80,7 @@
<div class="flex flex-col items-center justify-start gap-4">
<div class="flex flex-row flex-wrap items-start justify-center gap-4">
<SocialButton class="w-60 shrink-0" href={PUBLIC_TS_DISCORD}>Сообщество</SocialButton>
<SocialButton class="w-60 shrink-0" href={PUBLIC_TS_TELEGRAM}>Канал</SocialButton>
<SocialButton class="w-60 shrink-0" href="https://github.com/TeaSanctuary/">
GitHub
</SocialButton>
@ -84,12 +98,14 @@
</section>
<section class="flex flex-col items-stretch bg-blue-900 pt-4 text-slate-50">
<h1 class="text-center">ПОСЛЕДНИЕ ПОСТЫ</h1>
<h1 class="font-disket text-center text-2xl font-bold sm:text-4xl">ПОСЛЕДНИЕ ПОСТЫ</h1>
<div class="flex flex-row items-stretch justify-evenly gap-4 overflow-x-auto p-4">
{#each page.data.posts as post, i}
{#each posts as post, i}
<div class="aspect-3/2 w-80 shrink-0">
<BlogCard {post} size={BlogCardSize.Short} fullHeight />
{#key post}
<BlogCard {post} size={BlogCardSize.Short} fullHeight />
{/key}
</div>
{/each}
</div>
@ -113,13 +129,13 @@
prose-p:mt-0
prose-p:mb-8
bg-slate-50
px-2
pt-8
pb-4
text-base
text-slate-950
px-2 sm:px-4 sm:text-xl"
text-slate-950 sm:px-4 sm:text-xl"
>
<section class="flex max-w-5xl flex-col flex-nowrap mx-auto">
<section class="mx-auto flex max-w-5xl flex-col flex-nowrap">
<svelte:component this={data.content} />
</section>
</article>

View file

@ -1,6 +1,6 @@
import { fetchPostsSorted, sortPostsByPostAndUpdateDate } from "$src/lib/util/Blogs";
const LATEST_POSTS_COUNT = 3;
const LATEST_POSTS_COUNT = 5;
export async function load() {
let md: any

View file

@ -1,4 +1,4 @@
import { fetchPostsSorted } from "$src/lib/util/Blogs";
import { fetchPostsSorted } from "$lib/util/Blogs";
export async function load() {
return { title: "Блог", description: "Новости и заметки проектов Tea Sanctuary", posts: await fetchPostsSorted() };

View file

@ -1,8 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import SocialHyperlink from '$src/lib/components/SocialHyperlink.svelte';
import InfoBlock from '$src/lib/components/InfoBlock.svelte';
import BlogCard from '$src/lib/components/BlogCard.svelte';
import SocialHyperlink from '$lib/components/SocialHyperlink.svelte';
import InfoBlock from '$lib/components/InfoBlock.svelte';
import BlogCard, { BlogCardSize } from '$lib/components/BlogCard.svelte';
import { postIsEventOfInterest, type PostComparer } from '$lib/util/Blogs';
import { onMount } from 'svelte';
function groupPostsByMonthYear(posts: App.BlogPost[]) {
const groupedPosts = new Map<string, App.BlogPost[]>();
@ -19,7 +21,20 @@
return groupedPosts;
}
function getEventsOfInterest(posts: App.BlogPost[]) {
// Сортировка в обратном порядке - чем ближе к завершению, тем раньше в списке
const sortPostsByEvent: PostComparer = (a, b) =>
new Date(a.dateEventTo!).valueOf() - new Date(b.dateEventTo!).valueOf();
return posts.filter((a) => postIsEventOfInterest(a)).sort(sortPostsByEvent);
}
const groupedPosts = groupPostsByMonthYear(page.data.posts);
let eventPosts: App.BlogPost[] = $state([]);
onMount(() => {
eventPosts = getEventsOfInterest(page.data.posts);
});
</script>
<section class="hero flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4">
@ -39,6 +54,20 @@
пропускать новые посты!
</InfoBlock>
{#if !!eventPosts && eventPosts.length > 0}
<section class="flex flex-col items-stretch bg-amber-50 pt-4">
<h1 class="font-disket text-center text-2xl font-bold sm:text-4xl">АКТУАЛЬНЫЕ СОБЫТИЯ</h1>
<div class="flex flex-row items-stretch justify-evenly gap-4 overflow-x-auto p-4">
{#each eventPosts as post, i}
<div class="aspect-3/2 w-80 shrink-0">
<BlogCard {post} size={BlogCardSize.Short} fullHeight />
</div>
{/each}
</div>
</section>
{/if}
<section class="flex flex-col items-stretch p-2 pb-8 md:p-4">
{#each groupedPosts.entries() as [monthYear, postsInMonthYear]}
<h1

View file

@ -1,12 +1,20 @@
<script lang="ts">
import { page } from '$app/state';
import DateWidget from '$src/lib/components/DateWidget.svelte';
import InfoBlock from '$src/lib/components/InfoBlock.svelte';
import DateWidget from '$lib/components/DateWidget.svelte';
import InfoBlock from '$lib/components/InfoBlock.svelte';
import Icon from '@iconify/svelte';
import type { PageData } from './$types';
import { blogPostTypeToIcon, blogPostTypeToString } from '$src/lib/util/Blogs';
import {
blogPostTypeToIcon,
blogPostTypeToString,
EventStatus,
postEventStatus
} from '$lib/util/Blogs';
import { onMount } from 'svelte';
import { durationHumanReadable } from '$lib/util/Dates';
import CountdownClock from '$lib/components/CountdownClock.svelte';
export let data: PageData;
let { data }: { data: PageData } = $props();
const isPublic = !!data.blogPost.date;
const authors =
@ -16,6 +24,32 @@
? [data.blogPost.authors]
: data.blogPost.authors;
const type: App.BlogPostType = data.blogPost.type ?? 'article';
let eventStatus = $state(EventStatus.NotEvent);
onMount(() => {
// HACK: rndtrash: NodeJS использует другой тип данных для таймаутов
let eventTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
const updateStatus = () => {
eventStatus = postEventStatus(data.blogPost);
if (eventStatus !== EventStatus.NotEvent && eventStatus !== EventStatus.IsOver) {
const endpointString =
eventStatus === EventStatus.NotStarted
? data.blogPost.dateEventFrom!
: data.blogPost.dateEventTo!;
const delay = Math.min(
new Date(endpointString).valueOf() - new Date().valueOf(),
// Из-за ограничения функции setTimeout, будем проверять максимум каждые 12 часов
12 * 60 * 60 * 1000
);
if (delay <= 0) return;
// Плюс пол секунды, чтобы анимация часов успела проиграть
eventTimeout = setTimeout(updateStatus, delay + 500);
}
};
updateStatus();
return () => clearTimeout(eventTimeout);
});
</script>
<base target="_blank" />
@ -63,6 +97,50 @@
{/each}
</section>
{#if type === 'event'}
<section
class="flex shrink-0 flex-col flex-wrap items-center justify-center p-2 font-bold
{eventStatus === EventStatus.NotStarted
? 'bg-amber-200'
: eventStatus === EventStatus.InProgress
? 'bg-green-600 text-slate-50'
: 'bg-slate-200'} gap-2"
>
{#if eventStatus === EventStatus.NotStarted || eventStatus === EventStatus.InProgress}
<span class="text-center text-4xl font-bold">
ДО {eventStatus === EventStatus.NotStarted ? 'НАЧАЛА' : 'КОНЦА'} ОСТАЛОСЬ
</span>
<CountdownClock
deadline={new Date(
eventStatus === EventStatus.NotStarted
? data.blogPost.dateEventFrom!
: data.blogPost.dateEventTo!
)}
/>
{:else if eventStatus === EventStatus.IsOver}
<span class="text-center text-4xl font-bold">СОБЫТИЕ ЗАВЕРШЕНО</span>
{/if}
<span class="flex flex-row flex-wrap items-center justify-center gap-2">
Событие {eventStatus === EventStatus.IsOver ? 'проводилось' : 'проводится'} с
<DateWidget
class="bg-slate-50 px-2 text-slate-950"
dateString={data.blogPost.dateEventFrom}
showTime
/>
по
<DateWidget
class="bg-slate-50 px-2 text-slate-950"
dateString={data.blogPost.dateEventTo}
showTime
/>
({durationHumanReadable(
new Date(data.blogPost.dateEventFrom!),
new Date(data.blogPost.dateEventTo!)
)})
</span>
</section>
{/if}
{#if page.data.blogPost.projects?.length > 0}
<InfoBlock>
<p>В данной заметке упоминаются наши проекты:</p>

View file

@ -1,4 +1,4 @@
import { fetchPostsSorted, resolveBlogPath } from "$src/lib/util/Blogs";
import { EventStatus, fetchPostsSorted, postEventStatus, resolveBlogPath } from "$lib/util/Blogs";
export const prerender = true;
@ -41,6 +41,14 @@ function makeAuthors(post: App.BlogPost): string {
return `\n<author>${authorsString}</author>`;
}
function makeEventDescription(post: App.BlogPost): string {
if (postEventStatus(post) === EventStatus.NotEvent)
return '';
const dateToUtcString = (s: string) => new Date(s).toUTCString();
return `<br><br>Событие проводится с ${dateToUtcString(post.dateEventFrom!)} по ${dateToUtcString(post.dateEventFrom!)}.`;
}
export async function GET({ setHeaders }) {
setHeaders({
'Cache-Control': 'max-age=0, s-maxage=3600',
@ -57,7 +65,7 @@ export async function GET({ setHeaders }) {
<ttl>1800</ttl>
${posts.map((post) => `<item>
<title>${escapeXml(post.title)}</title>
<description><![CDATA[${escapeXml(post.description)}${makeThumbnail(post)}]]></description>${makeAuthors(post)}
<description><![CDATA[${escapeXml(post.description)}${makeEventDescription(post)}${makeThumbnail(post)}]]></description>${makeAuthors(post)}
<guid isPermaLink="true">https://teasanctuary.ru/blog/${post.slug}</guid>
<link>https://teasanctuary.ru/blog/${post.slug}</link>
<pubDate>${(new Date(post.date!)).toUTCString()}</pubDate>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import WarningBlock from '$lib/components/WarningBlock.svelte';
import SocialHyperlink from '$src/lib/components/SocialHyperlink.svelte';
</script>
@ -15,17 +14,31 @@
</div>
</section>
<WarningBlock>Страница находится в разработке!</WarningBlock>
<section class="flex justify-center">
<div
class="flex w-5xl max-w-screen flex-col flex-nowrap gap-12 p-2 px-2 pt-12 pb-12 text-base sm:text-xl"
>
<section>
На данный момент вы можете связаться с администрацией сайта и участниками команды через
<SocialHyperlink href="https://teasanctuary.ru/discord">
нашу гильдию в Discord
</SocialHyperlink>:
На данный момент вы можете связаться с администрацией сайта и участниками команды
следующими способами:
<ol class="list-inside list-decimal">
<li>
Через
<SocialHyperlink href="https://teasanctuary.ru/discord">
нашу гильдию в Discord
</SocialHyperlink>
</li>
<li>
Через
<SocialHyperlink href="https://t.me/tea_sanctuary">
публичный канал Tea Sanctuary в Telegram
</SocialHyperlink>
</li>
<li>
По персональным контактам, указанным на странице
<SocialHyperlink href="/team">Команда</SocialHyperlink>.
</li>
</ol>
</section>
<iframe
src="https://discord.com/widget?id=1176141874390638662&theme=dark"
@ -37,9 +50,5 @@
frameborder="0"
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
></iframe>
<section>
Вы также можете ознакомиться с социальными сетями каждого отдельного участника команды
на странице <SocialHyperlink href="/team">Команда</SocialHyperlink>.
</section>
</div>
</section>

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB