hensei-web/components/party/PartyHeader/index.tsx
Justin Edmund 2d02f88622 Fix Link component legacy behavior and tab switching
- Remove legacyBehavior prop and nested <a> tags from Link components
- Modernize to Next.js 13+ Link API with className directly on Link
- Convert external links to plain <a> tags (LinkItem, Discord link)
- Remove unnecessary passHref props from Header components
- Fix tab switching by mapping string values to GridType enum

The tab switching issue was caused by trying to parse string values
("characters", "weapons", "summons") as integers when they needed to
be mapped to GridType enum values.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 15:58:12 -07:00

535 lines
15 KiB
TypeScript

'use client'
import React, { useState } from 'react'
import { Link } from '~/i18n/navigation'
import { useRouter, usePathname } from '~/i18n/navigation'
import { useSearchParams } from 'next/navigation'
import { getCookie } from 'cookies-next'
import { useSnapshot } from 'valtio'
import { useTranslations } from 'next-intl'
import classNames from 'classnames'
import Button from '~components/common/Button'
import Tooltip from '~components/common/Tooltip'
import Token from '~components/common/Token'
import EditPartyModal from '~components/party/EditPartyModal'
import PartyDropdown from '~components/party/PartyDropdown'
import api from '~utils/api'
import { accountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState'
import { formatTimeAgo } from '~utils/timeAgo'
import RemixTeamAlert from '~components/dialogs/RemixTeamAlert'
import RemixedToast from '~components/toasts/RemixedToast'
import PartyVisibilityDialog from '~components/party/PartyVisibilityDialog'
import UrlCopiedToast from '~components/toasts/UrlCopiedToast'
import EditIcon from '~public/icons/Edit.svg'
import RemixIcon from '~public/icons/Remix.svg'
import SaveIcon from '~public/icons/Save.svg'
import PrivateIcon from '~public/icons/Private.svg'
import UnlistedIcon from '~public/icons/Unlisted.svg'
import type { DetailsObject } from '~types'
import styles from './index.module.scss'
// Props
interface Props {
party?: Party
new: boolean
editable: boolean
raidGroups: RaidGroup[]
deleteCallback: () => void
remixCallback: () => void
updateCallback: (details: DetailsObject) => Promise<any>
}
const PartyHeader = (props: Props) => {
const { party } = useSnapshot(appState)
const t = useTranslations('common')
const router = useRouter()
const pathname = usePathname()
const locale = getCookie('NEXT_LOCALE') || 'en'
const { party: partySnapshot } = useSnapshot(appState)
// State: Component
const [detailsOpen, setDetailsOpen] = useState(false)
const [copyToastOpen, setCopyToastOpen] = useState(false)
const [remixAlertOpen, setRemixAlertOpen] = useState(false)
const [remixToastOpen, setRemixToastOpen] = useState(false)
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false)
const userClass = classNames({
[styles.user]: true,
[styles.empty]: !party.user,
})
const linkClass = classNames({
wind: party && party.element == 1,
fire: party && party.element == 2,
water: party && party.element == 3,
earth: party && party.element == 4,
dark: party && party.element == 5,
light: party && party.element == 6,
})
// Actions: Favorites
function toggleFavorite() {
if (appState.party.favorited) unsaveFavorite()
else saveFavorite()
}
function saveFavorite() {
if (appState.party.id)
api.saveTeam({ id: appState.party.id }).then((response) => {
if (response.status == 201) appState.party.favorited = true
})
else console.error('Failed to save team: No party ID')
}
function unsaveFavorite() {
if (appState.party.id)
api.unsaveTeam({ id: appState.party.id }).then((response) => {
if (response.status == 200) appState.party.favorited = false
})
else console.error('Failed to unsave team: No party ID')
}
// Methods: Navigation
function goTo(shortcode?: string) {
if (shortcode) router.push(`/p/${shortcode}`)
}
const userImage = (picture?: string, element?: string) => {
if (picture && element)
return (
<img
alt={picture}
className={`profile ${element}`}
srcSet={`/profile/${picture}.png,
/profile/${picture}@2x.png 2x`}
src={`/profile/${picture}.png`}
/>
)
else
return (
<img
alt={t('no_user')}
className={`profile anonymous`}
srcSet={`/profile/npc.png,
/profile/npc@2x.png 2x`}
src={`/profile/npc.png`}
/>
)
}
// Actions: Edit info
function handleDetailsOpenChange(open: boolean) {
setDetailsOpen(open)
}
// Dialogs: Visibility
function visibilityDialogCallback() {
setVisibilityDialogOpen(true)
}
function handleVisibilityDialogChange(open: boolean) {
setVisibilityDialogOpen(open)
}
// Actions: Remix team
function remixTeamCallback() {
setRemixToastOpen(true)
props.remixCallback()
}
// Actions: Copy URL
function copyToClipboard() {
if (pathname.split('/')[1] === 'p') {
navigator.clipboard.writeText(window.location.href)
setCopyToastOpen(true)
}
}
// Alerts: Remix team
function openRemixTeamAlert() {
setRemixAlertOpen(true)
}
function handleRemixTeamAlertChange(open: boolean) {
setRemixAlertOpen(open)
}
// Toasts: Remix team
function handleRemixToastOpenChanged(open: boolean) {
setRemixToastOpen(!open)
}
function handleRemixToastCloseClicked() {
setRemixToastOpen(false)
}
// Toasts / Copy URL
function handleCopyToastOpenChanged(open: boolean) {
setCopyToastOpen(!open)
}
function handleCopyToastCloseClicked() {
setCopyToastOpen(false)
}
// Rendering
const userBlock = (username?: string, picture?: string, element?: string) => {
return (
<div className={userClass}>
{userImage(picture, element)}
{username ? username : t('no_user')}
</div>
)
}
const renderUserBlock = () => {
let username, picture, element
if (accountState.account.authorized && props.new) {
username = accountState.account.user?.username
picture = accountState.account.user?.avatar.picture
element = accountState.account.user?.avatar.element
} else if (party.user && !props.new) {
username = party.user.username
picture = party.user.avatar.picture
element = party.user.avatar.element
}
if (username && picture && element) {
return linkedUserBlock(username, picture, element)
} else if (!props.new) {
return userBlock()
}
}
const linkedUserBlock = (
username?: string,
picture?: string,
element?: string
) => {
return (
<div>
<Link href={`/${username}`} className={linkClass}>
{userBlock(username, picture, element)}
</Link>
</div>
)
}
const linkedRaidBlock = (raid: Raid) => {
return (
<div>
<Link href={`/teams?raid=${raid.slug}`} className={`Raid ${linkClass}`}>
{raid.name[locale]}
</Link>
</div>
)
}
// Render: Tokens
const chargeAttackToken = (
<Token active={party.chargeAttack} className="chargeAttack">
{`${t('party.details.labels.charge_attack')} ${
party.chargeAttack ? 'On' : 'Off'
}`}
</Token>
)
const fullAutoToken = (
<Token active={party.fullAuto} className="fullAuto">
{`${t('party.details.labels.full_auto')} ${
party.fullAuto ? 'On' : 'Off'
}`}
</Token>
)
const autoGuardToken = (
<Token active={party.autoGuard} className="autoGuard">
{`${t('party.details.labels.auto_guard')} ${
party.autoGuard ? 'On' : 'Off'
}`}
</Token>
)
const autoSummonToken = (
<Token active={party.autoSummon} className="autoSummon">
{`${t('party.details.labels.auto_summon')} ${
party.autoSummon ? 'On' : 'Off'
}`}
</Token>
)
const turnCountToken = (
<Token>
{t('party.details.turns.with_count', {
count: party.turnCount,
})}
</Token>
)
const buttonChainToken = () => {
if (party.buttonCount !== undefined || party.chainCount !== undefined) {
let string = ''
if (party.buttonCount !== undefined) {
string += `${party.buttonCount}b`
}
if (party.buttonCount === undefined && party.chainCount !== undefined) {
string += `0${t('party.details.suffix.buttons')}${party.chainCount}${t(
'party.details.suffix.chains'
)}`
} else if (
party.buttonCount !== undefined &&
party.chainCount !== undefined
) {
string += `${party.chainCount}${t('party.details.suffix.chains')}`
} else if (
party.buttonCount !== undefined &&
party.chainCount === undefined
) {
string += `0${t('party.details.suffix.chains')}`
}
return <Token>{string}</Token>
}
}
const clearTimeToken = () => {
const minutes = Math.floor(party.clearTime / 60)
const seconds = party.clearTime - minutes * 60
let string = ''
if (minutes > 0)
string = `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t(
'party.details.suffix.seconds'
)}`
else string = `${seconds}${t('party.details.suffix.seconds')}`
return <Token>{string}</Token>
}
function renderTokens() {
return (
<>
{' '}
{chargeAttackToken}
{fullAutoToken}
{autoSummonToken}
{autoGuardToken}
{party.turnCount !== undefined && turnCountToken}
{party.clearTime > 0 && clearTimeToken()}
{buttonChainToken()}
</>
)
}
// Render: Notice
const unlistedNotice = (
<div className={styles.notice}>
<div className={styles.icon}>
<UnlistedIcon />
</div>
<p>{t('party.notices.unlisted')}</p>
<div className={styles.buttons}>
<Button
bound={true}
className="notice no-shrink"
key="copy_link"
text={t('party.notices.buttons.copy_link')}
onClick={copyToClipboard}
/>
{party.editable && (
<Button
bound={true}
className="notice no-shrink"
key="change_visibility"
text={t('party.notices.buttons.change_visibility')}
onClick={() => handleVisibilityDialogChange(true)}
/>
)}
</div>
</div>
)
const privateNotice = (
<div className={styles.notice}>
<div className={styles.icon}>
<PrivateIcon />
</div>
<p>{t('party.notices.private')}</p>
{party.editable && (
<div className={styles.buttons}>
<Button
bound={true}
className="notice"
key="change_visibility"
text={t('party.notices.buttons.change_visibility')}
onClick={() => handleVisibilityDialogChange(true)}
/>
</div>
)}
</div>
)
// Render: Buttons
const saveButton = () => {
return (
<Tooltip content={t('tooltips.save')}>
<Button
leftAccessoryIcon={<SaveIcon />}
className={classNames({
save: true,
grow: true,
saved: partySnapshot.favorited,
})}
text={
appState.party.favorited ? t('buttons.saved') : t('buttons.save')
}
onClick={toggleFavorite}
/>
</Tooltip>
)
}
const remixButton = () => {
return (
<Tooltip content={t('tooltips.remix.create')}>
<Button
leftAccessoryIcon={<RemixIcon />}
className="grow"
text={t('buttons.remix')}
onClick={openRemixTeamAlert}
/>
</Tooltip>
)
}
const remixedButton = () => {
const tooltipString =
party.remix && party.sourceParty
? t('tooltips.remix.source')
: t('tooltips.remix.deleted')
const buttonAction =
party.sourceParty && (() => goTo(party.sourceParty?.shortcode))
return (
<Tooltip content={tooltipString}>
<Button
blended={true}
className="remixed"
leftAccessoryIcon={<RemixIcon />}
text={t('tokens.remix')}
size="small"
disabled={!party.sourceParty}
onClick={buttonAction}
/>
</Tooltip>
)
}
return (
<>
<header className={styles.wrapper}>
{party.visibility == 2 && unlistedNotice}
{party.visibility == 3 && privateNotice}
<section className={styles.info}>
<div className={styles.left}>
<div className={styles.header}>
<h1 className={party.name ? '' : styles.empty}>
{party.name ? party.name : t('no_title')}
</h1>
{party.remix && remixedButton()}
</div>
<div className={styles.attribution}>
{renderUserBlock()}
{appState.party.raid && linkedRaidBlock(appState.party.raid)}
{party.created_at != '' && (
<time
className={styles.lastUpdated}
dateTime={new Date(party.created_at).toString()}
>
{formatTimeAgo(new Date(party.created_at), locale)}
</time>
)}
</div>
</div>
{props.editable ? (
<div className={styles.right}>
<EditPartyModal
open={detailsOpen}
party={props.party}
raidGroups={props.raidGroups}
onOpenChange={handleDetailsOpenChange}
updateParty={props.updateCallback}
>
<Button
className="full"
leftAccessoryIcon={<EditIcon />}
text={t('buttons.show_info')}
/>
</EditPartyModal>
{!props.new && (
<PartyDropdown
editable={props.editable}
deleteTeamCallback={props.deleteCallback}
remixTeamCallback={props.remixCallback}
teamVisibilityCallback={visibilityDialogCallback}
/>
)}
</div>
) : (
<div className={styles.right}>
{saveButton()}
{remixButton()}
</div>
)}
</section>
<section className={styles.tokens}>{renderTokens()}</section>
</header>
<PartyVisibilityDialog
open={visibilityDialogOpen}
value={party.visibility as 1 | 2 | 3}
onOpenChange={handleVisibilityDialogChange}
updateParty={props.updateCallback}
/>
<RemixTeamAlert
creator={props.editable}
name={partySnapshot.name ? partySnapshot.name : t('no_title')}
open={remixAlertOpen}
onOpenChange={handleRemixTeamAlertChange}
remixCallback={remixTeamCallback}
/>
<RemixedToast
open={remixToastOpen}
partyName={props.party?.name || t('no_title')}
onOpenChange={handleRemixToastOpenChanged}
onCloseClick={handleRemixToastCloseClicked}
/>
<UrlCopiedToast
open={copyToastOpen}
onOpenChange={handleCopyToastOpenChanged}
onCloseClick={handleCopyToastCloseClicked}
/>
</>
)
}
export default PartyHeader