mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-28 22:31:22 -05:00
Migrate react-flip-toolkit to React 19.3 ViewTransition
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
.mobileNav {
|
||||
display: block;
|
||||
position: fixed;
|
||||
/* captured on its own so animating content slides under it instead of over it */
|
||||
view-transition-name: layout-mobile-nav;
|
||||
inset: auto 0 0 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
@@ -403,3 +405,13 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-group(layout-mobile-nav) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-group(layout-mobile-nav),
|
||||
::view-transition-old(layout-mobile-nav),
|
||||
::view-transition-new(layout-mobile-nav) {
|
||||
animation-duration: 0s;
|
||||
}
|
||||
|
||||
@@ -30,15 +30,14 @@
|
||||
border: 2px solid var(--color-info);
|
||||
width: 250px;
|
||||
outline: none;
|
||||
view-transition-class: toast;
|
||||
}
|
||||
|
||||
::view-transition-new(.toast):only-child {
|
||||
::view-transition-new(:global(.toast)):only-child {
|
||||
animation: appear 400ms;
|
||||
}
|
||||
|
||||
::view-transition-old(.toast):only-child {
|
||||
animation: disappear 400ms;
|
||||
::view-transition-old(:global(.toast)):only-child {
|
||||
animation: disappear 400ms forwards;
|
||||
}
|
||||
|
||||
@keyframes disappear {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import clsx from "clsx";
|
||||
import { Check, CircleAlert, OctagonAlert, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { ViewTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import { finishUpdateIfUnmoved } from "~/utils/view-transition";
|
||||
import { SendouButton } from "./Button";
|
||||
import styles from "./Toast.module.css";
|
||||
|
||||
@@ -42,27 +44,16 @@ class ToastQueue {
|
||||
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
getSnapshot = () => this.toasts;
|
||||
|
||||
private update(mutate: () => void) {
|
||||
const notify = () => {
|
||||
mutate();
|
||||
flushSync(() => {
|
||||
for (const listener of this.listeners) listener();
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof document !== "undefined" && "startViewTransition" in document) {
|
||||
const transition = document.startViewTransition(notify);
|
||||
// rejects with AbortError if another transition interrupts this one
|
||||
transition.ready.catch(() => {});
|
||||
} else {
|
||||
mutate();
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
mutate();
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,14 +63,20 @@ const EMPTY_TOASTS: QueuedToast[] = [];
|
||||
|
||||
export function SendouToastRegion() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const toasts = React.useSyncExternalStore(
|
||||
toastQueue.subscribe,
|
||||
toastQueue.getSnapshot,
|
||||
() => EMPTY_TOASTS,
|
||||
);
|
||||
const [toasts, setToasts] = React.useState(EMPTY_TOASTS);
|
||||
React.useEffect(() => {
|
||||
const sync = () => {
|
||||
React.startTransition(() => setToasts(toastQueue.getSnapshot()));
|
||||
};
|
||||
const unsubscribe = toastQueue.subscribe(sync);
|
||||
// a toast added before this subscription (e.g. from a layout effect on load)
|
||||
sync();
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
const regionRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// layout effect so the region is open before the entering toast gets snapshotted
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const region = regionRef.current;
|
||||
if (!region) return;
|
||||
if (toasts.length > 0 && !region.matches(":popover-open")) {
|
||||
@@ -97,35 +94,40 @@ export function SendouToastRegion() {
|
||||
className={clsx(styles.toastRegion, { hidden: IS_E2E_TEST_RUN })}
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
<ViewTransition
|
||||
key={toast.key}
|
||||
role={toast.content.variant === "error" ? "alert" : "status"}
|
||||
style={{ viewTransitionName: toast.key }}
|
||||
className={clsx(styles.toast, {
|
||||
[styles.errorToast]: toast.content.variant === "error",
|
||||
[styles.successToast]: toast.content.variant === "success",
|
||||
[styles.infoToast]: toast.content.variant === "info",
|
||||
})}
|
||||
enter="toast"
|
||||
exit="toast"
|
||||
onUpdate={finishUpdateIfUnmoved}
|
||||
>
|
||||
<div className={styles.topRow}>
|
||||
{toast.content.variant === "success" ? (
|
||||
<Check className={styles.alertIcon} />
|
||||
) : toast.content.variant === "error" ? (
|
||||
<OctagonAlert className={styles.alertIcon} />
|
||||
) : (
|
||||
<CircleAlert className={styles.alertIcon} />
|
||||
)}
|
||||
{t(`common:toasts.${toast.content.variant}`)}
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
icon={<X />}
|
||||
className={styles.closeButton}
|
||||
aria-label="Close"
|
||||
onClick={() => toastQueue.close(toast.key)}
|
||||
/>
|
||||
<div
|
||||
role={toast.content.variant === "error" ? "alert" : "status"}
|
||||
className={clsx(styles.toast, {
|
||||
[styles.errorToast]: toast.content.variant === "error",
|
||||
[styles.successToast]: toast.content.variant === "success",
|
||||
[styles.infoToast]: toast.content.variant === "info",
|
||||
})}
|
||||
>
|
||||
<div className={styles.topRow}>
|
||||
{toast.content.variant === "success" ? (
|
||||
<Check className={styles.alertIcon} />
|
||||
) : toast.content.variant === "error" ? (
|
||||
<OctagonAlert className={styles.alertIcon} />
|
||||
) : (
|
||||
<CircleAlert className={styles.alertIcon} />
|
||||
)}
|
||||
{t(`common:toasts.${toast.content.variant}`)}
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
icon={<X />}
|
||||
className={styles.closeButton}
|
||||
aria-label="Close"
|
||||
onClick={() => toastQueue.close(toast.key)}
|
||||
/>
|
||||
</div>
|
||||
<div>{toast.content.message}</div>
|
||||
</div>
|
||||
<div>{toast.content.message}</div>
|
||||
</div>
|
||||
</ViewTransition>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -17,12 +17,18 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
/* captured on its own so animating content slides under it instead of over it */
|
||||
view-transition-name: layout-header;
|
||||
}
|
||||
|
||||
.siteTitleFlipper {
|
||||
min-width: 0;
|
||||
padding: 3px;
|
||||
margin: -3px;
|
||||
::view-transition-group(layout-header) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-group(layout-header),
|
||||
::view-transition-old(layout-header),
|
||||
::view-transition-new(layout-header) {
|
||||
animation-duration: 0s;
|
||||
}
|
||||
|
||||
.siteTitle {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Flipped, Flipper } from "react-flip-toolkit";
|
||||
import { ViewTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useFetcher, useLocation, useMatches } from "react-router";
|
||||
import { Config } from "~/config";
|
||||
@@ -24,7 +24,6 @@ import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigati
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { MOBILE_LAYOUT_QUERY, useLayoutSize } from "~/hooks/useLayoutSize";
|
||||
import { useMediaQuery } from "~/hooks/useMediaQuery";
|
||||
import { usePrefersReducedMotion } from "~/hooks/usePrefersReducedMotion";
|
||||
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
|
||||
import { useVisualViewportHeight } from "~/hooks/useVisualViewportHeight";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
EVENTS_PAGE,
|
||||
FRIENDS_PAGE,
|
||||
navIconUrl,
|
||||
PLANNER_URL,
|
||||
SETTINGS_PAGE,
|
||||
teamPage,
|
||||
userPage,
|
||||
@@ -508,58 +506,52 @@ export function Layout({
|
||||
|
||||
function SiteTitle() {
|
||||
const location = useLocation();
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const { breadcrumbs, currentPageText } = useBreadcrumbData();
|
||||
|
||||
const isFrontPage = location.pathname === "/";
|
||||
const hasBreadcrumbs = breadcrumbs.length > 0;
|
||||
|
||||
return (
|
||||
<Flipper
|
||||
flipKey={isFrontPage ? "front" : "other"}
|
||||
className={styles.siteTitleFlipper}
|
||||
decisionData={{ pathname: location.pathname }}
|
||||
>
|
||||
<div className={styles.siteTitle}>
|
||||
<Flipped
|
||||
flipId="site-logo"
|
||||
shouldFlip={(prev, current) =>
|
||||
!prefersReducedMotion &&
|
||||
prev?.pathname !== PLANNER_URL &&
|
||||
current?.pathname !== PLANNER_URL
|
||||
}
|
||||
>
|
||||
<Link to="/" className={styles.siteLogo}>
|
||||
<SiteLogoContent />
|
||||
</Link>
|
||||
</Flipped>
|
||||
<div className={styles.siteTitle}>
|
||||
{/* the key remounts the logo when it changes place so it animates as a
|
||||
shared element; an update animation would instead run on every
|
||||
navigation since React can't tell the logo didn't move */}
|
||||
<ViewTransition
|
||||
key={isFrontPage ? "front" : "other"}
|
||||
name="site-logo"
|
||||
share="auto"
|
||||
default="none"
|
||||
>
|
||||
<Link to="/" className={styles.siteLogo}>
|
||||
<SiteLogoContent />
|
||||
</Link>
|
||||
</ViewTransition>
|
||||
|
||||
{hasBreadcrumbs ? (
|
||||
<>
|
||||
{breadcrumbs.map((crumb) => {
|
||||
const isCurrentPage = location.pathname === crumb.href;
|
||||
{hasBreadcrumbs ? (
|
||||
<>
|
||||
{breadcrumbs.map((crumb) => {
|
||||
const isCurrentPage = location.pathname === crumb.href;
|
||||
|
||||
return (
|
||||
<React.Fragment key={crumb.href}>
|
||||
<span className={styles.separator}>/</span>
|
||||
{isCurrentPage ? (
|
||||
return (
|
||||
<React.Fragment key={crumb.href}>
|
||||
<span className={styles.separator}>/</span>
|
||||
{isCurrentPage ? (
|
||||
<PageIcon crumb={crumb} />
|
||||
) : (
|
||||
<Link to={crumb.href} className={styles.breadcrumbLink}>
|
||||
<PageIcon crumb={crumb} />
|
||||
) : (
|
||||
<Link to={crumb.href} className={styles.breadcrumbLink}>
|
||||
<PageIcon crumb={crumb} />
|
||||
</Link>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Link>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{currentPageText ? (
|
||||
<span className={styles.pageName}>{currentPageText}</span>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Flipper>
|
||||
{currentPageText ? (
|
||||
<span className={styles.pageName}>{currentPageText}</span>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from "clsx";
|
||||
import type { SqlBool } from "kysely";
|
||||
import { Check, Hourglass, Mic, Volume2, VolumeX } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Flipped } from "react-flip-toolkit";
|
||||
import { ViewTransition } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { ActionButton } from "~/components/ActionButton";
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
TIERS_PAGE,
|
||||
tierImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import { finishUpdateIfUnmoved } from "~/utils/view-transition";
|
||||
import type {
|
||||
SQGroup,
|
||||
SQGroupMember,
|
||||
@@ -60,7 +61,6 @@ export function GroupCard({
|
||||
ownGroup,
|
||||
readyUserIds,
|
||||
kickableUserIds,
|
||||
layout = "desktop",
|
||||
}: {
|
||||
group: SQGroup | SQOwnGroup;
|
||||
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE";
|
||||
@@ -78,7 +78,6 @@ export function GroupCard({
|
||||
readyUserIds?: number[];
|
||||
/** Members the viewer can kick out of the group. */
|
||||
kickableUserIds?: number[];
|
||||
layout?: "mobile" | "desktop";
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
@@ -98,11 +97,7 @@ export function GroupCard({
|
||||
const actionToShow = ownGroup ? action : undefined;
|
||||
|
||||
return (
|
||||
<GroupCardContainer
|
||||
groupId={group.id}
|
||||
isOwnGroup={isOwnGroup}
|
||||
layout={layout}
|
||||
>
|
||||
<GroupCardContainer isOwnGroup={isOwnGroup}>
|
||||
<section
|
||||
className={clsx(styles.group, { [styles.suggested]: isSuggested })}
|
||||
data-testid="sendouq-group-card"
|
||||
@@ -307,18 +302,18 @@ function GroupCardTrailText({
|
||||
|
||||
function GroupCardContainer({
|
||||
isOwnGroup,
|
||||
groupId,
|
||||
layout,
|
||||
children,
|
||||
}: {
|
||||
isOwnGroup: boolean;
|
||||
groupId: number;
|
||||
layout: "mobile" | "desktop";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (isOwnGroup) return <>{children}</>;
|
||||
|
||||
return <Flipped flipId={`${layout}-${groupId}`}>{children}</Flipped>;
|
||||
return (
|
||||
<ViewTransition update="card-update" onUpdate={finishUpdateIfUnmoved}>
|
||||
{children}
|
||||
</ViewTransition>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupMember({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
import { Flipper } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
@@ -224,13 +223,10 @@ function Groups() {
|
||||
|
||||
const width = useMainContentWidth();
|
||||
|
||||
// width === 0 means the main content hasn't been measured yet; rendering the
|
||||
// Flipper before measurement makes it snapshot the width-0 (mobile) default
|
||||
// layout and then morph every card into the real layout on first navigation
|
||||
// width === 0 means the main content hasn't been measured yet
|
||||
if (!isHydrated || width === 0) return null;
|
||||
|
||||
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
|
||||
const layout = isMobile ? "mobile" : "desktop";
|
||||
const isFullGroup =
|
||||
data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE;
|
||||
|
||||
@@ -294,7 +290,6 @@ function Groups() {
|
||||
trail={trailOf(group.id)}
|
||||
isSuggested={suggestedGroupIds.has(group.id)}
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -333,153 +328,135 @@ function Groups() {
|
||||
data.likes.received.some((like) => like.groupId === group.id),
|
||||
);
|
||||
|
||||
// no animations needed if liking group on mobile as they stay in place
|
||||
const flipKey = `${neutralGroups
|
||||
.map(
|
||||
(g) =>
|
||||
`${g.id}-${isMobile ? true : data.likes.given.some((l) => l.groupId === g.id)}`,
|
||||
)
|
||||
.join(":")};${groupsReceivedLikesFrom.map((g) => g.id).join(":")}`;
|
||||
|
||||
return (
|
||||
<Flipper flipKey={flipKey}>
|
||||
<div
|
||||
className={clsx(styles.container, {
|
||||
[styles.containerMobile]: isMobile,
|
||||
})}
|
||||
>
|
||||
{!isMobile ? <div>{ownGroupElement}</div> : null}
|
||||
<div className={styles.innerContainer}>
|
||||
<SendouTabs>
|
||||
<SendouTabList>
|
||||
<SendouTab id="groups" number={neutralGroups.length}>
|
||||
{t("q:looking.columns.groups")}
|
||||
<div
|
||||
className={clsx(styles.container, {
|
||||
[styles.containerMobile]: isMobile,
|
||||
})}
|
||||
>
|
||||
{!isMobile ? <div>{ownGroupElement}</div> : null}
|
||||
<div className={styles.innerContainer}>
|
||||
<SendouTabs>
|
||||
<SendouTabList>
|
||||
<SendouTab id="groups" number={neutralGroups.length}>
|
||||
{t("q:looking.columns.groups")}
|
||||
</SendouTab>
|
||||
{isMobile ? (
|
||||
<SendouTab id="received" number={groupsReceivedLikesFrom.length}>
|
||||
{t(
|
||||
isFullGroup
|
||||
? "q:looking.columns.challenges"
|
||||
: "q:looking.columns.invitations",
|
||||
)}
|
||||
</SendouTab>
|
||||
{isMobile ? (
|
||||
<SendouTab
|
||||
id="received"
|
||||
number={groupsReceivedLikesFrom.length}
|
||||
>
|
||||
{t(
|
||||
isFullGroup
|
||||
? "q:looking.columns.challenges"
|
||||
: "q:looking.columns.invitations",
|
||||
)}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
{isMobile && data.ownGroup ? (
|
||||
<SendouTab id="own" number={data.ownGroup.members.length}>
|
||||
{t("q:looking.columns.myGroup")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="groups">
|
||||
<div className="stack sm">
|
||||
<ColumnHeader isMobile={isMobile}>
|
||||
{t("q:looking.columns.available")}
|
||||
</ColumnHeader>
|
||||
{(isMobile
|
||||
? groups.filter(
|
||||
(group) =>
|
||||
!data.likes.received.some(
|
||||
(like) => like.groupId === group.id,
|
||||
),
|
||||
)
|
||||
: neutralGroups
|
||||
).map((group) => {
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={
|
||||
data.likes.given.some(
|
||||
(like) => like.groupId === group.id,
|
||||
)
|
||||
? "UNLIKE"
|
||||
: "LIKE"
|
||||
}
|
||||
suggestable={isSuggestable(group.id)}
|
||||
trail={trailOf(group.id)}
|
||||
isSuggested={suggestedGroupIds.has(group.id)}
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="received">
|
||||
<div className="stack sm">
|
||||
{!data.ownGroup ? <JoinQueuePrompt /> : null}
|
||||
{groupsReceivedLikesFrom.map((group) => {
|
||||
const like = data.likes.received.find(
|
||||
(l) => l.groupId === group.id,
|
||||
)!;
|
||||
) : null}
|
||||
{isMobile && data.ownGroup ? (
|
||||
<SendouTab id="own" number={data.ownGroup.members.length}>
|
||||
{t("q:looking.columns.myGroup")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="groups">
|
||||
<div className="stack sm">
|
||||
<ColumnHeader isMobile={isMobile}>
|
||||
{t("q:looking.columns.available")}
|
||||
</ColumnHeader>
|
||||
{(isMobile
|
||||
? groups.filter(
|
||||
(group) =>
|
||||
!data.likes.received.some(
|
||||
(like) => like.groupId === group.id,
|
||||
),
|
||||
)
|
||||
: neutralGroups
|
||||
).map((group) => {
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={
|
||||
data.likes.given.some((like) => like.groupId === group.id)
|
||||
? "UNLIKE"
|
||||
: "LIKE"
|
||||
}
|
||||
suggestable={isSuggestable(group.id)}
|
||||
trail={trailOf(group.id)}
|
||||
isSuggested={suggestedGroupIds.has(group.id)}
|
||||
ownGroup={data.ownGroup}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="received">
|
||||
<div className="stack sm">
|
||||
{!data.ownGroup ? <JoinQueuePrompt /> : null}
|
||||
{groupsReceivedLikesFrom.map((group) => {
|
||||
const like = data.likes.received.find(
|
||||
(l) => l.groupId === group.id,
|
||||
)!;
|
||||
|
||||
const likeAction = () => {
|
||||
if (!isFullGroup) return "GROUP_UP";
|
||||
const likeAction = () => {
|
||||
if (!isFullGroup) return "GROUP_UP";
|
||||
|
||||
if (like.isRechallenge) return "MATCH_UP_RECHALLENGE";
|
||||
return "MATCH_UP";
|
||||
};
|
||||
if (like.isRechallenge) return "MATCH_UP_RECHALLENGE";
|
||||
return "MATCH_UP";
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={likeAction()}
|
||||
suggestable={isSuggestable(group.id)}
|
||||
trail={trailOf(group.id)}
|
||||
isSuggested={suggestedGroupIds.has(group.id)}
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="own">{ownGroupElement}</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</div>
|
||||
{!isMobile ? (
|
||||
<div className="stack sm">
|
||||
<ColumnHeader isMobile={isMobile}>
|
||||
{t(
|
||||
isFullGroup
|
||||
? "q:looking.columns.challenges"
|
||||
: "q:looking.columns.invitations",
|
||||
)}
|
||||
</ColumnHeader>
|
||||
{!data.ownGroup ? <JoinQueuePrompt /> : null}
|
||||
{groupsReceivedLikesFrom.map((group) => {
|
||||
const like = data.likes.received.find(
|
||||
(l) => l.groupId === group.id,
|
||||
)!;
|
||||
|
||||
const likeAction = () => {
|
||||
if (!isFullGroup) return "GROUP_UP";
|
||||
|
||||
if (like.isRechallenge) return "MATCH_UP_RECHALLENGE";
|
||||
return "MATCH_UP";
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={likeAction()}
|
||||
suggestable={isSuggestable(group.id)}
|
||||
trail={trailOf(group.id)}
|
||||
isSuggested={suggestedGroupIds.has(group.id)}
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={likeAction()}
|
||||
suggestable={isSuggestable(group.id)}
|
||||
trail={trailOf(group.id)}
|
||||
isSuggested={suggestedGroupIds.has(group.id)}
|
||||
ownGroup={data.ownGroup}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="own">{ownGroupElement}</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</div>
|
||||
</Flipper>
|
||||
{!isMobile ? (
|
||||
<div className="stack sm">
|
||||
<ColumnHeader isMobile={isMobile}>
|
||||
{t(
|
||||
isFullGroup
|
||||
? "q:looking.columns.challenges"
|
||||
: "q:looking.columns.invitations",
|
||||
)}
|
||||
</ColumnHeader>
|
||||
{!data.ownGroup ? <JoinQueuePrompt /> : null}
|
||||
{groupsReceivedLikesFrom.map((group) => {
|
||||
const like = data.likes.received.find(
|
||||
(l) => l.groupId === group.id,
|
||||
)!;
|
||||
|
||||
const likeAction = () => {
|
||||
if (!isFullGroup) return "GROUP_UP";
|
||||
|
||||
if (like.isRechallenge) return "MATCH_UP_RECHALLENGE";
|
||||
return "MATCH_UP";
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={likeAction()}
|
||||
suggestable={isSuggestable(group.id)}
|
||||
trail={trailOf(group.id)}
|
||||
isSuggested={suggestedGroupIds.has(group.id)}
|
||||
ownGroup={data.ownGroup}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { Edit, Mic, Star, Trash, Volume2, VolumeX } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Flipped } from "react-flip-toolkit";
|
||||
import { ViewTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionButton } from "~/components/ActionButton";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
@@ -13,7 +13,6 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { NoteAvatar } from "~/components/NoteAvatar";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
|
||||
import { useTournament } from "~/features/tournament/tournament-context";
|
||||
import {
|
||||
UserCard,
|
||||
@@ -21,11 +20,11 @@ import {
|
||||
} from "~/features/user-card/components/UserCard";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useActionSubmit } from "~/hooks/useActionSubmit";
|
||||
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
|
||||
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { navIconUrl } from "~/utils/urls";
|
||||
import { finishUpdateIfUnmoved } from "~/utils/view-transition";
|
||||
import {
|
||||
lookingSchema,
|
||||
updateGroupFormSchema,
|
||||
@@ -84,7 +83,7 @@ export function LFGGroupCard({
|
||||
const showOrganizerDelete = !currentMember && tournament.isOrganizer(user);
|
||||
|
||||
return (
|
||||
<LFGGroupCardContainer groupId={group.id} isOwnGroup={isOwnGroup}>
|
||||
<LFGGroupCardContainer isOwnGroup={isOwnGroup}>
|
||||
<section className={styles.group}>
|
||||
{group.teamName ? (
|
||||
<Divider smallText className={styles.teamHeader}>
|
||||
@@ -178,19 +177,18 @@ function LFGOrganizerGroupRemover({ group }: { group: LFGGroup }) {
|
||||
|
||||
function LFGGroupCardContainer({
|
||||
isOwnGroup,
|
||||
groupId,
|
||||
children,
|
||||
}: {
|
||||
isOwnGroup: boolean;
|
||||
groupId: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const width = useMainContentWidth();
|
||||
const layout = width < IS_Q_LOOKING_MOBILE_BREAKPOINT ? "mobile" : "desktop";
|
||||
|
||||
if (isOwnGroup) return <>{children}</>;
|
||||
|
||||
return <Flipped flipId={`${layout}-${groupId}`}>{children}</Flipped>;
|
||||
return (
|
||||
<ViewTransition update="card-update" onUpdate={finishUpdateIfUnmoved}>
|
||||
{children}
|
||||
</ViewTransition>
|
||||
);
|
||||
}
|
||||
|
||||
function LFGGroupMemberRow({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { Mic, Trash } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Flipper } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { ActionButton } from "~/components/ActionButton";
|
||||
@@ -100,8 +99,6 @@ function GroupsView({
|
||||
data.likes.received.some((like) => like.teamId === group.id),
|
||||
);
|
||||
|
||||
const flipKey = `${neutralGroups.map((g) => `${g.id}-${isMobile ? true : data.likes.given.some((l) => l.teamId === g.id)}`).join(":")}:${groupsReceivedLikesFrom.map((g) => g.id).join(":")}`;
|
||||
|
||||
const invitedGroupsDesktop = (
|
||||
<div className="stack sm">
|
||||
<ColumnHeader>{t("q:looking.columns.invited")}</ColumnHeader>
|
||||
@@ -139,99 +136,94 @@ function GroupsView({
|
||||
);
|
||||
|
||||
return (
|
||||
<Flipper flipKey={flipKey}>
|
||||
<div
|
||||
className={clsx(styles.container, {
|
||||
[styles.containerMobile]: isMobile,
|
||||
})}
|
||||
>
|
||||
{!isMobile ? (
|
||||
<div>
|
||||
<SendouTabs>
|
||||
<SendouTabList>
|
||||
<SendouTab id="own" number={ownTabMemberCount}>
|
||||
{t("q:looking.columns.myGroup")}
|
||||
</SendouTab>
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="own">{leftColumnContent}</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.innerContainer}>
|
||||
<div
|
||||
className={clsx(styles.container, {
|
||||
[styles.containerMobile]: isMobile,
|
||||
})}
|
||||
>
|
||||
{!isMobile ? (
|
||||
<div>
|
||||
<SendouTabs>
|
||||
<SendouTabList>
|
||||
<SendouTab id="groups" number={neutralGroups.length}>
|
||||
{t("q:looking.columns.groups")}
|
||||
<SendouTab id="own" number={ownTabMemberCount}>
|
||||
{t("q:looking.columns.myGroup")}
|
||||
</SendouTab>
|
||||
{isMobile ? (
|
||||
<SendouTab
|
||||
id="received"
|
||||
number={groupsReceivedLikesFrom.length}
|
||||
>
|
||||
{t("q:looking.columns.invitations")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
{isMobile ? (
|
||||
<SendouTab id="own" number={ownTabMemberCount}>
|
||||
{t("q:looking.columns.myGroup")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="groups">
|
||||
<div className="stack sm">
|
||||
<ColumnHeader>{t("q:looking.columns.available")}</ColumnHeader>
|
||||
{(isMobile
|
||||
? compatibleGroups.filter(
|
||||
(group) =>
|
||||
!data.likes.received.some(
|
||||
(like) => like.teamId === group.id,
|
||||
),
|
||||
)
|
||||
: neutralGroups
|
||||
).map((group) => (
|
||||
<LFGGroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={
|
||||
data.likes.given.some((like) => like.teamId === group.id)
|
||||
? "UNLIKE"
|
||||
: "LIKE"
|
||||
}
|
||||
ownGroup={data.ownGroup ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="received">
|
||||
<div className="stack sm">
|
||||
{groupsReceivedLikesFrom.map((group) => (
|
||||
<LFGGroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action="ACCEPT"
|
||||
ownGroup={data.ownGroup ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="own">{leftColumnContent}</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</div>
|
||||
{!isMobile ? (
|
||||
<div className="stack sm">
|
||||
<ColumnHeader>{t("q:looking.columns.invitations")}</ColumnHeader>
|
||||
{groupsReceivedLikesFrom.map((group) => (
|
||||
<LFGGroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action="ACCEPT"
|
||||
ownGroup={data.ownGroup ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
<div className={styles.innerContainer}>
|
||||
<SendouTabs>
|
||||
<SendouTabList>
|
||||
<SendouTab id="groups" number={neutralGroups.length}>
|
||||
{t("q:looking.columns.groups")}
|
||||
</SendouTab>
|
||||
{isMobile ? (
|
||||
<SendouTab id="received" number={groupsReceivedLikesFrom.length}>
|
||||
{t("q:looking.columns.invitations")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
{isMobile ? (
|
||||
<SendouTab id="own" number={ownTabMemberCount}>
|
||||
{t("q:looking.columns.myGroup")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="groups">
|
||||
<div className="stack sm">
|
||||
<ColumnHeader>{t("q:looking.columns.available")}</ColumnHeader>
|
||||
{(isMobile
|
||||
? compatibleGroups.filter(
|
||||
(group) =>
|
||||
!data.likes.received.some(
|
||||
(like) => like.teamId === group.id,
|
||||
),
|
||||
)
|
||||
: neutralGroups
|
||||
).map((group) => (
|
||||
<LFGGroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={
|
||||
data.likes.given.some((like) => like.teamId === group.id)
|
||||
? "UNLIKE"
|
||||
: "LIKE"
|
||||
}
|
||||
ownGroup={data.ownGroup ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="received">
|
||||
<div className="stack sm">
|
||||
{groupsReceivedLikesFrom.map((group) => (
|
||||
<LFGGroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action="ACCEPT"
|
||||
ownGroup={data.ownGroup ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="own">{leftColumnContent}</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</div>
|
||||
</Flipper>
|
||||
{!isMobile ? (
|
||||
<div className="stack sm">
|
||||
<ColumnHeader>{t("q:looking.columns.invitations")}</ColumnHeader>
|
||||
{groupsReceivedLikesFrom.map((group) => (
|
||||
<LFGGroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action="ACCEPT"
|
||||
ownGroup={data.ownGroup ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
|
||||
/** `prefers-reduced-motion` media query; `false` on the server and the first client render. */
|
||||
export function usePrefersReducedMotion() {
|
||||
return useMediaQuery("(prefers-reduced-motion: reduce)");
|
||||
}
|
||||
@@ -7,10 +7,23 @@
|
||||
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
/* only elements React names take part in view transitions, the rest of the page swaps instantly */
|
||||
view-transition-name: none;
|
||||
accent-color: var(--color-text-accent);
|
||||
scroll-padding-top: var(--layout-sticky-top);
|
||||
}
|
||||
|
||||
/* the page stays usable while a transition plays */
|
||||
::view-transition {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* a card React decided not to animate is left as an old-only snapshot that would still
|
||||
fade for the full duration, holding the next render for a transition of no change */
|
||||
::view-transition-old(.card-update):only-child {
|
||||
animation-duration: 0s;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
@@ -534,4 +534,13 @@
|
||||
max-width: 24rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(*),
|
||||
::view-transition-image-pair(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
app/utils/view-transition.ts
Normal file
58
app/utils/view-transition.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { ViewTransitionInstance } from "react";
|
||||
|
||||
interface PseudoElement {
|
||||
getAnimations(): Animation[];
|
||||
getComputedStyle(): CSSStyleDeclaration;
|
||||
}
|
||||
|
||||
type PseudoElements = Record<
|
||||
"group" | "imagePair" | "old" | "new",
|
||||
PseudoElement
|
||||
>;
|
||||
|
||||
const MEASURED_PROPERTIES = ["transform", "width", "height"] as const;
|
||||
|
||||
/**
|
||||
* `onUpdate` handler for a `<ViewTransition>` that ends its animation right away when the
|
||||
* boundary kept its size and position. React starts an update transition for any DOM change
|
||||
* inside the boundary (e.g. a button going disabled) and holds the next render until it ends,
|
||||
* so without this a full animation of no visible change would delay every server response.
|
||||
*/
|
||||
export function finishUpdateIfUnmoved(instance: ViewTransitionInstance) {
|
||||
const pseudos = instance as ViewTransitionInstance & PseudoElements;
|
||||
const groupAnimation = pseudos.group.getAnimations()[0];
|
||||
if (!groupAnimation || hasMoved(groupAnimation, pseudos.group)) return;
|
||||
|
||||
for (const pseudo of [
|
||||
pseudos.group,
|
||||
pseudos.imagePair,
|
||||
pseudos.old,
|
||||
pseudos.new,
|
||||
]) {
|
||||
for (const animation of pseudo.getAnimations()) {
|
||||
animation.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasMoved(groupAnimation: Animation, group: PseudoElement) {
|
||||
const effect = groupAnimation.effect;
|
||||
if (!(effect instanceof KeyframeEffect)) return true;
|
||||
|
||||
const [from] = effect.getKeyframes();
|
||||
if (!from) return true;
|
||||
|
||||
// while the animation plays the computed style is the animated value, so the end
|
||||
// state is only readable with the animation seeked to its end
|
||||
const startTime = groupAnimation.currentTime;
|
||||
groupAnimation.currentTime = effect.getComputedTiming().endTime ?? 0;
|
||||
const style = group.getComputedStyle();
|
||||
const to = MEASURED_PROPERTIES.map((property) =>
|
||||
style.getPropertyValue(property),
|
||||
);
|
||||
groupAnimation.currentTime = startTime;
|
||||
|
||||
return MEASURED_PROPERTIES.some(
|
||||
(property, i) => property in from && from[property] !== to[i],
|
||||
);
|
||||
}
|
||||
@@ -90,11 +90,10 @@
|
||||
"p-limit": "7.3.1",
|
||||
"picocad2-web": "2.0.2",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.8",
|
||||
"react": "19.3.0",
|
||||
"react-chartjs-2": "5.3.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-dom": "19.3.0",
|
||||
"react-error-boundary": "6.1.3",
|
||||
"react-flip-toolkit": "7.2.4",
|
||||
"react-i18next": "17.0.11",
|
||||
"react-router": "8.3.0",
|
||||
"remeda": "2.45.0",
|
||||
@@ -116,8 +115,8 @@
|
||||
"@react-router/dev": "8.3.0",
|
||||
"@types/node": "26.4.0",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
|
||||
910
pnpm-lock.yaml
generated
910
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -137,7 +137,6 @@ export default defineConfig((config) => {
|
||||
"picocad2-web",
|
||||
"qrcode.react",
|
||||
"react-chartjs-2",
|
||||
"react-flip-toolkit",
|
||||
"remeda",
|
||||
"remix-auth",
|
||||
"remix-auth-oauth2",
|
||||
|
||||
Reference in New Issue
Block a user