Implement load transitions and fix resetting filters (#365)

This PR implements:
* Fade-in transitions when cells load in, making navigation and loading
appear less janky.
* When scrolling, skeleton reps show up before the actual ones load in.
* Resetting filters will also reset any set inclusions or exclusions
This commit is contained in:
Justin Edmund 2023-08-22 01:29:48 -07:00 committed by GitHub
parent 6dd2579e6e
commit 2d1af335c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 371 additions and 69 deletions

View file

@ -1,5 +1,6 @@
import TableField from '~components/common/TableField'
import MentionTypeahead from '../MentionTypeahead'
import Typeahead from 'react-bootstrap-typeahead/types/core/Typeahead'
interface Props
extends React.DetailedHTMLProps<
@ -11,6 +12,7 @@ interface Props
placeholder?: string
inclusions: MentionItem[]
exclusions: MentionItem[]
typeaheadRef: React.Ref<Typeahead>
onUpdate: (content: MentionItem[]) => void
}
@ -20,6 +22,7 @@ const MentionTableField = ({
placeholder,
inclusions,
exclusions,
typeaheadRef,
...props
}: Props) => {
return (
@ -31,6 +34,7 @@ const MentionTableField = ({
label={label}
>
<MentionTypeahead
ref={typeaheadRef}
label={label}
description={description}
placeholder={placeholder}

View file

@ -1,3 +1,4 @@
import React from 'react'
import { useState } from 'react'
import { getCookie } from 'cookies-next'
import { useTranslation } from 'next-i18next'
@ -14,6 +15,7 @@ import {
RenderMenuProps,
Token,
} from 'react-bootstrap-typeahead'
import Typeahead from 'react-bootstrap-typeahead/types/core/Typeahead'
import api from '~utils/api'
import { numberToElement } from '~utils/elements'
@ -41,14 +43,10 @@ interface RawSearchResponse {
element: number
}
const MentionTypeahead = ({
label,
description,
placeholder,
inclusions,
exclusions,
...props
}: Props) => {
const MentionTypeahead = React.forwardRef<Typeahead, Props>(function Typeahead(
{ label, description, placeholder, inclusions, exclusions, ...props }: Props,
forwardedRef
) {
const { t } = useTranslation('common')
const locale = getCookie('NEXT_LOCALE')
? (getCookie('NEXT_LOCALE') as string)
@ -159,6 +157,7 @@ const MentionTypeahead = ({
return (
<AsyncTypeahead
multiple
ref={forwardedRef}
className={styles.typeahead}
id={label}
align="left"
@ -166,7 +165,6 @@ const MentionTypeahead = ({
labelKey={(option) => (option as MentionItem).name[locale]}
defaultSelected={inclusions}
filterBy={() => true}
minLength={3}
onSearch={handleSearch}
options={options}
useCache={false}
@ -181,6 +179,6 @@ const MentionTypeahead = ({
onChange={(selected) => props.onUpdate(selected as MentionItem[])}
/>
)
}
})
export default MentionTypeahead

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { getCookie, setCookie } from 'cookies-next'
import { useRouter } from 'next/router'
import { Trans, useTranslation } from 'react-i18next'
@ -10,16 +10,16 @@ import DialogContent from '~components/common/DialogContent'
import Button from '~components/common/Button'
import InputTableField from '~components/common/InputTableField'
import MentionTableField from '~components/common/MentionTableField'
import SelectTableField from '~components/common/SelectTableField'
import SliderTableField from '~components/common/SliderTableField'
import SwitchTableField from '~components/common/SwitchTableField'
import SelectItem from '~components/common/SelectItem'
import type { DialogProps } from '@radix-ui/react-dialog'
import Typeahead from 'react-bootstrap-typeahead/types/core/Typeahead'
import styles from './index.module.scss'
import MentionTableField from '~components/common/MentionTableField'
import classNames from 'classnames'
interface Props extends DialogProps {
defaultFilterSet: FilterSet
@ -43,6 +43,8 @@ const FilterModal = (props: Props) => {
// Refs
const headerRef = React.createRef<HTMLDivElement>()
const footerRef = React.createRef<HTMLDivElement>()
const inclusionRef = useRef<Typeahead>(null)
const exclusionRef = useRef<Typeahead>(null)
// States
const [open, setOpen] = useState(false)
@ -88,7 +90,6 @@ const FilterModal = (props: Props) => {
useEffect(() => {
if (props.open !== undefined) {
setOpen(props.open)
// When should we reset the filter state?
}
})
@ -160,6 +161,12 @@ const FilterModal = (props: Props) => {
setUserQuality(props.defaultFilterSet.user_quality)
setNameQuality(props.defaultFilterSet.name_quality)
setOriginalOnly(props.defaultFilterSet.original)
setInclusions([])
inclusionRef.current?.clear()
setExclusions([])
exclusionRef.current?.clear()
}
function openChange() {
@ -413,6 +420,7 @@ const FilterModal = (props: Props) => {
exclusions={exclusions}
placeholder={t('modals.filters.placeholders.inclusion')}
label={t('modals.filters.labels.inclusion')}
typeaheadRef={inclusionRef}
onUpdate={storeInclusions}
/>
)
@ -424,6 +432,7 @@ const FilterModal = (props: Props) => {
exclusions={inclusions}
placeholder={t('modals.filters.placeholders.exclusion')}
label={t('modals.filters.labels.exclusion')}
typeaheadRef={exclusionRef}
onUpdate={storeExclusions}
/>
)

View file

@ -8,8 +8,8 @@ import DOMPurify from 'dompurify'
import Button from '~components/common/Button'
import SegmentedControl from '~components/common/SegmentedControl'
import Segment from '~components/common/Segment'
import GridRepCollection from '~components/GridRepCollection'
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/reps/GridRepCollection'
import GridRep from '~components/reps/GridRep'
import RemixTeamAlert from '~components/dialogs/RemixTeamAlert'
import RemixedToast from '~components/toasts/RemixedToast'
import EditPartyModal from '../EditPartyModal'

View file

@ -9,6 +9,16 @@
padding: $unit-2x;
min-width: 320px;
width: 100%;
opacity: 1;
transition: opacity 0.3s ease-in-out;
&.visible {
opacity: 1;
}
&.hidden {
opacity: 0;
}
&:hover {
background: var(--grid-rep-hover);

View file

@ -40,10 +40,16 @@ const GridRep = (props: Props) => {
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const [visible, setVisible] = useState(false)
const [mainhand, setMainhand] = useState<Weapon>()
const [weapons, setWeapons] = useState<GridArray<Weapon>>({})
const [grid, setGrid] = useState<GridArray<GridWeapon>>({})
const gridRepStyles = classNames({
[styles.gridRep]: true,
[styles.visible]: visible,
[styles.hidden]: !visible,
})
const titleClass = classNames({
empty: !props.name,
})
@ -68,6 +74,14 @@ const GridRep = (props: Props) => {
[styles.grid]: true,
})
useEffect(() => {
setVisible(false) // Trigger fade out
const timeout = setTimeout(() => {
setVisible(true) // Trigger fade in
}, 300) // You can adjust the timing based on your preference
return () => clearTimeout(timeout)
}, [])
useEffect(() => {
const newWeapons = Array(numWeapons)
const gridWeapons = Array(numWeapons)
@ -249,7 +263,7 @@ const GridRep = (props: Props) => {
return (
<Link legacyBehavior href={`/p/${props.shortcode}`}>
<a className={styles.gridRep}>
<a className={gridRepStyles}>
{detailsWithUsername}
<div className={styles.weaponGrid}>
<div className={mainhandClasses}>{generateMainhandImage()}</div>

View file

@ -0,0 +1,150 @@
.gridRep {
aspect-ratio: 3/2;
border: 1px solid transparent;
border-radius: $card-corner;
box-sizing: border-box;
display: grid;
grid-template-rows: 1fr 1fr;
gap: $unit;
padding: $unit-2x;
min-width: 320px;
width: 100%;
.placeholder {
// background: var(--placeholder-bg);
animation-duration: 2s;
animation-fill-mode: forwards;
animation-iteration-count: infinite;
animation-name: placeHolderShimmer;
animation-timing-function: linear;
background-color: #f6f7f8;
background: linear-gradient(
to right,
var(--placeholder-bg) 8%,
var(--placeholder-bg-accent) 18%,
var(--placeholder-bg) 33%
);
background-size: 1200px 104px;
&.small {
border-radius: calc($font-small / 2);
height: $font-small;
}
&.regular {
border-radius: calc($font-regular / 2);
height: $font-regular;
}
}
& > .weaponGrid {
aspect-ratio: 2/0.95;
display: grid;
grid-template-columns: 1fr 3.36fr; /* left column takes up 1 fraction, right column takes up 3 fractions */
grid-gap: $unit; /* add a gap of 8px between grid items */
.weapon {
border-radius: $item-corner-small;
}
.mainhand.weapon {
aspect-ratio: 73/153;
display: grid;
grid-column: 1 / 2; /* spans one column */
height: calc(100% - $unit-fourth);
}
.weapons {
display: grid; /* make the right-images container a grid */
grid-template-columns: repeat(
3,
1fr
); /* create 3 columns, each taking up 1 fraction */
grid-template-rows: repeat(
3,
1fr
); /* create 3 rows, each taking up 1 fraction */
gap: $unit;
// column-gap: $unit;
// row-gap: $unit-2x;
}
.grid.weapon {
aspect-ratio: 280 / 160;
display: grid;
}
.mainhand.weapon img[src*='jpg'],
.grid.weapon img[src*='jpg'] {
border-radius: 4px;
width: 100%;
}
}
.details {
display: flex;
flex-direction: column;
gap: $unit-half;
.top {
display: flex;
flex-direction: row;
gap: calc($unit / 2);
align-items: center;
.info {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: calc($unit / 2);
.title {
width: 100%;
}
}
}
.attributed {
display: flex;
gap: $unit-half;
align-items: center;
justify-content: space-between;
.user {
display: flex;
flex-grow: 1;
gap: calc($unit / 2);
align-items: center;
.image {
$diameter: 18px;
border-radius: calc($diameter / 2);
height: $diameter;
width: $diameter;
}
.text {
border-radius: calc($font-small / 2);
height: $font-small;
width: 40%;
}
}
.timestamp {
width: 20%;
}
}
}
}
@keyframes placeHolderShimmer {
$width: 400px;
0% {
background-position: ($width * -1) 0;
}
100% {
background-position: $width 0;
}
}

View file

@ -0,0 +1,81 @@
import classNames from 'classnames'
import styles from './index.module.scss'
interface Props {}
const LoadingRep = (props: Props) => {
const numWeapons: number = 9
const mainhandClasses = classNames({
[styles.weapon]: true,
[styles.mainhand]: true,
[styles.placeholder]: true,
})
const weaponClasses = classNames({
[styles.weapon]: true,
[styles.grid]: true,
[styles.placeholder]: true,
})
return (
<div className={styles.gridRep}>
<div className={styles.details}>
<div className={styles.top}>
<div className={styles.info}>
<div
className={classNames({
[styles.title]: true,
[styles.placeholder]: true,
[styles.regular]: true,
})}
/>
<div className={styles.properties}>
<span className={styles.raid} />
</div>
</div>
</div>
<div className={styles.attributed}>
<span className={styles.user}>
<figure
className={classNames({
[styles.image]: true,
[styles.placeholder]: true,
})}
/>
<span
className={classNames({
[styles.text]: true,
[styles.placeholder]: true,
[styles.small]: true,
})}
/>
</span>
<div
className={classNames({
[styles.timestamp]: true,
[styles.placeholder]: true,
[styles.small]: true,
})}
/>
</div>
</div>
<div className={styles.weaponGrid}>
<div className={mainhandClasses} />
<ul className={styles.weapons}>
{Array.from(Array(numWeapons)).map((x, i) => {
return (
<li
key={`placeholder-${Math.random()}`}
className={weaponClasses}
/>
)
})}
</ul>
</div>
</div>
)
}
export default LoadingRep

View file

@ -15,8 +15,9 @@ import { permissiveFilterset } from '~utils/defaultFilters'
import { elements, allElement } from '~data/elements'
import { emptyPaginationObject } from '~utils/emptyStates'
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/GridRepCollection'
import GridRep from '~components/reps/GridRep'
import GridRepCollection from '~components/reps/GridRepCollection'
import LoadingRep from '~components/reps/LoadingRep'
import ErrorSection from '~components/ErrorSection'
import FilterBar from '~components/filters/FilterBar'
import ProfileHead from '~components/head/ProfileHead'
@ -265,6 +266,26 @@ const ProfileRoute: React.FC<Props> = ({
)
})
}
function renderLoading(number: number) {
return (
<GridRepCollection>
{Array.from(Array(number)).map((x, i) => (
<LoadingRep key={`loading-${i}`} />
))}
</GridRepCollection>
)
}
const renderInfiniteScroll = (
<InfiniteScroll
dataLength={parties && parties.length > 0 ? parties.length : 0}
next={() => setCurrentPage(currentPage + 1)}
hasMore={totalPages > currentPage}
loader={renderLoading(3)}
>
<GridRepCollection>{renderParties()}</GridRepCollection>
</InfiniteScroll>
)
if (context) {
return (
@ -285,18 +306,7 @@ const ProfileRoute: React.FC<Props> = ({
</FilterBar>
<section>
<InfiniteScroll
dataLength={parties && parties.length > 0 ? parties.length : 0}
next={() => setCurrentPage(currentPage + 1)}
hasMore={totalPages > currentPage}
loader={
<div className="notFound">
<h2>{t('loading')}</h2>
</div>
}
>
<GridRepCollection>{renderParties()}</GridRepCollection>
</InfiniteScroll>
{renderInfiniteScroll}
{parties.length == 0 ? (
<div className="notFound">

View file

@ -17,8 +17,9 @@ import { elements, allElement } from '~data/elements'
import { emptyPaginationObject } from '~utils/emptyStates'
import ErrorSection from '~components/ErrorSection'
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/GridRepCollection'
import GridRep from '~components/reps/GridRep'
import GridRepCollection from '~components/reps/GridRepCollection'
import LoadingRep from '~components/reps/LoadingRep'
import FilterBar from '~components/filters/FilterBar'
import SavedHead from '~components/head/SavedHead'
@ -306,6 +307,27 @@ const SavedRoute: React.FC<Props> = ({
})
}
function renderLoading(number: number) {
return (
<GridRepCollection>
{Array.from(Array(number)).map((x, i) => (
<LoadingRep key={`loading-${i}`} />
))}
</GridRepCollection>
)
}
const renderInfiniteScroll = (
<InfiniteScroll
dataLength={parties && parties.length > 0 ? parties.length : 0}
next={() => setCurrentPage(currentPage + 1)}
hasMore={totalPages > currentPage}
loader={renderLoading(3)}
>
<GridRepCollection>{renderParties()}</GridRepCollection>
</InfiniteScroll>
)
if (context) {
return (
<div className="teams">
@ -325,18 +347,7 @@ const SavedRoute: React.FC<Props> = ({
</FilterBar>
<section>
<InfiniteScroll
dataLength={parties && parties.length > 0 ? parties.length : 0}
next={() => setCurrentPage(currentPage + 1)}
hasMore={totalPages > currentPage}
loader={
<div className="notFound">
<h2>{t('loading')}</h2>
</div>
}
>
<GridRepCollection>{renderParties()}</GridRepCollection>
</InfiniteScroll>
{renderInfiniteScroll}
{parties.length == 0 ? (
<div className="notFound">

View file

@ -19,8 +19,9 @@ import { emptyPaginationObject } from '~utils/emptyStates'
import { convertAdvancedFilters } from '~utils/convertAdvancedFilters'
import ErrorSection from '~components/ErrorSection'
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/GridRepCollection'
import GridRep from '~components/reps/GridRep'
import GridRepCollection from '~components/reps/GridRepCollection'
import LoadingRep from '~components/reps/LoadingRep'
import FilterBar from '~components/filters/FilterBar'
import TeamsHead from '~components/head/TeamsHead'
@ -55,6 +56,7 @@ const TeamsRoute: React.FC<Props> = ({
// Set up app-specific states
const [mounted, setMounted] = useState(false)
const [scrolled, setScrolled] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Set up page-specific states
const [parties, setParties] = useState<Party[]>([])
@ -122,6 +124,8 @@ const TeamsRoute: React.FC<Props> = ({
appState.version = version
}
setCurrentPage(1)
setIsLoading(false)
}, [])
// Add scroll event listener for shadow on FilterBar on mount
@ -151,6 +155,8 @@ const TeamsRoute: React.FC<Props> = ({
const fetchTeams = useCallback(
({ replace }: { replace: boolean }) => {
setIsLoading(true)
const filters: {
[key: string]: any
} = {
@ -183,6 +189,10 @@ const TeamsRoute: React.FC<Props> = ({
if (replace) replaceResults(meta.count, results)
else appendResults(results)
})
.then(() => {
console.log('You are here')
setIsLoading(false)
})
.catch((error) => handleError(error))
},
[currentPage, parties, element, raid, recency, advancedFilters]
@ -318,6 +328,27 @@ const TeamsRoute: React.FC<Props> = ({
})
}
function renderLoading(number: number) {
return (
<GridRepCollection>
{Array.from(Array(number)).map((x, i) => (
<LoadingRep key={`loading-${i}`} />
))}
</GridRepCollection>
)
}
const renderInfiniteScroll = (
<InfiniteScroll
dataLength={parties && parties.length > 0 ? parties.length : 0}
next={() => setCurrentPage(currentPage + 1)}
hasMore={totalPages > currentPage}
loader={renderLoading(3)}
>
<GridRepCollection>{renderParties()}</GridRepCollection>
</InfiniteScroll>
)
if (context) {
return (
<div className="teams">
@ -336,28 +367,7 @@ const TeamsRoute: React.FC<Props> = ({
<h1>{t('teams.title')}</h1>
</FilterBar>
<section>
<InfiniteScroll
dataLength={parties && parties.length > 0 ? parties.length : 0}
next={() => setCurrentPage(currentPage + 1)}
hasMore={totalPages > currentPage}
loader={
<div className="notFound">
<h2>{t('loading')}</h2>
</div>
}
>
<GridRepCollection>{renderParties()}</GridRepCollection>
</InfiniteScroll>
{parties.length == 0 ? (
<div className="notFound">
<h2>{t('teams.not_found')}</h2>
</div>
) : (
''
)}
</section>
<section>{renderInfiniteScroll}</section>
</div>
)
} else return pageError()

View file

@ -28,7 +28,9 @@
--selected-item-bg: #{$selected--item--bg--light};
--selected-item-bg-hover: #{$selected--item--bg--light--hover};
--anonymous-bg: #{$anonymous--bg--light};
--placeholder-bg: #{$grey-80};
--placeholder-bg-accent: #{$grey-75};
--transparent-stroke: #{$transparent--stroke--light};
@ -252,7 +254,9 @@
--selected-item-bg-hover: #{$selected--item--bg--dark--hover};
--anonymous-bg: #{$anonymous--bg--dark};
--placeholder-bg: #{$grey-40};
--placeholder-bg-accent: #{$grey-45};
--transparent-stroke: #{$transparent--stroke--dark};

View file

@ -45,6 +45,7 @@ $grey-20: #212121;
$grey-25: #232323;
$grey-30: #2f2f2f;
$grey-40: #444;
$grey-45: #515151;
$grey-50: #777;
$grey-55: #888;
$grey-60: #a9a9a9;