Compare commits
2 commits
b0bd04b8d6
...
703695ffe5
| Author | SHA1 | Date | |
|---|---|---|---|
| 703695ffe5 | |||
| a6bccea822 |
9 changed files with 334 additions and 22 deletions
|
|
@ -10,12 +10,12 @@ module.exports = {
|
|||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
ecmaVersion: 2024,
|
||||
extraFileExtensions: ['.svelte']
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
es2024: true,
|
||||
node: true
|
||||
},
|
||||
rules: {
|
||||
|
|
|
|||
51
package-lock.json
generated
51
package-lock.json
generated
|
|
@ -7,6 +7,9 @@
|
|||
"": {
|
||||
"name": "teasanctuary-ru",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.7.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^4.2.0",
|
||||
"@react2svelte/swipeable": "^0.1.4",
|
||||
|
|
@ -619,6 +622,47 @@
|
|||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
|
||||
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "2.2.7",
|
||||
"@formatjs/intl-localematcher": "0.6.2",
|
||||
"decimal.js": "^10.4.3",
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/fast-memoize": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
|
||||
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/intl-durationformat": {
|
||||
"version": "0.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.7.6.tgz",
|
||||
"integrity": "sha512-jatAN3E84X6aP2UOGK1jTrwD1a7BiG3qWUSEDAhtyNd1BgYeS5wQPtXlnuGF1QRx0DjnwwNOIssyd7oQoRlQeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "2.3.6",
|
||||
"@formatjs/intl-localematcher": "0.6.2",
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/intl-localematcher": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
|
||||
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
|
@ -1960,6 +2004,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -4097,7 +4147,6 @@
|
|||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
|
|
|
|||
|
|
@ -41,5 +41,8 @@
|
|||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.7.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
src/app.d.ts
vendored
13
src/app.d.ts
vendored
|
|
@ -1,4 +1,17 @@
|
|||
import type {
|
||||
DurationFormatConstructor,
|
||||
DurationFormatOptions as _DurationFormatOptions,
|
||||
DurationInput as _DurationInput,
|
||||
} from '@formatjs/intl-durationformat/src/types';
|
||||
|
||||
declare global {
|
||||
// rndtrash: Терпим. https://github.com/microsoft/TypeScript/issues/60608
|
||||
namespace Intl {
|
||||
const DurationFormat: DurationFormatConstructor;
|
||||
type DurationFormatOptions = _DurationFormatOptions;
|
||||
type DurationInput = _DurationInput;
|
||||
}
|
||||
|
||||
namespace App {
|
||||
interface Route {
|
||||
label: string;
|
||||
|
|
|
|||
99
src/lib/components/CountdownClock.svelte
Normal file
99
src/lib/components/CountdownClock.svelte
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<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,15 +1,27 @@
|
|||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { dateFormatLong, dateFormatShort } from '$lib/util/Dates';
|
||||
|
||||
let className: string = '';
|
||||
export { className as class };
|
||||
let {
|
||||
dateString,
|
||||
type = undefined,
|
||||
highlight = false,
|
||||
showTime = false,
|
||||
class: className = ''
|
||||
}: {
|
||||
dateString?: string;
|
||||
type?: 'published' | 'updated';
|
||||
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: 'material-symbols:calendar-today',
|
||||
updated: 'material-symbols:update'
|
||||
};
|
||||
|
||||
const icon =
|
||||
type == 'published' ? 'material-symbols:calendar-today' : 'material-symbols:update';
|
||||
const icon = type ? typeToIcon[type] : undefined;
|
||||
|
||||
const highlightClasses = (classes: string) => (highlight ? classes : '');
|
||||
</script>
|
||||
|
|
@ -19,14 +31,12 @@
|
|||
{highlightClasses(type == 'published' ? 'bg-amber-600' : 'bg-purple-600')}
|
||||
{highlightClasses('text-slate-50')}"
|
||||
>
|
||||
<Icon {icon} width={28} height={28} />
|
||||
{#if icon}
|
||||
<Icon {icon} width={28} height={28} />
|
||||
{/if}
|
||||
<span class="text-nowrap">
|
||||
{dateString
|
||||
? new Date(dateString).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
? (showTime ? dateFormatLong : dateFormatShort).format(new Date(dateString))
|
||||
: 'Не опубликован!'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,28 @@
|
|||
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 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();
|
||||
|
|
|
|||
42
src/lib/util/Dates.ts
Normal file
42
src/lib/util/Dates.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { shouldPolyfill } from '@formatjs/intl-durationformat/should-polyfill'
|
||||
if (shouldPolyfill()) {
|
||||
import('@formatjs/intl-durationformat/polyfill-force');
|
||||
}
|
||||
|
||||
export const dateFormatShort = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
export const dateFormatLong = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
export const dateDurationLong = new Intl.DurationFormat(undefined, {
|
||||
style: "long"
|
||||
});
|
||||
|
||||
export const dateDurationLongBackup = new Intl.DurationFormat(undefined, {
|
||||
style: "long",
|
||||
seconds: "long"
|
||||
});
|
||||
|
||||
export const durationHumanReadable = (a: Date, b: Date) => durationHumanReadableMillis(b.valueOf() - a.valueOf());
|
||||
|
||||
export function durationHumanReadableMillis(dur: number): string {
|
||||
dur = Math.max(dur, 0);
|
||||
const seconds = dur / 1000;
|
||||
const minutes = dur / (1000 * 60);
|
||||
const hours = dur / (1000 * 60 * 60);
|
||||
const days = dur / (1000 * 60 * 60 * 24);
|
||||
|
||||
const formatObject = { days: Math.floor(days), hours: Math.floor(hours) % 24, minutes: Math.floor(minutes) % 60, seconds: Math.floor(seconds) % 60 };
|
||||
const result = dateDurationLong.format(formatObject);
|
||||
// Если на выходе получаем пустую строку, то хоть выведем 0 секунд
|
||||
return result === '' ? dateDurationLongBackup.format(formatObject) : result;
|
||||
}
|
||||
|
|
@ -1,12 +1,20 @@
|
|||
<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 DateWidget from '$lib/components/DateWidget.svelte';
|
||||
import InfoBlock from '$lib/components/InfoBlock.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { blogPostTypeToIcon, blogPostTypeToString } from '$src/lib/util/Blogs';
|
||||
import {
|
||||
blogPostTypeToIcon,
|
||||
blogPostTypeToString,
|
||||
EventStatus,
|
||||
postEventStatus
|
||||
} from '$lib/util/Blogs';
|
||||
import { onMount } from 'svelte';
|
||||
import { durationHumanReadable } from '$lib/util/Dates';
|
||||
import CountdownClock from '$lib/components/CountdownClock.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const isPublic = !!data.blogPost.date;
|
||||
const authors =
|
||||
|
|
@ -16,6 +24,28 @@
|
|||
? [data.blogPost.authors]
|
||||
: data.blogPost.authors;
|
||||
const type: App.BlogPostType = data.blogPost.type ?? 'article';
|
||||
let eventStatus = $state(EventStatus.NotEvent);
|
||||
|
||||
onMount(() => {
|
||||
// HACK: rndtrash: NodeJS использует другой тип данных для таймаутов
|
||||
let eventTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
const updateStatus = () => {
|
||||
eventStatus = postEventStatus(data.blogPost);
|
||||
if (eventStatus !== EventStatus.NotEvent && eventStatus !== EventStatus.IsOver) {
|
||||
const endpointString =
|
||||
eventStatus === EventStatus.NotStarted
|
||||
? data.blogPost.dateEventFrom!
|
||||
: data.blogPost.dateEventTo!;
|
||||
const delay = new Date(endpointString).valueOf() - new Date().valueOf();
|
||||
if (delay <= 0) return;
|
||||
// Плюс пол секунды, чтобы анимация часов успела проиграть
|
||||
eventTimeout = setTimeout(updateStatus, delay + 500);
|
||||
}
|
||||
};
|
||||
updateStatus();
|
||||
|
||||
return () => clearTimeout(eventTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<base target="_blank" />
|
||||
|
|
@ -63,6 +93,50 @@
|
|||
{/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>
|
||||
|
|
@ -93,4 +167,4 @@
|
|||
sm:text-xl lg:p-8"
|
||||
>
|
||||
<svelte:component this={data.content} />
|
||||
</article>
|
||||
</article>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue