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 (
<GridRep
id={party.id}
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}
party={party}
key={`party-${i}`}
loading={false}
onClick={goTo}

View file

@ -6,7 +6,7 @@
display: grid;
grid-template-rows: 1fr 1fr;
gap: $unit;
padding: $unit-2x;
padding: $unit-2x $unit-2x 0 $unit-2x;
min-width: 320px;
width: 100%;
opacity: 1;
@ -29,6 +29,10 @@
text-decoration: none;
}
.indicators {
opacity: 1;
}
.weaponGrid {
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;
display: grid;
grid-template-columns: 1fr 3.36fr; /* left column takes up 1 fraction, right column takes up 3 fractions */
@ -54,7 +82,7 @@
.weapon {
background: var(--unit-bg);
border-radius: 4px;
border-radius: $item-corner-small;
}
.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 {
display: flex;
flex-direction: column;
@ -104,6 +177,7 @@
padding-bottom: 1px;
text-overflow: ellipsis;
white-space: nowrap;
min-height: 24px;
max-width: 258px; // Can we not do this?
&.empty {
@ -157,6 +231,7 @@
}
time {
line-height: 18px;
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'
interface Props {
shortcode: string
id: string
name: string
raid: Raid
grid: GridWeapon[]
user?: User
fullAuto: boolean
autoGuard: boolean
favorited: boolean
party: Party
loading: boolean
createdAt: Date
onClick: (shortcode: string) => void
onSave?: (partyId: string, favorited: boolean) => void
}
const GridRep = (props: Props) => {
const GridRep = ({ party, loading, onClick, onSave }: Props) => {
const numWeapons: number = 9
const numSummons: number = 4
const { account } = useSnapshot(accountState)
@ -42,27 +34,41 @@ const GridRep = (props: Props) => {
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const [visible, setVisible] = useState(false)
const [currentView, setCurrentView] = useState<
'characters' | 'weapons' | 'summons'
>('summons')
const [mainhand, setMainhand] = useState<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.visible]: visible,
[styles.hidden]: !visible,
})
const titleClass = classNames({
empty: !props.name,
empty: !party.name,
})
const raidClass = classNames({
[styles.raid]: true,
[styles.empty]: !props.raid,
[styles.empty]: !party.raid,
})
const userClass = classNames({
[styles.user]: true,
[styles.empty]: !props.user,
[styles.empty]: !party.user,
})
const mainhandClasses = classNames({
@ -75,8 +81,20 @@ const GridRep = (props: Props) => {
[styles.grid]: true,
})
const protagonistClasses = classNames({
[styles.protagonist]: true,
[styles.grid]: true,
})
const characterClasses = classNames({
[styles.character]: true,
[styles.grid]: true,
})
// Hooks
useEffect(() => {
if (props.loading) {
if (loading) {
setVisible(false)
} else {
const timeout = setTimeout(() => {
@ -84,7 +102,7 @@ const GridRep = (props: Props) => {
}, 150)
return () => clearTimeout(timeout)
}
}, [props.loading])
}, [loading])
useEffect(() => {
setVisible(false) // Trigger fade out
@ -99,7 +117,7 @@ const GridRep = (props: Props) => {
const gridWeapons = Array(numWeapons)
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) {
setMainhand(value.object)
foundMainhand = true
@ -114,18 +132,55 @@ const GridRep = (props: Props) => {
}
setWeapons(newWeapons)
setGrid(gridWeapons)
}, [props.grid])
setWeaponGrid(gridWeapons)
}, [party])
function navigate() {
props.onClick(props.shortcode)
}
useEffect(() => {
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() {
let url = ''
if (mainhand) {
const weapon = Object.values(props.grid).find(
const weapon = Object.values(party.weapons).find(
(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} />
) : (
''
)
}
function generateGridImage(position: number) {
function generateWeaponGridImage(position: number) {
let url = ''
const weapon = weapons[position]
const gridWeapon = grid[position]
const gridWeapon = weaponGrid[position]
if (weapon && gridWeapon) {
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() {
if (props.onSave) props.onSave(props.id, props.favorited)
if (onSave) onSave(party.id, party.favorited)
}
const userImage = () => {
if (props.user && props.user.avatar) {
if (party.user && party.user.avatar) {
return (
<img
alt={props.user.avatar.picture}
className={`profile ${props.user.avatar.element}`}
srcSet={`/profile/${props.user.avatar.picture}.png,
/profile/${props.user.avatar.picture}@2x.png 2x`}
src={`/profile/${props.user.avatar.picture}.png`}
alt={party.user.avatar.picture}
className={`profile ${party.user.avatar.element}`}
srcSet={`/profile/${party.user.avatar.picture}.png,
/profile/${party.user.avatar.picture}@2x.png 2x`}
src={`/profile/${party.user.avatar.picture}.png`}
/>
)
} else
@ -194,63 +395,92 @@ const GridRep = (props: Props) => {
const attribution = () => (
<span className={userClass}>
{userImage()}
{props.user ? props.user.username : t('no_user')}
{party.user ? party.user.username : t('no_user')}
</span>
)
function fullAutoString() {
const fullAutoElement = (
<span className={styles.fullAuto}>
{` · ${t('party.details.labels.full_auto')}`}
</span>
)
const renderWeaponGrid = (
<div className={styles.weaponGrid}>
<div className={mainhandClasses}>{generateMainhandImage()}</div>
const autoGuardElement = (
<span className={styles.autoGuard}>
<ShieldIcon />
</span>
)
<ul className={styles.weapons}>
{Array.from(Array(numWeapons)).map((x, i) => {
return (
<li
key={`${party.shortcode}-weapon-${i}`}
className={weaponClasses}
>
{generateWeaponGridImage(i)}
</li>
)
})}
</ul>
</div>
)
return (
<div className={styles.auto}>
{fullAutoElement}
{props.autoGuard ? autoGuardElement : ''}
</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>
)
const detailsWithUsername = (
<div className={styles.details}>
<div className={styles.top}>
<div className={styles.info}>
<h2 className={titleClass}>
{props.name ? props.name : t('no_title')}
{party.name ? party.name : t('no_title')}
</h2>
<div className={styles.properties}>
<span className={raidClass}>
{props.raid ? props.raid.name[locale] : t('no_raid')}
{party.raid ? party.raid.name[locale] : t('no_raid')}
</span>
{props.fullAuto && (
{party.full_auto && (
<span className={styles.fullAuto}>
{` · ${t('party.details.labels.full_auto')}`}
</span>
)}
{props.raid && props.raid.group.extra && (
{party.raid && party.raid.group.extra && (
<span className={styles.extra}>{` · EX`}</span>
)}
</div>
</div>
{account.authorized &&
((props.user && account.user && account.user.id !== props.user.id) ||
!props.user) ? (
((party.user && account.user && account.user.id !== party.user.id) ||
!party.user) ? (
<Link href="#">
<Button
className={classNames({
save: true,
saved: props.favorited,
saved: party.favorited,
})}
leftAccessoryIcon={<SaveIcon className="stroke" />}
active={props.favorited}
active={party.favorited}
bound={true}
size="small"
onClick={sendSaveData}
@ -265,32 +495,59 @@ const GridRep = (props: Props) => {
<time
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>
</div>
</div>
)
return (
<Link legacyBehavior href={`/p/${props.shortcode}`}>
<a className={gridRepStyles}>
{detailsWithUsername}
<div className={styles.weaponGrid}>
<div className={mainhandClasses}>{generateMainhandImage()}</div>
function changeView(view: 'characters' | 'weapons' | 'summons') {
setCurrentView(view)
}
<ul className={styles.weapons}>
{Array.from(Array(numWeapons)).map((x, i) => {
return (
<li key={`${props.shortcode}-${i}`} className={weaponClasses}>
{generateGridImage(i)}
</li>
)
})}
</ul>
</div>
</a>
return (
<Link href={`/p/${party.shortcode}`} className={gridRepClasses}>
{detailsWithUsername}
<div className={styles.gridContainer}>
{currentView === 'characters'
? renderCharacterGrid
: currentView === 'summons'
? renderSummonGrid
: renderWeaponGrid}
</div>
<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>
)
}

View file

@ -255,16 +255,7 @@ const ProfileRoute: React.FC<Props> = ({
return parties.map((party, i) => {
return (
<GridRep
id={party.id}
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}
party={party}
key={`party-${i}`}
loading={isLoading}
onClick={goTo}

View file

@ -294,16 +294,7 @@ const SavedRoute: React.FC<Props> = ({
return parties.map((party, i) => {
return (
<GridRep
id={party.id}
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}
party={party}
key={`party-${i}`}
loading={isLoading}
onClick={goTo}

View file

@ -308,16 +308,7 @@ const TeamsRoute: React.FC<Props> = ({
return parties.map((party, i) => {
return (
<GridRep
id={party.id}
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}
party={party}
key={`party-${i}`}
loading={isLoading}
onClick={goTo}