События и первый конкурс #6
3 changed files with 200 additions and 5 deletions
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,6 +1,28 @@
|
||||||
export const THUMBNAIL_DEFAULT = "https://teasanctuary.ru/common/background-day.webp";
|
export const THUMBNAIL_DEFAULT = "https://teasanctuary.ru/common/background-day.webp";
|
||||||
export const BLOG_POST_FRESHNESS_MILLIS = 3 * 24 * 60 * 60 * 1000; // 3 дня
|
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 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 sortPostsByPostDate: PostComparer = (a, b) => new Date(b.date!).valueOf() - new Date(a.date!).valueOf();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import DateWidget from '$src/lib/components/DateWidget.svelte';
|
import DateWidget from '$lib/components/DateWidget.svelte';
|
||||||
import InfoBlock from '$src/lib/components/InfoBlock.svelte';
|
import InfoBlock from '$lib/components/InfoBlock.svelte';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import type { PageData } from './$types';
|
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 isPublic = !!data.blogPost.date;
|
||||||
const authors =
|
const authors =
|
||||||
|
|
@ -16,6 +24,28 @@
|
||||||
? [data.blogPost.authors]
|
? [data.blogPost.authors]
|
||||||
: data.blogPost.authors;
|
: data.blogPost.authors;
|
||||||
const type: App.BlogPostType = data.blogPost.type ?? 'article';
|
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>
|
</script>
|
||||||
|
|
||||||
<base target="_blank" />
|
<base target="_blank" />
|
||||||
|
|
@ -63,6 +93,50 @@
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</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}
|
{#if page.data.blogPost.projects?.length > 0}
|
||||||
<InfoBlock>
|
<InfoBlock>
|
||||||
<p>В данной заметке упоминаются наши проекты:</p>
|
<p>В данной заметке упоминаются наши проекты:</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue