feat(web): polish catalog motion, empty states, and mobile now-playing
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
39aaaed969
commit
72567acd28
31 files changed
+939
-131
No files matched your search
@@ -227,12 +227,33 @@
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 8px;
|
||||
z-index: 50;
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--elev);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transform: translateY(-120%);
|
||||
}
|
||||
|
||||
.skip:focus {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 32px 36px;
|
||||
}
|
||||
|
||||
.routeIn {
|
||||
animation: pipuIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queueIcon } from '../shared/ui/icons';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
chevronRightIcon,
|
||||
storageFillIcon,
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||
import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
import { Avatar, cx, Dialog, Icon, Logo, Menu, PageSkeleton } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from './LanguageSwitch';
|
||||
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
|
||||
import { Ambient } from './Ambient';
|
||||
@@ -226,6 +226,9 @@ function Shell() {
|
||||
return (
|
||||
<div className={cx(s.shell, island && s.island)}>
|
||||
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
||||
<a href="#content" className={s.skip}>
|
||||
{t('nav.skip')}
|
||||
</a>
|
||||
<header className={s.top}>
|
||||
<div className={s.topLeft}>
|
||||
<Link to="/" className={s.logoLink}>
|
||||
@@ -284,17 +287,26 @@ function Shell() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<main className={s.content}>
|
||||
<Suspense fallback={null}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
<ShellMain />
|
||||
|
||||
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShellMain() {
|
||||
const key = useRouterState({ select: (st) => st.location.pathname });
|
||||
return (
|
||||
<main id="content" className={s.content} tabIndex={-1}>
|
||||
<div key={key} className={s.routeIn}>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Третье пятно фона — в цвете обложки текущего трека. */
|
||||
function ShellAmbient() {
|
||||
const player = usePlayer();
|
||||
|
||||
@@ -149,6 +149,115 @@ a.source:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.play:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.nowArt {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.nowEq {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.coverLive {
|
||||
animation: pipuCoverPulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pipuCoverPulse {
|
||||
50% {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
.queueScrim,
|
||||
.sheetScrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 15;
|
||||
background: var(--overlay);
|
||||
animation: pipuIn 0.18s ease-out;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 16;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
max-height: min(92dvh, 760px);
|
||||
padding: 12px 20px calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 24px 24px 0 0;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(28px) saturate(1.3);
|
||||
animation: pipuIn 0.22s ease-out;
|
||||
}
|
||||
|
||||
.sheetHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sheetCover {
|
||||
width: min(240px, 56vw) !important;
|
||||
height: min(240px, 56vw) !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.sheetMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sheetTitle {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sheetControls,
|
||||
.sheetExtra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sheet .progress {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.sheet .volume {
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.play[aria-busy='true'] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -218,6 +327,10 @@ a.source:hover {
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.now {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 0 12px;
|
||||
@@ -228,7 +341,7 @@ a.source:hover {
|
||||
.right,
|
||||
.controls > :first-child,
|
||||
.controls > :last-child,
|
||||
.now > button {
|
||||
.now > button:not(.nowArt) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -237,7 +350,7 @@ a.source:hover {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 100px;
|
||||
z-index: 6;
|
||||
z-index: 16;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100% - 32px));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||
import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
@@ -37,6 +37,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const narrow = useNarrow();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
@@ -97,8 +99,24 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
|
||||
return (
|
||||
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
||||
<div className={s.now}>
|
||||
<Cover album={track.album} size={52} />
|
||||
<div
|
||||
className={s.now}
|
||||
onClick={
|
||||
narrow
|
||||
? (e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{narrow ? (
|
||||
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
|
||||
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
|
||||
</button>
|
||||
) : (
|
||||
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||
)}
|
||||
<div className={s.meta}>
|
||||
<span className={s.title}>{track.title}</span>
|
||||
<span className={s.artists}>
|
||||
@@ -232,12 +250,150 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
{sheetOpen && narrow && (
|
||||
<NowPlayingSheet
|
||||
variant={variant}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
share={share}
|
||||
sharing={sharing}
|
||||
/>
|
||||
)}
|
||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function useNarrow() {
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.('(max-width: 767px)');
|
||||
if (!mq) return;
|
||||
const sync = () => setNarrow(mq.matches);
|
||||
sync();
|
||||
mq.addEventListener?.('change', sync);
|
||||
return () => mq.removeEventListener?.('change', sync);
|
||||
}, []);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
function NowPlayingSheet({
|
||||
variant,
|
||||
onClose,
|
||||
share,
|
||||
sharing,
|
||||
}: {
|
||||
variant: 'bar' | 'island';
|
||||
onClose: () => void;
|
||||
share: () => Promise<void>;
|
||||
sharing: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const track = st.track;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [onClose]);
|
||||
if (!track) return null;
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||
return createPortal(
|
||||
<div className={s.sheetScrim} onClick={onClose}>
|
||||
<div
|
||||
className={cx(s.sheet, variant === 'island' && s.sheetIsland)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.now_playing')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={s.sheetHead}>
|
||||
<span>{t('player.now_playing')}</span>
|
||||
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||
<Icon src={closeIcon} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<Cover album={track.album} size={240} className={cx(s.sheetCover, st.playing && s.coverLive)} />
|
||||
<div className={s.sheetMeta}>
|
||||
<Link to="/track/$id" params={{ id: track.id }} className={s.sheetTitle} onClick={onClose}>
|
||||
{track.title}
|
||||
</Link>
|
||||
<span className={s.artists}>
|
||||
<ArtistLinks artists={track.artists} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.progress}>
|
||||
<span className={s.time}>{formatDuration(st.positionMs)}</span>
|
||||
<input
|
||||
type="range"
|
||||
className={s.range}
|
||||
min={0}
|
||||
max={st.durationMs || track.duration_ms}
|
||||
step={1000}
|
||||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||
aria-label={t('player.progress')}
|
||||
onChange={(e) => player.seek(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||
</div>
|
||||
<div className={s.sheetControls}>
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
|
||||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
|
||||
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.sheetExtra}>
|
||||
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
|
||||
<Icon src={shuffleIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||
<Icon src={shareIcon} size={20} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
|
||||
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={22} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className={cx(s.range, s.volume)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={st.muted ? 0 : st.volume}
|
||||
aria-label={t('player.volume')}
|
||||
onChange={(e) => player.setVolume(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
||||
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -256,7 +412,9 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
}, [onClose]);
|
||||
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
|
||||
return createPortal(
|
||||
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||||
<>
|
||||
<div className={s.queueScrim} onClick={onClose} />
|
||||
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||||
<div className={s.drawerHead}>
|
||||
<span>{t('player.queue')}</span>
|
||||
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||
@@ -282,7 +440,8 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
))}
|
||||
</div>
|
||||
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
|
||||
</aside>,
|
||||
</aside>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover } from '../entities/catalog';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { searchIcon } from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
@@ -36,6 +37,7 @@ export function SearchBox({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const navigate = useNavigate();
|
||||
const listId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -118,8 +120,7 @@ export function SearchBox({
|
||||
title: tr.title,
|
||||
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
|
||||
cover: tr.album,
|
||||
// Воспроизведение — M3; пока трек открывает свой альбом.
|
||||
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
|
||||
go: () => player.playList([tr], 0, { type: 'search', id: debounced, label: debounced }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,7 +150,8 @@ export function SearchBox({
|
||||
}
|
||||
};
|
||||
|
||||
const showList = open && debounced !== '' && data !== undefined;
|
||||
const showPopover = open && debounced !== '';
|
||||
const showList = showPopover && data !== undefined;
|
||||
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
|
||||
const optionId = (i: number) => `${listId}-${i}`;
|
||||
|
||||
@@ -188,12 +190,17 @@ export function SearchBox({
|
||||
<kbd className={s.kbd} aria-hidden>
|
||||
/
|
||||
</kbd>
|
||||
{showList && (
|
||||
{showPopover && (
|
||||
<div className={s.popover}>
|
||||
{data.corrected_query && (
|
||||
{showPopover && !showList && (
|
||||
<div className={s.nothing} role="status">
|
||||
{t('search.searching')}
|
||||
</div>
|
||||
)}
|
||||
{data?.corrected_query && (
|
||||
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
|
||||
)}
|
||||
<div id={listId} role="listbox" aria-label={t('search.label')}>
|
||||
{showList && <div id={listId} role="listbox" aria-label={t('search.label')}>
|
||||
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
|
||||
{groups.map((g) => {
|
||||
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
|
||||
@@ -221,7 +228,7 @@ export function SearchBox({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>}
|
||||
<button
|
||||
type="button"
|
||||
className={s.all}
|
||||
|
||||
@@ -129,6 +129,15 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
aria-pressed={interactive ? !!props.playing : undefined}
|
||||
aria-hidden={interactive ? undefined : true}
|
||||
onMouseMove={interactive ? onMove : undefined}
|
||||
onFocus={interactive ? () => (s.hoverT = 1) : undefined}
|
||||
onBlur={
|
||||
interactive
|
||||
? () => {
|
||||
s.hoverT = 0;
|
||||
s.pressT = 0;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
|
||||
onMouseLeave={
|
||||
interactive
|
||||
|
||||
Reference in new issue
Block a user