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' +--- + +![Тизер 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) в региональной цене на момент выдачи приза +- Также __отличительная роль__, но чуть круче😁 + +# Тема + +Для первого конкурса мы взяли незамысловатую тему: __зима__.⛄ + +Что для вас значит это слово? +- Праздничная обстановка накануне Рождества или Нового Года? +- Зимняя сказка в тихом лесу? +- Предпраздничный аврал в исследовательском центре Чёрной Мезы? +- Тоскливые панельки в заснеженном городе? + - А может, и не заснеженном, а так, *по-южному*, весь в лужах и грязи, но с новогодними декорациями? + +Мы призываем вас проявить творческий подход в осмыслении этой темы. Не ограничивайтесь устоявшимися шаблонами и существующими текстурами — удивите нас! Главное, чтобы было и глазу приятно, и игралось весело! + +# Правила + + +Правила снизу работают по джентльменскому соглашению: если вы не согласны с правилами или с решениями судей и администрации — то вы можете отказаться от участия в конкурсе, а мы не будем пользоваться вашими трудами, и разойдёмся мы как в море корабли. + + +## Краткие правила + + 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} +
+ {:else if isPostFresh}
{#if isPostUpdated} ОБНОВЛЕНО @@ -99,25 +122,40 @@
- - {#if post.dateChanged} + {#if isPostEventOfInterest} + + {:else} + + {#if post.dateChanged} + + {/if} +
+ + + {blogPostTypeToString(type)} + +
{/if} -
- - - {blogPostTypeToString(type)} - -

{post.title}

@@ -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; diff --git a/src/lib/components/CountdownClock.svelte b/src/lib/components/CountdownClock.svelte new file mode 100644 index 0000000..71c95c9 --- /dev/null +++ b/src/lib/components/CountdownClock.svelte @@ -0,0 +1,99 @@ + + +
+ {#if daysLeft > 0} +
{dateDurationLong.format({ days: daysLeft })}
+ {/if} +
+
{hoursLeft[0]}
+
{hoursLeft[1]}
+
:
+
{minutesLeft[0]}
+
{minutesLeft[1]}
+
:
+
{secondsLeft[0]}
+
{secondsLeft[1]}
+
+
+ + diff --git a/src/lib/components/DateWidget.svelte b/src/lib/components/DateWidget.svelte index 69ab90d..90065a3 100644 --- a/src/lib/components/DateWidget.svelte +++ b/src/lib/components/DateWidget.svelte @@ -1,32 +1,49 @@
- + {#if icon} + + {/if} {dateString - ? new Date(dateString).toLocaleString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric' - }) + ? (showTime ? dateFormatLong : dateFormatShort).format(new Date(dateString)) : 'Не опубликован!'}
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 - : + На данный момент вы можете связаться с администрацией сайта и участниками команды + следующими способами: +
    +
  1. + Через + + нашу гильдию в Discord + +
  2. +
  3. + Через + + публичный канал Tea Sanctuary в Telegram + +
  4. +
  5. + По персональным контактам, указанным на странице + Команда. +
  6. +
-
- Вы также можете ознакомиться с социальными сетями каждого отдельного участника команды - на странице Команда. -
-
\ 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