125 lines
4.0 KiB
TypeScript
125 lines
4.0 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { useRouter } from 'next/router';
|
|
import Link from 'next/link';
|
|
import { Card } from '@/components/card';
|
|
import { CursorEffect } from '@/components/cursor';
|
|
import Mask from '@/assets/images/cities-star-mask.svg';
|
|
import LinkIcon from '@/assets/icons/link.svg';
|
|
import type { City } from '@/lib/ridebus';
|
|
import useLocale from '@/utils/useLocale';
|
|
import useFrame from '@/utils/useFrame';
|
|
import useMediaQuery from '@/utils/useMediaQuery';
|
|
import { useResource } from '../departuresCard/useResource';
|
|
import { locales } from './locales';
|
|
import styles from './citiesCard.module.scss';
|
|
|
|
function CityBadge({ city, hidden = false }: { city: City; hidden?: boolean }) {
|
|
return (
|
|
<li className={styles.cityBadge} aria-hidden={hidden || undefined}>
|
|
<span>{city.name}</span>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export function CitiesCard() {
|
|
const t = useLocale(locales);
|
|
const { locale } = useRouter();
|
|
const [revision, setRevision] = useState(0);
|
|
const cities = useResource<City[]>(
|
|
'/api/departures?resource=cities',
|
|
revision
|
|
);
|
|
const backdrop = useRef<HTMLDivElement>(null);
|
|
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
const cityList = cities.data ?? [];
|
|
const count = cityList.length;
|
|
// Each half fills the card even with a short city list, so the loop has no gaps.
|
|
const ribbonCities = Array.from(
|
|
{ length: Math.max(1, Math.ceil(8 / Math.max(1, count))) },
|
|
() => cityList
|
|
).flat();
|
|
const plural = new Intl.PluralRules(locale === 'ru' ? 'ru' : 'en').select(
|
|
count
|
|
);
|
|
|
|
useFrame((delta, time) => {
|
|
if (backdrop.current && !reducedMotion)
|
|
backdrop.current.style.transform = `rotate(${(time * 0.005) % 360}deg)`;
|
|
});
|
|
|
|
return (
|
|
<Card
|
|
className={styles.card}
|
|
title={t('title')}
|
|
subtitle={t('description')}
|
|
classes={{ content: styles.content }}
|
|
>
|
|
<div ref={backdrop} className={styles.backdrop} aria-hidden="true">
|
|
<Mask />
|
|
</div>
|
|
<div className={styles.catalog}>
|
|
{cities.status === 'ready' && count > 0 ? (
|
|
<>
|
|
<p className={styles.count}>
|
|
<strong>{count}</strong>
|
|
<span>
|
|
{t(`cities_${plural}`)}
|
|
<br />
|
|
{t('inRideBus')}
|
|
</span>
|
|
</p>
|
|
<div className={styles.marquee}>
|
|
<div className={styles.marqueeTrack}>
|
|
<ul className={styles.cities} aria-label={t('availableCities')}>
|
|
{ribbonCities.map((city, index) => (
|
|
<CityBadge
|
|
key={`${city.id}:${index}`}
|
|
city={city}
|
|
hidden={index >= count}
|
|
/>
|
|
))}
|
|
</ul>
|
|
<ul className={styles.cities} aria-hidden="true">
|
|
{ribbonCities.map((city, index) => (
|
|
<CityBadge key={`${city.id}:${index}`} city={city} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className={styles.status} role="status">
|
|
<p>
|
|
{t(
|
|
cities.status === 'error'
|
|
? 'unavailable'
|
|
: cities.status === 'ready'
|
|
? 'empty'
|
|
: 'loading'
|
|
)}
|
|
</p>
|
|
{cities.status === 'error' && (
|
|
<CursorEffect cursorBorderRadius={16} cursorPadding={4}>
|
|
<button type="button" onClick={() => setRevision((r) => r + 1)}>
|
|
{t('retry')}
|
|
</button>
|
|
</CursorEffect>
|
|
)}
|
|
</div>
|
|
)}
|
|
<CursorEffect
|
|
className={styles.contribute}
|
|
cursorBorderRadius={16}
|
|
cursorPadding={4}
|
|
effectForce={3}
|
|
>
|
|
<Link href="/contribute">
|
|
<span>{t('helpCity')}</span>
|
|
<LinkIcon aria-hidden="true" />
|
|
</Link>
|
|
</CursorEffect>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|