import React, { useEffect, useState, ChangeEvent, KeyboardEvent } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' import { subscribe, useSnapshot } from 'valtio' import { useTranslation } from 'next-i18next' import clonedeep from 'lodash.clonedeep' import Linkify from 'react-linkify' import LiteYouTubeEmbed from 'react-lite-youtube-embed' import classNames from 'classnames' import reactStringReplace from 'react-string-replace' import Alert from '~components/Alert' import Button from '~components/Button' import CharLimitedFieldset from '~components/CharLimitedFieldset' import DurationInput from '~components/DurationInput' import GridRepCollection from '~components/GridRepCollection' import GridRep from '~components/GridRep' import Input from '~components/Input' import RaidDropdown from '~components/RaidDropdown' import Switch from '~components/Switch' import Tooltip from '~components/Tooltip' import TextFieldset from '~components/TextFieldset' import Token from '~components/Token' import api from '~utils/api' import { accountState } from '~utils/accountState' import { appState, initialAppState } from '~utils/appState' import { formatTimeAgo } from '~utils/timeAgo' import { youtube } from '~utils/youtube' import CheckIcon from '~public/icons/Check.svg' import CrossIcon from '~public/icons/Cross.svg' import EditIcon from '~public/icons/Edit.svg' import RemixIcon from '~public/icons/Remix.svg' import type { DetailsObject } from 'types' import './index.scss' // Props interface Props { party?: Party new: boolean editable: boolean updateCallback: (details: DetailsObject) => void deleteCallback: () => void } const PartyDetails = (props: Props) => { const { party, raids } = useSnapshot(appState) const { t } = useTranslation('common') const router = useRouter() const locale = router.locale || 'en' const youtubeUrlRegex = /(?:https:\/\/www\.youtube\.com\/watch\?v=|https:\/\/youtu\.be\/)([\w-]+)/g const nameInput = React.createRef() const descriptionInput = React.createRef() const [open, setOpen] = useState(false) const [name, setName] = useState('') const [alertOpen, setAlertOpen] = useState(false) const [chargeAttack, setChargeAttack] = useState(true) const [fullAuto, setFullAuto] = useState(false) const [autoGuard, setAutoGuard] = useState(false) const [buttonCount, setButtonCount] = useState(undefined) const [chainCount, setChainCount] = useState(undefined) const [turnCount, setTurnCount] = useState(undefined) const [clearTime, setClearTime] = useState(0) const [remixes, setRemixes] = useState([]) const [raidSlug, setRaidSlug] = useState('') const [embeddedDescription, setEmbeddedDescription] = useState() const readOnlyClasses = classNames({ PartyDetails: true, ReadOnly: true, Visible: !open, }) const editableClasses = classNames({ PartyDetails: true, Editable: true, Visible: open, }) const userClass = classNames({ user: true, 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, }) const [errors, setErrors] = useState<{ [key: string]: string }>({ name: '', description: '', }) useEffect(() => { if (props.party) { setName(props.party.name) setAutoGuard(props.party.auto_guard) setFullAuto(props.party.full_auto) setChargeAttack(props.party.charge_attack) setClearTime(props.party.clear_time) setRemixes(props.party.remixes) if (props.party.turn_count) setTurnCount(props.party.turn_count) if (props.party.button_count) setButtonCount(props.party.button_count) if (props.party.chain_count) setChainCount(props.party.chain_count) } }, [props.party]) // Subscribe to router changes and reset state // if the new route is a new team useEffect(() => { router.events.on('routeChangeStart', (url, { shallow }) => { if (url === '/new' || url === '/') { const party = initialAppState.party setName(party.name ? party.name : '') setAutoGuard(party.autoGuard) setFullAuto(party.fullAuto) setChargeAttack(party.chargeAttack) setClearTime(party.clearTime) setRemixes(party.remixes) setTurnCount(party.turnCount) setButtonCount(party.buttonCount) setChainCount(party.chainCount) } }) }, []) useEffect(() => { // Extract the video IDs from the description if (appState.party.description) { const videoIds = extractYoutubeVideoIds(appState.party.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( appState.party.description, youtubeUrlRegex, (match, i) => ( ) ) // Update the state with the new description setEmbeddedDescription(newDescription) }) } else { setEmbeddedDescription('') } }, [appState.party.description]) function handleInputChange(event: React.ChangeEvent) { event.preventDefault() const { name, value } = event.target setName(value) let newErrors = errors setErrors(newErrors) } function handleTextAreaChange(event: React.ChangeEvent) { event.preventDefault() const { name, value } = event.target let newErrors = errors setErrors(newErrors) } function handleChargeAttackChanged(checked: boolean) { setChargeAttack(checked) } function handleFullAutoChanged(checked: boolean) { setFullAuto(checked) } function handleAutoGuardChanged(checked: boolean) { setAutoGuard(checked) } function handleClearTimeInput(value: number) { if (!isNaN(value)) setClearTime(value) } function handleTurnCountInput(event: React.ChangeEvent) { const value = parseInt(event.currentTarget.value) if (!isNaN(value)) setTurnCount(value) } function handleButtonCountInput(event: ChangeEvent) { const value = parseInt(event.currentTarget.value) if (!isNaN(value)) setButtonCount(value) } function handleChainCountInput(event: ChangeEvent) { const value = parseInt(event.currentTarget.value) if (!isNaN(value)) setChainCount(value) } function handleInputKeyDown(event: KeyboardEvent) { if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { // Allow the key to be processed normally return } // Get the current value const input = event.currentTarget let value = event.currentTarget.value // Check if the key that was pressed is the backspace key if (event.key === 'Backspace') { // Remove the colon if the value is "12:" if (value.length === 4) { value = value.slice(0, -1) } // Allow the backspace key to be processed normally input.value = value return } // Check if the key that was pressed is the tab key if (event.key === 'Tab') { // Allow the tab key to be processed normally return } // Get the character that was entered and check if it is numeric const char = parseInt(event.key) const isNumber = !isNaN(char) // Check if the character should be accepted or rejected const numberValue = parseInt(`${value}${char}`) const minValue = parseInt(event.currentTarget.min) const maxValue = parseInt(event.currentTarget.max) if (!isNumber || numberValue < minValue || numberValue > maxValue) { // Reject the character if it isn't a number, // or if it exceeds the min and max values event.preventDefault() } } async function fetchYoutubeData(videoId: string) { return await youtube .getVideoById(videoId, { maxResults: 1 }) .then((data) => data.items[0].snippet.localized.title) } function toggleDetails() { // Enabling this code will make live updates not work, // but I'm not sure why it's here, so we're not going to remove it. // if (name !== party.name) { // const resetName = party.name ? party.name : '' // setName(resetName) // if (nameInput.current) nameInput.current.value = resetName // } setOpen(!open) } function receiveRaid(slug?: string) { if (slug) setRaidSlug(slug) } function switchValue(value: boolean) { if (value) return 'on' else return 'off' } function updateDetails(event: React.MouseEvent) { const descriptionValue = descriptionInput.current?.value const raid = raids.find((raid) => raid.slug === raidSlug) const details: DetailsObject = { fullAuto: fullAuto, autoGuard: autoGuard, chargeAttack: chargeAttack, clearTime: clearTime, buttonCount: buttonCount, turnCount: turnCount, chainCount: chainCount, name: name, description: descriptionValue, raid: raid, } props.updateCallback(details) toggleDetails() } function handleClick() { setAlertOpen(!alertOpen) } function deleteParty() { props.deleteCallback() } // Methods: Navigation function goTo(shortcode?: string) { if (shortcode) router.push(`/p/${shortcode}`) } // 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) } }) } 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 } const userImage = (picture?: string, element?: string) => { if (picture && element) return ( {picture} ) else return ( {t('no_user')} ) } const userBlock = (username?: string, picture?: string, element?: string) => { return (
{userImage(picture, element)} {username ? username : t('no_user')}
) } 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 ( ) } const linkedRaidBlock = (raid: Raid) => { return ( ) } function renderRemixes() { return remixes.map((party, i) => { return ( ) }) } const deleteAlert = () => { if (party.editable) { return ( setAlertOpen(false)} cancelActionText={t('modals.delete_team.buttons.cancel')} message={t('modals.delete_team.description')} /> ) } } const editable = () => { return (
{router.pathname !== '/new' ? (
) } const clearTimeString = () => { const minutes = Math.floor(clearTime / 60) const seconds = clearTime - minutes * 60 if (minutes > 0) return `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t( 'party.details.suffix.seconds' )}` else return `${seconds}${t('party.details.suffix.seconds')}` } const buttonChainToken = () => { if (buttonCount || chainCount) { let string = '' if (buttonCount && buttonCount > 0) { string += `${buttonCount}b` } if (!buttonCount && chainCount && chainCount > 0) { string += `0${t('party.details.suffix.buttons')}${chainCount}${t( 'party.details.suffix.chains' )}` } else if (buttonCount && chainCount && chainCount > 0) { string += `${chainCount}${t('party.details.suffix.chains')}` } else if (buttonCount && !chainCount) { string += `0${t('party.details.suffix.chains')}` } return {string} } } const readOnly = () => { return (
{`${t('party.details.labels.charge_attack')} ${ chargeAttack ? 'On' : 'Off' }`} {`${t('party.details.labels.full_auto')} ${ fullAuto ? 'On' : 'Off' }`} {`${t('party.details.labels.auto_guard')} ${ autoGuard ? 'On' : 'Off' }`} {turnCount ? ( {t('party.details.turns.with_count', { count: turnCount, })} ) : ( '' )} {clearTime > 0 ? {clearTimeString()} : ''} {buttonChainToken()}
{embeddedDescription}
) } const remixSection = () => { return (

{t('remixes')}

{{renderRemixes()}}
) } return ( <>

{name ? name : t('no_title')}

{party.remix && party.sourceParty ? (
{renderUserBlock()} {party.raid ? linkedRaidBlock(party.raid) : ''} {party.created_at != '' ? ( ) : ( '' )}
{party.editable ? (
) : ( '' )}
{readOnly()} {editable()} {deleteAlert()}
{remixes && remixes.length > 0 ? remixSection() : ''} ) } export default PartyDetails