First pass

This implements hover previews that let the user preview a team's characters or summons in addition to weapons.

There's still work to do on finding a good layout for summons and making this usable or hidden on mobile.
This commit is contained in:
Justin Edmund 2023-08-23 22:44:55 -07:00
parent 62b957034f
commit 9061b28406
6 changed files with 453 additions and 124 deletions

View file

@ -260,16 +260,7 @@ const PartyFooter = (props: Props) => {
return partySnapshot?.remixes.map((party, i) => { return partySnapshot?.remixes.map((party, i) => {
return ( return (
<GridRep <GridRep
id={party.id} party={party}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
user={party.user}
favorited={party.favorited}
fullAuto={party.full_auto}
autoGuard={party.auto_guard}
key={`party-${i}`} key={`party-${i}`}
loading={false} loading={false}
onClick={goTo} onClick={goTo}

View file

@ -6,7 +6,7 @@
display: grid; display: grid;
grid-template-rows: 1fr 1fr; grid-template-rows: 1fr 1fr;
gap: $unit; gap: $unit;
padding: $unit-2x; padding: $unit-2x $unit-2x 0 $unit-2x;
min-width: 320px; min-width: 320px;
width: 100%; width: 100%;
opacity: 1; opacity: 1;
@ -29,6 +29,10 @@
text-decoration: none; text-decoration: none;
} }
.indicators {
opacity: 1;
}
.weaponGrid { .weaponGrid {
cursor: pointer; cursor: pointer;
@ -46,7 +50,31 @@
} }
} }
& > .weaponGrid { .gridContainer {
aspect-ratio: 2/0.95;
width: 100%;
}
.characterGrid {
aspect-ratio: 2/0.95;
display: flex;
justify-content: space-between;
.grid {
background: var(--unit-bg);
border-radius: $item-corner-small;
aspect-ratio: 69/142;
list-style: none;
height: calc(100% - $unit-half);
img {
border-radius: $item-corner-small;
width: 100%;
}
}
}
.weaponGrid {
aspect-ratio: 2/0.95; aspect-ratio: 2/0.95;
display: grid; display: grid;
grid-template-columns: 1fr 3.36fr; /* left column takes up 1 fraction, right column takes up 3 fractions */ grid-template-columns: 1fr 3.36fr; /* left column takes up 1 fraction, right column takes up 3 fractions */
@ -54,7 +82,7 @@
.weapon { .weapon {
background: var(--unit-bg); background: var(--unit-bg);
border-radius: 4px; border-radius: $item-corner-small;
} }
.mainhand.weapon { .mainhand.weapon {
@ -91,6 +119,51 @@
} }
} }
.summonGrid {
aspect-ratio: 2/0.95;
display: flex;
gap: $unit;
justify-content: space-between;
.summon,
.mainSummon {
background: var(--card-bg);
border-radius: $item-corner-small;
img {
border-radius: $item-corner-small;
width: 100%;
}
}
.mainSummon {
aspect-ratio: 56/97;
display: grid;
grid-column: 1 / 2; /* spans one column */
}
.summons {
display: grid; /* make the right-images container a grid */
grid-template-columns: repeat(
2,
1fr
); /* create 3 columns, each taking up 1 fraction */
grid-template-rows: repeat(
2,
1fr
); /* create 3 rows, each taking up 1 fraction */
gap: $unit;
aspect-ratio: 130/100;
// column-gap: $unit;
// row-gap: $unit-2x;
}
.summon {
aspect-ratio: 184 / 138;
display: grid;
}
}
.details { .details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -104,6 +177,7 @@
padding-bottom: 1px; padding-bottom: 1px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-height: 24px;
max-width: 258px; // Can we not do this? max-width: 258px; // Can we not do this?
&.empty { &.empty {
@ -157,6 +231,7 @@
} }
time { time {
line-height: 18px;
white-space: nowrap; white-space: nowrap;
} }
@ -234,4 +309,37 @@
} }
} }
} }
.indicators {
display: flex;
flex-direction: row;
gap: $unit;
margin-top: $unit * -1;
margin-bottom: $unit-fourth;
justify-content: center;
opacity: 0;
li {
flex-grow: 1;
text-indent: -9999px;
padding: $unit 0;
.indicator {
transition: background-color 0.12s ease-in-out;
height: $unit;
border-radius: $unit-half;
background-color: var(--button-contained-bg-hover);
}
span {
text-indent: -9999px;
position: absolute;
}
&:hover .indicator,
&.active .indicator {
background-color: var(--text-secondary);
}
}
}
} }

View file

@ -16,23 +16,15 @@ import ShieldIcon from '~public/icons/Shield.svg'
import styles from './index.module.scss' import styles from './index.module.scss'
interface Props { interface Props {
shortcode: string party: Party
id: string
name: string
raid: Raid
grid: GridWeapon[]
user?: User
fullAuto: boolean
autoGuard: boolean
favorited: boolean
loading: boolean loading: boolean
createdAt: Date
onClick: (shortcode: string) => void onClick: (shortcode: string) => void
onSave?: (partyId: string, favorited: boolean) => void onSave?: (partyId: string, favorited: boolean) => void
} }
const GridRep = (props: Props) => { const GridRep = ({ party, loading, onClick, onSave }: Props) => {
const numWeapons: number = 9 const numWeapons: number = 9
const numSummons: number = 4
const { account } = useSnapshot(accountState) const { account } = useSnapshot(accountState)
@ -42,27 +34,41 @@ const GridRep = (props: Props) => {
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [currentView, setCurrentView] = useState<
'characters' | 'weapons' | 'summons'
>('summons')
const [mainhand, setMainhand] = useState<Weapon>() const [mainhand, setMainhand] = useState<Weapon>()
const [weapons, setWeapons] = useState<GridArray<Weapon>>({}) const [weapons, setWeapons] = useState<GridArray<Weapon>>({})
const [grid, setGrid] = useState<GridArray<GridWeapon>>({}) const [weaponGrid, setWeaponGrid] = useState<GridArray<GridWeapon>>({})
const [characters, setCharacters] = useState<GridArray<Character>>({})
const [characterGrid, setCharacterGrid] = useState<GridArray<GridCharacter>>(
{}
)
const [mainSummon, setMainSummon] = useState<GridSummon>()
const [summons, setSummons] = useState<GridArray<Summon>>({})
const [summonGrid, setSummonGrid] = useState<GridArray<GridSummon>>({})
const gridRepStyles = classNames({ // Style construction
const gridRepClasses = classNames({
[styles.gridRep]: true, [styles.gridRep]: true,
[styles.visible]: visible, [styles.visible]: visible,
[styles.hidden]: !visible, [styles.hidden]: !visible,
}) })
const titleClass = classNames({ const titleClass = classNames({
empty: !props.name, empty: !party.name,
}) })
const raidClass = classNames({ const raidClass = classNames({
[styles.raid]: true, [styles.raid]: true,
[styles.empty]: !props.raid, [styles.empty]: !party.raid,
}) })
const userClass = classNames({ const userClass = classNames({
[styles.user]: true, [styles.user]: true,
[styles.empty]: !props.user, [styles.empty]: !party.user,
}) })
const mainhandClasses = classNames({ const mainhandClasses = classNames({
@ -75,8 +81,20 @@ const GridRep = (props: Props) => {
[styles.grid]: true, [styles.grid]: true,
}) })
const protagonistClasses = classNames({
[styles.protagonist]: true,
[styles.grid]: true,
})
const characterClasses = classNames({
[styles.character]: true,
[styles.grid]: true,
})
// Hooks
useEffect(() => { useEffect(() => {
if (props.loading) { if (loading) {
setVisible(false) setVisible(false)
} else { } else {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -84,7 +102,7 @@ const GridRep = (props: Props) => {
}, 150) }, 150)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
} }
}, [props.loading]) }, [loading])
useEffect(() => { useEffect(() => {
setVisible(false) // Trigger fade out setVisible(false) // Trigger fade out
@ -99,7 +117,7 @@ const GridRep = (props: Props) => {
const gridWeapons = Array(numWeapons) const gridWeapons = Array(numWeapons)
let foundMainhand = false let foundMainhand = false
for (const [key, value] of Object.entries(props.grid)) { for (const [key, value] of Object.entries(party.weapons)) {
if (value.position == -1) { if (value.position == -1) {
setMainhand(value.object) setMainhand(value.object)
foundMainhand = true foundMainhand = true
@ -114,18 +132,55 @@ const GridRep = (props: Props) => {
} }
setWeapons(newWeapons) setWeapons(newWeapons)
setGrid(gridWeapons) setWeaponGrid(gridWeapons)
}, [props.grid]) }, [party])
function navigate() { useEffect(() => {
props.onClick(props.shortcode) const newCharacters = Array(3)
const gridCharacters = Array(3)
if (party.characters) {
for (const [key, value] of Object.entries(party.characters)) {
if (value.position != null) {
newCharacters[value.position] = value.object
gridCharacters[value.position] = value
} }
}
setCharacters(newCharacters)
setCharacterGrid(gridCharacters)
}
}, [party])
useEffect(() => {
const newSummons = Array(numSummons)
const gridSummons = Array(numSummons)
if (party.summons) {
let foundMainSummon = false
for (const [key, value] of Object.entries(party.summons)) {
if (value.main) {
setMainSummon(value)
foundMainSummon = true
} else if (!value.main && !value.friend && value.position != null) {
newSummons[value.position] = value.object
gridSummons[value.position] = value
}
}
setSummons(newSummons)
setSummonGrid(gridSummons)
}
}, [party])
// Methods: Image generation
function generateMainhandImage() { function generateMainhandImage() {
let url = '' let url = ''
if (mainhand) { if (mainhand) {
const weapon = Object.values(props.grid).find( const weapon = Object.values(party.weapons).find(
(w) => w && w.object.id === mainhand.id (w) => w && w.object.id === mainhand.id
) )
@ -136,18 +191,18 @@ const GridRep = (props: Props) => {
} }
} }
return mainhand && props.grid[0] ? ( return mainhand && party.weapons[0] ? (
<img alt={mainhand.name[locale]} src={url} /> <img alt={mainhand.name[locale]} src={url} />
) : ( ) : (
'' ''
) )
} }
function generateGridImage(position: number) { function generateWeaponGridImage(position: number) {
let url = '' let url = ''
const weapon = weapons[position] const weapon = weapons[position]
const gridWeapon = grid[position] const gridWeapon = weaponGrid[position]
if (weapon && gridWeapon) { if (weapon && gridWeapon) {
if (weapon.element == 0 && gridWeapon.element) { if (weapon.element == 0 && gridWeapon.element) {
@ -164,19 +219,165 @@ const GridRep = (props: Props) => {
) )
} }
function generateMCImage() {
let source = ''
if (party.job) {
const slug = party.job.name.en.replaceAll(' ', '-').toLowerCase()
const gender = party.user?.gender == 1 ? 'b' : 'a'
source = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-portraits/${slug}_${gender}.png`
}
return (
party.job &&
party.job.id !== '-1' && (
<img alt={party.job ? party.job?.name[locale] : ''} src={source} />
)
)
}
function generateCharacterGridImage(position: number) {
let url = ''
const gridCharacter = characterGrid[position]
const character = characters[position]
if (character && gridCharacter) {
// Change the image based on the uncap level
let suffix = '01'
if (gridCharacter.transcendence_step > 0) suffix = '04'
else if (gridCharacter.uncap_level >= 5) suffix = '03'
else if (gridCharacter.uncap_level > 2) suffix = '02'
if (gridCharacter.object.granblue_id === '3030182000') {
let element = 1
if (mainhand && mainhand.element) {
element = mainhand.element
}
suffix = `${suffix}_0${element}`
}
const url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-main/${character.granblue_id}_${suffix}.jpg`
return (
characters[position] && (
<img alt={characters[position]?.name[locale]} src={url} />
)
)
}
}
function generateMainSummonImage() {
let url = ''
const upgradedSummons = [
'2040094000',
'2040100000',
'2040080000',
'2040098000',
'2040090000',
'2040084000',
'2040003000',
'2040056000',
'2040020000',
'2040034000',
'2040028000',
'2040027000',
'2040046000',
'2040047000',
]
if (mainSummon) {
// Change the image based on the uncap level
let suffix = ''
if (mainSummon.object.uncap.xlb && mainSummon.uncap_level == 6) {
if (
mainSummon.transcendence_step >= 1 &&
mainSummon.transcendence_step < 5
) {
suffix = '_03'
} else if (mainSummon.transcendence_step === 5) {
suffix = '_04'
}
} else if (
upgradedSummons.indexOf(mainSummon.object.granblue_id.toString()) !=
-1 &&
mainSummon.uncap_level == 5
) {
suffix = '_02'
}
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-main/${mainSummon.object.granblue_id}${suffix}.jpg`
}
return mainSummon && <img alt={mainSummon.object.name[locale]} src={url} />
}
function generateSummonGridImage(position: number) {
let url = ''
const gridSummon = party.summons[position]
const summon = gridSummon.object
const upgradedSummons = [
'2040094000',
'2040100000',
'2040080000',
'2040098000',
'2040090000',
'2040084000',
'2040003000',
'2040056000',
'2040020000',
'2040034000',
'2040028000',
'2040027000',
'2040046000',
'2040047000',
]
if (summon && gridSummon) {
// Change the image based on the uncap level
let suffix = ''
if (gridSummon.object.uncap.xlb && gridSummon.uncap_level == 6) {
if (
gridSummon.transcendence_step >= 1 &&
gridSummon.transcendence_step < 5
) {
suffix = '_03'
} else if (gridSummon.transcendence_step === 5) {
suffix = '_04'
}
} else if (
upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 &&
gridSummon.uncap_level == 5
) {
suffix = '_02'
}
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${summon.granblue_id}${suffix}.jpg`
}
return (
summons[position] && (
<img alt={summons[position]?.name[locale]} src={url} />
)
)
}
function sendSaveData() { function sendSaveData() {
if (props.onSave) props.onSave(props.id, props.favorited) if (onSave) onSave(party.id, party.favorited)
} }
const userImage = () => { const userImage = () => {
if (props.user && props.user.avatar) { if (party.user && party.user.avatar) {
return ( return (
<img <img
alt={props.user.avatar.picture} alt={party.user.avatar.picture}
className={`profile ${props.user.avatar.element}`} className={`profile ${party.user.avatar.element}`}
srcSet={`/profile/${props.user.avatar.picture}.png, srcSet={`/profile/${party.user.avatar.picture}.png,
/profile/${props.user.avatar.picture}@2x.png 2x`} /profile/${party.user.avatar.picture}@2x.png 2x`}
src={`/profile/${props.user.avatar.picture}.png`} src={`/profile/${party.user.avatar.picture}.png`}
/> />
) )
} else } else
@ -194,63 +395,92 @@ const GridRep = (props: Props) => {
const attribution = () => ( const attribution = () => (
<span className={userClass}> <span className={userClass}>
{userImage()} {userImage()}
{props.user ? props.user.username : t('no_user')} {party.user ? party.user.username : t('no_user')}
</span> </span>
) )
function fullAutoString() { const renderWeaponGrid = (
const fullAutoElement = ( <div className={styles.weaponGrid}>
<span className={styles.fullAuto}> <div className={mainhandClasses}>{generateMainhandImage()}</div>
{` · ${t('party.details.labels.full_auto')}`}
</span>
)
const autoGuardElement = (
<span className={styles.autoGuard}>
<ShieldIcon />
</span>
)
<ul className={styles.weapons}>
{Array.from(Array(numWeapons)).map((x, i) => {
return ( return (
<div className={styles.auto}> <li
{fullAutoElement} key={`${party.shortcode}-weapon-${i}`}
{props.autoGuard ? autoGuardElement : ''} className={weaponClasses}
>
{generateWeaponGridImage(i)}
</li>
)
})}
</ul>
</div>
)
const renderCharacterGrid = (
<div className={styles.characterGrid}>
<div className={protagonistClasses}>{generateMCImage()}</div>
{Array.from(Array(3)).map((x, i) => {
return (
<li
key={`${party.shortcode}-chara-${i}`}
className={characterClasses}
>
{generateCharacterGridImage(i)}
</li>
)
})}
</div>
)
const renderSummonGrid = (
<div className={styles.summonGrid}>
<div className={styles.mainSummon}>{generateMainSummonImage()}</div>
<ul className={styles.summons}>
{Array.from(Array(numSummons)).map((x, i) => {
return (
<li key={`summons-${i}`} className={styles.summon}>
{generateSummonGridImage(i)}
</li>
)
})}
</ul>
</div> </div>
) )
}
const detailsWithUsername = ( const detailsWithUsername = (
<div className={styles.details}> <div className={styles.details}>
<div className={styles.top}> <div className={styles.top}>
<div className={styles.info}> <div className={styles.info}>
<h2 className={titleClass}> <h2 className={titleClass}>
{props.name ? props.name : t('no_title')} {party.name ? party.name : t('no_title')}
</h2> </h2>
<div className={styles.properties}> <div className={styles.properties}>
<span className={raidClass}> <span className={raidClass}>
{props.raid ? props.raid.name[locale] : t('no_raid')} {party.raid ? party.raid.name[locale] : t('no_raid')}
</span> </span>
{props.fullAuto && ( {party.full_auto && (
<span className={styles.fullAuto}> <span className={styles.fullAuto}>
{` · ${t('party.details.labels.full_auto')}`} {` · ${t('party.details.labels.full_auto')}`}
</span> </span>
)} )}
{props.raid && props.raid.group.extra && ( {party.raid && party.raid.group.extra && (
<span className={styles.extra}>{` · EX`}</span> <span className={styles.extra}>{` · EX`}</span>
)} )}
</div> </div>
</div> </div>
{account.authorized && {account.authorized &&
((props.user && account.user && account.user.id !== props.user.id) || ((party.user && account.user && account.user.id !== party.user.id) ||
!props.user) ? ( !party.user) ? (
<Link href="#"> <Link href="#">
<Button <Button
className={classNames({ className={classNames({
save: true, save: true,
saved: props.favorited, saved: party.favorited,
})} })}
leftAccessoryIcon={<SaveIcon className="stroke" />} leftAccessoryIcon={<SaveIcon className="stroke" />}
active={props.favorited} active={party.favorited}
bound={true} bound={true}
size="small" size="small"
onClick={sendSaveData} onClick={sendSaveData}
@ -265,32 +495,59 @@ const GridRep = (props: Props) => {
<time <time
className={styles.lastUpdated} className={styles.lastUpdated}
dateTime={props.createdAt.toISOString()} dateTime={new Date(party.created_at).toISOString()}
> >
{formatTimeAgo(props.createdAt, locale)} {formatTimeAgo(new Date(party.created_at), locale)}
</time> </time>
</div> </div>
</div> </div>
) )
return ( function changeView(view: 'characters' | 'weapons' | 'summons') {
<Link legacyBehavior href={`/p/${props.shortcode}`}> setCurrentView(view)
<a className={gridRepStyles}> }
{detailsWithUsername}
<div className={styles.weaponGrid}>
<div className={mainhandClasses}>{generateMainhandImage()}</div>
<ul className={styles.weapons}>
{Array.from(Array(numWeapons)).map((x, i) => {
return ( return (
<li key={`${props.shortcode}-${i}`} className={weaponClasses}> <Link href={`/p/${party.shortcode}`} className={gridRepClasses}>
{generateGridImage(i)} {detailsWithUsername}
</li> <div className={styles.gridContainer}>
) {currentView === 'characters'
})} ? renderCharacterGrid
</ul> : currentView === 'summons'
? renderSummonGrid
: renderWeaponGrid}
</div> </div>
</a> <ul className={styles.indicators}>
<li
className={classNames({
[styles.active]: currentView === 'characters',
})}
onMouseEnter={() => changeView('characters')}
onMouseLeave={() => changeView('weapons')}
>
<div className={styles.indicator} />
<span>Characters</span>
</li>
<li
className={classNames({
[styles.active]: currentView === 'weapons',
})}
onMouseEnter={() => changeView('weapons')}
>
<div className={styles.indicator} />
<span>Weapons</span>
</li>
<li
className={classNames({
[styles.active]: currentView === 'summons',
})}
onMouseEnter={() => changeView('summons')}
onMouseLeave={() => changeView('weapons')}
>
<div className={styles.indicator} />
<span>Summons</span>
</li>
</ul>
</Link> </Link>
) )
} }

View file

@ -255,16 +255,7 @@ const ProfileRoute: React.FC<Props> = ({
return parties.map((party, i) => { return parties.map((party, i) => {
return ( return (
<GridRep <GridRep
id={party.id} party={party}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
user={party.user}
favorited={party.favorited}
fullAuto={party.full_auto}
autoGuard={party.auto_guard}
key={`party-${i}`} key={`party-${i}`}
loading={isLoading} loading={isLoading}
onClick={goTo} onClick={goTo}

View file

@ -294,16 +294,7 @@ const SavedRoute: React.FC<Props> = ({
return parties.map((party, i) => { return parties.map((party, i) => {
return ( return (
<GridRep <GridRep
id={party.id} party={party}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
user={party.user}
favorited={party.favorited}
fullAuto={party.full_auto}
autoGuard={party.auto_guard}
key={`party-${i}`} key={`party-${i}`}
loading={isLoading} loading={isLoading}
onClick={goTo} onClick={goTo}

View file

@ -308,16 +308,7 @@ const TeamsRoute: React.FC<Props> = ({
return parties.map((party, i) => { return parties.map((party, i) => {
return ( return (
<GridRep <GridRep
id={party.id} party={party}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
user={party.user}
favorited={party.favorited}
fullAuto={party.full_auto}
autoGuard={party.auto_guard}
key={`party-${i}`} key={`party-${i}`}
loading={isLoading} loading={isLoading}
onClick={goTo} onClick={goTo}