From f8df3a2a4997e5961cadc75a3e84e4439028cffc Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 16 Apr 2023 03:54:07 -0700 Subject: [PATCH] Began refactoring PartyDetails into several files * PartyHeader will live at the top, above the new segmented control * PartyDetails stays below, only showing remixed teams and the description * PartyDropdown handles the new ... menu --- .../common/DropdownMenuContent/index.scss | 13 +- components/party/Party/index.scss | 8 + components/party/Party/index.tsx | 39 +- components/party/PartyDetails/index.tsx | 573 +----------- components/party/PartyDropdown/index.scss | 0 components/party/PartyDropdown/index.tsx | 197 +++++ components/party/PartyHeader/index.scss | 394 +++++++++ components/party/PartyHeader/index.tsx | 831 ++++++++++++++++++ 8 files changed, 1505 insertions(+), 550 deletions(-) create mode 100644 components/party/PartyDropdown/index.scss create mode 100644 components/party/PartyDropdown/index.tsx create mode 100644 components/party/PartyHeader/index.scss create mode 100644 components/party/PartyHeader/index.tsx diff --git a/components/common/DropdownMenuContent/index.scss b/components/common/DropdownMenuContent/index.scss index caf82c8b..160ce013 100644 --- a/components/common/DropdownMenuContent/index.scss +++ b/components/common/DropdownMenuContent/index.scss @@ -4,6 +4,7 @@ border-radius: 6px; box-shadow: 0 1px 4px rgb(0 0 0 / 8%); box-sizing: border-box; + overflow: auto; width: 30vw; max-width: 180px; margin: 0 $unit-2x; @@ -130,6 +131,14 @@ } } + & .destructive { + color: $error; + + &:hover { + background: $error; + color: #fff; + } + } a { color: $grey-50; @@ -177,12 +186,12 @@ .MenuGroup { border-bottom: 1px solid var(--menu-separator); - &:first-child .MenuItem:first-child:hover { + &:first-child .MenuItem:first-child { border-top-left-radius: 6px; border-top-right-radius: 6px; } - &:last-child .MenuItem:last-child:hover { + &:last-child .MenuItem:last-child { border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; } diff --git a/components/party/Party/index.scss b/components/party/Party/index.scss index c6b14b47..4da01944 100644 --- a/components/party/Party/index.scss +++ b/components/party/Party/index.scss @@ -5,3 +5,11 @@ gap: 8px; line-height: 34px; } + +nav.RepNavigation { + display: flex; + gap: 0; + justify-content: center; + margin-bottom: $unit-4x; + width: 100%; +} diff --git a/components/party/Party/index.tsx b/components/party/Party/index.tsx index 4462b861..58a7acbd 100644 --- a/components/party/Party/index.tsx +++ b/components/party/Party/index.tsx @@ -22,6 +22,11 @@ import type { DetailsObject } from '~types' import './index.scss' +import WeaponRep from '~components/reps/WeaponRep' +import CharacterRep from '~components/reps/CharacterRep' +import SummonRep from '~components/reps/SummonRep' +import PartyHeader from '../PartyHeader' + // Props interface Props { new?: boolean @@ -160,6 +165,29 @@ const Party = (props: Props) => { } } + // Remixing the party + function remixTeam() { + // setOriginalName(partySnapshot.name ? partySnapshot.name : t('no_title')) + + if (props.team && props.team.shortcode) { + const body = getLocalId() + api + .remix({ shortcode: props.team.shortcode, body: body }) + .then((response) => { + const remix = response.data.party + + // Store the edit key in local storage + if (remix.edit_key) { + storeEditKey(remix.id, remix.edit_key) + setEditKey(remix.id, remix.user) + } + + router.push(`/p/${remix.shortcode}`) + // setRemixToastOpen(true) + }) + } + } + // Deleting the party function deleteTeam() { if (props.team && editable) { @@ -348,14 +376,23 @@ const Party = (props: Props) => { return ( + + {navigation} +
{currentGrid()}
) diff --git a/components/party/PartyDetails/index.tsx b/components/party/PartyDetails/index.tsx index 45b29554..0fd37033 100644 --- a/components/party/PartyDetails/index.tsx +++ b/components/party/PartyDetails/index.tsx @@ -1,7 +1,6 @@ -import React, { useEffect, useState, ChangeEvent, KeyboardEvent } from 'react' -import Link from 'next/link' +import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' -import { subscribe, useSnapshot } from 'valtio' +import { useSnapshot } from 'valtio' import { useTranslation } from 'next-i18next' import clonedeep from 'lodash.clonedeep' @@ -10,27 +9,20 @@ import LiteYouTubeEmbed from 'react-lite-youtube-embed' import classNames from 'classnames' import reactStringReplace from 'react-string-replace' -import Alert from '~components/common/Alert' import Button from '~components/common/Button' -import CharLimitedFieldset from '~components/common/CharLimitedFieldset' -import DurationInput from '~components/common/DurationInput' import GridRepCollection from '~components/GridRepCollection' import GridRep from '~components/GridRep' -import Input from '~components/common/Input' -import RaidDropdown from '~components/RaidDropdown' -import Switch from '~components/common/Switch' import Tooltip from '~components/common/Tooltip' import TextFieldset from '~components/common/TextFieldset' -import Token from '~components/common/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 EllipsisIcon from '~public/icons/Ellipsis.svg' import EditIcon from '~public/icons/Edit.svg' import RemixIcon from '~public/icons/Remix.svg' @@ -44,38 +36,21 @@ interface Props { 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() @@ -91,59 +66,11 @@ const PartyDetails = (props: Props) => { 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) { @@ -177,16 +104,6 @@ const PartyDetails = (props: Props) => { } }, [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() @@ -196,81 +113,6 @@ const PartyDetails = (props: Props) => { 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 }) @@ -289,30 +131,9 @@ const PartyDetails = (props: Props) => { 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, + description: descriptionInput.current?.value, } props.updateCallback(details) @@ -323,15 +144,33 @@ const PartyDetails = (props: Props) => { setAlertOpen(!alertOpen) } - function deleteParty() { - props.deleteCallback() - } - // 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) @@ -370,103 +209,6 @@ const PartyDetails = (props: Props) => { }) } - 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 ( @@ -490,142 +232,9 @@ const PartyDetails = (props: Props) => { }) } - const deleteAlert = () => { - if (party.editable) { - return ( - setAlertOpen(false)} - cancelActionText={t('modals.delete_team.buttons.cancel')} - message={t('modals.delete_team.description')} - /> - ) - } - } - const editable = () => { return (
- - -
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
{ ) } - 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}
) @@ -765,56 +292,8 @@ const PartyDetails = (props: Props) => { 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() : ''} diff --git a/components/party/PartyDropdown/index.scss b/components/party/PartyDropdown/index.scss new file mode 100644 index 00000000..e69de29b diff --git a/components/party/PartyDropdown/index.tsx b/components/party/PartyDropdown/index.tsx new file mode 100644 index 00000000..189f867c --- /dev/null +++ b/components/party/PartyDropdown/index.tsx @@ -0,0 +1,197 @@ +// Libraries +import React, { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { subscribe, useSnapshot } from 'valtio' +import { Trans, useTranslation } from 'next-i18next' +import Link from 'next/link' +import classNames from 'classnames' + +// Dependencies: Common +import Button from '~components/common/Button' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, +} from '~components/common/DropdownMenuContent' + +// Dependencies: Toasts +import RemixedToast from '~components/toasts/RemixedToast' +import UrlCopiedToast from '~components/toasts/UrlCopiedToast' + +// Dependencies: Alerts +import DeleteTeamAlert from '~components/dialogs/DeleteTeamAlert' +import RemixTeamAlert from '~components/dialogs/RemixTeamAlert' + +// Dependencies: Utils +import api from '~utils/api' +import { accountState } from '~utils/accountState' +import { appState } from '~utils/appState' +import { getLocalId } from '~utils/localId' +import { retrieveLocaleCookies } from '~utils/retrieveCookies' +import { setEditKey, storeEditKey } from '~utils/userToken' + +// Dependencies: Icons +import EllipsisIcon from '~public/icons/Ellipsis.svg' + +// Dependencies: Props +interface Props { + editable: boolean + deleteTeamCallback: () => void + remixTeamCallback: () => void +} + +const PartyDropdown = ({ + editable, + deleteTeamCallback, + remixTeamCallback, +}: Props) => { + // Localization + const { t } = useTranslation('common') + + // Router + const router = useRouter() + const locale = + router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' + const localeData = retrieveLocaleCookies() + + const [open, setOpen] = useState(false) + + const [deleteAlertOpen, setDeleteAlertOpen] = useState(false) + const [remixAlertOpen, setRemixAlertOpen] = useState(false) + + const [copyToastOpen, setCopyToastOpen] = useState(false) + const [remixToastOpen, setRemixToastOpen] = useState(false) + + const [name, setName] = useState('') + const [originalName, setOriginalName] = useState('') + + // Snapshots + const { account } = useSnapshot(accountState) + const { party: partySnapshot } = useSnapshot(appState) + + // Subscribe to app state to listen for party name and + // unsubscribe when component is unmounted + const unsubscribe = subscribe(appState, () => { + const newName = + appState.party && appState.party.name ? appState.party.name : '' + setName(newName) + }) + + useEffect(() => () => unsubscribe(), []) + + // Methods: Event handlers (Buttons) + function handleButtonClicked() { + setOpen(!open) + } + + // Methods: Event handlers (Menus) + function handleOpenChange(open: boolean) { + setOpen(open) + } + + function closeMenu() { + setOpen(false) + } + + // Method: Actions + function copyToClipboard() { + if (router.asPath.split('/')[1] === 'p') { + navigator.clipboard.writeText(window.location.href) + setCopyToastOpen(true) + } + } + + // Methods: Event handlers + + // Alerts / Delete team + function openDeleteTeamAlert() { + setDeleteAlertOpen(true) + } + + function handleDeleteTeamAlertChange(open: boolean) { + setDeleteAlertOpen(open) + } + + // Alerts / Remix team + function openRemixTeamAlert() { + setRemixAlertOpen(true) + } + + function handleRemixTeamAlertChange(open: boolean) { + setRemixAlertOpen(open) + } + + // Toasts / Copy URL + function handleCopyToastOpenChanged(open: boolean) { + setCopyToastOpen(open) + } + + function handleCopyToastCloseClicked() { + setCopyToastOpen(false) + } + + // Toasts / Remix team + function handleRemixToastOpenChanged(open: boolean) { + setRemixToastOpen(open) + } + + function handleRemixToastCloseClicked() { + setRemixToastOpen(false) + } + + const editableItems = () => { + return ( + <> + + + Copy link to team + + + Remix team + + + + + Delete team + + + + ) + } + + return ( + <> + + + + + + + ) +} + +export default PartyDropdown diff --git a/components/party/PartyHeader/index.scss b/components/party/PartyHeader/index.scss new file mode 100644 index 00000000..3bdb453f --- /dev/null +++ b/components/party/PartyHeader/index.scss @@ -0,0 +1,394 @@ +.DetailsWrapper { + display: flex; + flex-direction: column; + gap: $unit-2x; + margin: $unit-4x auto 0 auto; + max-width: $grid-width; + + @include breakpoint(phone) { + .Button:not(.IconButton) { + justify-content: center; + width: 100%; + + .Text { + width: auto; + } + } + } + + .PartyDetails { + box-sizing: border-box; + display: none; + margin: 0 auto $unit-2x; + max-width: $unit * 94; + overflow: hidden; + width: 100%; + + @include breakpoint(phone) { + padding: 0 $unit; + } + + &.Visible { + // margin-bottom: $unit-12x; + } + + &.Editable { + gap: $unit; + + &.Visible { + display: grid; + } + + fieldset { + display: block; + width: 100%; + + textarea { + min-height: $unit * 22; + width: 100%; + } + } + + .SelectTrigger { + padding: $unit-2x; + width: 100%; + } + + .DetailToggleGroup { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: $unit; + + @include breakpoint(phone) { + grid-template-columns: 1fr; + } + + .ToggleSection, + .InputSection { + align-items: center; + display: flex; + background: var(--card-bg); + border-radius: $input-corner; + + & > label { + align-items: center; + display: flex; + font-size: $font-regular; + gap: $unit; + grid-template-columns: 2fr 1fr; + justify-content: space-between; + width: 100%; + + & > span { + flex-grow: 1; + } + } + } + + .ToggleSection { + padding: ($unit * 1.5) $unit-2x; + } + + .InputSection { + padding: $unit-half $unit-2x; + padding-right: $unit-half; + + .Input { + border-radius: 7px; + } + + div.Input { + align-items: center; + border: 2px solid transparent; + box-sizing: border-box; + display: flex; + padding: $unit; + + &:has(> input:focus) { + border: 2px solid $blue; + outline: none; + } + + & > input { + background: transparent; + border: none; + padding: $unit 0; + text-align: right; + width: 2rem; + + &:focus { + outline: none; + border: none; + } + } + } + + label { + display: flex; + justify-content: space-between; + + span { + flex-grow: 1; + } + + .Input { + border-radius: 7px; + max-width: 10rem; + } + + div { + display: flex; + flex-direction: row; + gap: $unit-half; + justify-content: right; + } + } + } + } + + .bottom { + display: flex; + flex-direction: row; + gap: $unit; + + @include breakpoint(phone) { + flex-direction: column; + width: 100%; + } + + .left { + flex-grow: 1; + } + + .right { + display: flex; + flex-direction: row; + gap: $unit; + + @include breakpoint(phone) { + .Button { + flex-grow: 1; + } + } + } + } + } + + &.ReadOnly { + box-sizing: border-box; + line-height: 1.4; + white-space: pre-wrap; + + &.Visible { + display: block; + } + + a:hover { + text-decoration: underline; + } + + p { + font-size: $font-regular; + line-height: $font-regular * 1.2; + white-space: pre-line; + } + + .Details { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: $unit; + margin-bottom: $unit-2x; + } + + .YoutubeWrapper { + background-color: var(--card-bg); + border-radius: $card-corner; + margin: $unit 0; + position: relative; + display: block; + contain: content; + background-position: center center; + background-size: cover; + cursor: pointer; + width: 60%; + height: 60%; + + @include breakpoint(tablet) { + width: 100%; + height: 100%; + } + + /* gradient */ + &::before { + content: ''; + display: block; + position: absolute; + top: 0; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==); + background-position: top; + background-repeat: repeat-x; + height: 60px; + padding-bottom: 50px; + width: 100%; + transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); + } + + /* responsive iframe with a 16:9 aspect ratio + thanks https://css-tricks.com/responsive-iframes/ + */ + &::after { + content: ''; + display: block; + padding-bottom: calc(100% / (16 / 9)); + } + + &:hover > .PlayerButton { + opacity: 1; + } + + & > iframe { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + } + + /* Play button */ + & > .PlayerButton { + background: none; + border: none; + background-image: url('/icons/youtube.svg'); + width: 68px; + height: 68px; + opacity: 0.8; + transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); + } + + & > .PlayerButton, + & > .PlayerButton:before { + position: absolute; + top: 50%; + left: 50%; + transform: translate3d(-50%, -50%, 0); + } + + /* Post-click styles */ + &.lyt-activated { + cursor: unset; + } + &.lyt-activated::before, + &.lyt-activated > .PlayerButton { + opacity: 0; + pointer-events: none; + } + } + } + } + + .PartyInfo { + box-sizing: border-box; + display: flex; + flex-direction: row; + gap: $unit; + margin: 0 auto; + max-width: $unit * 94; + width: 100%; + + @include breakpoint(phone) { + flex-direction: column; + gap: $unit; + padding: 0 $unit; + } + + & > .Right { + display: flex; + gap: $unit-half; + } + + & > .Left { + flex-grow: 1; + + .Header { + align-items: center; + display: flex; + gap: $unit; + margin-bottom: $unit; + + h1 { + font-size: $font-xlarge; + font-weight: $normal; + text-align: left; + color: var(--text-primary); + + &.empty { + color: var(--text-secondary); + } + } + } + + .attribution { + align-items: center; + display: flex; + flex-direction: row; + + & > div { + align-items: center; + display: inline-flex; + font-size: $font-small; + height: 26px; + } + + time { + font-size: $font-small; + } + + a:visited:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not( + .light + ) { + color: var(--text-primary); + } + + a:hover:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not( + .light + ) { + color: $blue; + } + + & > *:not(:last-child):after { + content: ' · '; + margin: 0 calc($unit / 2); + } + } + } + + .user { + align-items: center; + display: inline-flex; + gap: calc($unit / 2); + margin-top: 1px; + + img, + .no-user { + $diameter: 24px; + + border-radius: calc($diameter / 2); + height: $diameter; + width: $diameter; + } + + img.gran { + background-color: #cee7fe; + } + + img.djeeta { + background-color: #ffe1fe; + } + + .no-user { + background: $grey-80; + } + } + } +} diff --git a/components/party/PartyHeader/index.tsx b/components/party/PartyHeader/index.tsx new file mode 100644 index 00000000..c42484ee --- /dev/null +++ b/components/party/PartyHeader/index.tsx @@ -0,0 +1,831 @@ +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/common/Alert' +import Button from '~components/common/Button' +import CharLimitedFieldset from '~components/common/CharLimitedFieldset' +import DurationInput from '~components/common/DurationInput' +import GridRepCollection from '~components/GridRepCollection' +import GridRep from '~components/GridRep' +import Input from '~components/common/Input' +import RaidDropdown from '~components/RaidDropdown' +import Switch from '~components/common/Switch' +import Tooltip from '~components/common/Tooltip' +import TextFieldset from '~components/common/TextFieldset' +import Token from '~components/common/Token' + +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 { 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 EllipsisIcon from '~public/icons/Ellipsis.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 + deleteCallback: () => void + remixCallback: () => void + updateCallback: (details: DetailsObject) => void +} + +const PartyHeader = (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()} +
+
+ ) + } + + 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()} +
+ + ) +} + +export default PartyHeader