diff --git a/.eslintrc.js b/.eslintrc.js
index 477d7a8..1c34b9a 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -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: {
diff --git a/package-lock.json b/package-lock.json
index 5438b0e..6a6592f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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": {
diff --git a/package.json b/package.json
index ee4f7bc..b211b22 100644
--- a/package.json
+++ b/package.json
@@ -41,5 +41,8 @@
"typescript": "^5.7.3",
"vite": "^6.2.0"
},
- "type": "module"
+ "type": "module",
+ "dependencies": {
+ "@formatjs/intl-durationformat": "^0.7.6"
+ }
}
diff --git a/src/app.d.ts b/src/app.d.ts
index 9dfaddb..520d030 100644
--- a/src/app.d.ts
+++ b/src/app.d.ts
@@ -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;
diff --git a/src/blogs/tsmc_1.md b/src/blogs/tsmc_1.md
new file mode 100644
index 0000000..28b19a6
--- /dev/null
+++ b/src/blogs/tsmc_1.md
@@ -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'
+---
+
+
+
+# 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) в региональной цене на момент выдачи приза
+- Также __отличительная роль__, но чуть круче😁
+
+# Тема
+
+Для первого конкурса мы взяли незамысловатую тему: __зима__.⛄
+
+Что для вас значит это слово?
+- Праздничная обстановка накануне Рождества или Нового Года?
+- Зимняя сказка в тихом лесу?
+- Предпраздничный аврал в исследовательском центре Чёрной Мезы?
+- Тоскливые панельки в заснеженном городе?
+ - А может, и не заснеженном, а так, *по-южному*, весь в лужах и грязи, но с новогодними декорациями?
+
+Мы призываем вас проявить творческий подход в осмыслении этой темы. Не ограничивайтесь устоявшимися шаблонами и существующими текстурами — удивите нас! Главное, чтобы было и глазу приятно, и игралось весело!
+
+# Правила
+
+
+Правила снизу работают по джентльменскому соглашению: если вы не согласны с правилами или с решениями судей и администрации — то вы можете отказаться от участия в конкурсе, а мы не будем пользоваться вашими трудами, и разойдёмся мы как в море корабли.
+
+
+## Краткие правила
+
+ 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. Организатор *может, но не обязан* сообщить участнику причину непрохождения начального отбора.
+
+
+От rndtrash: одной из такой причин могут быть слова "экспериментальный юмор", которым трудно дать точное определение.
+Скажем, это хаотичное нагромождение текстур, звуков, геометрии и мемов с отсылками, и просто троллинг,
+который ну никак не клеится в весёлый геймплей.
+
+При всём уважении к далее перечисленным авторам, половина их работ может послужить эталонным примером того, что мы не захотим принять:
+CryoKeen,
+Robootto
+
+
+### 5. Оценка работ
+
+ 1. Этап оценки работ начинается сразу после объявления об окончании начального отбора
+ 2. В оценке работ не могут участвовать лица, отправившие до этого работу на данный конкурс
+ 3. Участники по запросу _(либо в публичных каналах связи)_ могут узнать состав жюри, оценивающих работы, в течение периода оценки работ
+ 4. Процесс оценки проводится взакрытую. Итоговый результат участник может узнать после заверешения церемонии награждения
+ 5. Ранжирование работ и дальнейший выбор призёров проводится на основе общей суммы баллов. Ранжирование проводится до достижения полного консенсуса среди судей и организаторов
+ 6. Критерии оценки для каждой работы следующие: __Соответствие теме__, __Геймплей__, __Визуал__, __Звуковое сопровождение__. Вес каждого критерия не является публичной информацией
+ 7. Судьи обязаны ознакомиться с каждой работой в течение двух недель с начала данного этапа. Если судья не оценил хоть одну из присланных работ — его мнение не учитывается при оценке ни одной из работ
+ 8. В исключительном случае отсутствия оценок от судей, оценка работ проводится непосредственно организаторами
+ 9. Конечные результаты будут опубликованы публично по окончанию крайнего срока оценки, либо по получению оценок от всех судей.
+
+### 6. Вознаграждение
+
+ 1. На вознаграждение могут претендовать только участники, прошедшие __начальный отбор__
+ 2. У участников после объявления организаторами призёров есть календарная неделя на то, чтобы связаться с организаторами, договориться о выбранном призе, и получить его. По истечению недели без связи приз автоматически считается вручённым
+ 3. Призёру предлагаются на выбор не более четырёх (4) игр, доступных на площадке Steam от компании Valve. Выбранные игры должны быть покупаемыми с аккаунта, зарегистрированным в РФ, а сумма стоимости всех игр в регионе РФ на момент вручения не должна превышать __две тысячи рублей__ (₽2000)
+ 4. Если работа над картой велась совместно несколькими участниками, то приз можно разделить между сокомандниками. Сумма стоимости игр для всех участников команды также не должна превышать __две тысячи рублей__ (₽2000)
diff --git a/src/lib/components/BlogCard.svelte b/src/lib/components/BlogCard.svelte
index dd02d40..aa704c9 100644
--- a/src/lib/components/BlogCard.svelte
+++ b/src/lib/components/BlogCard.svelte
@@ -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}
+
+ {#if eventStatus === EventStatus.NotStarted}
+ СКОРО НАЧАЛО
+ {:else}
+ СОБЫТИЕ
+ {/if}
+
diff --git a/src/lib/util/Blogs.ts b/src/lib/util/Blogs.ts
index 5878e26..223ca87 100644
--- a/src/lib/util/Blogs.ts
+++ b/src/lib/util/Blogs.ts
@@ -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();
diff --git a/src/lib/util/Dates.ts b/src/lib/util/Dates.ts
new file mode 100644
index 0000000..bcf9e71
--- /dev/null
+++ b/src/lib/util/Dates.ts
@@ -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;
+}
diff --git a/src/lib/util/LinkResolver.ts b/src/lib/util/LinkResolver.ts
index 9ada1da..2e138d5 100644
--- a/src/lib/util/LinkResolver.ts
+++ b/src/lib/util/LinkResolver.ts
@@ -2,6 +2,8 @@
const icons: Record = {
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> = {
// Игнорируем имя пользователя
'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 {
diff --git a/src/pages/index.md b/src/pages/index.md
index e206d50..ae91af5 100644
--- a/src/pages/index.md
+++ b/src/pages/index.md
@@ -36,4 +36,7 @@ __Tea Sanctuary__ — это также и сообщество едином
Общие вопросы можно задавать в [сообществе Tea Sanctuary](https://teasanctuary.ru/discord).
Там же можете написать личное сообщение администраторам.
+Есть и другие способы следить за нашими новостями — например, [канал в Telegram](https://t.me/tea_sanctuary),
+с комментариями и отдельным чатом.
+
Наши соцсети и почту для более важных обращений можно найти на странице [Контакты](/contact).
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 8afa883..a0b9cab 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -25,6 +25,7 @@
property="og:image"
content={page.data.thumbnail ?? 'https://teasanctuary.ru/common/logo.png'}
/>
+
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index c03fc29..f6024d4 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,11 +1,24 @@
@@ -67,6 +80,7 @@
Сообщество
+ Канал
GitHub
@@ -84,12 +98,14 @@
-
ПОСЛЕДНИЕ ПОСТЫ
+
ПОСЛЕДНИЕ ПОСТЫ
- {#each page.data.posts as post, i}
+ {#each posts as post, i}
-
+ {#key post}
+
+ {/key}
{/each}
@@ -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"
>
-
+
-
\ No newline at end of file
+
diff --git a/src/routes/+page.ts b/src/routes/+page.ts
index e430cc1..a94c88b 100644
--- a/src/routes/+page.ts
+++ b/src/routes/+page.ts
@@ -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
diff --git a/src/routes/blog/+page.server.ts b/src/routes/blog/+page.server.ts
index c5653b5..916e748 100644
--- a/src/routes/blog/+page.server.ts
+++ b/src/routes/blog/+page.server.ts
@@ -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() };
diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte
index da243da..3eed89a 100644
--- a/src/routes/blog/+page.svelte
+++ b/src/routes/blog/+page.svelte
@@ -1,8 +1,10 @@
@@ -39,6 +54,20 @@
пропускать новые посты!
+{#if !!eventPosts && eventPosts.length > 0}
+
+
АКТУАЛЬНЫЕ СОБЫТИЯ
+
+
+ {#each eventPosts as post, i}
+
+
+
+ {/each}
+
+
+{/if}
+
{#each groupedPosts.entries() as [monthYear, postsInMonthYear]}
{/each}
-
\ No newline at end of file
+
diff --git a/src/routes/blog/[slug]/+page.svelte b/src/routes/blog/[slug]/+page.svelte
index 0b0b105..2013493 100644
--- a/src/routes/blog/[slug]/+page.svelte
+++ b/src/routes/blog/[slug]/+page.svelte
@@ -1,12 +1,20 @@
@@ -63,6 +97,50 @@
{/each}
+{#if type === 'event'}
+
+ {#if eventStatus === EventStatus.NotStarted || eventStatus === EventStatus.InProgress}
+
+ ДО {eventStatus === EventStatus.NotStarted ? 'НАЧАЛА' : 'КОНЦА'} ОСТАЛОСЬ
+
+
+ {:else if eventStatus === EventStatus.IsOver}
+ СОБЫТИЕ ЗАВЕРШЕНО
+ {/if}
+
+ Событие {eventStatus === EventStatus.IsOver ? 'проводилось' : 'проводится'} с
+
+ по
+
+ ({durationHumanReadable(
+ new Date(data.blogPost.dateEventFrom!),
+ new Date(data.blogPost.dateEventTo!)
+ )})
+
+
+{/if}
+
{#if page.data.blogPost.projects?.length > 0}
В данной заметке упоминаются наши проекты:
@@ -93,4 +171,4 @@
sm:text-xl lg:p-8"
>
-
\ No newline at end of file
+
diff --git a/src/routes/blog/rss.xml/+server.ts b/src/routes/blog/rss.xml/+server.ts
index 52aa732..ac85b3f 100644
--- a/src/routes/blog/rss.xml/+server.ts
+++ b/src/routes/blog/rss.xml/+server.ts
@@ -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${authorsString}`;
}
+function makeEventDescription(post: App.BlogPost): string {
+ if (postEventStatus(post) === EventStatus.NotEvent)
+ return '';
+
+ const dateToUtcString = (s: string) => new Date(s).toUTCString();
+ return `
Событие проводится с ${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 }) {
1800
${posts.map((post) => `${escapeXml(post.title)}
-${makeAuthors(post)}
+${makeAuthors(post)}
https://teasanctuary.ru/blog/${post.slug}
https://teasanctuary.ru/blog/${post.slug}
${(new Date(post.date!)).toUTCString()}
diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte
index 4b8c87e..92dd560 100644
--- a/src/routes/contact/+page.svelte
+++ b/src/routes/contact/+page.svelte
@@ -1,5 +1,4 @@
@@ -15,17 +14,31 @@
-Страница находится в разработке!
-
- На данный момент вы можете связаться с администрацией сайта и участниками команды через
-
- нашу гильдию в Discord
- :
+ На данный момент вы можете связаться с администрацией сайта и участниками команды
+ следующими способами:
+
+
+ Через
+
+ нашу гильдию в Discord
+
+
+
+ Через
+
+ публичный канал Tea Sanctuary в Telegram
+
+
+
+ По персональным контактам, указанным на странице
+ Команда.
+
+
-
- Вы также можете ознакомиться с социальными сетями каждого отдельного участника команды
- на странице Команда.
-
-
\ No newline at end of file
+
diff --git a/static/blog/tsmc_1/challenge_teaser.webp b/static/blog/tsmc_1/challenge_teaser.webp
new file mode 100644
index 0000000..38c448a
Binary files /dev/null and b/static/blog/tsmc_1/challenge_teaser.webp differ
diff --git a/static/blog/tsmc_1/poster.webp b/static/blog/tsmc_1/poster.webp
new file mode 100644
index 0000000..f0275e2
Binary files /dev/null and b/static/blog/tsmc_1/poster.webp differ