Compare commits
No commits in common. "master" and "feature-blogs" have entirely different histories.
master
...
feature-bl
37 changed files with 1061 additions and 4094 deletions
|
|
@ -10,12 +10,12 @@ module.exports = {
|
|||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2024,
|
||||
ecmaVersion: 2020,
|
||||
extraFileExtensions: ['.svelte']
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2024: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
},
|
||||
rules: {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ jobs:
|
|||
container:
|
||||
image: docker.io/library/node:24
|
||||
steps:
|
||||
- name: Install Git
|
||||
run: apt update && apt install -y git
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -30,7 +33,7 @@ jobs:
|
|||
key: ${{ steps.tsru-npm.outputs.cache-primary-key }}
|
||||
|
||||
- name: Populate the .env file
|
||||
run: printf "PUBLIC_TS_DISCORD=%s\nPUBLIC_TS_TELEGRAM=%s\n" "${{ vars.PUBLIC_TS_DISCORD }}" "${{ vars.PUBLIC_TS_TELEGRAM }}" >> .env
|
||||
run: echo "PUBLIC_TS_DISCORD=${{ vars.PUBLIC_TS_DISCORD }}" >> .env
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
|
|
|
|||
3964
package-lock.json
generated
3964
package-lock.json
generated
File diff suppressed because it is too large
Load diff
53
package.json
53
package.json
|
|
@ -11,42 +11,35 @@
|
|||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/css-svelte": "^1.0.0",
|
||||
"@iconify/json": "^2.2.439",
|
||||
"@iconify/svelte": "^4.2.0",
|
||||
"@react2svelte/swipeable": "^0.1.4",
|
||||
"@svelte-put/dragscroll": "^4.0.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^25.2.3",
|
||||
"@vitejs/plugin-legacy": "^7.2.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.17.3",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.0.9",
|
||||
"@types/node": "^22.13.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"mdsvex": "^0.12.6",
|
||||
"mdsvex-relative-images": "^2.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.50.2",
|
||||
"svelte-check": "^4.3.6",
|
||||
"mdsvex-relative-images": "^1.0.3",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"svelte": "^5.20.4",
|
||||
"svelte-check": "^4.1.4",
|
||||
"svelte-disable-preload": "^0.0.3",
|
||||
"svelte-resize-observer-action": "^0.0.4",
|
||||
"svelte-sitemap": "^2.7.1",
|
||||
"sveltekit-autoimport": "^1.8.2",
|
||||
"svelte-sitemap": "^2.7.0",
|
||||
"sveltekit-autoimport": "^1.8.1",
|
||||
"tailwindcss": "^4.0.9",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"vite": "^7.3.1"
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.10.1"
|
||||
}
|
||||
"type": "module"
|
||||
}
|
||||
|
|
|
|||
26
src/app.d.ts
vendored
26
src/app.d.ts
vendored
|
|
@ -1,27 +1,13 @@
|
|||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="unplugin-icons/types/svelte" />
|
||||
|
||||
import type {
|
||||
DurationFormatConstructor,
|
||||
DurationFormatOptions as _DurationFormatOptions,
|
||||
DurationInput as _DurationInput,
|
||||
} from '@formatjs/intl-durationformat/src/types';
|
||||
import type { Component } from 'svelte';
|
||||
import 'unplugin-icons/types/svelte';
|
||||
|
||||
declare global {
|
||||
// rndtrash: Терпим. https://github.com/microsoft/TypeScript/issues/60608
|
||||
namespace Intl {
|
||||
const DurationFormat: DurationFormatConstructor;
|
||||
type DurationFormatOptions = _DurationFormatOptions;
|
||||
type DurationInput = _DurationInput;
|
||||
}
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
|
||||
interface Route {
|
||||
label: string;
|
||||
icon?: Component | string;
|
||||
icon: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,8 +126,8 @@ export const prerender = true;
|
|||
|
||||
**ДЖЕКПОТ!!! 🎰🎰🎰**
|
||||
|
||||
Дальше дело за малым: я просверлил несколько вентиляционных отверстий, чтобы коробка совсем не прокоптилась (вы бы видели местный охлад...), зарегистрировал её в нашем Forgejo,
|
||||
и проработал план действий. Ввиду несложности сайта, наш [deploy-скрипт](https://git.teasanctuary.ru/TeaSanctuary/teasanctuary.ru/src/branch/master/.forgejo/workflows/deploy.yaml)
|
||||
Дальше дело за малым: я просверлил несколько вентиляционных отверстий, чтобы коробка совсем не прокоптилась (вы бы видели местный охлад...), разрегистрировал её в нашем Forgejo,
|
||||
и проработал план действий. Ввиду простоты сайта, наш [deploy-скрипт](https://git.teasanctuary.ru/TeaSanctuary/teasanctuary.ru/src/branch/master/.forgejo/workflows/deploy.yaml)
|
||||
предельно прост:
|
||||
|
||||
1. Ставим Ноду, клонируем репозиторий, устанавливаем Npm-зависимости
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
---
|
||||
|
||||

|
||||
|
||||
# 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>: если вы не согласны с правилами или с решениями судей и администрации — то вы можете отказаться от участия в конкурсе, а мы не будем пользоваться вашими трудами, и разойдёмся мы как в море корабли.
|
||||
</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. Судьи обязаны ознакомиться с каждой работой в течение двух недель с начала данного этапа. Если судья не оценил хоть одну из присланных работ — его мнение не учитывается при оценке ни одной из работ
|
||||
8. В исключительном случае отсутствия оценок от судей, оценка работ проводится непосредственно организаторами
|
||||
9. Конечные результаты будут опубликованы публично по окончанию крайнего срока оценки, либо по получению оценок от всех судей.
|
||||
|
||||
### 6. Вознаграждение
|
||||
|
||||
1. На вознаграждение могут претендовать только участники, прошедшие __начальный отбор__
|
||||
2. У участников после объявления организаторами призёров есть календарная неделя на то, чтобы связаться с организаторами, договориться о выбранном призе, и получить его. По истечению недели без связи приз автоматически считается вручённым
|
||||
3. Призёру предлагаются на выбор не более четырёх (4) игр, доступных на площадке Steam от компании Valve. Выбранные игры должны быть покупаемыми с аккаунта, зарегистрированным в РФ, а сумма стоимости всех игр в регионе РФ на момент вручения не должна превышать __две тысячи рублей__ (₽2000)
|
||||
4. Если работа над картой велась совместно несколькими участниками, то приз можно разделить между сокомандниками. Сумма стоимости игр для всех участников команды также не должна превышать __две тысячи рублей__ (₽2000)
|
||||
|
|
@ -1 +0,0 @@
|
|||
import "@formatjs/intl-durationformat/polyfill.js";
|
||||
|
|
@ -9,41 +9,24 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
BLOG_POST_FRESHNESS_MILLIS,
|
||||
blogPostTypeToIconComponent,
|
||||
blogPostTypeToString,
|
||||
EventStatus,
|
||||
postEventStatus
|
||||
blogPostTypeToIcon,
|
||||
blogPostTypeToString
|
||||
} from '$lib/util/Blogs';
|
||||
import DateWidget from '$lib/components/DateWidget.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let {
|
||||
post,
|
||||
size = BlogCardSize.Both,
|
||||
fullHeight = false
|
||||
}: { post: App.BlogPost; size?: BlogCardSize; fullHeight?: boolean } = $props();
|
||||
export let post: App.BlogPost;
|
||||
export let size: BlogCardSize = BlogCardSize.Both;
|
||||
export let fullHeight = false;
|
||||
|
||||
const type: App.BlogPostType = $derived(post.type ?? 'article');
|
||||
let isPostNew = $state(false);
|
||||
let isPostUpdated = $state(false);
|
||||
let isPostFresh = $derived(isPostNew || isPostUpdated);
|
||||
let eventStatus: EventStatus | undefined = $state(undefined);
|
||||
let isPostEventOfInterest = $derived(
|
||||
type === 'event' &&
|
||||
(eventStatus === EventStatus.NotStarted || eventStatus === EventStatus.InProgress)
|
||||
);
|
||||
let eventHasStarted = $derived(eventStatus === EventStatus.InProgress);
|
||||
|
||||
const PostTypeIcon = $derived(blogPostTypeToIconComponent(type));
|
||||
|
||||
onMount(() => {
|
||||
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);
|
||||
});
|
||||
const type: App.BlogPostType = post.type ?? 'article';
|
||||
const dateNow = new Date().valueOf();
|
||||
const isPostNew = dateNow - new Date(post.date!).valueOf() <= BLOG_POST_FRESHNESS_MILLIS;
|
||||
const isPostUpdated =
|
||||
post.dateChanged != null &&
|
||||
dateNow - new Date(post.dateChanged).valueOf() <= BLOG_POST_FRESHNESS_MILLIS;
|
||||
const isPostFresh = isPostNew || isPostUpdated;
|
||||
// TODO: rndtrash: события и их актуальность
|
||||
|
||||
/**
|
||||
* rndtrash: пришлось дублировать классы с модификатором и без, потому что Tailwind просто не понимает,
|
||||
|
|
@ -79,15 +62,7 @@
|
|||
href="/blog/{post.slug}"
|
||||
class="blog-card
|
||||
{fullHeight ? 'min-h-full' : ''}
|
||||
{isPostEventOfInterest
|
||||
? eventHasStarted
|
||||
? 'eventOngoing'
|
||||
: 'eventPreStart'
|
||||
: isPostUpdated
|
||||
? 'updated'
|
||||
: isPostNew
|
||||
? 'new'
|
||||
: ''}
|
||||
{isPostUpdated ? 'updated' : isPostNew ? 'new' : ''}
|
||||
{shortClass('flex-col justify-baseline')}
|
||||
{fullClass('flex-row justify-stretch', 'sm:flex-row sm:justify-stretch')}"
|
||||
>
|
||||
|
|
@ -103,15 +78,7 @@
|
|||
alt={post.thumbnailAlt ?? 'Миниатюра поста'}
|
||||
/>
|
||||
{/if}
|
||||
{#if isPostEventOfInterest}
|
||||
<div class="toast {fullClass('hidden', 'sm:hidden')}">
|
||||
{#if eventStatus === EventStatus.NotStarted}
|
||||
СКОРО НАЧАЛО
|
||||
{:else}
|
||||
СОБЫТИЕ
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isPostFresh}
|
||||
{#if isPostFresh}
|
||||
<div class="toast {fullClass('hidden', 'sm:hidden')}">
|
||||
{#if isPostUpdated}
|
||||
ОБНОВЛЕНО
|
||||
|
|
@ -123,40 +90,25 @@
|
|||
</div>
|
||||
<div class="flex w-full flex-col justify-center p-4 break-words">
|
||||
<div class="flex flex-row flex-wrap justify-start gap-4 pb-2">
|
||||
{#if isPostEventOfInterest}
|
||||
<DateWidget
|
||||
class={post.dateChanged ? shortClass('hidden', 'not-sm:hidden') : ''}
|
||||
dateString={post.date}
|
||||
type="published"
|
||||
highlight={isPostNew && !isPostUpdated}
|
||||
/>
|
||||
{#if post.dateChanged}
|
||||
<DateWidget
|
||||
class={eventHasStarted ? shortClass('hidden', 'not-sm:hidden') : ''}
|
||||
dateString={post.dateEventFrom}
|
||||
type="eventStart"
|
||||
highlight={!eventHasStarted}
|
||||
dateString={post.dateChanged}
|
||||
type="updated"
|
||||
highlight={isPostUpdated}
|
||||
/>
|
||||
<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">
|
||||
<PostTypeIcon width={28} height={28} />
|
||||
<span class={shortClass('hidden', 'not-md:hidden')}>
|
||||
{blogPostTypeToString(type)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 p-1 text-lg font-bold">
|
||||
<Icon icon={blogPostTypeToIcon(type)} width={28} height={28} />
|
||||
<span class={shortClass('hidden', 'not-md:hidden')}>
|
||||
{blogPostTypeToString(type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-3xl font-bold">{post.title}</h2>
|
||||
|
|
@ -178,7 +130,7 @@
|
|||
}
|
||||
|
||||
&.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 {
|
||||
@apply bg-purple-600;
|
||||
|
|
@ -186,29 +138,13 @@
|
|||
}
|
||||
|
||||
&.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 {
|
||||
@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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,54 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { dateFormatLong, dateFormatShort } from '$lib/util/Dates';
|
||||
import type { Component } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
import PublishedIcon from '~icons/material-symbols/calendar-today';
|
||||
import UpdatedIcon from '~icons/material-symbols/update';
|
||||
import EventStartIcon from '~icons/material-symbols/rocket-launch';
|
||||
import EventFinishIcon from '~icons/material-symbols/sports-score';
|
||||
let className: string = '';
|
||||
export { className as class };
|
||||
|
||||
let {
|
||||
dateString,
|
||||
type = undefined,
|
||||
highlight = false,
|
||||
showTime = false,
|
||||
class: className = ''
|
||||
}: {
|
||||
dateString?: string;
|
||||
type?: 'published' | 'updated' | 'eventStart' | 'eventFinish';
|
||||
highlight?: boolean;
|
||||
showTime?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
export let dateString: string | undefined;
|
||||
export let type: 'published' | 'updated' = 'published';
|
||||
export let highlight = false;
|
||||
|
||||
const typeToIcon = {
|
||||
published: PublishedIcon,
|
||||
updated: UpdatedIcon,
|
||||
eventStart: EventStartIcon,
|
||||
eventFinish: EventFinishIcon
|
||||
};
|
||||
|
||||
const IconComponent: Component | undefined = $derived(type ? typeToIcon[type] : undefined);
|
||||
const icon =
|
||||
type == 'published' ? 'material-symbols:calendar-today' : 'material-symbols:update';
|
||||
|
||||
const highlightClasses = (classes: string) => (highlight ? classes : '');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-nowrap items-center gap-2 p-1 text-lg font-bold {className} rounded-lg
|
||||
{highlightClasses(
|
||||
type === 'published' ? 'bg-amber-600' : type === 'updated' ? 'bg-purple-600' : ''
|
||||
)}
|
||||
{highlightClasses(
|
||||
type === 'eventStart' ? 'bg-rose-600' : type === 'eventFinish' ? 'bg-teal-600' : ''
|
||||
)}
|
||||
{highlightClasses(type == 'published' ? 'bg-amber-600' : 'bg-purple-600')}
|
||||
{highlightClasses('text-slate-50')}"
|
||||
>
|
||||
{#if IconComponent}
|
||||
<IconComponent width={28} height={28} />
|
||||
{/if}
|
||||
<Icon {icon} width={28} height={28} />
|
||||
<span class="text-nowrap">
|
||||
{dateString
|
||||
? (showTime ? dateFormatLong : dateFormatShort).format(new Date(dateString))
|
||||
? new Date(dateString).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
: 'Не опубликован!'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,12 @@
|
|||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let {
|
||||
class: klass = '',
|
||||
src,
|
||||
alt = '',
|
||||
size = 32,
|
||||
black = false
|
||||
}: {
|
||||
class?: string;
|
||||
src?: Component | string;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
black?: boolean;
|
||||
} = $props();
|
||||
|
||||
const IconComponent: Component | undefined = $derived(
|
||||
typeof src === 'function' ? src : undefined
|
||||
);
|
||||
let className: string = '';
|
||||
export { className as class };
|
||||
export let src: string | null = null;
|
||||
export let alt: string | undefined = undefined;
|
||||
export let size: number = 32;
|
||||
export let black: boolean = false;
|
||||
|
||||
function isUrl(src?: string) {
|
||||
return src?.startsWith('/') || src?.startsWith('http');
|
||||
|
|
@ -25,14 +14,16 @@
|
|||
</script>
|
||||
|
||||
<span
|
||||
class="{klass} {black ? 'fill-slate-950' : 'fill-slate-50'} hover-icon"
|
||||
class="{className} {black ? 'fill-slate-950' : 'fill-slate-50'} hover-icon"
|
||||
style:width="{size}px"
|
||||
style:height="{size}px"
|
||||
>
|
||||
{#if IconComponent}
|
||||
<IconComponent width={size} height={size} color={black ? '#020618' : '#f8fafc'} />
|
||||
{:else if typeof src === 'string' && isUrl(src)}
|
||||
<img {src} {alt} width={size} height={size} />
|
||||
{#if src}
|
||||
{#if isUrl(src)}
|
||||
<img {src} {alt} width={size} height={size} />
|
||||
{:else}
|
||||
<Icon width={size} height={size} icon={src} color={black ? '#020618' : '#f8fafc'} />
|
||||
{/if}
|
||||
{:else}
|
||||
{alt ?? 'Без иконки'}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,18 @@
|
|||
<script lang="ts">
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let {
|
||||
icon: IconComponent,
|
||||
bgStrong,
|
||||
bgBleak,
|
||||
caption,
|
||||
children
|
||||
}: {
|
||||
icon: Component;
|
||||
bgStrong: string;
|
||||
bgBleak: string;
|
||||
caption: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
export let bgStrong: string;
|
||||
export let bgBleak: string;
|
||||
export let icon: string;
|
||||
export let caption: string;
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col sm:flex-row">
|
||||
<div class="flex flex-row items-center gap-2 {bgStrong} p-2 text-slate-50">
|
||||
<IconComponent width={32} height={32} color={'#f8fafc'} />
|
||||
<Icon width={32} height={32} {icon} color={'#f8fafc'} />
|
||||
<span class="sm:hidden">{caption}</span>
|
||||
</div>
|
||||
<div class="{bgBleak} p-4 sm:grow">
|
||||
{@render children()}
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import IconBlock from './IconBlock.svelte';
|
||||
import InfoIcon from '~icons/material-symbols/info';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<IconBlock bgStrong="bg-blue-500" bgBleak="bg-blue-50" icon={InfoIcon} caption="Примечание">
|
||||
{@render children()}
|
||||
<IconBlock
|
||||
bgStrong="bg-blue-500"
|
||||
bgBleak="bg-blue-50"
|
||||
icon="material-symbols:info"
|
||||
caption="Обратите внимание"
|
||||
>
|
||||
<slot />
|
||||
</IconBlock>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import HoverIcon from '$lib/components/HoverIcon.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { routes }: { routes: App.Route[] } = $props();
|
||||
export let routes: App.Route[];
|
||||
|
||||
function isActive(route: string): boolean {
|
||||
if (route === '/') return page.url.pathname === route;
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
</script>
|
||||
|
||||
<nav
|
||||
class="flex shrink-0 flex-row gap-2 bg-slate-100 not-landscape:justify-around not-landscape:overflow-x-auto not-landscape:sm:px-2 landscape:flex-col landscape:overflow-y-auto landscape:sm:py-2"
|
||||
class="flex shrink-0 flex-row gap-2 bg-slate-100 not-landscape:justify-around not-landscape:overflow-x-auto not-landscape:px-2 landscape:flex-col landscape:overflow-y-auto landscape:py-2"
|
||||
>
|
||||
{#each routes as route (route.href)}
|
||||
<a class="nav-button {isActive(route.href) ? 'active' : ''}" href={route.href}>
|
||||
|
|
@ -36,10 +36,10 @@
|
|||
@import '$src/app.css';
|
||||
|
||||
.nav-button {
|
||||
@apply flex aspect-square shrink-0 flex-col items-center justify-center gap-0.5 p-1 text-slate-950 not-landscape:h-20 hover:bg-emerald-400 sm:p-2 not-landscape:sm:h-24 landscape:w-20 landscape:sm:w-24;
|
||||
@apply flex aspect-square shrink-0 flex-col items-center justify-center gap-0.5 p-2 text-slate-950 not-landscape:h-24 hover:bg-emerald-400 landscape:w-24;
|
||||
|
||||
> .contour {
|
||||
@apply rounded-full px-1 py-0.5 text-center sm:px-1.5;
|
||||
@apply rounded-full px-1.5 py-0.5 text-center;
|
||||
}
|
||||
|
||||
&.active {
|
||||
|
|
|
|||
|
|
@ -1,29 +1,27 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { isLinkLocal, tryGetIcon } from '$lib/util/LinkResolver';
|
||||
import HoverIcon from '$lib/components/HoverIcon.svelte';
|
||||
import HoverIcon from './HoverIcon.svelte';
|
||||
|
||||
let {
|
||||
href,
|
||||
class: klass = '',
|
||||
customIcon = null,
|
||||
children
|
||||
}: { href: string; class?: string; customIcon?: string | null; children: Snippet } = $props();
|
||||
|
||||
const iconSrc = $derived(customIcon ?? tryGetIcon(href));
|
||||
export let href: string;
|
||||
let className: string = '';
|
||||
export { className as class };
|
||||
export let customIcon: string | null = null;
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="{klass} flex flex-row drop-shadow-2xl transition-all hover:scale-110"
|
||||
class="{className} flex flex-row drop-shadow-2xl transition-all hover:scale-110"
|
||||
target={isLinkLocal(href) ? '_self' : '_blank'}
|
||||
>
|
||||
<div class="shrink-0 rounded-l-xl bg-slate-800 p-2">
|
||||
<HoverIcon src={iconSrc} class="text-sm uppercase" />
|
||||
<HoverIcon src={customIcon ?? tryGetIcon(href)} class="text-sm uppercase" />
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 grow flex-nowrap items-center justify-center rounded-r-xl bg-slate-100 p-2 text-2xl text-nowrap text-slate-950"
|
||||
>
|
||||
{@render children()}
|
||||
<slot />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,25 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
import HoverIcon from './HoverIcon.svelte';
|
||||
import { isLinkLocal, tryGetIcon } from '$lib/util/LinkResolver';
|
||||
|
||||
let {
|
||||
href,
|
||||
class: klass = '',
|
||||
customIcon = null,
|
||||
children
|
||||
}: { href: string; class?: string; customIcon?: string | null; children: Snippet } = $props();
|
||||
|
||||
const iconSrc = $derived(customIcon ?? tryGetIcon(href));
|
||||
export let href: string;
|
||||
let className: string = '';
|
||||
export { className as class };
|
||||
export let customIcon: string | null = null;
|
||||
|
||||
const sm = new MediaQuery('width >= 40rem', false);
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="{klass} group inline-block no-underline"
|
||||
target={isLinkLocal(href) ? '_self' : '_blank'}
|
||||
>
|
||||
<a {href} class="{className} group inline-block no-underline" target={isLinkLocal(href) ? '_self' : '_blank'}>
|
||||
<span
|
||||
class="inline-block size-6 rounded-sm bg-emerald-800 p-0.5 align-bottom transition-all group-hover:scale-110 sm:size-8 sm:rounded-xl sm:p-1"
|
||||
>
|
||||
<HoverIcon src={iconSrc} size={sm.current ? 24 : 20} />
|
||||
<HoverIcon src={customIcon ?? tryGetIcon(href)} size={sm.current ? 24 : 20} />
|
||||
</span>
|
||||
<span class="text-emerald-900 underline">
|
||||
{@render children()}
|
||||
<slot />
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<style></style>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import IconBlock from './IconBlock.svelte';
|
||||
import WarningIcon from '~icons/material-symbols/warning';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<IconBlock bgStrong="bg-yellow-500" bgBleak="bg-yellow-50" icon={WarningIcon} caption="Внимание">
|
||||
{@render children()}
|
||||
<IconBlock
|
||||
bgStrong="bg-yellow-500"
|
||||
bgBleak="bg-yellow-50"
|
||||
icon="material-symbols:warning"
|
||||
caption="Внимание"
|
||||
>
|
||||
<slot />
|
||||
</IconBlock>
|
||||
|
|
|
|||
|
|
@ -1,53 +1,20 @@
|
|||
import type { Component } from "svelte";
|
||||
|
||||
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();
|
||||
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();
|
||||
if (dates.length <= 0) return dateA;
|
||||
if (!b) 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;
|
||||
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);
|
||||
|
||||
export async function fetchPostsSorted(postComparer?: PostComparer) {
|
||||
const allPosts = await fetchPosts();
|
||||
|
||||
|
|
@ -99,20 +66,16 @@ const bptToString: Record<App.BlogPostType, string> = {
|
|||
'update': 'Обновление'
|
||||
};
|
||||
|
||||
import ArticleIcon from '~icons/material-symbols/article';
|
||||
import EventIcon from '~icons/material-symbols/event';
|
||||
import UpdateIcon from '~icons/material-symbols/autorenew';
|
||||
|
||||
const bptToIcon: Record<App.BlogPostType, Component> = {
|
||||
'article': ArticleIcon,
|
||||
'event': EventIcon,
|
||||
'update': UpdateIcon
|
||||
const bptToIcon: Record<App.BlogPostType, string> = {
|
||||
'article': 'material-symbols:article',
|
||||
'event': 'material-symbols:event',
|
||||
'update': 'material-symbols:autorenew'
|
||||
};
|
||||
|
||||
export function blogPostTypeToString(type: App.BlogPostType): string {
|
||||
return bptToString[type] ?? bptToString['article'];
|
||||
}
|
||||
|
||||
export function blogPostTypeToIconComponent(type: App.BlogPostType): Component {
|
||||
export function blogPostTypeToIcon(type: App.BlogPostType): string {
|
||||
return bptToIcon[type] ?? bptToIcon['article'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,54 +1,32 @@
|
|||
import type { Component } from "svelte";
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
import DefaultLinkIcon from '~icons/material-symbols/link';
|
||||
import SteamIcon from '~icons/simple-icons/steam';
|
||||
import TelegramIcon from '~icons/simple-icons/telegram';
|
||||
import TwitterIcon from '~icons/simple-icons/x';
|
||||
import GitHubIcon from '~icons/simple-icons/github';
|
||||
import YouTubeIcon from '~icons/simple-icons/youtube';
|
||||
import ItchIoIcon from '~icons/simple-icons/itchdotio';
|
||||
import DiscordIcon from '~icons/simple-icons/discord';
|
||||
import GameBananaIcon from '~icons/simple-icons/gamebanana';
|
||||
import BlueSkyIcon from '~icons/simple-icons/bluesky';
|
||||
import HamsterIcon from '~icons/fluent-emoji-high-contrast/hamster';
|
||||
import GitIcon from '~icons/devicon-plain/git';
|
||||
import EmailIcon from '~icons/material-symbols/alternate-email';
|
||||
import RssIcon from '~icons/material-symbols/rss-feed';
|
||||
|
||||
type LinkIcon = Component | string;
|
||||
|
||||
// TODO: чашки на иконках выглядят страшненько, пока что пусть лучше будет голая ссылка
|
||||
const icons: Record<string, LinkIcon> = {
|
||||
none: DefaultLinkIcon,
|
||||
'steamcommunity.com': SteamIcon,
|
||||
'steampowered.com': SteamIcon,
|
||||
't.me': TelegramIcon,
|
||||
'twitter.com': TwitterIcon,
|
||||
'x.com': TwitterIcon,
|
||||
'github.com': GitHubIcon,
|
||||
'youtube.com': YouTubeIcon,
|
||||
'itch.io': ItchIoIcon,
|
||||
'discord.com': DiscordIcon,
|
||||
'discord.gg': DiscordIcon,
|
||||
'gamebanana.com': GameBananaIcon,
|
||||
'bsky.app': BlueSkyIcon,
|
||||
'bsky.social': BlueSkyIcon,
|
||||
const icons: Record<string, string> = {
|
||||
none: 'material-symbols:link',
|
||||
'steamcommunity.com': 'simple-icons:steam',
|
||||
'twitter.com': 'simple-icons:x',
|
||||
'x.com': 'simple-icons:x',
|
||||
'github.com': 'simple-icons:github',
|
||||
'youtube.com': 'simple-icons:youtube',
|
||||
'itch.io': 'simple-icons:itchdotio',
|
||||
'discord.com': 'simple-icons:discord',
|
||||
'discord.gg': 'simple-icons:discord',
|
||||
'gamebanana.com': 'simple-icons:gamebanana',
|
||||
'bsky.app': 'simple-icons:bluesky',
|
||||
'bsky.social': 'simple-icons:bluesky',
|
||||
// https://хамяк.рф
|
||||
'xn--80auf8a2c.xn--p1ai': HamsterIcon,
|
||||
'xn--80auf8a2c.xn--p1ai': 'fluent-emoji-high-contrast:hamster',
|
||||
// 'teasanctuary.ru': '/icons/tea-sanctuary-white.svg',
|
||||
'hl.teasanctuary.ru': '/icons/half-life.svg',
|
||||
'git.teasanctuary.ru': GitIcon,
|
||||
'git.teasanctuary.ru': 'devicon-plain:git',
|
||||
// localhost: '/icons/tea-sanctuary-white.svg',
|
||||
email: EmailIcon,
|
||||
rss: RssIcon
|
||||
email: 'material-symbols:alternate-email',
|
||||
rss: 'material-symbols:rss-feed'
|
||||
};
|
||||
|
||||
// Особые случаи, когда одним доменом второго уровня не ограничишься (например, randomtrash.itch.io)
|
||||
const specialResolvers: Record<string, (url: URL) => string> = {
|
||||
'teasanctuary.ru': (url) => {
|
||||
// Домены третьего уровня и выше
|
||||
const prefix = url.hostname.split('.').reverse();
|
||||
const prefix = url.hostname.split('.').toReversed();
|
||||
prefix.shift();
|
||||
prefix.shift();
|
||||
if (prefix[0] === "hl") {
|
||||
|
|
@ -66,14 +44,10 @@ const specialResolvers: Record<string, (url: URL) => string> = {
|
|||
// Игнорируем имя пользователя
|
||||
'bsky.app': (url) => 'bsky.app',
|
||||
'bsky.social': (url) => 'bsky.social',
|
||||
'itch.io': (url) => 'itch.io',
|
||||
'steamcommunity.com': (url) => 'steamcommunity.com',
|
||||
'steampowered.com': (url) => 'steampowered.com',
|
||||
'itch.io': (url) => 'itch.io'
|
||||
}
|
||||
|
||||
function getIconNameFromUrl(url?: URL): string | undefined {
|
||||
if (!url) return undefined;
|
||||
|
||||
function getIconFromUrl(url: URL): string | undefined {
|
||||
const href = url.href;
|
||||
if (href.startsWith('mailto:'))
|
||||
return 'email';
|
||||
|
|
@ -88,16 +62,15 @@ function getIconNameFromUrl(url?: URL): string | undefined {
|
|||
return hostname;
|
||||
}
|
||||
|
||||
const baseURI = browser ? document.baseURI : 'https://teasanctuary.ru';
|
||||
export function tryGetIcon(link: string): LinkIcon {
|
||||
export function tryGetIcon(link: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(link, baseURI);
|
||||
url = new URL(link, document.baseURI);
|
||||
} catch {
|
||||
return icons['none'];
|
||||
}
|
||||
|
||||
return icons[getIconNameFromUrl(url) ?? ''] ?? icons['none'];
|
||||
return icons[getIconFromUrl(url) ?? ''] ?? icons['none'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -36,7 +36,4 @@ __Tea Sanctuary__ — это также и сообщество едином
|
|||
Общие вопросы можно задавать в [сообществе Tea Sanctuary](https://teasanctuary.ru/discord).
|
||||
Там же можете написать личное сообщение администраторам.
|
||||
|
||||
Есть и другие способы следить за нашими новостями — например, [канал в Telegram](https://t.me/tea_sanctuary),
|
||||
с комментариями и отдельным чатом.
|
||||
|
||||
Наши соцсети и почту для более важных обращений можно найти на странице [Контакты](/contact).
|
||||
|
|
|
|||
|
|
@ -26,3 +26,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@import "$src/app.css";
|
||||
</style>
|
||||
|
|
@ -3,42 +3,13 @@
|
|||
import { page } from '$app/state';
|
||||
import '$src/syntax-highlight.css'; // https://github.com/PrismJS/prism-themes
|
||||
import NavBar from '$lib/components/NavBar.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
import TeaIcon from '~icons/material-symbols/emoji-food-beverage';
|
||||
import TeamIcon from '~icons/material-symbols/groups';
|
||||
import BlogIcon from '~icons/material-symbols/newspaper';
|
||||
import ProjectsIcon from '~icons/material-symbols/work';
|
||||
import ContactsIcon from '~icons/material-symbols/chat';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
const routes: App.Route[] = [
|
||||
{
|
||||
label: 'главная',
|
||||
icon: TeaIcon,
|
||||
href: '/'
|
||||
},
|
||||
{
|
||||
label: 'команда',
|
||||
icon: TeamIcon,
|
||||
href: '/team'
|
||||
},
|
||||
{
|
||||
label: 'блог',
|
||||
icon: BlogIcon,
|
||||
href: '/blog'
|
||||
},
|
||||
{
|
||||
label: 'проекты',
|
||||
icon: ProjectsIcon,
|
||||
href: '/projects'
|
||||
},
|
||||
{
|
||||
label: 'контакты',
|
||||
icon: ContactsIcon,
|
||||
href: '/contact'
|
||||
}
|
||||
{ label: 'главная', icon: 'material-symbols:emoji-food-beverage', href: '/' },
|
||||
{ label: 'команда', icon: 'material-symbols:groups', href: '/team' },
|
||||
{ label: 'блог', icon: 'material-symbols:newspaper', href: '/blog' },
|
||||
{ label: 'проекты', icon: 'material-symbols:work', href: '/projects' },
|
||||
{ label: 'контакты', icon: 'material-symbols:chat', href: '/contact' }
|
||||
];
|
||||
</script>
|
||||
|
||||
|
|
@ -54,7 +25,6 @@
|
|||
property="og:image"
|
||||
content={page.data.thumbnail ?? 'https://teasanctuary.ru/common/logo.png'}
|
||||
/>
|
||||
<meta name="description" content={page.data.description ?? 'Делаем вещи как можем.'} />
|
||||
<meta property="og:description" content={page.data.description ?? 'Делаем вещи как можем.'} />
|
||||
</svelte:head>
|
||||
|
||||
|
|
@ -62,7 +32,7 @@
|
|||
<NavBar {routes} />
|
||||
<div class="flex grow-1 flex-col overflow-auto">
|
||||
<div class="relative grow-1">
|
||||
{@render children()}
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<footer class="bg-emerald-950">
|
||||
|
|
@ -70,15 +40,9 @@
|
|||
class="mx-auto w-full max-w-screen-xl justify-center px-2 py-6 text-center text-emerald-50 md:flex"
|
||||
>
|
||||
<p>
|
||||
<span class="font-bold">© 2026 Tea Sanctuary</span>
|
||||
<span class="font-bold">© 2025 Tea Sanctuary</span>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global {
|
||||
@import '$src/app.css';
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,11 @@
|
|||
<script lang="ts">
|
||||
import SocialButton from '$lib/components/SocialButton.svelte';
|
||||
import { PUBLIC_TS_DISCORD, PUBLIC_TS_TELEGRAM } from '$env/static/public';
|
||||
import BlogCard, { BlogCardSize } from '$lib/components/BlogCard.svelte';
|
||||
import { PUBLIC_TS_DISCORD } from '$env/static/public';
|
||||
import BlogCard, { BlogCardSize } from '$src/lib/components/BlogCard.svelte';
|
||||
import { page } from '$app/state';
|
||||
import type { PageData } from './$types';
|
||||
import { onMount } from 'svelte';
|
||||
import { postIsEventOfInterest } from '$lib/util/Blogs';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// postIsEventOfInterest(b) ? b.dateEventTo : undefined
|
||||
let posts: App.BlogPost[] = $state(page.data.posts);
|
||||
onMount(() => {
|
||||
const dateToNum = (d: string) => new Date(d).valueOf();
|
||||
posts = posts.sort(
|
||||
(a, b) =>
|
||||
dateToNum(postIsEventOfInterest(b) ? b.dateEventTo! : b.date!) -
|
||||
dateToNum(postIsEventOfInterest(a) ? a.dateEventTo! : a.date!)
|
||||
);
|
||||
});
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -80,7 +67,6 @@
|
|||
<div class="flex flex-col items-center justify-start gap-4">
|
||||
<div class="flex flex-row flex-wrap items-start justify-center gap-4">
|
||||
<SocialButton class="w-60 shrink-0" href={PUBLIC_TS_DISCORD}>Сообщество</SocialButton>
|
||||
<SocialButton class="w-60 shrink-0" href={PUBLIC_TS_TELEGRAM}>Канал</SocialButton>
|
||||
<SocialButton class="w-60 shrink-0" href="https://github.com/TeaSanctuary/">
|
||||
GitHub
|
||||
</SocialButton>
|
||||
|
|
@ -98,14 +84,12 @@
|
|||
</section>
|
||||
|
||||
<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">
|
||||
{#each posts as post, i}
|
||||
{#each page.data.posts as post, i}
|
||||
<div class="aspect-3/2 w-80 shrink-0">
|
||||
{#key post}
|
||||
<BlogCard {post} size={BlogCardSize.Short} fullHeight />
|
||||
{/key}
|
||||
<BlogCard {post} size={BlogCardSize.Short} fullHeight />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -129,13 +113,30 @@
|
|||
prose-p:mt-0
|
||||
prose-p:mb-8
|
||||
bg-slate-50
|
||||
px-2
|
||||
pt-8
|
||||
pb-4
|
||||
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">
|
||||
<data.content />
|
||||
<section class="flex max-w-5xl flex-col flex-nowrap mx-auto">
|
||||
<svelte:component this={data.content} />
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
@import '$src/app.css';
|
||||
|
||||
section > h1,
|
||||
section > h2 {
|
||||
@apply font-disket mb-4 font-bold;
|
||||
}
|
||||
|
||||
section > h1 {
|
||||
@apply text-2xl sm:text-4xl;
|
||||
}
|
||||
|
||||
section > h2 {
|
||||
@apply text-xl sm:text-3xl;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { fetchPostsSorted, sortPostsByPostAndUpdateDate } from "$src/lib/util/Blogs";
|
||||
|
||||
const LATEST_POSTS_COUNT = 5;
|
||||
const LATEST_POSTS_COUNT = 3;
|
||||
|
||||
export async function load() {
|
||||
let md: any
|
||||
|
|
@ -9,6 +9,7 @@ export async function load() {
|
|||
} catch (ex) {
|
||||
throw "Не удалось найти текст для главной страницы";
|
||||
}
|
||||
console.log("TEST", md.default);
|
||||
return {
|
||||
content: md.default,
|
||||
posts: (await fetchPostsSorted(sortPostsByPostAndUpdateDate)).slice(0, LATEST_POSTS_COUNT)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { fetchPostsSorted } from "$lib/util/Blogs";
|
||||
import { fetchPostsSorted } from "$src/lib/util/Blogs";
|
||||
|
||||
export async function load() {
|
||||
return { title: "Блог", description: "Новости и заметки проектов Tea Sanctuary", posts: await fetchPostsSorted() };
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import SocialHyperlink from '$lib/components/SocialHyperlink.svelte';
|
||||
import InfoBlock from '$lib/components/InfoBlock.svelte';
|
||||
import BlogCard, { BlogCardSize } from '$lib/components/BlogCard.svelte';
|
||||
import { postIsEventOfInterest, type PostComparer } from '$lib/util/Blogs';
|
||||
import { onMount } from 'svelte';
|
||||
import SocialHyperlink from '$src/lib/components/SocialHyperlink.svelte';
|
||||
import InfoBlock from '$src/lib/components/InfoBlock.svelte';
|
||||
import BlogCard from '$src/lib/components/BlogCard.svelte';
|
||||
|
||||
function groupPostsByMonthYear(posts: App.BlogPost[]) {
|
||||
const groupedPosts = new Map<string, App.BlogPost[]>();
|
||||
|
|
@ -21,20 +19,7 @@
|
|||
return groupedPosts;
|
||||
}
|
||||
|
||||
function getEventsOfInterest(posts: App.BlogPost[]) {
|
||||
// Сортировка в обратном порядке - чем ближе к завершению, тем раньше в списке
|
||||
const sortPostsByEvent: PostComparer = (a, b) =>
|
||||
new Date(a.dateEventTo!).valueOf() - new Date(b.dateEventTo!).valueOf();
|
||||
|
||||
return posts.filter((a) => postIsEventOfInterest(a)).sort(sortPostsByEvent);
|
||||
}
|
||||
|
||||
const groupedPosts = groupPostsByMonthYear(page.data.posts);
|
||||
let eventPosts: App.BlogPost[] = $state([]);
|
||||
|
||||
onMount(() => {
|
||||
eventPosts = getEventsOfInterest(page.data.posts);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="hero flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4">
|
||||
|
|
@ -54,20 +39,6 @@
|
|||
пропускать новые посты!
|
||||
</InfoBlock>
|
||||
|
||||
{#if !!eventPosts && eventPosts.length > 0}
|
||||
<section class="flex flex-col items-stretch bg-amber-50 pt-4">
|
||||
<h1 class="font-disket text-center text-2xl font-bold sm:text-4xl">АКТУАЛЬНЫЕ СОБЫТИЯ</h1>
|
||||
|
||||
<div class="flex flex-row items-stretch justify-evenly gap-4 overflow-x-auto p-4">
|
||||
{#each eventPosts as post, i}
|
||||
<div class="aspect-3/2 w-80 shrink-0">
|
||||
<BlogCard {post} size={BlogCardSize.Short} fullHeight />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="flex flex-col items-stretch p-2 pb-8 md:p-4">
|
||||
{#each groupedPosts.entries() as [monthYear, postsInMonthYear]}
|
||||
<h1
|
||||
|
|
@ -82,3 +53,7 @@
|
|||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@import '$src/app.css';
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,58 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import DateWidget from '$lib/components/DateWidget.svelte';
|
||||
import InfoBlock from '$lib/components/InfoBlock.svelte';
|
||||
import PersonIcon from '~icons/material-symbols/person';
|
||||
import DateWidget from '$src/lib/components/DateWidget.svelte';
|
||||
import InfoBlock from '$src/lib/components/InfoBlock.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { PageData } from './$types';
|
||||
import {
|
||||
blogPostTypeToIconComponent,
|
||||
blogPostTypeToString,
|
||||
EventStatus,
|
||||
postEventStatus
|
||||
} from '$lib/util/Blogs';
|
||||
import { onMount } from 'svelte';
|
||||
import { durationHumanReadable } from '$lib/util/Dates';
|
||||
import CountdownClock from '$lib/components/CountdownClock.svelte';
|
||||
import { blogPostTypeToIcon, blogPostTypeToString } from '$src/lib/util/Blogs';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
export let data: PageData;
|
||||
|
||||
const isPublic = $derived(!!data.blogPost.date);
|
||||
const authors = $derived(
|
||||
const isPublic = !!data.blogPost.date;
|
||||
const authors =
|
||||
data.blogPost.authors == null
|
||||
? []
|
||||
: typeof data.blogPost.authors === 'string'
|
||||
? [data.blogPost.authors]
|
||||
: data.blogPost.authors
|
||||
);
|
||||
const type: App.BlogPostType = $derived(data.blogPost.type ?? 'article');
|
||||
let eventStatus = $state(EventStatus.NotEvent);
|
||||
|
||||
const PostTypeIcon = $derived(blogPostTypeToIconComponent(type));
|
||||
|
||||
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);
|
||||
});
|
||||
: data.blogPost.authors;
|
||||
const type: App.BlogPostType = data.blogPost.type ?? 'article';
|
||||
</script>
|
||||
|
||||
<base target="_blank" />
|
||||
|
|
@ -75,16 +38,16 @@
|
|||
</section>
|
||||
|
||||
<section
|
||||
class="flex shrink-0 flex-row flex-wrap items-center justify-center p-2 font-bold {isPublic
|
||||
class="flex shrink-0 flex-col flex-wrap items-center justify-center p-2 font-bold {isPublic
|
||||
? 'bg-amber-50 text-slate-950'
|
||||
: 'bg-red-500 text-slate-50'} sm:gap-x-5"
|
||||
: 'bg-red-500 text-slate-50'} sm:flex-row sm:gap-x-5"
|
||||
>
|
||||
<DateWidget dateString={data.blogPost.date} type="published" />
|
||||
{#if data.blogPost.dateChanged}
|
||||
<DateWidget dateString={data.blogPost.dateChanged} type="updated" />
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 p-1 text-lg font-bold">
|
||||
<PostTypeIcon width={28} height={28} />
|
||||
<Icon icon={blogPostTypeToIcon(type)} width={28} height={28} />
|
||||
<span>
|
||||
{blogPostTypeToString(type)}
|
||||
</span>
|
||||
|
|
@ -92,7 +55,7 @@
|
|||
{#each authors as author}
|
||||
<!-- TODO: rndtrash: из-за 404 не даёт собрать сайт. href="/team/{author}" -->
|
||||
<a class="flex items-center gap-2 p-1 text-lg font-bold" href="#">
|
||||
<PersonIcon width={28} height={28} />
|
||||
<Icon icon="material-symbols:person" width={28} height={28} />
|
||||
<span class="underline">
|
||||
{author}
|
||||
</span>
|
||||
|
|
@ -100,50 +63,6 @@
|
|||
{/each}
|
||||
</section>
|
||||
|
||||
{#if type === 'event'}
|
||||
<section
|
||||
class="flex shrink-0 flex-col flex-wrap items-center justify-center p-2 font-bold
|
||||
{eventStatus === EventStatus.NotStarted
|
||||
? 'bg-amber-200'
|
||||
: eventStatus === EventStatus.InProgress
|
||||
? 'bg-green-600 text-slate-50'
|
||||
: 'bg-slate-200'} gap-2"
|
||||
>
|
||||
{#if eventStatus === EventStatus.NotStarted || eventStatus === EventStatus.InProgress}
|
||||
<span class="text-center text-4xl font-bold">
|
||||
ДО {eventStatus === EventStatus.NotStarted ? 'НАЧАЛА' : 'КОНЦА'} ОСТАЛОСЬ
|
||||
</span>
|
||||
<CountdownClock
|
||||
deadline={new Date(
|
||||
eventStatus === EventStatus.NotStarted
|
||||
? data.blogPost.dateEventFrom!
|
||||
: data.blogPost.dateEventTo!
|
||||
)}
|
||||
/>
|
||||
{:else if eventStatus === EventStatus.IsOver}
|
||||
<span class="text-center text-4xl font-bold">СОБЫТИЕ ЗАВЕРШЕНО</span>
|
||||
{/if}
|
||||
<span class="flex flex-row flex-wrap items-center justify-center gap-2">
|
||||
Событие {eventStatus === EventStatus.IsOver ? 'проводилось' : 'проводится'} с
|
||||
<DateWidget
|
||||
class="bg-slate-50 px-2 text-slate-950"
|
||||
dateString={data.blogPost.dateEventFrom}
|
||||
showTime
|
||||
/>
|
||||
по
|
||||
<DateWidget
|
||||
class="bg-slate-50 px-2 text-slate-950"
|
||||
dateString={data.blogPost.dateEventTo}
|
||||
showTime
|
||||
/>
|
||||
({durationHumanReadable(
|
||||
new Date(data.blogPost.dateEventFrom!),
|
||||
new Date(data.blogPost.dateEventTo!)
|
||||
)})
|
||||
</span>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if page.data.blogPost.projects?.length > 0}
|
||||
<InfoBlock>
|
||||
<p>В данной заметке упоминаются наши проекты:</p>
|
||||
|
|
@ -159,7 +78,7 @@
|
|||
class="prose
|
||||
sm:prose-xl
|
||||
prose-slate
|
||||
prose-code:wrap-break-word
|
||||
prose-code:break-words
|
||||
prose-pre:drop-shadow-md
|
||||
prose-headings:font-disket
|
||||
prose-headings:mb-4
|
||||
|
|
@ -173,5 +92,9 @@
|
|||
text-slate-950
|
||||
sm:text-xl lg:p-8"
|
||||
>
|
||||
<data.content />
|
||||
<svelte:component this={data.content} />
|
||||
</article>
|
||||
|
||||
<style>
|
||||
@import '$src/app.css';
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -41,14 +41,6 @@ function makeAuthors(post: App.BlogPost): string {
|
|||
return `\n<author>${authorsString}</author>`;
|
||||
}
|
||||
|
||||
function makeEventDescription(post: App.BlogPost): string {
|
||||
if (postEventStatus(post) === EventStatus.NotEvent)
|
||||
return '';
|
||||
|
||||
const dateToUtcString = (s: string) => new Date(s).toUTCString();
|
||||
return `<br><br>Событие проводится с ${dateToUtcString(post.dateEventFrom!)} по ${dateToUtcString(post.dateEventTo!)}.`;
|
||||
}
|
||||
|
||||
export async function GET({ setHeaders }) {
|
||||
setHeaders({
|
||||
'Cache-Control': 'max-age=0, s-maxage=3600',
|
||||
|
|
@ -56,16 +48,15 @@ export async function GET({ setHeaders }) {
|
|||
});
|
||||
const posts = await fetchPostsSorted();
|
||||
return new Response(String(`<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<rss xmlns:dc="https://purl.org/dc/elements/1.1/" xmlns:content="https://purl.org/rss/1.0/modules/content/" xmlns:atom="https://www.w3.org/2005/Atom" version="2.0">
|
||||
<channel>
|
||||
<title>Блог Tea Sanctuary</title>
|
||||
<description>Лента с новостями, заметками и событиями Tea Sanctuary</description>
|
||||
<link>https://teasanctuary.ru/blog</link>
|
||||
<atom:link href="https://teasanctuary.ru/blog/rss.xml" rel="self" type="application/rss+xml" />
|
||||
<ttl>1800</ttl>
|
||||
<updated>${feedUpdated.toUTCString()}</updated>
|
||||
${posts.map((post) => `<item>
|
||||
<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>
|
||||
<link>https://teasanctuary.ru/blog/${post.slug}</link>
|
||||
<pubDate>${(new Date(post.date!)).toUTCString()}</pubDate>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import WarningBlock from '$lib/components/WarningBlock.svelte';
|
||||
import SocialHyperlink from '$src/lib/components/SocialHyperlink.svelte';
|
||||
</script>
|
||||
|
||||
|
|
@ -14,31 +15,17 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<WarningBlock>Страница находится в разработке!</WarningBlock>
|
||||
|
||||
<section class="flex justify-center">
|
||||
<div
|
||||
class="flex w-5xl max-w-screen flex-col flex-nowrap gap-12 p-2 px-2 pt-12 pb-12 text-base sm:text-xl"
|
||||
>
|
||||
<section>
|
||||
На данный момент вы можете связаться с администрацией сайта и участниками команды
|
||||
следующими способами:
|
||||
<ol class="list-inside list-decimal">
|
||||
<li>
|
||||
Через
|
||||
<SocialHyperlink href="https://teasanctuary.ru/discord">
|
||||
нашу гильдию в Discord
|
||||
</SocialHyperlink>
|
||||
</li>
|
||||
<li>
|
||||
Через
|
||||
<SocialHyperlink href="https://t.me/tea_sanctuary">
|
||||
публичный канал Tea Sanctuary в Telegram
|
||||
</SocialHyperlink>
|
||||
</li>
|
||||
<li>
|
||||
По персональным контактам, указанным на странице
|
||||
<SocialHyperlink href="/team">Команда</SocialHyperlink>.
|
||||
</li>
|
||||
</ol>
|
||||
На данный момент вы можете связаться с администрацией сайта и участниками команды через
|
||||
<SocialHyperlink href="https://teasanctuary.ru/discord">
|
||||
нашу гильдию в Discord
|
||||
</SocialHyperlink>:
|
||||
</section>
|
||||
<iframe
|
||||
src="https://discord.com/widget?id=1176141874390638662&theme=dark"
|
||||
|
|
@ -50,5 +37,13 @@
|
|||
frameborder="0"
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
<section>
|
||||
Вы также можете ознакомиться с социальными сетями каждого отдельного участника команды
|
||||
на странице <SocialHyperlink href="/team">Команда</SocialHyperlink>.
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@import '$src/app.css';
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -15,3 +15,7 @@
|
|||
</section>
|
||||
|
||||
<WarningBlock>Страница находится в разработке!</WarningBlock>
|
||||
|
||||
<style>
|
||||
@import '$src/app.css';
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -15,3 +15,7 @@
|
|||
</section>
|
||||
|
||||
<WarningBlock>Страница находится в разработке!</WarningBlock>
|
||||
|
||||
<style>
|
||||
@import '$src/app.css';
|
||||
</style>
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 231 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 230 KiB |
9
vite.config.js
Normal file
9
vite.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
tailwindcss()
|
||||
]
|
||||
};
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import type { UserConfig } from 'vite';
|
||||
import legacy from '@vitejs/plugin-legacy';
|
||||
import Icons from 'unplugin-icons/vite';
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
Icons({
|
||||
compiler: 'svelte',
|
||||
}),
|
||||
tailwindcss(),
|
||||
legacy({
|
||||
renderLegacyChunks: false
|
||||
})
|
||||
]
|
||||
} satisfies UserConfig;
|
||||
Loading…
Add table
Add a link
Reference in a new issue