Compare commits

...

65 commits

Author SHA1 Message Date
ae3d16549e Merge pull request 'Блоги' (#1) from feature-blogs into master
Some checks failed
/ build (push) Has started running
/ deploy (push) Has been cancelled
Reviewed-on: #1
2025-10-22 08:44:56 +03:00
f7e93d4806 Подогнал размеры карточек на главной странице 2025-10-22 08:44:35 +03:00
b275856aae Сегодняшняя дата на первой заметке 2025-10-22 08:32:28 +03:00
451d064dea Типы блог-постов 2025-10-22 08:32:10 +03:00
0643f48189 Перенёс текст с главной страницы в файл Markdown 2025-10-22 07:44:57 +03:00
2024fa9f4b Обновил package-lock.json, почему бы и нет 2025-10-22 07:11:53 +03:00
8ef0c99819 Простенькая страница Контактов, чтоб не совсем пусто 2025-10-22 06:08:48 +03:00
ddd73c1412 Исправил переносы при нескольких авторах 2025-10-22 05:49:25 +03:00
dcbd8a5980 Автор статьи в RSS ленте 2025-10-22 05:45:22 +03:00
2e24cbe238 Временно убрал ссылку на автора, пока не сделал раздел Команда 2025-10-19 01:51:59 +03:00
74bbae5101 Автор(ы) поста 2025-10-01 04:27:02 +03:00
4eb1cb0a0e Исправил изменение размера иконки во время загрузки 2025-09-30 09:45:05 +03:00
2e99b04261 Подсветка свежих или недавно обновлённых постов 2025-09-30 07:11:32 +03:00
3c55a37ed8 Виджет даты публикации или обновления 2025-09-30 06:59:47 +03:00
e8532af1de Последние 3 поста на главной странице 2025-09-30 06:06:22 +03:00
07325e57dd Переформатировал главную страницу и разделил стили 2025-09-30 06:05:50 +03:00
90f14bc652 Сортировка постов по дате обновления 2025-09-30 06:02:54 +03:00
be6615ff48 Выделил карточку блога в компонент 2025-09-30 05:50:07 +03:00
224187422a Не надо меня пинать! 2025-09-30 05:34:36 +03:00
dfd6d09c97 Исправил опечатку в блоге 2025-09-30 05:03:20 +03:00
f0fc543181 Выравниваем параграфы по ширине, как на главной странице 2025-09-30 04:57:28 +03:00
68c1ff7572 Убрал странное масштабирование иконок 2025-09-30 04:53:33 +03:00
233048fba4 А мне серый фон больше нравится 2025-09-30 04:45:20 +03:00
1c5e428d65 Первая итерация первого блог-поста 2025-09-30 02:13:54 +03:00
1515503e14 +1 домен Discord-у 2025-09-30 01:21:44 +03:00
d30cf4cef4 Добавил особый обработчик ссылки на конкретно наш Discord 2025-09-30 01:09:15 +03:00
ebed4bb3e0 Настало время включить обратно подсветку синтаксиса 2025-09-30 01:09:02 +03:00
c83d4221cc Докрутил стили заголовков, позже сделаю остальные уровни 2025-09-30 00:36:07 +03:00
3cecfc1cc4 Временно убрал чашки у ссылок с иконкой 2025-09-30 00:26:04 +03:00
d30ab1b8d0 Заменил ссылки в Md-блогах на SocialHyperlink 2025-09-30 00:24:03 +03:00
9722448c36 Переработал HoverIcon, чтобы его можно было использовать в <p> 2025-09-30 00:23:29 +03:00
632620484d Более консистентный стиль текста в блогах 2025-09-30 00:22:30 +03:00
2363b9a901 Сделал блок предупреждения более жёлтым, потому что могу 2025-09-29 22:59:13 +03:00
49646c3a09 Предупреждения на незаконченных страницах 2025-09-29 22:19:01 +03:00
89211b3e22 Разделил блок с информацией 2025-09-29 22:18:34 +03:00
84512c7a9b RSS генерирует HTML с миниатюрой и её альт-текстом 2025-09-29 17:35:38 +03:00
0edf7022e8 Миниатюры заметок могут иметь альт-текст 2025-09-29 17:35:04 +03:00
be9715aca0 Ограничил ширину блока с тектом до 5XL, как на главной странице 2025-09-29 17:32:58 +03:00
2610a4466b Блоги отображаются горизонтально для всех экранов, кроме самых маленьких 2025-09-29 06:31:34 +03:00
7b8a82ebc0 Merge remote-tracking branch 'origin/master' into feature-blogs 2025-09-29 04:50:23 +03:00
d758bb3b3f Ну и конечно-же фавиконка, достали 404-ые 2025-09-29 04:38:14 +03:00
68149616b6 Исправил 404 для несуществующих заметок 2025-09-29 04:30:41 +03:00
3d41ce538f Список всех блогов 2025-09-29 04:28:02 +03:00
9bf0c14d1f Забыл убрать лишнее 2025-09-29 03:26:35 +03:00
6e6a927f76 Дата изменения и упомянутые проекты в заметке 2025-09-29 03:23:26 +03:00
f0a361c56d Страница заметки в блоге 2025-09-29 03:23:08 +03:00
f79762b1e0 Компонент блока с информацией 2025-09-29 02:58:34 +03:00
13c341d4ff Сделал компонент <Img/> для блог-постов 2025-09-29 02:45:41 +03:00
86dff5272e Добавил дату последнего обновления фида и включил пререндер 2025-09-29 02:28:51 +03:00
2d010fdcce Добавил скрытие постов без даты публикации и пример скрытого поста 2025-09-29 02:19:38 +03:00
b9e309b52f Сделал настраиваемыми все поля метаданных страницы 2025-09-29 02:14:44 +03:00
efd41dce7c Провёл аудит зависимостей, почему бы и нет 2025-09-29 01:40:21 +03:00
7db1d5ef4e Прочие изменения на страничке Блоги 2025-09-29 01:29:43 +03:00
772444e384 Обновил версию обработчика Markdown 2025-09-29 01:28:54 +03:00
0a60d80167 Нашёл ещё один вариант ссылки на новости Atom 2025-09-23 01:34:02 +03:00
3190a5b61d Некоторые исправления в RSS 2025-09-19 23:46:13 +03:00
8e09b8f0d9 Первый блог + простой генератор RSS-фида (работает в Thunderbird) 2025-09-19 04:51:27 +03:00
6a60ce55e5 Исправил поведение обработчика иконок для ссылок 2025-09-19 02:02:21 +03:00
d455aeb957 Упростил стиль hero-карточки 2025-09-19 00:57:56 +03:00
06c371db53 Попытка исправить странный скроллинг на телефонах 2025-09-17 14:11:04 +03:00
db06b79cf4 Поменял цвет темы заголовка на телефонах 2025-09-17 14:06:40 +03:00
9dc1fabdf1 Добавил ещё одного жестяного нарушителя порядка 2025-09-17 14:05:15 +03:00
b6cf05b0af Объединил логику изменения заголовка страницы в +layout.svelte 2025-07-19 04:05:08 +03:00
51c4c024a8 Добавил распознавание ссылок на RSS 2025-07-19 03:48:03 +03:00
a3db23bceb Исправил сжатие панели навигации 2025-07-19 03:45:26 +03:00
43 changed files with 1175 additions and 268 deletions

View file

@ -1,3 +0,0 @@
export default {
extensions: ['.md']
};

227
package-lock.json generated
View file

@ -21,7 +21,7 @@
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"mdsvex": "^0.12.3",
"mdsvex": "^0.12.6",
"mdsvex-relative-images": "^1.0.3",
"postcss": "^8.5.3",
"prettier": "^3.5.2",
@ -521,9 +521,9 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz",
"integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -535,10 +535,20 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz",
"integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -549,9 +559,9 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz",
"integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -573,13 +583,16 @@
}
},
"node_modules/@eslint/js": {
"version": "9.21.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.21.0.tgz",
"integrity": "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
"integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
@ -593,13 +606,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz",
"integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==",
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.12.0",
"@eslint/core": "^0.15.2",
"levn": "^0.4.1"
},
"engines": {
@ -1159,6 +1172,13 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@svelte-put/dragscroll": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@svelte-put/dragscroll/-/dragscroll-4.0.0.tgz",
@ -1193,9 +1213,9 @@
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz",
"integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==",
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.9.tgz",
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@ -1203,25 +1223,26 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.22.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.22.2.tgz",
"integrity": "sha512-2MvEpSYabUrsJAoq5qCOBGAlkICjfjunrnLcx3YAk2XV7TvAIhomlKsAgR4H/4uns5rAfYmj7Wet5KRtc8dPIg==",
"version": "2.43.5",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.43.5.tgz",
"integrity": "sha512-44Mm5csR4mesKx2Eyhtk8UVrLJ4c04BT2wMTfYGKJMOkUqpHP5KLL2DPV0hXUA4t4+T3ZYe0aBygd42lVYv2cA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"cookie": "^0.6.0",
"devalue": "^5.1.0",
"devalue": "^5.3.2",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
"mrmime": "^2.0.0",
"sade": "^1.8.1",
"set-cookie-parser": "^2.6.0",
"sirv": "^3.0.0",
"vitefu": "^1.0.6"
"sirv": "^3.0.0"
},
"bin": {
"svelte-kit": "svelte-kit.js"
@ -1230,9 +1251,15 @@
"node": ">=18.13"
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
@ -1264,7 +1291,6 @@
"integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.3.7"
},
@ -1552,12 +1578,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/node": {
"version": "22.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.20.0"
}
@ -1575,6 +1612,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1751,6 +1789,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@ -1934,7 +1973,6 @@
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -1953,9 +1991,9 @@
}
},
"node_modules/devalue": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
"integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
"dev": true,
"license": "MIT"
},
@ -2045,19 +2083,21 @@
}
},
"node_modules/eslint": {
"version": "9.21.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.21.0.tgz",
"integrity": "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
"integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.2",
"@eslint/core": "^0.12.0",
"@eslint/eslintrc": "^3.3.0",
"@eslint/js": "9.21.0",
"@eslint/plugin-kit": "^0.2.7",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.15.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.31.0",
"@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@ -2068,9 +2108,9 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.2.0",
"eslint-visitor-keys": "^4.2.0",
"espree": "^10.3.0",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@ -2168,9 +2208,9 @@
}
},
"node_modules/eslint-scope": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz",
"integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@ -2185,9 +2225,9 @@
}
},
"node_modules/eslint-visitor-keys": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@ -2205,15 +2245,15 @@
"license": "MIT"
},
"node_modules/espree": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.14.0",
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.0"
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -3005,15 +3045,17 @@
}
},
"node_modules/mdsvex": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/mdsvex/-/mdsvex-0.12.3.tgz",
"integrity": "sha512-C/uIJamjNo5PHHnR3JHqsBPoLcfUBpzRmAEB6FLMXI/s7XHOceswjDMKqSPEW2WHmYpKm0taZ3U20GSyhMridA==",
"version": "0.12.6",
"resolved": "https://registry.npmjs.org/mdsvex/-/mdsvex-0.12.6.tgz",
"integrity": "sha512-pupx2gzWh3hDtm/iDW4WuCpljmyHbHi34r7ktOqpPGvyiM4MyfNgdJ3qMizXdgCErmvYC9Nn/qyjePy+4ss9Wg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.4",
"@types/unist": "^2.0.3",
"prism-svelte": "^0.4.7",
"prismjs": "^1.17.1",
"unist-util-visit": "^2.0.1",
"vfile-message": "^2.0.4"
},
"peerDependencies": {
@ -3031,6 +3073,48 @@
"unist-util-visit": "^3.1.0"
}
},
"node_modules/mdsvex/node_modules/unist-util-is": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz",
"integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdsvex/node_modules/unist-util-visit": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz",
"integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0",
"unist-util-is": "^4.0.0",
"unist-util-visit-parents": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdsvex/node_modules/unist-util-visit-parents": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz",
"integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0",
"unist-util-is": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3257,6 +3341,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -3284,6 +3369,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@ -3424,6 +3510,7 @@
"integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -3440,6 +3527,7 @@
"integrity": "sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"prettier": "^3.0.0",
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
@ -3799,6 +3887,7 @@
"integrity": "sha512-2Mo/AfObaw9zuD0u1JJ7sOVzRCGcpETEyDkLbtkcctWpCMCIyT0iz83xD8JT29SR7O4SgswuPRIDYReYF/607A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@jridgewell/sourcemap-codec": "^1.5.0",
@ -3951,7 +4040,8 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.9.tgz",
"integrity": "sha512-12laZu+fv1ONDRoNR9ipTOpUD7RN9essRVkX36sjxuRUInpN7hIiHN4lBd/SIFjbISvnXzp8h/hXzmU8SQQYhw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.2.1",
@ -4029,6 +4119,7 @@
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -4167,11 +4258,12 @@
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -4326,21 +4418,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/yaml": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
"integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -24,7 +24,7 @@
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"mdsvex": "^0.12.3",
"mdsvex": "^0.12.6",
"mdsvex-relative-images": "^1.0.3",
"postcss": "^8.5.3",
"prettier": "^3.5.2",

View file

@ -26,11 +26,15 @@
src: url('/fonts/Disket-Mono-Bold.ttf');
}
.no-x-scroll {
max-width: 100%;
overflow-x: hidden;
}
.hero {
@apply bg-[url('/common/background-day.webp')] dark:bg-[url('/common/background-night.webp')] bg-cover bg-fixed text-slate-50;
text-shadow: 0 0 15px rgba(0, 0, 0, 0.25);
.hero-background {
@apply bg-[url('/common/background-day.webp')] dark:bg-[url('/common/background-night.webp')] bg-cover bg-fixed;
h1 {
@apply font-disket text-4xl font-bold sm:text-6xl md:text-8xl;
}
h2 {
@apply font-sans text-xl font-bold sm:text-2xl md:text-4xl;
}
}

18
src/app.d.ts vendored
View file

@ -1,5 +1,3 @@
import type { Member } from '$lib/types/Member';
declare global {
namespace App {
// interface Error {}
@ -20,17 +18,25 @@ declare global {
type MdsvexResolver = () => Promise<MdsvexFile>;
type BlogPostType = 'article' | 'update' | 'event';
interface BlogPost {
slug: string;
type?: BlogPostType;
title: string;
thumbnail: string;
date: string;
thumbnail?: string;
thumbnailAlt?: string;
date?: string;
dateChanged?: string;
dateEventFrom?: string;
dateEventTo?: string;
description: string;
publisher: string;
published?: boolean;
member?: Member;
projects?: string[];
authors?: string | string[];
}
}
}
export {};
export { };

View file

@ -5,7 +5,7 @@
<meta property="og:type" content="website" />
<meta property="og:url" content="https://teasanctuary.ru" />
<meta name="theme-color" content="#63A002" />
<meta name="theme-color" content="#319668" />
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png">

166
src/blogs/hello_world.md Normal file
View file

@ -0,0 +1,166 @@
---
title: 'Наш первый блог'
thumbnail: 'wave.png'
thumbnailAlt: 'Смайлик с махающей рукой'
date: '2025-10-22'
description: 'Немного о том, как мы делали наш сайт'
type: 'article'
authors: 'rndtrash'
---
# ПРИВЕТ !
Добро пожаловать на первый блог-пост нашего нового сайта!
На момент написания мы ведём работу над кучей проектов, и ввиду общей занятости [народа](/team),
работа идёт медленно, да и посты писать особо некому.
Так давайте расскажем хотя бы про этот сайт!
## Вводные данные
Сайт написан на TypeScript с применением фреймворка [SvelteKit](https://svelte.dev). Некоторые отзываются о нём, мягко говоря, не лестно,
но нас Svelte подкупил тем, что все страницы, и даже ручки API, можно предварительно отрендерить по файлам, получив полностью статичный сайт!
А фреймворк [Tailwind CSS](https://tailwindcss.com) даёт нам удобные классы и неплохие дефолтные стили с цветовой палитрой. Пока у нас нет
_полноценных_ веб-дизайнеров, довольствуемся их встроенной палитрой.
Это решение нам досталось в наследство от сайта [Small Fish](https://smallfi.sh). Впрочем, как и половина их кодовой базы:
для быстрого старта с разрешения команды мы воспользовались [их исходниками](https://github.com/Small-Fish-Dev/small-fish-dev.github.io).
Но вы не подумайте, что сайт слизан подчистую! Как минимум в процессе обновления версий фреймворков пришлось провести небольшой рефакторинг,
а потом ещё пошли наши прочие хотелки (об этом дальше), и можно смело сказать, что спустя
[сотню коммитов](https://git.teasanctuary.ru/TeaSanctuary/teasanctuary.ru/commits/branch/master),
из общего остались только фреймворки и сама структура сайта.
В общем-то, главная причина почему мы взяли их сайт за основу, а не написали вручную &mdash; это удобная **система блогов**.
## Собственно, блоги
Конечно, в нашей небольшой команде абсолютно все компьютерно грамотные ([вы можете это исправить!](https://teasanctuary.ru/discord)), и никому не составит
труда настрочить текст вот так, просто HTML-ом. Но как же хочется писать блог в удобном WYSIWYG-редакторе, или хотя бы в простом формате!
На помощь приходит библиотека [mdsvex](https://mdsvex.pngwn.io/). Она по сути превращает `.md` файлы в Svelte-компоненты, которые можно потом
встроить куда угодно. Здорово!
Только надо решить парочку проблем. Во-первых, все элементы, вроде ссылок или картинок, переводятся в обычные HTML5-объекты. А у нас есть свои собственные,
особые ссылки ([вот такие!](https://хамяк.рф)). Благо, mdsvex позволяет просто использовать Svelte-компоненты, если правильно настроить авто-импортирование.
_Но это же не удобно!_ Это ведь надо знать, что есть акие_ ссылки, а есть е_ такие. Плюс, это будет выглядеть уродливо в тех же WYSIWYG-редакторах по
типу [Obsidian](https://obsidian.md).
Решилось это с помощью системы layout-ов внутри mdsvex: можно задавать шаблоны, которые будут натянуты вокруг преобразованного Markdown-текста. Тут же
можно экспортировать особый объект, где к названию каждого стандартного HTML-элемента можно поставить ссылку на свой собственный класс. Таким образом,
каждый `<a href="...">...</a>` превращается в `<SocialHyperlink href="...">...</SocialHyperlink>`, без лишних телодвижений писателя блога.
Отлично, у нас есть отдельные блоги, теперь хочется собрать их все вместе, и желательно рассортировать по дате. И вот тут всплывает вторая проблема:
система Git, которой мы пользуемся, просто не хранит дату создания и изменения файла! Надо это всё записать куда-то в метаданные. Самое простое решение:
присобачить к каждому Markdown-блогу по JSON-у. Но есть решение ещё лучше, оно
называется [frontmatter YAML](https://docs.github.com/ru/contributing/writing-for-github-docs/using-yaml-frontmatter) &mdash; де-факто стандарт встраивания
любых метаданных в файл Markdown.
Здесь мы пишем дату в формате ISO, ну и на сдачу можно добавить кучу других данных. За примером далеко идти не надо: вот вам заголовок этой же заметки:
```md
---
title: 'Наш первый блог'
thumbnail: 'wave.png'
thumbnailAlt: 'Смайлик с махающей рукой'
date: '2025-10-22'
description: 'Немного о том, как мы делали наш сайт'
type: 'article'
authors: 'rndtrash'
---
```
Тут вам и дата, и описание, и даже красивая миниатюра! И сюда можно вставить вообще всё, что поддерживает YAML, в том числе списки и булевы переменные.
Зачем? Ну-у-у... Потом разберёмся🙂
Важно то, что теперь с помощью `import.meta.glob('/src/blogs/*.md')` можно просто пройтись по всем постам, выдернуть метаданные и отсортировать их по дате.
Эта система нам пригодилась в ещё одной вещи, которую, казалось бы, давно забыли, а зря!
## RSS-лента
Да, мы сами в шоке, но даже после становления фактической олигополии интернета Google-Twitter-ЧёЕщёТам, RSS-ленты продолжали использоваться для подкастов.
А с недавней волной популярности децентрализации ([Mastodon](https://joinmastodon.org/ru), [BlueSky](https://bsky.social), [NeoCities](https://neocities.org/) и прочие),
в моду вернулись маленькие бложики. _Да, мы знаем, что они никуда и не уходили, можете не писать гневные комментарии. Ах, да, их некуда писать. Ой._
RSS в этом плане нам очень удобен, во-первых, потому, что это просто XML-файл, и его можно просто куда-то положить, а во-вторых, на него могут подписаться не только люди,
но и машины. В нашем случае это Discord-бот [FeedCord](https://github.com/Qolors/FeedCord/). Ура, можно не городить свой воркфлоу, дёргающий [веб-хуки](https://discord.com/developers/docs/resources/webhook)!
Поскольку SvelteKit &mdash; это fullstack-фреймворк, на нём же напишем простую ручку [rss.xml](https://teasanctuary.ru/blog/rss.xml). Это будет простая функция `GET()`,
возвращающая текст, ручками слепленный в что-то приблизительно похожее на XML. Но погодите, наш сайт же статичный, как мы будем вызывать эту JS-функцию?
_Внимание, фокус!_ Мы берём обычный файл `src/routes/blog/rss.xml/+server.ts`, и добавляем в него волшебную строчку:
```ts
export const prerender = true;
```
При сборке SvelteKit видит, что страницу можно сгенерировать заранее, выполняет весь этот код (а там то же самое, что в странице со списком блог-постов, но очевидно, в формате XML),
и в папке `build/` создаёт файл `rss.xml`. _Та-да!_
Конечно, это отвратительный хак, но мне честно не хотелось добавлять этап постобработки, у нас и так там стоит [генератор sitemap.xml](https://github.com/bartholomej/svelte-sitemap).
Да, ещё одна забытая технология. Надеюсь, хоть так [чёртовы ЖиПиТи-боты](https://git.teasanctuary.ru/TeaSanctuary/teasanctuary.ru/src/branch/master/static/robots.txt) перестанут
вслепую тыкаться по нашим сайтам, особенно по нашему инстансу Forgejo.
## Деплой
Сайт Small Fish, упомянутый в самом начале, располагается на GitHub Pages: CI/CD хук собирает сайт, запаковывает его в zip-архив и публикует на своём домене `.github.io`,
который ещё файлом `CNAME` можно поменять на абсолютно любой.
Мы же хотим оставаться независимыми, в конце концов, зачем мы платим за серваки? Вот Nginx в контейнере, вот SFTP, закидывай и всё!
Так и было, когда наш сайт был проще, амного проще_ (умоляю вас, **не смотрите** в Web-архив!!!), но потом захотелось CD как у больших дядь.
На это есть [Forgejo Actions](https://forgejo.org/docs/latest/user/actions/reference/), местный аналог GitHub Actions. Пишешь скрипт, пушишь коммит,
всё будет сделано за тебя. Так, стоп, а почему не работает? Стоп, надо ещё хостить свой раннер?
Да уж, совсем забыл упомянуть... На нашем основном сервере с доменом [teasanctuary.ru](https://teasanctuary.ru) всего **один гигабайт ОЗУ**. **Один**. Сюда еле влез Forgejo
с несколькими сервисами для нашего внутреннего пользования, а тут ещё надо запускать NodeJs, который запросто выжрет остаток и не подавится.
Как быть?..
Помощь пришла откуда не ждали. Я, **rndtrash**, некоторое время назад переехал на другую квартиру, и пока я разбирал вещи, среди ящичков моему взору предстала ма-а-аленькая чёрная
коробочка с логотипом екоторого пчелиного оператора_. Как позже выяснилось, это Smart TV-приставка, которую провайдер просто отказался забирать назад, ну а хозяевам квартиры она
просто не нужна.
Меня накрыло в пот, и спустя один запрос в поисковике, один тред на 4PDA, один звонок владельцам квартиры и тридцать минут беготни в магазин электроники,
я понял, что передо мной лежал халявный сервер на Linux с ARMv8 процессором на 1.5 ГГц с одним гигабайтом оперативки.
**ДЖЕКПОТ!!! 🎰🎰🎰**
Дальше дело за малым: я просверлил несколько вентиляционных отверстий, чтобы коробка совсем не прокоптилась (вы бы видели местный охлад...), разрегистрировал её в нашем Forgejo,
и проработал план действий. Ввиду простоты сайта, наш [deploy-скрипт](https://git.teasanctuary.ru/TeaSanctuary/teasanctuary.ru/src/branch/master/.forgejo/workflows/deploy.yaml)
предельно прост:
1. Ставим Ноду, клонируем репозиторий, устанавливаем Npm-зависимости
2. Кешируем зависимости, потому что это всё непотребство работает от оего_ Интернета
3. Собираем сайт командой `npm run build` и формируем артефакт &mdash; архив с сайтом
4. Достаём SSH-ключ к основному серверу из секретного хранилища и регистрируем в SSH-агенте. Этот ключ имеет доступ только к особому пользователю,
который в свою очередь имеет доступ только к особой папке только для сайта
5. С помощью `rsync`, полностью заменяем содержимое удалённой папки на содержимое артефакта, сделанного на этапе **3**.
Это сделано на случай, если в репозитории случайно оказалось что-то непубличное👻
В этом плане есть только один прокол, причём очень важный.
## SSL
Тот самый заветный зелёный замочек у нас был с самого начала, но в какой-то момент он пропал. Покопавшись в логах [Certbot](https://certbot.eff.org/), мы с ужасом обнаружили,
что с переездом всего ПО в Podman-контейнер, он вот уже месяц как не мог запустить свой собственный сервер для сертификации.
Вообще, процесс получения сертификата [Let's Encrypt](https://letsencrypt.org/) происходит вот так: некий бот раз в два-три месяца запускает свой собственный сервер на порту 80,
и с его помощью раздаёт особый файл-ключ по адресу `домен/.well-known/acme-challenge/случайные-буквы`. Главный сервер Let's Encrypt стучится по этому адресу, тем самым удостоверяется,
что к нему обратился действительно владелец домена, а не случайный прохожий, и наконец, регистрирует обновлённый файл сертификата.
У нас опять всё не как у людей, поэтому мы сделали вот как: Certbot имеет особый режим, где он просто кладёт нужный файл, а дальше системный администратор как-то сам решает, каким
образом поднять 80 порт. Мы не можем закинуть этот файл прямо в папку сайта, потому что наш deploy-скрипт полностью уничтожает постороннее содержимое, поэтому мы сделали отдельную
папку, а Nginx в свою очередь сконфигурировали так, что все обращения по пути `/.well-known/acme-challenge` идут мимо папки сайта.
## Финал
Вот столько трудностей нам пришлось преодолеть, чтобы довести сайт хотя бы до такого состояния. Если что, на момент написания работает только главная страница, и собственно блоги.
Остаётся надеяться, что написание остальных страниц не составит особых усилий.
Если вам понравился наш блог, то подписывайтесь на наш [RSS-фид](https://teasanctuary.ru/blog/rss.xml), и обязательно загляните в
[наше сообщество в Discord](https://teasanctuary.ru/discord).
Ещё увидимся!
- Команда Tea Sanctuary, 2025

View file

@ -0,0 +1,13 @@
---
title: 'Тестовый блог'
date:
dateChanged: '2025-09-29'
description: 'Немного о самом сайте'
projects: ['ts-hldm']
---
# ПРИВЕТ !
Добро пожаловать на наш первый блог-пост!
<Img src="./wasd_perelesoq_game_jam_2025.png" />

View file

@ -0,0 +1,158 @@
<script lang="ts" module>
export enum BlogCardSize {
Short,
Long,
Both
}
</script>
<script lang="ts">
import {
BLOG_POST_FRESHNESS_MILLIS,
blogPostTypeToIcon,
blogPostTypeToString
} from '$lib/util/Blogs';
import DateWidget from '$lib/components/DateWidget.svelte';
import Icon from '@iconify/svelte';
export let post: App.BlogPost;
export let size: BlogCardSize = BlogCardSize.Both;
export let fullHeight = false;
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 просто не понимает,
* когда его классы склеивают из нескольких частей
*/
/**
* Возвращает список классов для полноразмерной плашки.
*
* Возвращает пустую строку, если плашка может быть только короткой
* @param classes
* @param modClasses
*/
function fullClass(classes: string, modClasses: string): string {
if (size === BlogCardSize.Short) return '';
return size === BlogCardSize.Both ? modClasses : classes;
}
/**
* Возвращает список классов для короткой карточки.
*
* Возвращает пустую строку, если плашка может быть только полноразмерной
* @param classes
* @param modClasses
*/
function shortClass(classes: string, modClasses?: string): string {
if (size === BlogCardSize.Long) return '';
return size === BlogCardSize.Both && modClasses ? modClasses : classes;
}
</script>
<a
href="/blog/{post.slug}"
class="blog-card
{fullHeight ? 'min-h-full' : ''}
{isPostUpdated ? 'updated' : isPostNew ? 'new' : ''}
{shortClass('flex-col justify-baseline')}
{fullClass('flex-row justify-stretch', 'sm:flex-row sm:justify-stretch')}"
>
<div
class="relative w-full basis-auto overflow-hidden
{shortClass('h-32 md:h-48')}
{fullClass('h-auto basis-1/3', 'sm:h-auto sm:basis-1/3')}"
>
{#if post.thumbnail}
<img
class="thumbnail"
src={`/blog/${post.slug}/${post.thumbnail}`}
alt={post.thumbnailAlt ?? 'Миниатюра поста'}
/>
{/if}
{#if isPostFresh}
<div class="toast {fullClass('hidden', 'sm:hidden')}">
{#if isPostUpdated}
ОБНОВЛЕНО
{:else}
НОВОЕ
{/if}
</div>
{/if}
</div>
<div class="flex w-full flex-col justify-center p-4 break-words">
<div class="flex flex-row flex-wrap justify-start gap-4 pb-2">
<DateWidget
class={post.dateChanged ? shortClass('hidden', 'not-sm:hidden') : ''}
dateString={post.date}
type="published"
highlight={isPostNew && !isPostUpdated}
/>
{#if post.dateChanged}
<DateWidget
dateString={post.dateChanged}
type="updated"
highlight={isPostUpdated}
/>
{/if}
<div class="flex items-center gap-2 p-1 text-lg font-bold">
<Icon icon={blogPostTypeToIcon(type)} width={28} height={28} />
<span class={shortClass('hidden', 'not-md:hidden')}>
{blogPostTypeToString(type)}
</span>
</div>
</div>
<h2 class="text-3xl font-bold">{post.title}</h2>
{#if post.description}
<p>{post.description}</p>
{/if}
</div>
</a>
<style>
@import '$src/app.css';
.blog-card {
@apply flex w-full max-w-5xl overflow-hidden rounded-lg bg-slate-100 text-slate-950 drop-shadow-xl transition-all hover:drop-shadow-2xl;
.toast {
@apply absolute top-0 right-0 rounded-bl-lg p-2 font-bold text-slate-50;
}
&.updated {
box-shadow: 0 0 0 4px var(--color-purple-600);
.toast {
@apply bg-purple-600;
}
}
&.new {
box-shadow: 0 0 0 4px var(--color-amber-600);
.toast {
@apply bg-amber-600;
}
}
img.thumbnail {
@apply absolute h-full w-full object-cover transition-transform;
transform: scale(1);
}
&:hover img.thumbnail {
transform: scale(1.1);
}
}
</style>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import Icon from '@iconify/svelte';
let className: string = '';
export { className as class };
export let dateString: string | undefined;
export let type: 'published' | 'updated' = 'published';
export let highlight = false;
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' : 'bg-purple-600')}
{highlightClasses('text-slate-50')}"
>
<Icon {icon} width={28} height={28} />
<span class="text-nowrap">
{dateString
? new Date(dateString).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
})
: 'Не опубликован!'}
</span>
</div>

View file

@ -4,29 +4,39 @@
let className: string = '';
export { className as class };
export let src: string | null = null;
export let text: string;
export let alt: string | undefined = undefined;
export let size: number = 32;
export let black: boolean = false;
let isUrl;
$: isUrl = src?.startsWith('/') || src?.startsWith('http');
function isUrl(src?: string) {
return src?.startsWith('/') || src?.startsWith('http');
}
</script>
<div
class="{className} {black
? 'fill-slate-950'
: 'fill-slate-50'} text-sm uppercase transition-all"
<span
class="{className} {black ? 'fill-slate-950' : 'fill-slate-50'} hover-icon"
style:width="{size}px"
style:height="{size}px"
>
{#if src}
{#if isUrl}
<img {src} alt={text} width={size} height={size} />
{#if isUrl(src)}
<img {src} {alt} width={size} height={size} />
{:else}
<Icon width={size} height={size} icon={src} color={black ? '#020618' : '#f8fafc'} />
{/if}
{:else}
{text}
{alt ?? 'Без иконки'}
{/if}
</div>
</span>
<style>
@import '$src/app.css';
.hover-icon {
@apply block text-sm uppercase transition-all;
img {
margin: 0;
}
}
</style>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import Icon from '@iconify/svelte';
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">
<Icon width={32} height={32} {icon} color={'#f8fafc'} />
<span class="sm:hidden">{caption}</span>
</div>
<div class="{bgBleak} p-4 sm:grow">
<slot />
</div>
</section>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { page } from '$app/state';
import { resolveBlogPath } from '$lib/util/Blogs';
export let src: string;
export let caption: string;
export let alt: string = '';
</script>
<div class="text-center">
<img
class="mx-auto mb-0 flex justify-center rounded-lg"
class:caption-img={caption}
src={resolveBlogPath(page.params.slug, src)}
alt={alt ? alt : src}
/>
{#if caption}
<p style="margin-top: 0px;">{caption}</p>
{/if}
</div>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import IconBlock from './IconBlock.svelte';
</script>
<IconBlock
bgStrong="bg-blue-500"
bgBleak="bg-blue-50"
icon="material-symbols:info"
caption="Обратите внимание"
>
<slot />
</IconBlock>

View file

@ -0,0 +1,11 @@
<script lang="ts" module>
import SocialHyperlink from './SocialHyperlink.svelte';
export { SocialHyperlink as a };
</script>
<script lang="ts">
const { children, ...rest } = $props();
</script>
{@render children()}

View file

@ -20,7 +20,7 @@
<HoverIcon
src={route.icon}
class="text-sm uppercase"
text={route.href}
alt={route.label}
size={32}
black={!isActive(route.href)}
/>

View file

@ -14,7 +14,7 @@
target={isLinkLocal(href) ? '_self' : '_blank'}
>
<div class="shrink-0 rounded-l-xl bg-slate-800 p-2">
<HoverIcon src={customIcon ?? tryGetIcon(href)} class="text-sm uppercase" text={href} />
<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"

View file

@ -11,11 +11,11 @@
const sm = new MediaQuery('width >= 40rem', false);
</script>
<a {href} class="{className} group inline-block" 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={customIcon ?? tryGetIcon(href)} text={href} size={sm.current ? 24 : 20} />
<HoverIcon src={customIcon ?? tryGetIcon(href)} size={sm.current ? 24 : 20} />
</span>
<span class="text-emerald-900 underline">
<slot />

View file

@ -0,0 +1,12 @@
<script lang="ts">
import IconBlock from './IconBlock.svelte';
</script>
<IconBlock
bgStrong="bg-yellow-500"
bgBleak="bg-yellow-50"
icon="material-symbols:warning"
caption="Внимание"
>
<slot />
</IconBlock>

81
src/lib/util/Blogs.ts Normal file
View file

@ -0,0 +1,81 @@
export const THUMBNAIL_DEFAULT = "https://teasanctuary.ru/common/background-day.webp";
export const BLOG_POST_FRESHNESS_MILLIS = 3 * 24 * 60 * 60 * 1000; // 3 дня
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);
export async function fetchPostsSorted(postComparer?: PostComparer) {
const allPosts = await fetchPosts();
const sortedPosts = allPosts
// Для списка постов оставляем только те, у которых объявлена дата публикации
.filter((a) => !!a.date)
.sort(postComparer ?? sortPostsByPostDate);
return sortedPosts;
};
// rndtrash: пришлось заменить `path.parse`, так как на стороне клиента его больше не реализуют
function getFilenameFromPath(path: string) {
const file = path.split("/").pop();
const dot = file?.lastIndexOf('.') ?? -1;
return dot === -1 ? file : file?.substring(0, file.lastIndexOf('.'));
}
export async function fetchPosts() {
const allPostFiles = import.meta.glob('/src/blogs/*.md');
const iterablePostFiles = Object.entries(allPostFiles);
const allPosts: App.BlogPost[] = await Promise.all(
iterablePostFiles.map(async ([filePath, resolver]) => {
const { metadata }: any = await resolver();
const name = getFilenameFromPath(filePath);
return {
slug: name,
...metadata
};
})
);
return allPosts;
};
export function resolveBlogPath(slug?: string, src?: string) {
if (!src) return null;
return src.startsWith('http://') || src.startsWith('https://')
? src
: `/blog/${slug}/${src}`;
}
const bptToString: Record<App.BlogPostType, string> = {
'article': 'Заметка',
'event': 'Событие',
'update': 'Обновление'
};
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 blogPostTypeToIcon(type: App.BlogPostType): string {
return bptToIcon[type] ?? bptToIcon['article'];
}

View file

@ -1,3 +1,4 @@
// TODO: чашки на иконках выглядят страшненько, пока что пусть лучше будет голая ссылка
const icons: Record<string, string> = {
none: 'material-symbols:link',
'steamcommunity.com': 'simple-icons:steam',
@ -6,17 +7,19 @@ const icons: Record<string, string> = {
'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': 'fluent-emoji-high-contrast:hamster',
'teasanctuary.ru': '/icons/tea-sanctuary-white.svg',
// 'teasanctuary.ru': '/icons/tea-sanctuary-white.svg',
'hl.teasanctuary.ru': '/icons/half-life.svg',
'git.teasanctuary.ru': 'devicon-plain:git',
localhost: '/icons/tea-sanctuary-white.svg',
email: 'material-symbols:alternate-email'
// localhost: '/icons/tea-sanctuary-white.svg',
email: 'material-symbols:alternate-email',
rss: 'material-symbols:rss-feed'
};
// Особые случаи, когда одним доменом второго уровня не ограничишься (например, randomtrash.itch.io)
@ -32,6 +35,10 @@ const specialResolvers: Record<string, (url: URL) => string> = {
if (prefix[0] === "git") {
return 'git.teasanctuary.ru';
}
// Особый случай: приглашение в Discord
if (url.pathname.startsWith('/discord')) {
return 'discord.gg';
}
return 'teasanctuary.ru';
},
// Игнорируем имя пользователя
@ -44,24 +51,26 @@ function getIconFromUrl(url: URL): string | undefined {
const href = url.href;
if (href.startsWith('mailto:'))
return 'email';
if (href.endsWith('/rss.xml') || href.endsWith('/atom.rss') || href.endsWith('.atom'))
return 'rss';
const hostname = url.hostname;
const secondLevel = hostname.match(/(([A-Za-z0-9\-])+\.([A-Za-z0-9\-])+)$/)?.at(0) ?? '';
if (specialResolvers[secondLevel])
return icons[specialResolvers[secondLevel](url)];
return specialResolvers[secondLevel](url);
return icons[hostname];
return hostname;
}
export function tryGetIcon(link: string): string {
let url: URL;
try {
url = new URL(link);
url = new URL(link, document.baseURI);
} catch {
return icons['none'];
}
return getIconFromUrl(url) ?? icons['none'];
return icons[getIconFromUrl(url) ?? ''] ?? icons['none'];
}
/**

39
src/pages/index.md Normal file
View file

@ -0,0 +1,39 @@
# Кто мы?
__Tea Sanctuary__ &mdash; это в первую очередь коллектив друзей, разрабатывающих
проекты для души, для всеобщего пользования и даже на заказ. С
__8 июля 2017 года__ мы ведём публичную деятельность в сфере разработки ПО и развлечений.
__Tea Sanctuary__ &mdash; это также и сообщество единомышленников. Любовь к добротным
видеоиграм и пассивная агрессия к вычислительной технике у нас в крови. Когда-то сообщество
было закрытым и насчитывало около 50 участников, но впоследствии мы решили его расширить.
Станьте частью коллектива!
# Что делаем?
Наша главная страсть &mdash; это, конечно, видеоигры. Мы часто участвуем в так
называемых "гейм джемах" &mdash; конкурсах на разработку игр. Наши игры вы можете
оценить здесь: [RandomTrash](https://randomtrash.itch.io)
[FriendlyWithMeat](https://friendlywithmeat.itch.io).
Также мы ведём работу над нашим первым полноценным игровым проектом.
Следите за новостями в нашем [сообществе](https://teasanctuary.ru/discord)!
Отдельные участники нашего коллектива занимаются модификацией существующих игр,
добавляя в них новый контент. Например, __MegaZerg__ создаёт оригинальные карты
для такой бессмертной классики, как __Counter-Strike 1.6__ и __Half-Life Deathmatch__,
и выкладывает их на ресурс GameBanana: [kemist](https://gamebanana.com/members/2971042)
Мы размещаем игровые сервера, как постоянные, так и временные для различных событий.
Например, у нас есть сервер [Tea Sanctuary HLDM](https://hl.teasanctuary.ru),
где вы можете ознакомиться с новыми картами от всего сообщества Half-Life.
Не одними играми едины, за нашими плечами есть несколько прикладных программ,
созданных под заказ. Про них ничего особо рассказать не можем, но если вам надо
что-нибудь сделать &mdash; пишите нам!
# Как с вами связаться?
Общие вопросы можно задавать в [сообществе Tea Sanctuary](https://teasanctuary.ru/discord).
Там же можете написать личное сообщение администраторам.
Наши соцсети и почту для более важных обращений можно найти на странице [Контакты](/contact).

View file

@ -7,7 +7,7 @@
</svelte:head>
<section
class="hero-background flex h-screen shrink-0 flex-col items-center justify-center gap-12 overflow-hidden p-4"
class="hero flex h-screen shrink-0 flex-col items-center justify-center gap-12 overflow-hidden p-4"
>
<div class="flex flex-nowrap flex-col gap-5">
<div class="flex flex-nowrap flex-col gap-3 items-center justify-center lg:flex-row lg:flex-nowrap">

View file

@ -1,6 +1,7 @@
<script lang="ts">
import '$src/app.css';
// import '../syntax-highlight.css'; // https://github.com/PrismJS/prism-themes
import { page } from '$app/state';
import '$src/syntax-highlight.css'; // https://github.com/PrismJS/prism-themes
import NavBar from '$lib/components/NavBar.svelte';
const routes: App.Route[] = [
@ -13,14 +14,21 @@
</script>
<svelte:head>
<meta property="og:title" content="Tea Sanctuary" />
<meta property="og:image" content="https://teasanctuary.ru/common/logo.png" />
<meta property="og:description" content="Делаем вещи как можем." />
{#if page.data.title !== undefined}
<title>{page.data.title} &mdash; Tea Sanctuary</title>
<meta property="og:title" content="{page.data.title} — Tea Sanctuary" />
{:else}
<title>Tea Sanctuary</title>
<meta property="og:title" content="Tea Sanctuary" />
{/if}
<meta
property="og:image"
content={page.data.thumbnail ?? 'https://teasanctuary.ru/common/logo.png'}
/>
<meta property="og:description" content={page.data.description ?? 'Делаем вещи как можем.'} />
</svelte:head>
<div class="flex h-screen w-screen flex-row portrait:flex-col-reverse">
<div class="flex h-dvh w-dvw flex-row portrait:flex-col-reverse">
<NavBar {routes} />
<div class="flex grow-1 flex-col overflow-auto">
<div class="relative grow-1">

View file

@ -1,16 +1,18 @@
<script lang="ts">
import SocialButton from '$lib/components/SocialButton.svelte';
import SocialHyperlink from '$lib/components/SocialHyperlink.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';
export let data: PageData;
</script>
<svelte:head>
<title>Tea Sanctuary</title>
</svelte:head>
<section
class="flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4 hero-background"
>
<section class="hero flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4">
<div
class="flex flex-col flex-nowrap items-center justify-center gap-3 lg:flex-row lg:flex-nowrap"
>
@ -57,19 +59,12 @@
>
</div>
<div
class="font-disket text-center text-4xl font-bold text-slate-50 [text-shadow:_0_0_15px_rgba(0,0,0,0.25)] sm:text-6xl md:text-8xl lg:text-left"
>
<div class="text-center font-bold lg:text-left">
<h1>TEA</h1>
<h1>SANCTUARY</h1>
</div>
</div>
<div class="flex flex-col items-center justify-start gap-4">
<!--
<div class="flex flex-row gap-2 rounded-2xl bg-amber-950 p-2 text-center text-amber-50 ring-2 shadow">
Сайт находится в разработке. Если эта плашка висит после 1.06.2025 - пинайте Ивана!
</div>
-->
<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="https://github.com/TeaSanctuary/">
@ -87,69 +82,61 @@
</SocialButton>
</div>
</section>
<section class="flex justify-center bg-slate-50 text-slate-950">
<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 id="who-are-we">
<h1>Кто мы?</h1>
<div class="text-justify">
<b>Tea Sanctuary</b> &mdash; это в первую очередь коллектив друзей, разрабатывающих проекты
для души, для всеобщего пользования и даже на заказ. С <b>8 июля 2017 года</b> мы ведём публичную
деятельность в сфере разработки ПО и развлечений.
<section class="flex flex-col items-stretch bg-blue-900 pt-4 text-slate-50">
<h1 class="text-center">ПОСЛЕДНИЕ ПОСТЫ</h1>
<div class="flex flex-row items-stretch justify-evenly gap-4 overflow-x-auto p-4">
{#each page.data.posts as post, i}
<div class="aspect-3/2 w-80 shrink-0">
<BlogCard {post} size={BlogCardSize.Short} fullHeight />
</div>
<br />
<div class="text-justify">
<b>Tea Sanctuary</b> &mdash; это также и сообщество единомышленников. Любовь к добротным видеоиграм
и пассивная агрессия к вычислительной технике у нас в крови. Когда-то сообщество было закрытым
и насчитывало около 50 участников, но впоследствии мы решили его расширить. Станьте частью коллектива!
</div>
</section>
<section id="what-are-we-doing">
<h1>Что делаем?</h1>
<div class="text-justify">
Наша главная страсть &mdash; это, конечно, видеоигры. Мы часто участвуем в так называемых
"гейм джемах" &mdash; конкурсах на разработку игр. Наши игры вы можете оценить здесь:
<SocialHyperlink href="https://randomtrash.itch.io">RandomTrash</SocialHyperlink>
<SocialHyperlink href="https://friendlywithmeat.itch.io/">FriendlyWithMeat</SocialHyperlink>.
Также мы ведём работу над нашим первым полноценным игровым проектом.
Следите за новостями в нашем
<SocialHyperlink href={PUBLIC_TS_DISCORD}>сообществе</SocialHyperlink>!
</div>
<br />
<div class="text-justify">
Отдельные участники нашего коллектива занимаются модификацией существующих игр, добавляя в
них новый контент. Например, <b>MegaZerg</b> создаёт оригинальные карты для такой
бессмертной классики, как Counter-Strike 1.6, и выкладывает их на ресурс GameBanana:
<SocialHyperlink href="https://gamebanana.com/members/2971042">kemist</SocialHyperlink>
</div>
<br />
<div class="text-justify">
Мы размещаем игровые сервера, как постоянные, так и временные для различных событий.
Например, у нас есть сервер
<SocialHyperlink href="https://hl.teasanctuary.ru">Tea Sanctuary HLDM</SocialHyperlink>, где
вы можете ознакомиться с новыми картами от всего сообщества Half-Life.
</div>
<br />
<div class="text-justify">
Не одними играми едины, за нашими плечами есть несколько прикладных программ, созданных под
заказ. Про них ничего особо рассказать не можем, но если вам надо что-нибудь сделать &mdash;
пишите нам!
</div>
</section>
<section id="how-can-you-contact-us">
<h1>Как с вами связаться?</h1>
<div class="text-justify">
TODO: <SocialHyperlink href="/contact">контакты</SocialHyperlink>.
</div>
</section>
{/each}
</div>
</section>
<article
class="prose
sm:prose-xl
prose-slate
prose-code:break-words
prose-pre:drop-shadow-md
prose-headings:font-disket
prose-headings:my-4
prose-headings:font-bold
prose-headings:text-slate-950
prose-h1:text-2xl
prose-h1:sm:text-4xl
prose-h2:text-xl
prose-h2:sm:text-3xl
prose-p:text-justify
prose-p:mt-0
prose-p:mb-8
bg-slate-50
pt-8
pb-4
text-base
text-slate-950
px-2 sm:px-4 sm:text-xl"
>
<section class="flex max-w-5xl flex-col flex-nowrap mx-auto">
<svelte:component this={data.content} />
</section>
</article>
<style>
@import "$src/app.css";
@import '$src/app.css';
section > h1,
section > h2 {
@apply font-disket mb-4 font-bold;
}
section > h1 {
@apply font-disket mb-4 text-2xl font-bold sm:text-4xl;
@apply text-2xl sm:text-4xl;
}
section > h2 {
@apply text-xl sm:text-3xl;
}
</style>

17
src/routes/+page.ts Normal file
View file

@ -0,0 +1,17 @@
import { fetchPostsSorted, sortPostsByPostAndUpdateDate } from "$src/lib/util/Blogs";
const LATEST_POSTS_COUNT = 3;
export async function load() {
let md: any
try {
md = await import("$src/pages/index.md");
} catch (ex) {
throw "Не удалось найти текст для главной страницы";
}
console.log("TEST", md.default);
return {
content: md.default,
posts: (await fetchPostsSorted(sortPostsByPostAndUpdateDate)).slice(0, LATEST_POSTS_COUNT)
};
}

View file

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

View file

@ -1,14 +1,28 @@
<script lang="ts">
import SocialButton from '$lib/components/SocialButton.svelte';
import { page } from '$app/state';
import SocialHyperlink from '$src/lib/components/SocialHyperlink.svelte';
import InfoBlock from '$src/lib/components/InfoBlock.svelte';
import BlogCard from '$src/lib/components/BlogCard.svelte';
function groupPostsByMonthYear(posts: App.BlogPost[]) {
const groupedPosts = new Map<string, App.BlogPost[]>();
posts.forEach((post) => {
const key = new Date(post.date!).toLocaleString('default', {
month: 'long',
year: 'numeric'
});
if (!groupedPosts.has(key)) groupedPosts.set(key, []);
groupedPosts.get(key)?.push(post);
});
return groupedPosts;
}
const groupedPosts = groupPostsByMonthYear(page.data.posts);
</script>
<svelte:head>
<title>Блог &mdash; Tea Sanctuary</title>
</svelte:head>
<section
class="flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4 hero-background"
>
<section class="hero flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4">
<div
class="flex flex-col flex-nowrap items-center justify-center gap-3 lg:flex-row lg:flex-nowrap"
>
@ -20,6 +34,26 @@
</div>
</section>
<InfoBlock>
Подпишитесь на нашу <SocialHyperlink href="/blog/rss.xml">RSS ленту</SocialHyperlink>, чтобы не
пропускать новые посты!
</InfoBlock>
<section class="flex flex-col items-stretch p-2 pb-8 md:p-4">
{#each groupedPosts.entries() as [monthYear, postsInMonthYear]}
<h1
class="mt-10 mb-4 text-left text-2xl font-bold underline decoration-4 md:text-center md:text-4xl md:decoration-8"
>
{monthYear}
</h1>
<div class="flex flex-col flex-wrap items-center gap-4">
{#each postsInMonthYear as post, i}
<BlogCard {post} />
{/each}
</div>
{/each}
</section>
<style>
@import "$src/app.css";
@import '$src/app.css';
</style>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import { page } from '$app/state';
import DateWidget from '$src/lib/components/DateWidget.svelte';
import InfoBlock from '$src/lib/components/InfoBlock.svelte';
import Icon from '@iconify/svelte';
import type { PageData } from './$types';
import { blogPostTypeToIcon, blogPostTypeToString } from '$src/lib/util/Blogs';
export let data: PageData;
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 = data.blogPost.type ?? 'article';
</script>
<base target="_blank" />
<svelte:head>
<meta name="twitter:card" content="summary_large_image" />
</svelte:head>
<section class="hero flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4">
<div
class="flex flex-col flex-nowrap items-center justify-center gap-3 lg:flex-row lg:flex-nowrap"
>
<div class="text-left">
<h1>{data.blogPost.title}</h1>
{#if data.blogPost.description}
<h2 class="text-gray">{data.blogPost.description}</h2>
{/if}
</div>
</div>
</section>
<section
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: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">
<Icon icon={blogPostTypeToIcon(type)} width={28} height={28} />
<span>
{blogPostTypeToString(type)}
</span>
</div>
{#each authors as author}
<!-- TODO: rndtrash: из-за 404 не даёт собрать сайт. href="/team/{author}" -->
<a class="flex items-center gap-2 p-1 text-lg font-bold" href="#">
<Icon icon="material-symbols:person" width={28} height={28} />
<span class="underline">
{author}
</span>
</a>
{/each}
</section>
{#if page.data.blogPost.projects?.length > 0}
<InfoBlock>
<p>В данной заметке упоминаются наши проекты:</p>
<ul>
{#each page.data.blogPost.projects as project}
<li>{project}</li>
{/each}
</ul>
</InfoBlock>
{/if}
<article
class="prose
sm:prose-xl
prose-slate
prose-code:break-words
prose-pre:drop-shadow-md
prose-headings:font-disket
prose-headings:mb-4
prose-headings:font-bold prose-headings:text-slate-950 prose-h1:text-2xl
prose-h1:sm:text-4xl prose-h2:text-xl
prose-h2:sm:text-3xl prose-p:text-justify
mx-auto
w-5xl
p-4
text-base
text-slate-950
sm:text-xl lg:p-8"
>
<svelte:component this={data.content} />
</article>
<style>
@import '$src/app.css';
</style>

View file

@ -0,0 +1,23 @@
import { resolveBlogPath, THUMBNAIL_DEFAULT } from "$src/lib/util/Blogs.js";
import { error } from "@sveltejs/kit";
export async function load({ params }) {
let post: any
try {
post = await import(`$src/blogs/${params.slug}.md`);
} catch (ex) {
error(404);
}
const blogPost: App.BlogPost = post.metadata;
const thumbnail = resolveBlogPath(params.slug, blogPost.thumbnail ?? THUMBNAIL_DEFAULT);
return {
title: `${blogPost.title} — Блог`,
description: blogPost.description,
thumbnail: thumbnail,
content: post.default,
blogPost: {
...blogPost
}
};
};

View file

@ -0,0 +1,66 @@
import { fetchPostsSorted, resolveBlogPath } from "$src/lib/util/Blogs";
export const prerender = true;
const feedUpdated = new Date();
function escapeXml(unsafe: string): string {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function makeThumbnail(post: App.BlogPost): string {
if (!post.thumbnail) return '';
const alt = !!post.thumbnailAlt ? ` alt="${escapeXml(post.thumbnailAlt)}"` : '';
return `<br><br><img src="https://teasanctuary.ru${resolveBlogPath(post.slug, post.thumbnail)}"${alt}>`;
}
function makeAuthors(post: App.BlogPost): string {
const authors =
(post.authors == null
? []
: typeof post.authors === 'string'
? [post.authors]
: post.authors)
.map(a => escapeXml(a));
if (authors.length === 0) return '';
let authorsString = authors[0];
if (authors.length > 1) {
const lastAuthor = authors.pop();
authorsString = `${authors.join(', ')} и ${lastAuthor}`;
}
return `\n<author>${authorsString}</author>`;
}
export async function GET({ setHeaders }) {
setHeaders({
'Cache-Control': 'max-age=0, s-maxage=3600',
'Content-Type': 'application/rss+xml',
});
const posts = await fetchPostsSorted();
return new Response(String(`<?xml version="1.0" encoding="UTF-8" ?>
<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>
<link>https://teasanctuary.ru/blog</link>
<ttl>1800</ttl>
<updated>${feedUpdated.toUTCString()}</updated>
${posts.map((post) => `<item>
<title>${escapeXml(post.title)}</title>
<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>
</item>`).join("\n")}
</channel>
</rss>`))
}

View file

@ -1,14 +1,9 @@
<script lang="ts">
import SocialButton from '$lib/components/SocialButton.svelte';
import WarningBlock from '$lib/components/WarningBlock.svelte';
import SocialHyperlink from '$src/lib/components/SocialHyperlink.svelte';
</script>
<svelte:head>
<title>Контакты &mdash; Tea Sanctuary</title>
</svelte:head>
<section
class="flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4 hero-background"
>
<section class="hero flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4">
<div
class="flex flex-col flex-nowrap items-center justify-center gap-3 lg:flex-row lg:flex-nowrap"
>
@ -20,6 +15,35 @@
</div>
</section>
<WarningBlock>Страница находится в разработке!</WarningBlock>
<section class="flex justify-center">
<div
class="flex w-5xl max-w-screen flex-col flex-nowrap gap-12 p-2 px-2 pt-12 pb-12 text-base sm:text-xl"
>
<section>
На данный момент вы можете связаться с администрацией сайта и участниками команды через
<SocialHyperlink href="https://teasanctuary.ru/discord">
нашу гильдию в Discord
</SocialHyperlink>:
</section>
<iframe
src="https://discord.com/widget?id=1176141874390638662&theme=dark"
title="Виджет Discord"
width="350"
height="350"
class="mx-auto"
allowtransparency
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";
@import '$src/app.css';
</style>

View file

@ -0,0 +1,3 @@
export async function load() {
return { title: "Контакты" };
}

View file

@ -1,14 +1,8 @@
<script lang="ts">
import SocialButton from '$lib/components/SocialButton.svelte';
import WarningBlock from '$lib/components/WarningBlock.svelte';
</script>
<svelte:head>
<title>Проекты &mdash; Tea Sanctuary</title>
</svelte:head>
<section
class="flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4 hero-background"
>
<section class="hero flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4">
<div
class="flex flex-col flex-nowrap items-center justify-center gap-3 lg:flex-row lg:flex-nowrap"
>
@ -20,6 +14,8 @@
</div>
</section>
<WarningBlock>Страница находится в разработке!</WarningBlock>
<style>
@import "$src/app.css";
@import '$src/app.css';
</style>

View file

@ -0,0 +1,3 @@
export async function load() {
return { title: "Проекты" };
}

View file

@ -1,14 +1,8 @@
<script lang="ts">
import SocialButton from '$lib/components/SocialButton.svelte';
import WarningBlock from '$lib/components/WarningBlock.svelte';
</script>
<svelte:head>
<title>Команда &mdash; Tea Sanctuary</title>
</svelte:head>
<section
class="flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4 hero-background"
>
<section class="hero flex shrink-0 flex-col items-center justify-center gap-5 overflow-hidden p-4">
<div
class="flex flex-col flex-nowrap items-center justify-center gap-3 lg:flex-row lg:flex-nowrap"
>
@ -20,6 +14,8 @@
</div>
</section>
<WarningBlock>Страница находится в разработке!</WarningBlock>
<style>
@import "$src/app.css";
@import '$src/app.css';
</style>

3
src/routes/team/+page.ts Normal file
View file

@ -0,0 +1,3 @@
export async function load() {
return { title: "Команда" };
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -6,6 +6,8 @@ User-agent: AhrefsBot
Disallow: /
User-agent: MJ12bot
Disallow: /
User-agent: GPTBot
Disallow: /
User-agent: *
Allow: /

View file

@ -1,8 +1,13 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { mdsvex } from 'mdsvex';
import mdsvexConfig from './mdsvex.config.js';
import autoImport from 'sveltekit-autoimport';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// https://flaviocopes.com/fix-dirname-not-defined-es-module-scope/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/** @type {import('@sveltejs/kit').Config} */
const config = {
@ -33,8 +38,11 @@ const config = {
},
extensions: ['.svelte', '.md'],
preprocess: [
vitePreprocess(),
mdsvex(mdsvexConfig),
vitePreprocess({ script: true }),
mdsvex({
extensions: ['.md'],
layout: join(__dirname, "./src/lib/components/MdsvexLayout.svelte")
}),
autoImport({
include: ['**/*.(svelte|md)'],
components: ['./src/lib/components/', { name: './src' }]

View file

@ -1,45 +1,12 @@
import plugin from 'tailwindcss/plugin';
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
// colors: {
// white: '#FFFFFF',
// transparentblue: '#2447f779',
// blue: '#2446f7',
// black: '#000000',
// transparentblack1: '#000000BB',
// transparentblack0: '#00000011',
// darkblue: '#091856',
// navyblue: '#0f2898',
// gray: '#e2e2e2',
// lightblue: '#0092ff',
// transparent: 'transparent'
// },
extend: {
fontFamily: {
sans: ['Lineyka', 'sans-serif'],
disket: ['Disket Mono', 'monospace'],
},
// height: {
// // 64 px for navbar
// screen: 'calc(100vh - 64px)'
// },
// dropShadow: {
// md: '0px 0px 2px #091856',
// hover: '0px 6px 2px #091856'
// },
// backgroundImage: {
// pixel: "url('/common/pixel-overlay.png')",
// 'pixel-dark': "url('/common/pixel-overlay-dark.png')",
// 'pixel-white': "url('/common/pixel-overlay-white.png')",
// 'pixel-large': "url('/common/pixel-overlay-large.png')"
// },
// backgroundSize: {
// pixel: '7px',
// 'pixel-lg': '14px'
// },
typography: {
DEFAULT: {
css: {