Compare commits

..

No commits in common. "746359e93a0ec7ca626a08642943a66fd99fc996" and "d319ee9528a53aeec34664829f1abbcdb372909b" have entirely different histories.

22 changed files with 89 additions and 681 deletions

View file

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

51
package-lock.json generated
View file

@ -7,9 +7,6 @@
"": { "": {
"name": "teasanctuary-ru", "name": "teasanctuary-ru",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"@formatjs/intl-durationformat": "^0.7.6"
},
"devDependencies": { "devDependencies": {
"@iconify/svelte": "^4.2.0", "@iconify/svelte": "^4.2.0",
"@react2svelte/swipeable": "^0.1.4", "@react2svelte/swipeable": "^0.1.4",
@ -622,47 +619,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2004,12 +1960,6 @@
} }
} }
}, },
"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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -4147,6 +4097,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-check": { "node_modules/type-check": {

View file

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

18
src/app.d.ts vendored
View file

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

View file

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

View file

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

View file

@ -1,51 +1,20 @@
export const THUMBNAIL_DEFAULT = "https://teasanctuary.ru/common/background-day.webp"; export const THUMBNAIL_DEFAULT = "https://teasanctuary.ru/common/background-day.webp";
export const BLOG_POST_FRESHNESS_MILLIS = 3 * 24 * 60 * 60 * 1000; // 3 дня 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 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(); export const sortPostsByPostDate: PostComparer = (a, b) => new Date(b.date!).valueOf() - new Date(a.date!).valueOf();
export const sortPostsByPostAndUpdateDate: PostComparer = (a, b) => laterDate(b.date!, b.dateChanged) - laterDate(a.date!, a.dateChanged);
function laterDate(a: string, ...dates: (string | undefined)[]): number { function laterDate(a: string, b?: string): number {
const dateA = new Date(a).valueOf(); const dateA = new Date(a).valueOf();
if (dates.length <= 0) return dateA; if (!b) return dateA;
let max = dateA; const dateB = new Date(b).valueOf();
for (const d of dates) { return Math.max(dateA, dateB);
if (!d) continue;
const date = new Date(d).valueOf();
max = Math.max(max, date);
};
return max;
} }
export const sortPostsByPostAndUpdateDate: PostComparer = (a, b) => laterDate(b.date!, b.dateChanged) - laterDate(a.date!, a.dateChanged);
export async function fetchPostsSorted(postComparer?: PostComparer) { export async function fetchPostsSorted(postComparer?: PostComparer) {
const allPosts = await fetchPosts(); const allPosts = await fetchPosts();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import SocialHyperlink from '$lib/components/SocialHyperlink.svelte'; import SocialHyperlink from '$src/lib/components/SocialHyperlink.svelte';
import InfoBlock from '$lib/components/InfoBlock.svelte'; import InfoBlock from '$src/lib/components/InfoBlock.svelte';
import BlogCard, { BlogCardSize } from '$lib/components/BlogCard.svelte'; import BlogCard from '$src/lib/components/BlogCard.svelte';
import { postIsEventOfInterest, type PostComparer } from '$lib/util/Blogs';
import { onMount } from 'svelte';
function groupPostsByMonthYear(posts: App.BlogPost[]) { function groupPostsByMonthYear(posts: App.BlogPost[]) {
const groupedPosts = new Map<string, App.BlogPost[]>(); const groupedPosts = new Map<string, App.BlogPost[]>();
@ -21,20 +19,7 @@
return groupedPosts; 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); const groupedPosts = groupPostsByMonthYear(page.data.posts);
let eventPosts: App.BlogPost[] = $state([]);
onMount(() => {
eventPosts = getEventsOfInterest(page.data.posts);
});
</script> </script>
<section class="hero flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4"> <section class="hero flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4">
@ -54,20 +39,6 @@
пропускать новые посты! пропускать новые посты!
</InfoBlock> </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"> <section class="flex flex-col items-stretch p-2 pb-8 md:p-4">
{#each groupedPosts.entries() as [monthYear, postsInMonthYear]} {#each groupedPosts.entries() as [monthYear, postsInMonthYear]}
<h1 <h1
@ -81,4 +52,4 @@
{/each} {/each}
</div> </div>
{/each} {/each}
</section> </section>

View file

@ -1,20 +1,12 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import DateWidget from '$lib/components/DateWidget.svelte'; import DateWidget from '$src/lib/components/DateWidget.svelte';
import InfoBlock from '$lib/components/InfoBlock.svelte'; import InfoBlock from '$src/lib/components/InfoBlock.svelte';
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { import { blogPostTypeToIcon, blogPostTypeToString } from '$src/lib/util/Blogs';
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';
let { data }: { data: PageData } = $props(); export let data: PageData;
const isPublic = !!data.blogPost.date; const isPublic = !!data.blogPost.date;
const authors = const authors =
@ -24,32 +16,6 @@
? [data.blogPost.authors] ? [data.blogPost.authors]
: data.blogPost.authors; : data.blogPost.authors;
const type: App.BlogPostType = data.blogPost.type ?? 'article'; 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> </script>
<base target="_blank" /> <base target="_blank" />
@ -97,50 +63,6 @@
{/each} {/each}
</section> </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} {#if page.data.blogPost.projects?.length > 0}
<InfoBlock> <InfoBlock>
<p>В данной заметке упоминаются наши проекты:</p> <p>В данной заметке упоминаются наши проекты:</p>
@ -171,4 +93,4 @@
sm:text-xl lg:p-8" sm:text-xl lg:p-8"
> >
<svelte:component this={data.content} /> <svelte:component this={data.content} />
</article> </article>

View file

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

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import WarningBlock from '$lib/components/WarningBlock.svelte';
import SocialHyperlink from '$src/lib/components/SocialHyperlink.svelte'; import SocialHyperlink from '$src/lib/components/SocialHyperlink.svelte';
</script> </script>
@ -14,31 +15,17 @@
</div> </div>
</section> </section>
<WarningBlock>Страница находится в разработке!</WarningBlock>
<section class="flex justify-center"> <section class="flex justify-center">
<div <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" 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> <section>
На данный момент вы можете связаться с администрацией сайта и участниками команды На данный момент вы можете связаться с администрацией сайта и участниками команды через
следующими способами: <SocialHyperlink href="https://teasanctuary.ru/discord">
<ol class="list-inside list-decimal"> нашу гильдию в Discord
<li> </SocialHyperlink>:
Через
<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> </section>
<iframe <iframe
src="https://discord.com/widget?id=1176141874390638662&theme=dark" src="https://discord.com/widget?id=1176141874390638662&theme=dark"
@ -50,5 +37,9 @@
frameborder="0" frameborder="0"
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts" sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
></iframe> ></iframe>
<section>
Вы также можете ознакомиться с социальными сетями каждого отдельного участника команды
на странице <SocialHyperlink href="/team">Команда</SocialHyperlink>.
</section>
</div> </div>
</section> </section>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB