## Summary
- Fixed translation key format compatibility with next-intl
- Fixed pluralization format from i18next to next-intl format
- Fixed dynamic translation key error handling
- Updated server components to match API response structure
- Fixed useSearchParams import location
## Changes
- Changed pluralization from `{{count}} items` to `{count} items` format
- Added proper error handling for missing translation keys
- Fixed import paths for next-intl hooks
- Fixed PartyPageClient trying to set non-existent appState.parties
## Test plan
- [x] Verified translations render correctly
- [x] Tested pluralization works with different counts
- [x] Confirmed no console errors about missing translations
- [x] Tested party page functionality
🤖 Generated with [Claude Code](https://claude.ai/code)
---------
Co-authored-by: Claude <noreply@anthropic.com>
301 lines
8.4 KiB
TypeScript
301 lines
8.4 KiB
TypeScript
'use client'
|
|
|
|
import React, { useEffect, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useSnapshot } from 'valtio'
|
|
import { useTranslations } from 'next-intl'
|
|
import clonedeep from 'lodash.clonedeep'
|
|
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/reps/GridRepCollection'
|
|
import GridRep from '~components/reps/GridRep'
|
|
import RemixTeamAlert from '~components/dialogs/RemixTeamAlert'
|
|
import RemixedToast from '~components/toasts/RemixedToast'
|
|
import EditPartyModal from '../EditPartyModal'
|
|
|
|
import api from '~utils/api'
|
|
import { appState } from '~utils/appState'
|
|
import { youtube } from '~utils/youtube'
|
|
|
|
import type { DetailsObject } from '~types'
|
|
|
|
import RemixIcon from '~public/icons/Remix.svg'
|
|
import EditIcon from '~public/icons/Edit.svg'
|
|
import styles from './index.module.scss'
|
|
import Editor from '~components/common/Editor'
|
|
|
|
// Props
|
|
interface Props {
|
|
party?: Party
|
|
new: boolean
|
|
editable: boolean
|
|
raidGroups: RaidGroup[]
|
|
remixCallback: () => void
|
|
updateCallback: (details: DetailsObject) => Promise<any>
|
|
}
|
|
|
|
const PartyFooter = (props: Props) => {
|
|
const t = useTranslations('common')
|
|
const router = useRouter()
|
|
|
|
const { party: partySnapshot } = useSnapshot(appState)
|
|
|
|
const youtubeUrlRegex =
|
|
/(?:https:\/\/www\.youtube\.com\/watch\?v=|https:\/\/youtu\.be\/)([\w-]+)/g
|
|
|
|
// State: Component
|
|
const [currentSegment, setCurrentSegment] = useState(0)
|
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
const [remixAlertOpen, setRemixAlertOpen] = useState(false)
|
|
const [remixToastOpen, setRemixToastOpen] = useState(false)
|
|
|
|
// State: Data
|
|
const [remixes, setRemixes] = useState<Party[]>([])
|
|
const [sanitizedDescription, setSanitizedDescription] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (partySnapshot.description) {
|
|
const purified = DOMPurify.sanitize(partySnapshot.description)
|
|
setSanitizedDescription(purified)
|
|
} else {
|
|
setSanitizedDescription('')
|
|
}
|
|
}, [partySnapshot.description])
|
|
|
|
// Extract the video IDs from the description
|
|
// const videoIds = extractYoutubeVideoIds(partySnapshot.description)
|
|
// Fetch the video titles for each ID
|
|
// const fetchPromises = videoIds.map(({ id }) => fetchYoutubeData(id))
|
|
// // Wait for all the video titles to be fetched
|
|
// Promise.all(fetchPromises).then((videoTitles) => {
|
|
// // Replace the video URLs in the description with LiteYoutubeEmbed elements
|
|
// const newDescription = reactStringReplace(
|
|
// partySnapshot.description,
|
|
// youtubeUrlRegex,
|
|
// (match, i) => (
|
|
// <LiteYouTubeEmbed
|
|
// key={`${match}-${i}`}
|
|
// id={match}
|
|
// title={videoTitles[i]}
|
|
// wrapperClass={styles.youtube}
|
|
// playerClass={styles.playerButton}
|
|
// />
|
|
// )
|
|
// )
|
|
// Update the state with the new description
|
|
|
|
async function fetchYoutubeData(videoId: string) {
|
|
return await youtube
|
|
.getVideoById(videoId, { maxResults: 1 })
|
|
.then((data) => data.items[0].snippet.localized.title)
|
|
}
|
|
|
|
// Methods: Navigation
|
|
function goTo(shortcode?: string) {
|
|
if (shortcode) router.push(`/p/${shortcode}`)
|
|
}
|
|
|
|
function extractYoutubeVideoIds(text: string) {
|
|
// Initialize an array to store the video IDs
|
|
const videoIds = []
|
|
|
|
// Use the regular expression to find all the Youtube URLs in the text
|
|
let match
|
|
while ((match = youtubeUrlRegex.exec(text)) !== null) {
|
|
// Extract the video ID from the URL
|
|
const videoId = match[1]
|
|
|
|
// Add the video ID to the array, along with the character position of the URL
|
|
videoIds.push({
|
|
id: videoId,
|
|
url: match[0],
|
|
position: match.index,
|
|
})
|
|
}
|
|
|
|
// Return the array of video IDs
|
|
return videoIds
|
|
}
|
|
|
|
// Methods: Favorites
|
|
function toggleFavorite(teamId: string, favorited: boolean) {
|
|
if (favorited) unsaveFavorite(teamId)
|
|
else saveFavorite(teamId)
|
|
}
|
|
|
|
function saveFavorite(teamId: string) {
|
|
api.saveTeam({ id: teamId }).then((response) => {
|
|
if (response.status == 201) {
|
|
const index = remixes.findIndex((p) => p.id === teamId)
|
|
const party = remixes[index]
|
|
|
|
party.favorited = true
|
|
|
|
let clonedParties = clonedeep(remixes)
|
|
clonedParties[index] = party
|
|
|
|
setRemixes(clonedParties)
|
|
}
|
|
})
|
|
}
|
|
|
|
function unsaveFavorite(teamId: string) {
|
|
api.unsaveTeam({ id: teamId }).then((response) => {
|
|
if (response.status == 200) {
|
|
const index = remixes.findIndex((p) => p.id === teamId)
|
|
const party = remixes[index]
|
|
|
|
party.favorited = false
|
|
|
|
let clonedParties = clonedeep(remixes)
|
|
clonedParties[index] = party
|
|
|
|
setRemixes(clonedParties)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Actions: Edit info
|
|
function handleDetailsOpenChange(open: boolean) {
|
|
setDetailsOpen(open)
|
|
}
|
|
|
|
// Actions: Remix team
|
|
function remixTeamCallback() {
|
|
setRemixToastOpen(true)
|
|
props.remixCallback()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
const segmentedControl = (
|
|
<SegmentedControl className="background">
|
|
<Segment
|
|
name="description"
|
|
groupName="footer"
|
|
selected={currentSegment === 0}
|
|
onClick={() => setCurrentSegment(0)}
|
|
>
|
|
{t('footer.description.label')}
|
|
</Segment>
|
|
<Segment
|
|
name="remixes"
|
|
groupName="footer"
|
|
selected={currentSegment === 1}
|
|
onClick={() => setCurrentSegment(1)}
|
|
>
|
|
{t('footer.remixes.label', { count: partySnapshot?.remixes?.length })}
|
|
</Segment>
|
|
</SegmentedControl>
|
|
)
|
|
|
|
const descriptionSection = (
|
|
<>
|
|
{props.party &&
|
|
props.party.description &&
|
|
props.party.description.length > 0 && (
|
|
<Editor
|
|
content={props.party.description}
|
|
key={props.party?.shortcode}
|
|
/>
|
|
)}
|
|
{(!partySnapshot || !partySnapshot.description) && (
|
|
<section className={styles.noDescription}>
|
|
<h3>{t('footer.description.empty')}</h3>
|
|
{props.editable && (
|
|
<EditPartyModal
|
|
open={detailsOpen}
|
|
party={props.party}
|
|
raidGroups={props.raidGroups}
|
|
onOpenChange={handleDetailsOpenChange}
|
|
updateParty={props.updateCallback}
|
|
>
|
|
<Button
|
|
leftAccessoryIcon={<EditIcon />}
|
|
text={t('buttons.show_info')}
|
|
/>
|
|
</EditPartyModal>
|
|
)}
|
|
</section>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
const remixesSection = (
|
|
<section className={styles.remixes}>
|
|
{partySnapshot?.remixes?.length > 0 && (
|
|
<GridRepCollection>{renderRemixes()}</GridRepCollection>
|
|
)}
|
|
{partySnapshot?.remixes?.length === 0 && (
|
|
<div className={styles.noRemixes}>
|
|
<h3>{t('footer.remixes.empty')}</h3>
|
|
<Button
|
|
leftAccessoryIcon={<RemixIcon />}
|
|
text={t('buttons.remix')}
|
|
onClick={openRemixTeamAlert}
|
|
/>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
|
|
function renderRemixes() {
|
|
return partySnapshot?.remixes.map((party, i) => {
|
|
return (
|
|
<GridRep
|
|
party={party}
|
|
key={`party-${i}`}
|
|
loading={false}
|
|
onClick={goTo}
|
|
onSave={toggleFavorite}
|
|
/>
|
|
)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className={styles.wrapper}>
|
|
{segmentedControl}
|
|
{currentSegment === 0 && descriptionSection}
|
|
{currentSegment === 1 && remixesSection}
|
|
</div>
|
|
|
|
<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}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default PartyFooter
|