diff --git a/__previewjs__/Wrapper.tsx b/__previewjs__/Wrapper.tsx index ec00dd1f7..a5139de75 100644 --- a/__previewjs__/Wrapper.tsx +++ b/__previewjs__/Wrapper.tsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react'; import type { RenderOptions } from '@testing-library/react'; import type { PreloadedState } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; -import { AppStore, RootState, setupStore } from '../src/app/Store'; +import { AppStore, RootState, globalInitialState, setupStore } from '../src/app/Store'; import { OfflineTestingGameState } from '../src/features/game/InitialGameState'; import '../src/index.css'; import { MemoryRouter } from 'react-router-dom'; @@ -20,7 +20,7 @@ export const Wrapper: React.FC = ({ store }) => { //console.log(OfflineTestingGameState); - store = setupStore({ game: OfflineTestingGameState }); + store = setupStore({ ...globalInitialState, game: OfflineTestingGameState }); return ( <> diff --git a/src/app/GameStateHandler.tsx b/src/app/GameStateHandler.tsx index fc22f12de..78198456e 100644 --- a/src/app/GameStateHandler.tsx +++ b/src/app/GameStateHandler.tsx @@ -1,10 +1,11 @@ -import React, { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useAppDispatch, useAppSelector } from './Hooks'; import { getGameInfo, receiveGameState, - setGameStart + setGameStart, + setOpponentTyping } from 'features/game/GameSlice'; import { useKnownSearchParams } from 'hooks/useKnownSearchParams'; import { GameLocationState } from 'interface/GameLocationState'; @@ -21,6 +22,8 @@ import { toast } from 'react-hot-toast'; import { useGetFriendsListQuery } from 'features/api/apiSlice'; import useAuth from 'hooks/useAuth'; +const MAX_RETRIES = 5; + const GameStateHandler = () => { const { gameID } = useParams(); const gameInfo = useAppSelector(getGameInfo); @@ -35,7 +38,6 @@ const GameStateHandler = () => { ); const navigate = useNavigate(); - // Fetch friends list to ensure it's available for hand visibility checks const { isLoggedIn } = useAuth(); const { data: friendsData } = useGetFriendsListQuery(undefined, { skip: !isLoggedIn @@ -44,9 +46,7 @@ const GameStateHandler = () => { const sourceRef = useRef(null); const gameParamsRef = useRef({ gameID: 0, playerID: 0, authKey: '' }); const retryCountRef = useRef(0); - const maxRetriesRef = useRef(5); const [forceRetry, setForceRetry] = useState(0); - const fatalErrorRef = useRef(false); useEffect(() => { const currentGameID = parseInt(gameID ?? gameName); @@ -57,11 +57,9 @@ const GameStateHandler = () => { currentAuthKey = gameInfo.authKey; } if (!currentAuthKey && currentGameID > 0) { - // Last resort: try to load from localStorage for same game currentAuthKey = loadGameAuthKey(currentGameID); } - // Only dispatch if values actually changed if ( gameParamsRef.current.gameID !== currentGameID || gameParamsRef.current.playerID !== currentPlayerID || @@ -97,42 +95,30 @@ const GameStateHandler = () => { dispatch ]); - // Sync friends data to sessionStorage whenever it's fetched + // Sync friends list to sessionStorage whenever it's fetched useEffect(() => { if (friendsData?.friends) { try { const friendsList = friendsData.friends.map((f) => f.username); sessionStorage.setItem('friendsList', JSON.stringify(friendsList)); - console.log( - 'GameStateHandler synced friendsList to sessionStorage:', - friendsList - ); } catch (e) { console.error('Failed to sync friendsList to sessionStorage:', e); } } }, [friendsData?.friends]); + // SSE connection to game server useEffect(() => { - // Use gameInfo from Redux state (which is already updated) rather than ref const currentGameID = gameInfo.gameID; const currentPlayerID = gameInfo.playerID; const currentAuthKey = gameInfo.authKey; - // Don't create EventSource if authKey is empty for actual players (playerID 1 or 2) - // Spectators (playerID 3) don't need authKey, but players do + // Players 1 and 2 require an authKey; spectators (3) do not if ((currentPlayerID === 1 || currentPlayerID === 2) && !currentAuthKey) { - // This is expected while authKey is loading, only log once per game change - if (gameParamsRef.current.gameID !== currentGameID) { - console.warn( - `⏳ AuthKey loading for game ${currentGameID}, player ${currentPlayerID}...` - ); - } - // Wait for authKey to be available before connecting return; } - // Reset retry count when game changes + // Reset retry count when the game changes if (gameParamsRef.current.gameID !== currentGameID) { retryCountRef.current = 0; gameParamsRef.current = { @@ -142,73 +128,54 @@ const GameStateHandler = () => { }; } - // Close existing connection before creating new one if (sourceRef.current) { sourceRef.current.close(); sourceRef.current = null; } - // Add a small delay before connecting to ensure page is ready + // Small delay to ensure the page is ready before connecting const connectionTimeout = setTimeout(() => { try { - console.log( - `πŸ”Œ Connecting to EventSource (attempt ${retryCountRef.current + 1}/${ - maxRetriesRef.current + 1 - })...` - ); let friendsList: string[] = []; try { const stored = sessionStorage.getItem('friendsList'); if (stored) { friendsList = JSON.parse(stored); } - } catch (e) { - // sessionStorage parsing failed, continue without friendsList + } catch { + // Continue without friendsList } - console.log( - 'GameStateHandler SSE - sending friendsList:', - friendsList, - 'playerID:', - currentPlayerID - ); + const resolvedUserName = getCurrentUsername(currentUserName) ?? ''; const source = new EventSource( `${BACKEND_URL}GetUpdateSSE.php?gameName=${currentGameID}&playerID=${currentPlayerID}&authKey=${currentAuthKey}&friendsList=${encodeURIComponent( JSON.stringify(friendsList) - )}` + )}&userName=${encodeURIComponent(resolvedUserName)}` ); sourceRef.current = source; - // Mark as successful when first message comes through let hasConnected = false; source.onmessage = (event) => { hasConnected = true; - retryCountRef.current = 0; // Reset retry counter on successful message + retryCountRef.current = 0; try { const data = JSON.parse(event.data); - // Check for error messages from SSE if (data.error) { - console.error('SSE Error:', data.error); const errorMsg = data.error.toLowerCase(); - // Handle game not found errors if ( errorMsg.includes('game no longer exists') || errorMsg.includes('does not exist') ) { toast.error(`Game Error: ${data.error}`); - window.sessionStorage.setItem( - 'gameNotFound', - String(currentGameID) - ); source.close(); + setTimeout(() => navigate('/'), 60000); return; } - // Handle auth errors if ( errorMsg.includes('invalid auth') || errorMsg.includes('authkey') @@ -218,61 +185,45 @@ const GameStateHandler = () => { return; } - // Display other errors toast.error(`Server Error: ${data.error}`); return; } - // Parse the game state directly from SSE data - const parsedState = ParseGameState(data); - - // Dispatch the parsed game state directly (no HTTP round-trip needed) - dispatch(receiveGameState(parsedState)); + dispatch(receiveGameState(ParseGameState(data))); } catch (parseError) { console.error('Failed to parse SSE data:', parseError); - // Don't close connection on parse errors - wait for next update } }; - source.onerror = () => { - // Only process error if connection was actually established (not just interruption during page load) - if (!hasConnected && retryCountRef.current === 0) { - console.warn( - '⚠️ EventSource connection interrupted during page load' - ); - // Treat interruptions during load as transient, retry once quickly - setTimeout(() => { - setForceRetry((prev) => prev + 1); - }, 500); - return; + // This replaces the old CheckOpponentTyping polling entirely. + source.addEventListener('typing', (event: MessageEvent) => { + try { + const data = JSON.parse(event.data); + if (typeof data.opponentIsTyping === 'boolean') { + dispatch(setOpponentTyping(data.opponentIsTyping)); + } + } catch { } + }); - console.error('❌ EventSource connection failed'); + source.onerror = () => { + retryCountRef.current++; source.close(); sourceRef.current = null; - // Retry with exponential backoff - if (retryCountRef.current < maxRetriesRef.current) { - retryCountRef.current++; + if (!hasConnected && retryCountRef.current === 1) { + // Transient interruption during page load β€” retry once quickly + setTimeout(() => setForceRetry((prev) => prev + 1), 500); + return; + } + + if (retryCountRef.current <= MAX_RETRIES) { const retryDelay = Math.min( 500 * Math.pow(2, retryCountRef.current), 5000 ); - console.warn( - `⏳ Will retry EventSource connection in ${retryDelay}ms (attempt ${ - retryCountRef.current + 1 - }/${maxRetriesRef.current + 1})` - ); - - setTimeout(() => { - setForceRetry((prev) => prev + 1); // Trigger the effect again - }, retryDelay); + setTimeout(() => setForceRetry((prev) => prev + 1), retryDelay); } else { - console.error( - `❌ EventSource failed after ${ - maxRetriesRef.current + 1 - } attempts.` - ); toast.error( 'Connection to game server lost. Please refresh the page.' ); @@ -281,24 +232,26 @@ const GameStateHandler = () => { } catch (error) { console.error('Failed to create EventSource:', error); } - }, 100); // Small delay to ensure page is ready + }, 100); + + const handleBeforeUnload = () => { + if (sourceRef.current) { + sourceRef.current.close(); + sourceRef.current = null; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); return () => { clearTimeout(connectionTimeout); + window.removeEventListener('beforeunload', handleBeforeUnload); if (sourceRef.current) { sourceRef.current.close(); sourceRef.current = null; } }; - }, [ - gameInfo.gameID, - gameInfo.playerID, - gameInfo.authKey, - gameInfo.isPrivateLobby, - gameInfo.isRoguelike, - forceRetry, - dispatch - ]); + }, [gameInfo.gameID, gameInfo.playerID, gameInfo.authKey, forceRetry, dispatch, navigate]); useEffect(() => { if (isFullRematch && gameID) { @@ -306,36 +259,6 @@ const GameStateHandler = () => { } }, [isFullRematch, gameID, navigate]); - // Check if game was reported as not found - const gameNotFoundTimeoutRef = useRef(null); - - useEffect(() => { - const checkGameNotFound = () => { - const state = window.sessionStorage.getItem('gameNotFound'); - if (state === String(gameInfo.gameID)) { - // Only schedule navigation once - if (!gameNotFoundTimeoutRef.current) { - console.log( - `Game ${gameInfo.gameID} no longer exists, will navigate to games list in 60s` - ); - gameNotFoundTimeoutRef.current = setTimeout(() => { - window.sessionStorage.removeItem('gameNotFound'); - navigate('/'); - }, 60000); - } - } - }; - - // Check periodically since errors might come asynchronously - const interval = setInterval(checkGameNotFound, 1000); - return () => { - clearInterval(interval); - if (gameNotFoundTimeoutRef.current) { - clearTimeout(gameNotFoundTimeoutRef.current); - } - }; - }, [gameInfo.gameID, navigate]); - return null; }; diff --git a/src/app/ParseGameState.ts b/src/app/ParseGameState.ts index e9f1184fd..0709461d3 100644 --- a/src/app/ParseGameState.ts +++ b/src/app/ParseGameState.ts @@ -515,6 +515,7 @@ export default function ParseGameState(input: any) { // spectator count result.gameDynamicInfo.spectatorCount = input.spectatorCount ?? 0; + result.gameDynamicInfo.spectatorNames = input.spectatorNames ?? []; // player inventory result.gameDynamicInfo.playerInventory = input.playerInventory ? input.playerInventory.map((card: any) => ParseCard(card)) @@ -590,9 +591,6 @@ export default function ParseGameState(input: any) { // AI infinite HP status for manual mode result.aiHasInfiniteHP = input.aiHasInfiniteHP ?? false; - // opponent activity status (0 = active, 2 = inactive) - result.opponentActivity = input.opponentActivity ?? 0; - // rematch acceptance status result.isFullRematch = input.fullRematchAccepted ?? false; diff --git a/src/appConstants.ts b/src/appConstants.ts index ec93306ea..698aebf4b 100644 --- a/src/appConstants.ts +++ b/src/appConstants.ts @@ -20,6 +20,10 @@ export const DATADOLL_URL = import.meta.env.DEV ? '/datadoll/' : `https://${import.meta.env.VITE_DATADOLL_URL}/`; +export const FAB_BAZAAR_DECKS_API_URL = + 'https://fabbazaar.app/api/talishar/decks'; +export const FAB_BAZAAR_DECK_URL_BASE = 'https://fabbazaar.app/decks/'; + // what playmat is the default export const DEFAULT_PLAYMAT = `Default`; @@ -293,9 +297,6 @@ export const PRECON_DECKS = { HEROES: SORTED_PRECON_DECKS.map((deck) => deck.hero) }; -// Feature toggle: set to true to re-enable private messaging API calls -export const PRIVATE_MESSAGING_ENABLED = false; - export const URL_END_POINT = { GET_GAME_LIST: 'APIs/GetGameList.php', GET_GAME_INFO: 'APIs/GetGameInfo.php', @@ -340,7 +341,6 @@ export const URL_END_POINT = { FRIEND_LIST: 'APIs/FriendListAPI.php', BLOCKED_USERS: 'APIs/BlockedUsersAPI.php', USERNAME_MODERATION: 'APIs/UsernameModeration.php', - PRIVATE_MESSAGING: 'APIs/PrivateMessagingAPI.php', SYSTEM_MESSAGE: 'APIs/SystemMessageAPI.php', GET_SYSTEM_MESSAGE: 'APIs/GetSystemMessage.php', GET_LAST_ACTIVE_GAME: 'APIs/GetLastActiveGame.php', diff --git a/src/components/ads/AdUnit.css b/src/components/ads/AdUnit.css index e7a8fe9a8..550805638 100644 --- a/src/components/ads/AdUnit.css +++ b/src/components/ads/AdUnit.css @@ -16,13 +16,10 @@ } .ad-unit[data-ad='left-rail-1'], -.ad-unit[data-ad='right-rail-1'] { - height: 250px; -} - +.ad-unit[data-ad='right-rail-1'], .ad-unit[data-ad='left-rail-2'], .ad-unit[data-ad='right-rail-2'] { - height: 600px; + height: fit-content; } /* Billboard ad units */ diff --git a/src/components/chatBar/ChatBar.module.scss b/src/components/chatBar/ChatBar.module.scss deleted file mode 100644 index 5e0486c88..000000000 --- a/src/components/chatBar/ChatBar.module.scss +++ /dev/null @@ -1,757 +0,0 @@ -.chatBar { - position: fixed; - bottom: var(--sticky-footer-height, 80px); - right: 0; - z-index: 9999; - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 10px; - padding: 10px 10px 0 0; - pointer-events: none; // Allow clicks through the container - max-height: 70vh; - overflow-y: hidden; - overflow-x: auto; - -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ - touch-action: pan-x; /* Allow horizontal pan on touch devices */ - width: 100vw; - max-width: 100%; - box-sizing: border-box; - - /* Selective reset of Pico CSS defaults */ - button { - margin: 0; - padding: 0; - min-width: auto; - min-height: auto; - } - - input { - margin: 0; - } - - /* Mobile responsiveness */ - @media (max-width: 768px) { - max-height: 70vh; - gap: 8px; - padding: 10px 10px 0 0; - } - - @media (max-width: 640px) { - max-height: 70vh; - gap: 6px; - padding: 10px 10px 0 0; - } -} - -.chatBar > * { - pointer-events: auto; // Re-enable pointer events for child elements -} - -/* Friends List Toggle Button */ -.friendsToggle { - background: var(--theme-primary) !important; - border: 2px solid var(--theme-border-light) !important; - border-radius: 8px 8px 0 0 !important; - padding: 8px 12px !important; - cursor: pointer !important; - font-weight: 600 !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - gap: 8px !important; - box-shadow: 0 -2px 10px var(--theme-overlay) !important; - transition: background 0.2s ease !important; - position: relative !important; - margin: 0 !important; - font-size: 1em !important; - line-height: 1.2 !important; - height: auto !important; - width: 240px !important; - overflow: hidden !important; - white-space: nowrap !important; - text-overflow: ellipsis !important; - - &:hover { - background: var(--primary-hover) !important; - } - - &:active { - transform: scale(0.98) !important; - } - - .unreadBadge { - position: absolute; - top: -5px; - right: -5px; - background: #e74c3c; - color: white; - border-radius: 50%; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: bold; - flex-shrink: 0; - } - - .onlineFriendsCount { - font-size: 0.75em !important; - font-weight: 400 !important; - opacity: 0.85 !important; - margin-left: 4px !important; - white-space: normal !important; - line-height: 1.1 !important; - } - - /* Mobile: reduce width */ - @media (max-width: 768px) { - width: 130px !important; - padding: 6px 10px !important; - font-size: 0.9em !important; - } - - @media (max-width: 640px) { - width: 110px !important; - padding: 6px 8px !important; - font-size: 0.85em !important; - } -} - -/* Minimized Chat Tab */ -.minimizedChatTab { - background: var(--theme-primary) !important; - border: 2px solid var(--theme-border-light) !important; - border-radius: 8px 8px 0 0 !important; - padding: 8px 12px !important; - cursor: pointer !important; - font-weight: 600 !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - gap: 8px !important; - box-shadow: 0 -2px 10px var(--theme-overlay) !important; - transition: background 0.2s ease !important; - position: relative !important; - margin: 0 !important; - font-size: 1em !important; - line-height: 1.2 !important; - height: auto !important; - width: 160px !important; - overflow: visible !important; - white-space: nowrap !important; - text-overflow: ellipsis !important; - color: black; - - &:hover { - background: var(--primary-hover) !important; - } - - &:active { - transform: scale(0.98) !important; - } - - .minimizedCloseButtonInline { - background: transparent !important; - border: none !important; - padding: 0 !important; - margin-left: 4px !important; - cursor: pointer !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - transition: opacity 0.2s ease, transform 0.2s ease !important; - opacity: 0 !important; - - &:hover { - transform: scale(1.2) !important; - } - - &:active { - transform: scale(0.9) !important; - } - } - - &:hover .minimizedCloseButtonInline { - opacity: 1 !important; - } - - .unreadBadge { - position: absolute; - top: -5px; - right: -5px; - background: #e74c3c; - color: white; - border-radius: 50%; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: bold; - } - - /* Mobile: reduce width */ - @media (max-width: 768px) { - width: 130px !important; - padding: 6px 10px !important; - font-size: 0.9em !important; - } - - @media (max-width: 640px) { - width: 110px !important; - padding: 6px 8px !important; - font-size: 0.85em !important; - } -} - -/* Minimized Chat Container - REMOVED, keeping for backwards compatibility if needed */ - -/* Friends List Panel */ -.friendsPanel { - background: var(--card-background-color) !important; - border: 2px solid var(--theme-border-light) !important; - border-radius: 8px 8px 0 0 !important; - width: 420px !important; - height: 400px !important; - box-shadow: 0 -2px 15px var(--theme-overlay) !important; - display: flex !important; - flex-direction: column !important; - position: absolute !important; - bottom: 60px !important; - right: 10px !important; - z-index: 10000 !important; -} - -/* Chat Container - Horizontal */ -.chatContainer { - display: flex !important; - flex-direction: row !important; - align-items: flex-end !important; - gap: 10px !important; - pointer-events: none !important; // Allow clicks through empty space - flex-wrap: nowrap !important; - justify-content: flex-end !important; - width: auto !important; // Only take up space needed by children - touch-action: pan-x !important; - - /* Mobile: allow horizontal scrolling */ - @media (max-width: 768px) { - gap: 8px !important; - } - - @media (max-width: 640px) { - gap: 6px !important; - } -} - -// Re-enable pointer events for actual chat windows -.chatContainer > * { - pointer-events: auto !important; -} - -.friendsPanelHeader { - padding: 10px 15px !important; - border-bottom: 1px solid var(--theme-border-light) !important; - font-weight: 600 !important; - display: flex !important; - justify-content: space-between !important; - align-items: center !important; - background: rgba(0, 0, 0, 0.3) !important; - gap: 8px !important; -} - -.closeButton { - background: transparent !important; - border: none !important; - color: var(--snow) !important; - cursor: pointer !important; - padding: 4px 6px !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - font-size: 1em !important; - margin: 0 !important; - line-height: 1 !important; - height: auto !important; - min-width: auto !important; - border-radius: 4px !important; - transition: background 0.2s ease !important; - flex-shrink: 0 !important; - - &:hover { - background: rgba(255, 255, 255, 0.1) !important; - } -} - -.friendsListContainer { - overflow-y: auto !important; - overflow-x: hidden !important; - flex: 1 !important; - padding: 5px !important; - -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ - background-color: var(--theme-form-background, #33383d); - color: var(--theme-text, #f4eded); -} - -.friendItem { - padding: 12px 15px; /* Increased from 10px 15px */ - cursor: pointer; - display: flex; - align-items: center; - gap: 10px; - border-radius: 6px; - transition: background 0.2s ease; - position: relative; - - &:hover { - background: rgba(255, 255, 255, 0.1); - } - - .friendInfo { - flex: 1; - min-width: 0; - - .friendName { - font-weight: 500; - font-size: 0.95em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--theme-text, #f4eded); - display: flex; - align-items: center; - } - - .friendIcons { - display: flex; - align-items: center; - gap: 0.2rem; - flex-shrink: 0; - } - - .friendIcon { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - } - - .friendIcon img { - height: 1rem; - width: 1rem; - display: block; - } - - .friendIcon:hover { - opacity: 0.8; - } - - .friendNickname { - font-size: 0.8em; - color: var(--theme-text-muted, rgba(255, 255, 255, 0.6)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .friendStatus { - font-size: 0.65em; - color: rgba(255, 255, 255, 0.4); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex-shrink: 0; - } - } - - .unreadIndicator { - background: #e74c3c; - color: white; - border-radius: 50%; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: bold; - flex-shrink: 0; - } -} - -.friendMessageButton { - background: var(--theme-primary) !important; - border: 1px solid var(--theme-border-light) !important; - border-radius: 4px !important; - padding: 6px 8px !important; - cursor: pointer !important; - color: black !important; - flex-shrink: 0 !important; - transition: background 0.2s ease !important; - margin: 0 !important; - line-height: 1 !important; - height: auto !important; - min-width: auto !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - width: auto !important; - - &:hover { - background: var(--primary-hover) !important; - } - - &:active { - transform: scale(0.95) !important; - } -} - -.emptyMessage { - padding: 20px; - text-align: center; - color: rgba(255, 255, 255, 0.6); - font-size: 0.9em; -} - -/* Chat Window */ -.chatWindow { - background: var(--card-background-color) !important; - border: 2px solid rgba(255, 255, 255, 0.3) !important; - border-radius: 8px 8px 0 0 !important; - width: 420px !important; - height: 30em !important; - box-shadow: 0 -2px 15px rgba(0, 0, 0, 0.5) !important; - display: flex !important; - flex-direction: column !important; - margin-bottom: 5px !important; - flex-shrink: 0 !important; - - /* Mobile: reduce width so it fits better on screen */ - @media (max-width: 768px) { - width: 280px !important; - height: 25em !important; - } - - @media (max-width: 640px) { - width: 260px !important; - height: 24em !important; - } -} - -.chatHeader { - padding: 10px 15px; - border-bottom: 1px solid var(--theme-border, rgba(255, 255, 255, 0.2)); - display: flex; - justify-content: space-between; - align-items: center; - background: var(--theme-form-background, rgba(0, 0, 0, 0.3)); - gap: 8px; - flex-shrink: 0; - - .chatHeaderInfo { - display: flex; - align-items: center; - gap: 8px; - flex: 1; - min-width: 0; - - .chatFriendName { - font-weight: 600; - font-size: 0.95em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: flex; - align-items: center; - gap: 6px; - - > div:first-child { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .friendStatus { - font-size: 0.65em; - color: rgba(255, 255, 255, 0.4); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex-shrink: 0; - } - } - } - - .chatActions { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; - } - - button { - background: transparent; - border: none; - color: var(--snow); - cursor: pointer; - padding: 4px 6px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1em; - border-radius: 4px; - transition: background 0.2s ease; - margin: 0; - line-height: 1; - height: auto; - min-width: auto; - - &:hover { - background: rgba(255, 255, 255, 0.1); - } - } - - /* Mobile: reduce padding and font sizes */ - @media (max-width: 768px) { - padding: 8px 12px; - - .chatHeaderInfo { - gap: 6px; - - .chatFriendName { - font-size: 0.9em; - } - } - - button { - padding: 3px 4px; - font-size: 0.95em; - } - } - - @media (max-width: 640px) { - padding: 6px 10px; - - .chatHeaderInfo { - gap: 5px; - - .chatFriendName { - font-size: 0.85em; - } - } - - button { - padding: 2px 3px; - font-size: 0.9em; - } - } -} - -.messagesContainer { - flex: 1; - overflow-y: auto; - padding: 15px; - display: flex; - flex-direction: column; - gap: 10px; - background-color: var(--theme-form-background, #33383d); - color: var(--theme-text, #f4eded); -} - -.message { - display: flex; - flex-direction: column; - max-width: 75%; - animation: slideIn 0.2s ease; - - &.sent { - align-self: flex-end; - - .messageContent { - background: var(--theme-primary); - border-radius: 12px 12px 0 12px; - color: black; - } - } - - &.received { - align-self: flex-start; - - .messageContent { - background: rgba(255, 255, 255, 0.1); - border-radius: 12px 12px 12px 0; - } - } - - .messageContent { - padding: 8px 12px; - word-wrap: break-word; - overflow-wrap: break-word; - display: flex; - align-items: center; - gap: 6px; - - .gameIcon { - flex-shrink: 0; - opacity: 0.8; - } - } - - .messageContentLink { - display: flex; - text-decoration: none; - color: inherit; - transition: opacity 0.2s ease; - - &:hover { - opacity: 0.8; - - .messageContent { - text-decoration: underline; - } - } - } - - .messageTime { - font-size: 0.75em; - color: rgba(255, 255, 255, 0.5); - margin-top: 4px; - padding: 0 4px; - } -} - -@keyframes slideIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.chatInput { - border-top: 1px solid var(--theme-border, rgba(255, 255, 255, 0.2)); - padding: 12px !important; - display: flex !important; - flex-direction: column !important; - gap: 10px !important; - background-color: var(--theme-form-background, #33383d); -} - -.inputRow { - display: flex !important; - gap: 8px !important; - align-items: center !important; - width: 100% !important; - margin-bottom: 0; -} - -.messageInput { - flex: 1 !important; - padding: 10px 14px !important; - background: rgba(255, 255, 255, 0.05) !important; - border: 1px solid rgba(255, 255, 255, 0.2) !important; - border-radius: 20px !important; - color: var(--snow) !important; - font-size: 0.95em !important; - outline: none !important; - transition: border-color 0.2s ease !important; - margin: 0 !important; - font-family: inherit !important; - min-height: 36px !important; - max-width: none !important; - box-sizing: border-box !important; - - &:focus { - border-color: var(--theme-primary) !important; - } - - &::placeholder { - color: rgba(255, 255, 255, 0.4) !important; - } -} - -.sendButton { - background: var(--theme-primary) !important; - border: none !important; - padding: 8px 12px !important; - border-radius: 10px !important; - cursor: pointer !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - font-weight: 600 !important; - transition: background 0.2s ease !important; - flex-shrink: 0 !important; - margin: 0 !important; - line-height: 1 !important; - width: 60px !important; - height: 60px !important; - - &:hover { - background: var(--primary-hover) !important; - } - - &:disabled { - opacity: 0.5 !important; - cursor: not-allowed !important; - } -} - -.inviteButton { - background: var(--theme-success, #28a745); - border: none; - padding: 6px 12px; - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - gap: 5px; - font-size: 0.85em; - font-weight: 600; - transition: background 0.2s ease; - width: 100%; - justify-content: center; - color: var(--theme-contrast-inverse, #ffffff); - - &:hover { - background: var(--theme-success, #28a745); - opacity: 0.9; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -} - -.loadingMessage { - display: none; -} - -/* Scrollbar styling */ -.messagesContainer::-webkit-scrollbar, -.friendsListContainer::-webkit-scrollbar { - width: 6px; -} - -.messagesContainer::-webkit-scrollbar-track, -.friendsListContainer::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.2); -} - -.messagesContainer::-webkit-scrollbar-thumb, -.friendsListContainer::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 3px; - - &:hover { - background: rgba(255, 255, 255, 0.4); - } -} diff --git a/src/components/chatBar/ChatBar.tsx b/src/components/chatBar/ChatBar.tsx deleted file mode 100644 index 54a0020e2..000000000 --- a/src/components/chatBar/ChatBar.tsx +++ /dev/null @@ -1,719 +0,0 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { - useGetFriendsListQuery, - useGetPrivateMessagesQuery, - useSendPrivateMessageMutation, - useGetOnlineFriendsQuery, - useMarkMessagesAsReadMutation, - useGetUnreadMessageCountQuery, - useGetUnreadMessageCountByFriendQuery, - useCreateQuickGameMutation -} from 'features/api/apiSlice'; -import { Friend } from 'interface/API/FriendListAPI.php'; -import { PrivateMessage } from 'interface/API/PrivateMessagingAPI.php'; -import { IoMdClose, IoMdSend } from 'react-icons/io'; -import { MdGames } from 'react-icons/md'; -import { AiOutlineUser } from 'react-icons/ai'; -import { IoChatbubble } from 'react-icons/io5'; -import styles from './ChatBar.module.scss'; -import useAuth from 'hooks/useAuth'; -import { PRIVATE_MESSAGING_ENABLED } from 'appConstants'; -import { toast } from 'react-hot-toast'; -import { getReadableFormatName } from 'utils/formatUtils'; -import { createPatreonIconMap } from 'utils/patronIcons'; - -interface ChatWindow { - friend: Friend; - isMinimized: boolean; - unreadCount: number; -} - -export const ChatBar: React.FC = () => { - const { isLoggedIn } = useAuth(); - const [showFriendsPanel, setShowFriendsPanel] = useState(false); - const [friendsPanelOpen, setFriendsPanelOpen] = useState(false); - const [openChats, setOpenChats] = useState>( - new Map() - ); - const messagesEndRef = useRef(null); - const chatBarRef = useRef(null); - - const { data: friendsData, isLoading: friendsLoading } = - useGetFriendsListQuery(undefined, { - skip: !isLoggedIn - }); - - const { data: onlineFriendsData } = useGetOnlineFriendsQuery(undefined, { - skip: !isLoggedIn || !PRIVATE_MESSAGING_ENABLED, - pollingInterval: 30000 // Poll every 30 seconds - }); - - const { data: unreadCountData } = useGetUnreadMessageCountQuery(undefined, { - skip: !isLoggedIn || !PRIVATE_MESSAGING_ENABLED, - pollingInterval: 30000 // Poll every 30 seconds to reduce API load - }); - - const { data: unreadByFriendData } = useGetUnreadMessageCountByFriendQuery( - undefined, - { - skip: !isLoggedIn || !PRIVATE_MESSAGING_ENABLED, - pollingInterval: 30000 // Poll every 30 seconds to reduce API load - } - ); - - const [sendMessage] = useSendPrivateMessageMutation(); - const [markAsRead] = useMarkMessagesAsReadMutation(); - const [createQuickGame] = useCreateQuickGameMutation(); - - const totalUnread = unreadCountData?.unreadCount ?? 0; - - // Sort friends by online status, then alphabetically - const sortedFriends = useMemo(() => { - if (!friendsData?.friends) return []; - - return [...friendsData.friends].sort((a, b) => { - // No longer sort by online status - just sort alphabetically by name - const aName = a.nickname || a.username; - const bName = b.nickname || b.username; - return aName.localeCompare(bName); - }); - }, [friendsData?.friends]); - - // Update ChatBar position to align with sticky footer (only in Lobby) - useEffect(() => { - const updatePosition = () => { - const stickyFooter = document.querySelector( - '[class*="stickyFooter"]' - ) as HTMLElement; - if (stickyFooter && chatBarRef.current) { - const footerHeight = stickyFooter.offsetHeight; - chatBarRef.current.style.bottom = `${footerHeight}px`; - } else if (chatBarRef.current) { - // Reset to default position if no sticky footer found - chatBarRef.current.style.bottom = '0px'; - } - }; - - updatePosition(); - window.addEventListener('resize', updatePosition); - const interval = setInterval(updatePosition, 100); // Check every 100ms for changes - - return () => { - window.removeEventListener('resize', updatePosition); - clearInterval(interval); - }; - }, []); - - // Auto-create minimized chat tabs ONLY for friends with unread messages - useEffect(() => { - if (!isLoggedIn || !friendsData) return; - - const friends = friendsData.friends; - if (!friends || friends.length === 0) return; - - const unreadByFriend = unreadByFriendData?.unreadByFriend ?? {}; - - setOpenChats((prevChats) => { - const newChats = new Map(prevChats); - - // For each friend with unread messages, create a minimized chat if not already open - friends.forEach((friend) => { - const unreadCount = unreadByFriend[friend.friendUserId] ?? 0; - - if (unreadCount > 0 && !newChats.has(friend.friendUserId)) { - // Create new minimized chat ONLY if there are unread messages - newChats.set(friend.friendUserId, { - friend, - isMinimized: true, - unreadCount - }); - } else if (newChats.has(friend.friendUserId)) { - // Update unread count for existing chat - const existing = newChats.get(friend.friendUserId)!; - newChats.set(friend.friendUserId, { - ...existing, - unreadCount: existing.isMinimized - ? unreadCount - : existing.unreadCount - }); - } - }); - - return newChats; - }); - }, [isLoggedIn, friendsData, unreadByFriendData]); - - // Auto-scroll to bottom when new messages arrive - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - useEffect(() => { - scrollToBottom(); - }, [openChats]); - - const handleOpenChat = (friend: Friend) => { - setOpenChats((prev) => { - const newChats = new Map(prev); - if (newChats.has(friend.friendUserId)) { - // If already open, just un-minimize it - const existing = newChats.get(friend.friendUserId)!; - newChats.set(friend.friendUserId, { - ...existing, - isMinimized: false, - unreadCount: 0 - }); - } else { - // Open new chat window - newChats.set(friend.friendUserId, { - friend, - isMinimized: false, - unreadCount: 0 - }); - } - return newChats; - }); - setShowFriendsPanel(false); - }; - - const handleCloseChat = (friendUserId: number) => { - setOpenChats((prev) => { - const newChats = new Map(prev); - newChats.delete(friendUserId); - return newChats; - }); - }; - - const handleMinimizeChat = (friendUserId: number) => { - setOpenChats((prev) => { - const newChats = new Map(prev); - const chat = newChats.get(friendUserId); - if (chat) { - newChats.set(friendUserId, { ...chat, isMinimized: !chat.isMinimized }); - } - return newChats; - }); - }; - - const handleUpdateUnreadCount = (friendUserId: number, count: number) => { - setOpenChats((prev) => { - const newChats = new Map(prev); - const chat = newChats.get(friendUserId); - if (chat) { - newChats.set(friendUserId, { ...chat, unreadCount: count }); - } - return newChats; - }); - }; - - const handleSendMessage = async ( - friendUserId: number, - message: string, - gameLink?: string - ) => { - if (!message.trim() && !gameLink) return; - - try { - await sendMessage({ - toUserId: friendUserId, - message: message.trim(), - gameLink - }).unwrap(); - } catch (err: any) { - toast.error(err.error || 'Failed to send message'); - console.error('Send message error:', err); - } - }; - - const handleSendGameInvite = async (friendUserId: number) => { - try { - // Create a quick game with sensible defaults - // Users can customize via CreateGame page if needed - const gameResponse = await createQuickGame({ - format: 'cc', // Classic Constructed - good default - visibility: 'friends-only' // Safe default for invites - }).unwrap(); - - if (gameResponse.error) { - toast.error(gameResponse.error); - return; - } - - if (!gameResponse.gameName) { - toast.error('Failed to create game'); - return; - } - - // Generate the join link - const gameJoinLink = `${window.location.origin}/game/join/${gameResponse.gameName}`; - const readableFormat = getReadableFormatName('cc'); - const message = `Join my ${readableFormat} game!`; - - // Send message with game link - await sendMessage({ - toUserId: friendUserId, - message: message, - gameLink: gameJoinLink - }).unwrap(); - - toast.success('Game created and invite sent!'); - } catch (err: any) { - toast.error(err.error || 'Failed to create game invite'); - console.error('Game invite error:', err); - } - }; - - if (!isLoggedIn) { - return null; - } - - return ( -
- {/* Chat Container - Horizontal */} -
- {/* Open Chat Windows */} - {Array.from(openChats.entries()).map(([friendUserId, chat]) => ( - handleCloseChat(friendUserId)} - onMinimize={() => handleMinimizeChat(friendUserId)} - onSendMessage={(message: string, gameLink?: string) => - handleSendMessage(friendUserId, message, gameLink) - } - onSendGameInvite={() => handleSendGameInvite(friendUserId)} - onUpdateUnreadCount={(count: number) => - handleUpdateUnreadCount(friendUserId, count) - } - onlineFriendsData={onlineFriendsData} - /> - ))} - - {/* Friends List Window */} - {friendsPanelOpen && ( -
-
setFriendsPanelOpen(false)} - style={{ cursor: 'pointer' }} - title="Click to minimize" - > -
-
Friends
-
-
- -
-
- -
- {friendsLoading && ( -
Loading friends...
- )} - {!friendsLoading && sortedFriends && sortedFriends.length > 0 ? ( - sortedFriends.map((friend) => { - const onlineFriend = onlineFriendsData?.onlineFriends?.find( - (f: any) => f.userId === friend.friendUserId - ); - - return ( -
-
{ - handleOpenChat(friend); - setFriendsPanelOpen(false); - }} - style={{ cursor: 'pointer' }} - > -
-
- {createPatreonIconMap( - friend.isContributor, - friend.isPvtVoidPatron, - friend.isPatron, - false, - friend.metafyTiers - ) - .filter((icon) => icon.condition) - .map((icon) => ( - - {icon.title} - - ))} -
- {friend.nickname || friend.username} -
- {friend.nickname && ( -
- {friend.username} -
- )} -
- {onlineFriend?.lastSeenText && ( - Last seen {onlineFriend.lastSeenText} - )} -
-
- -
- ); - }) - ) : ( -
- No friends yet. Add friends to start chatting! -
- )} -
-
- )} - - {/* Friends Toggle Button */} - {!friendsPanelOpen && ( - - )} -
-
- ); -}; - -interface ChatWindowProps { - chat: ChatWindow; - friendUserId: number; - onClose: () => void; - onMinimize: () => void; - onSendMessage: (message: string, gameLink?: string) => void; - onSendGameInvite: () => void; - onUpdateUnreadCount: (count: number) => void; - onlineFriendsData?: any; -} - -const ChatWindowComponent: React.FC = ({ - chat, - friendUserId, - onClose, - onMinimize, - onSendMessage: onSendMessageProp, - onSendGameInvite, - onUpdateUnreadCount, - onlineFriendsData -}) => { - const [inputMessage, setInputMessage] = useState(''); - const messagesEndRef = useRef(null); - const { isLoggedIn } = useAuth(); - const [markAsRead] = useMarkMessagesAsReadMutation(); - const [lastMessageCount, setLastMessageCount] = useState(0); - - // Fetch messages for this chat - const { - data: messagesData, - isLoading: messagesLoading, - refetch: refetchMessages - } = useGetPrivateMessagesQuery( - { friendUserId: friendUserId, limit: 50 }, - { skip: !isLoggedIn || chat.isMinimized || !PRIVATE_MESSAGING_ENABLED, pollingInterval: 30000 } // Poll every 30 seconds to reduce API load - ); - - const messages = messagesData?.messages ?? []; - - // Scroll to bottom when chat opens (initial load) - useEffect(() => { - if (!chat.isMinimized) { - setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }); - }, 0); - } - }, [chat.isMinimized]); - - // Scroll to bottom when new messages arrive (smooth scroll) - useEffect(() => { - if (!chat.isMinimized) { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - } - }, [messages, chat.isMinimized]); - - // Track unread count when messages arrive while minimized - useEffect(() => { - if (chat.isMinimized && messages.length > 0) { - // Count new unread messages from this friend - const unreadFromFriend = messages.filter( - (msg) => msg.fromUserId === friendUserId && !msg.isRead - ).length; - - // Update unread count if there are new messages - if (unreadFromFriend > 0) { - onUpdateUnreadCount(unreadFromFriend); - } - } - }, [messages, chat.isMinimized, friendUserId, onUpdateUnreadCount]); - - // Mark messages as read when opening chat - useEffect(() => { - if (!chat.isMinimized && messages.length > 0) { - // Get unread message IDs (messages we received that are unread) - const unreadMessageIds = messages - .filter((msg) => msg.fromUserId === friendUserId && !msg.isRead) - .map((msg) => msg.messageId); - - if (unreadMessageIds.length > 0) { - markAsRead({ messageIds: unreadMessageIds }); - // Clear the unread badge when opening - onUpdateUnreadCount(0); - } - } - }, [ - chat.isMinimized, - messages, - friendUserId, - markAsRead, - onUpdateUnreadCount - ]); - - // Refetch messages when window is opened - useEffect(() => { - if (!chat.isMinimized) { - refetchMessages(); - } - }, [chat.isMinimized, refetchMessages]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (inputMessage.trim()) { - // Refetch messages immediately after sending for faster feedback - setTimeout(() => refetchMessages(), 500); - onSendMessageProp(inputMessage); - setInputMessage(''); - } - }; - - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); - } - }; - - const formatTime = (dateString: string) => { - // Parse the UTC time from the server - const date = new Date(dateString + 'Z'); // Add 'Z' to treat as UTC - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - }; - - if (chat.isMinimized) { - return ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onMinimize(); - } - }} - role="button" - tabIndex={0} - style={{ cursor: 'pointer' }} - title="Click to open chat" - > - - {chat.friend.nickname || chat.friend.username} - {chat.unreadCount > 0 && ( -
{chat.unreadCount}
- )} - -
- ); - } - - return ( -
-
{ - // Mark messages as read when clicking header to open/minimize - if (!chat.isMinimized && messages.length > 0) { - const unreadMessageIds = messages - .filter((msg) => msg.fromUserId === friendUserId && !msg.isRead) - .map((msg) => msg.messageId); - - if (unreadMessageIds.length > 0) { - markAsRead({ messageIds: unreadMessageIds }); - onUpdateUnreadCount(0); - } - } - onMinimize(); - }} - style={{ cursor: 'pointer' }} - title="Click to minimize" - > -
-
-
{chat.friend.nickname || chat.friend.username}
- {(() => { - const onlineFriend = onlineFriendsData?.onlineFriends?.find( - (f: any) => f.userId === friendUserId - ); - const isAway = onlineFriend?.isAway === true; - return ( -
- {isAway && - onlineFriend?.timeSinceActivity && - (() => { - const minutesAway = Math.floor( - (onlineFriend.timeSinceActivity - 60) / 60 - ); - return minutesAway > 0 - ? `(away ${minutesAway}m)` - : '(away)'; - })()} -
- ); - })()} -
-
-
- - -
-
- -
- {messagesLoading ? ( -
Loading messages...
- ) : messages.length === 0 ? ( -
- Start a conversation with{' '} - {chat.friend.nickname || chat.friend.username}! -
- ) : ( - messages.map((message: PrivateMessage) => ( -
- {message.gameLink ? ( - -
- - {message.message} -
-
- ) : ( -
{message.message}
- )} -
- {formatTime(message.createdAt)} -
-
- )) - )} -
-
- -
-
- setInputMessage(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="Type a message..." - className={styles.messageInput} - maxLength={500} - /> - -
-
-
- ); -}; - -export default ChatBar; diff --git a/src/components/header/Header.module.scss b/src/components/header/Header.module.scss index 2753e0f35..3891e24da 100644 --- a/src/components/header/Header.module.scss +++ b/src/components/header/Header.module.scss @@ -191,6 +191,49 @@ } +.burgerButton { + display: none; + background: none; + border: 2px solid transparent; + color: var(--snow, #fff); + cursor: pointer; + padding: 0; + width: 44px; + height: 44px; + font-size: 24px; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + margin-bottom: 0 !important; + margin-right: 10px; + align-self: center; + border-radius: 6px; + transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease; + + &:hover { + color: var(--theme-primary); + background-color: var(--theme-tertiary, #4a5056); + border-color: var(--theme-primary, #d4af37); + } + + &[aria-expanded='true'] { + color: var(--theme-primary); + background-color: var(--theme-tertiary, #4a5056); + border-color: var(--theme-primary, #d4af37); + } +} + +@media (max-width: 768px) and (orientation: portrait) { + .navBar .rightNav { + display: none; + } + + .burgerButton { + display: flex; + } +} + @media (max-width: 400px) { .navBar { li.languageSelectorListElement { @@ -199,6 +242,118 @@ } } +.mobileMenu { + position: fixed; + top: 70px; + left: 0; + right: 0; + background: var(--theme-card-background, #1e2329); + border-top: 2px solid var(--theme-primary, #d4af37); + z-index: 9999; + overflow-y: auto; + max-height: calc(100dvh - 70px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.7); + + ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + + > li { + border-bottom: 1px solid var(--muted-border-color, #202632); + + > a, + > button { + display: flex; + flex-direction: row !important; + align-items: center; + gap: 14px; + padding: 14px 20px; + width: 100%; + color: var(--snow, #fff); + text-decoration: none; + font-size: 15px; + font-weight: 500; + min-width: unset; + height: auto; + border-radius: 0; + justify-content: flex-start; + border-left: 3px solid transparent; + transition: background-color 150ms ease, color 150ms ease, border-left-color 150ms ease; + + &:hover { + background-color: var(--theme-form-background, #33383d); + color: var(--theme-primary, #d4af37); + border-left-color: var(--theme-primary, #d4af37); + } + + &::before, + &::after { + display: none !important; + } + + svg { + font-size: 20px; + flex-shrink: 0; + } + + span { + display: inline !important; + font-size: 15px; + } + + // Strip native button styles from Login button rendered inside a Link + > button { + background: none; + border: none; + color: inherit; + padding: 0; + font: inherit; + cursor: pointer; + font-weight: 500; + margin: 0; + } + } + + // LanguageSelector / SocialDropdown wrappers rendered as
  • + &[class] { + > button { + flex-direction: row !important; + justify-content: flex-start !important; + align-items: center !important; + gap: 14px !important; + min-width: unset; + padding: 14px 20px; + height: auto; + width: 100%; + font-size: 15px; + font-weight: 500; + border-left: 3px solid transparent; + transition: background-color 150ms ease, color 150ms ease, border-left-color 150ms ease; + + &:hover { + background-color: var(--theme-form-background, #33383d); + color: var(--theme-primary, #d4af37); + border-left-color: var(--theme-primary, #d4af37); + } + + svg { + font-size: 20px; + flex-shrink: 0; + } + + span { + display: inline !important; + font-size: 15px; + } + } + } + } + } +} + // Rainbow animation for Support Us link @keyframes rainbow-flow { 0% { diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx index 1737e46c5..c5be9b345 100644 --- a/src/components/header/Header.tsx +++ b/src/components/header/Header.tsx @@ -1,5 +1,5 @@ import useAuth from 'hooks/useAuth'; -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Link, Outlet } from 'react-router-dom'; import styles from './Header.module.scss'; import TalisharLogo from '../../img/CoinLogo.png'; @@ -7,7 +7,9 @@ import { BsPersonFill, BsShieldFillCheck, BsGear, - BsFillBookFill + BsFillBookFill, + BsList, + BsX } from 'react-icons/bs'; import { RiLogoutBoxRLine } from 'react-icons/ri'; import { MdVideoLibrary } from 'react-icons/md'; @@ -27,13 +29,23 @@ const Header = () => { }); const pendingRequestCount = pendingData?.requests?.length || 0; const canAccessReplays = isMod || currentUserName === 'Tegunn' || isPatron; + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); // Initial stuff to allow the lang to change const { t, i18n, ready } = useTranslation(); + useEffect(() => { + const handleOrientationChange = () => setMobileMenuOpen(false); + window.addEventListener('orientationchange', handleOrientationChange); + return () => window.removeEventListener('orientationchange', handleOrientationChange); + }, []); + + const closeMobileMenu = () => setMobileMenuOpen(false); + const handleLogOut = (e: React.MouseEvent) => { e.preventDefault(); logOut(); + setMobileMenuOpen(false); }; return ( @@ -75,7 +87,7 @@ const Header = () => {
  • -
      +
      • {t('HEADER.LEARN')} @@ -101,7 +113,7 @@ const Header = () => {
      • {isLoggedIn ? ( - + {t('HEADER.PROFILE')} {pendingRequestCount > 0 && ( @@ -131,7 +143,72 @@ const Header = () => {
      • )}
      + + {mobileMenuOpen && ( +
      +
        +
      • + + {t('HEADER.LEARN')} + +
      • + {isLoggedIn && isMod && ( +
      • + + Mod Page + +
      • + )} + {isLoggedIn && canAccessReplays && ( +
      • + + {t('HEADER.REPLAYS')} + +
      • + )} + + +
      • + {isLoggedIn ? ( + + {t('HEADER.PROFILE')} + {pendingRequestCount > 0 && ( + + {pendingRequestCount} + + )} + + ) : ( + + + + )} +
      • + {isLoggedIn && ( +
      • + + {t('HEADER.SETTINGS')} + +
      • + )} + {isLoggedIn && ( +
      • + + {t('HEADER.LOGOUT')} + +
      • + )} +
      +
      + )}
      diff --git a/src/components/header/LanguageSelector.module.scss b/src/components/header/LanguageSelector.module.scss index 662d177eb..4952bdeb3 100644 --- a/src/components/header/LanguageSelector.module.scss +++ b/src/components/header/LanguageSelector.module.scss @@ -19,6 +19,7 @@ flex-direction: column; gap: 2px; position: relative; + margin-bottom: 0 !important; svg { font-size: 30px; @@ -163,3 +164,68 @@ } } } + +// In the burger menu (portrait mobile) the dropdown must expand inline, not float off-screen +@media (max-width: 768px) and (orientation: portrait) { + .socialDropdown { + width: 100%; + height: auto; + display: block; + + .dropdownToggle { + width: 100%; + height: auto; + flex-direction: row !important; + justify-content: flex-start; + gap: 14px; + padding: 14px 20px; + min-width: unset; + font-size: 15px; + font-weight: 500; + border-left: 3px solid transparent; + transition: background-color 150ms ease, color 150ms ease, border-left-color 150ms ease; + + &:hover { + background-color: var(--theme-form-background, #33383d); + border-left-color: var(--theme-primary, #d4af37); + } + + svg { + font-size: 20px; + } + + .label { + display: inline !important; + font-size: 15px; + } + } + + .dropdownMenu { + position: static; + width: 100%; + display: flex; + flex-direction: column; + border: none; + border-radius: 0; + box-shadow: none; + background: var(--theme-near-black, rgba(0, 0, 0, 0.95)); + min-width: unset; + + .socialLink { + padding: 12px 20px 12px 54px; + border-right: none; + border-bottom: 1px solid var(--muted-border-color, #202632); + font-size: 14px; + + &:last-child { + border-bottom: none; + } + + span { + display: inline !important; + font-size: 14px; + } + } + } + } +} diff --git a/src/components/header/LanguageSelector.tsx b/src/components/header/LanguageSelector.tsx index a4b45d99a..41fae92ef 100644 --- a/src/components/header/LanguageSelector.tsx +++ b/src/components/header/LanguageSelector.tsx @@ -34,7 +34,7 @@ const LanguageSelector = () => { - {opponentActivity === 2 && ( - - )} -
    - ) : null} - - - )} - - - ); -}; - -export default InactivityWarning; diff --git a/src/routes/game/components/elements/menu/Menu.module.css b/src/routes/game/components/elements/menu/Menu.module.css index 389bc8d17..9603e004b 100644 --- a/src/routes/game/components/elements/menu/Menu.module.css +++ b/src/routes/game/components/elements/menu/Menu.module.css @@ -101,7 +101,7 @@ top: 50%; transform: translateY(-50%); white-space: nowrap; - pointer-events: none; + pointer-events: auto; margin-right: 0.5em; } diff --git a/src/routes/game/components/elements/spectatorCount/SpectatorCount.tsx b/src/routes/game/components/elements/spectatorCount/SpectatorCount.tsx index 9afc50c2c..1e4ceaf66 100644 --- a/src/routes/game/components/elements/spectatorCount/SpectatorCount.tsx +++ b/src/routes/game/components/elements/spectatorCount/SpectatorCount.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useAppSelector } from 'app/Hooks'; import { RootState } from 'app/Store'; import styles from './SpectatorCount.module.css'; @@ -8,17 +8,31 @@ export default function SpectatorCount() { const spectatorCount = useAppSelector( (state: RootState) => state.game?.gameDynamicInfo?.spectatorCount ?? 0 ); + const spectatorNames = useAppSelector( + (state: RootState) => state.game?.gameDynamicInfo?.spectatorNames ?? [] + ); + const [showTooltip, setShowTooltip] = useState(false); - // Hide component if no spectators if (spectatorCount === 0) { return null; } return (
    -
    - {spectatorCount === 1 ? 'Spectator' : 'Spectators'}:{' '} +
    setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + {spectatorCount === 1 ? 'Spectator' : 'Spectators'}:{' '} {spectatorCount} + {showTooltip && spectatorNames.length > 0 && ( +
    + {spectatorNames.map((name, i) => ( +
    {name}
    + ))} +
    + )}
    ); diff --git a/src/routes/game/components/rightColumn/RightColumn.module.css b/src/routes/game/components/rightColumn/RightColumn.module.css index 3a962234d..a68c41629 100644 --- a/src/routes/game/components/rightColumn/RightColumn.module.css +++ b/src/routes/game/components/rightColumn/RightColumn.module.css @@ -124,6 +124,7 @@ align-items: center; padding: 4px 0; background-color: var(--near-black); + overflow: visible; } .adHeader { @@ -142,11 +143,12 @@ } } -/* Fixed-size clipping box: ad scales dynamically to fill the column width, centred */ +/* Fixed 300Γ—250 β€” renders at native ad size, overflows the column edge if needed */ .adWrapper { - width: 100%; - aspect-ratio: 300 / 250; - overflow: hidden; + width: 300px; + height: 250px; + flex-shrink: 0; + overflow: visible; position: relative; background: rgba(255, 255, 255, 0.04); border-radius: 4px; @@ -164,17 +166,10 @@ user-select: none; } -/* Scale the native 300Γ—250 ad unit to fill the full column width, centred. - calc(100cqw / 300px) produces the exact unitless scale factor at any column size. - The parent .rightColumn provides the container context (container-name: right-column). */ +/* Ad unit sits naturally at 300Γ—250 β€” no scaling needed */ .adWrapper :global(.ad-unit) { - position: absolute !important; - top: 0 !important; - left: 50% !important; width: 300px !important; height: 250px !important; - transform-origin: top center; - transform: translateX(-50%) scale(calc(100cqw / 300px)); margin: 0 !important; } diff --git a/src/routes/game/components/rightColumn/RightColumn.tsx b/src/routes/game/components/rightColumn/RightColumn.tsx index e18155f12..c06e0c7f5 100644 --- a/src/routes/game/components/rightColumn/RightColumn.tsx +++ b/src/routes/game/components/rightColumn/RightColumn.tsx @@ -47,27 +47,6 @@ export default function RightColumn() { {isStreamerMode ? : ''}
    - {showAds && ( -
    -
    - {/*Community Ads*/} - - Remove ads - -
    -
    - - {import.meta.env.DEV && ( -
    Ad Β· 300Γ—250
    - )} -
    -
    - )} ); diff --git a/src/routes/game/components/zones/arsenalZone/ArsenalZone.tsx b/src/routes/game/components/zones/arsenalZone/ArsenalZone.tsx index 88fdaf949..f2af3c4ef 100644 --- a/src/routes/game/components/zones/arsenalZone/ArsenalZone.tsx +++ b/src/routes/game/components/zones/arsenalZone/ArsenalZone.tsx @@ -128,8 +128,7 @@ const ArsenalPrompt = () => { turnPhase === 'CHOOSEHAND' || turnPhase === 'MAYCHOOSEHAND' || turnPhase === 'MAYCHOOSEHANDHEAVE'; - playerPrompt?.helpText?.includes('Opponent is inactive'); - + const buttons = playerPrompt?.buttons?.map((button, ix) => { return (
    >(new Map()); + // Maps actionDataOverride β†’ { id, cardNumber } for cross-render matching + const cardIdMapRef = useRef< + Map + >(new Map()); const nextIdCounterRef = useRef(0); const handCardsWithStableIds = useMemo(() => { - const cardIdMap = cardIdMapRef.current; const cards = handCards ?? []; + const prevMap = cardIdMapRef.current; - // Create fingerprints WITHOUT index so reordering doesn't create new IDs - const baseFingerprints = cards.map( - (card) => - `${card.cardNumber}-${card.uniqueId ?? 'na'}-${card.cardIndex ?? 'na'}` - ); - - // Track which fingerprints we've seen in this render (for duplicates) - const fingerprintOccurrences = new Map(); const usedIds = new Set(); + const result: CardWithStableId[] = new Array(cards.length); + const unmatchedIndices: number[] = []; - const result = cards.map((card, index) => { - const baseFingerprint = baseFingerprints[index]; - - // Count occurrences to handle duplicates - const occurrence = fingerprintOccurrences.get(baseFingerprint) ?? 0; - fingerprintOccurrences.set(baseFingerprint, occurrence + 1); - - // Create unique fingerprint including occurrence - const uniqueFingerprint = - occurrence === 0 - ? baseFingerprint - : `${baseFingerprint}-dup-${occurrence}`; + for (let i = 0; i < cards.length; i++) { + const card = cards[i]; + const key = card.actionDataOverride; + const existing = + key != null && key !== '' ? prevMap.get(key) : undefined; - let id = cardIdMap.get(uniqueFingerprint); - - // If no existing ID or ID is already used, generate a new one - if (!id || usedIds.has(id)) { - id = `hand-card-${nextIdCounterRef.current++}`; - cardIdMap.set(uniqueFingerprint, id); + if ( + existing && + !usedIds.has(existing.id) && + existing.cardNumber === card.cardNumber + ) { + result[i] = { card, id: existing.id }; + usedIds.add(existing.id); + } else { + unmatchedIndices.push(i); } + } - usedIds.add(id); - return { card, id }; - }); + if (unmatchedIndices.length > 0) { + const unusedOldByCardNumber = new Map(); + for (const entry of prevMap.values()) { + if (!usedIds.has(entry.id)) { + const arr = unusedOldByCardNumber.get(entry.cardNumber) ?? []; + arr.push(entry.id); + unusedOldByCardNumber.set(entry.cardNumber, arr); + } + } - // Clean up unused IDs from the map - const currentFingerprints = new Set(); - result.forEach((_, idx) => { - const baseFingerprint = baseFingerprints[idx]; - const occurrence = Array.from(currentFingerprints).filter((fp) => - fp.startsWith(baseFingerprint) - ).length; - const uniqueFingerprint = - occurrence === 0 - ? baseFingerprint - : `${baseFingerprint}-dup-${occurrence}`; - currentFingerprints.add(uniqueFingerprint); - }); + for (const idx of unmatchedIndices) { + const card = cards[idx]; + const available = unusedOldByCardNumber.get(card.cardNumber); + if (available && available.length > 0) { + const oldId = available.shift()!; + result[idx] = { card, id: oldId }; + usedIds.add(oldId); + } else { + const newId = `hand-card-${nextIdCounterRef.current++}`; + result[idx] = { card, id: newId }; + usedIds.add(newId); + } + } + } - for (const [fingerprint] of cardIdMap) { - if (!currentFingerprints.has(fingerprint)) { - cardIdMap.delete(fingerprint); + // Rebuild map for next render + const newMap = new Map(); + for (const { card, id } of result) { + const key = card.actionDataOverride; + if (key != null && key !== '') { + newMap.set(key, { id, cardNumber: card.cardNumber }); } } + cardIdMapRef.current = newMap; return result; }, [handCards]); @@ -398,6 +403,7 @@ export default function PlayerHand() { target.isContentEditable ) return; + let didShuffle = false; setOrderedHandIds((currentOrder) => { if (currentOrder.length <= 1) return currentOrder; @@ -414,10 +420,11 @@ export default function PlayerHand() { hasChanged = shuffled.some((card, idx) => card !== currentOrder[idx]); } while (!hasChanged); + didShuffle = true; return shuffled; }); - if (!isMuted) { + if (didShuffle && !isMuted) { playDrawingCardsSound(); } }; diff --git a/src/routes/game/create/CreateGame.module.css b/src/routes/game/create/CreateGame.module.css index 608e45e20..31cd5aee7 100644 --- a/src/routes/game/create/CreateGame.module.css +++ b/src/routes/game/create/CreateGame.module.css @@ -33,6 +33,10 @@ margin: 0 !important; } +.embeddedForm form { + margin-bottom: 0 !important; +} + @media (max-width: 768px) { .formContainer { width: 100%; @@ -87,6 +91,7 @@ .button { color: var(--primary-inverse); width: 100%; + margin-bottom: 0 !important; } .alarm { @@ -220,3 +225,74 @@ font-weight: bold; color: var(--theme-text-muted, #999); } + +/* ── Deck source tabs (standalone Create Game form) ─────────────── */ + +.deckTabBar { + display: flex; + border: 1px solid var(--theme-border, rgba(255, 255, 255, 0.15)); + border-radius: 8px; + overflow: hidden; + background: rgba(0, 0, 0, 0.15); +} + +.deckTab { + flex: 1; + padding: 0.4rem 0.75rem; + border: none; + background: transparent; + color: var(--theme-text-muted, rgba(237, 237, 237, 0.55)); + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; + transition: background 0.15s ease, color 0.15s ease; + text-align: center; + width: auto !important; + margin-bottom: 0 !important; +} + +.deckTab:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--theme-text, #ededed); +} + +.deckTab + .deckTab { + border-left: 1px solid var(--theme-border, rgba(255, 255, 255, 0.15)); +} + +.deckTabActive { + background: var(--theme-primary-muted, rgba(212, 175, 55, 0.2)); + color: var(--theme-primary, #d4af37); + font-weight: 600; +} + +.deckTabDisabled { + opacity: 0.45 !important; + cursor: not-allowed !important; + /* Pico CSS sets pointer-events: none on button:disabled, which prevents cursor: not-allowed from showing. + Restoring pointer-events here is safe because the disabled HTML attribute still blocks click events. */ + pointer-events: auto !important; +} + +.deckTabDisabled:hover { + background: transparent !important; + color: var(--theme-text-muted, rgba(237, 237, 237, 0.55)) !important; +} + +.comingSoonBadge { + font-size: 0.75rem; + font-style: italic; + opacity: 0.8; +} + +.bazaarMessage { + font-size: 0.875rem; + color: var(--theme-text-muted, rgba(237, 237, 237, 0.6)); + margin: 0.25rem 0; + text-align: center; +} + +.bazaarMessage a { + color: var(--theme-primary, #d4af37); + text-decoration: underline; +} diff --git a/src/routes/game/create/CreateGame.tsx b/src/routes/game/create/CreateGame.tsx index 8c477ef6f..c30f52746 100644 --- a/src/routes/game/create/CreateGame.tsx +++ b/src/routes/game/create/CreateGame.tsx @@ -1,17 +1,20 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { useAppDispatch } from 'app/Hooks'; +import { useAppDispatch, useAppSelector } from 'app/Hooks'; import classNames from 'classnames'; import { GAME_FORMAT, GAME_VISIBILITY, AI_DECK, isPreconFormat, - PRECON_DECKS + PRECON_DECKS, + FAB_BAZAAR_DECK_URL_BASE } from 'appConstants'; import { useCreateGameMutation, - useGetFavoriteDecksQuery + useGetFavoriteDecksQuery, + useGetBazaarDecksQuery } from 'features/api/apiSlice'; +import { selectCurrentUser, selectCurrentUserName, selectMetafyHash } from 'features/auth/authSlice'; import { setGameStart } from 'features/game/GameSlice'; import useAuth from 'hooks/useAuth'; import { CreateGameAPI } from 'interface/API/CreateGame.php'; @@ -84,6 +87,55 @@ const CreateGame = () => { const [searchParams, setSearchParams] = useSearchParams(); const [createGame, createGameResult] = useCreateGameMutation(); + // FaB Bazaar β€” standalone mode only (embedded mode uses QuickJoinContext) + const metafyHash = useAppSelector(selectMetafyHash); + const metafyId = useAppSelector(selectCurrentUser); + const currentUserName = useAppSelector(selectCurrentUserName); + const isBazaarEnabled = currentUserName === 'OotTheMonk'; + const [standaloneDeckSource, setStandaloneDeckSourceState] = useState<'talishar' | 'bazaar'>( + () => + (localStorage.getItem('quickJoin_deckSource') as 'talishar' | 'bazaar') ?? 'talishar' + ); + const [standaloneSelectedBazaarDeck, setStandaloneSelectedBazaarDeck] = useState( + () => localStorage.getItem('quickJoin_bazaarDeck') ?? '' + ); + const canFetchBazaarStandalone = + !isEmbedded && + standaloneDeckSource === 'bazaar' && + !!metafyId && + !!metafyHash; + const { data: bazaarData, isLoading: isBazaarLoading } = useGetBazaarDecksQuery( + { metafyId: metafyId!, metafyHash: metafyHash! }, + { skip: !canFetchBazaarStandalone } + ); + const standaloneBazaarDeckOptions = useMemo(() => { + if (!bazaarData?.decks) return []; + return bazaarData.decks.map((deck) => ({ + value: deck.deckId, + label: deck.name + })); + }, [bazaarData?.decks]); + + // If FaB Bazaar isn't enabled for this user, reset any stored 'bazaar' selection so + // the disabled tab doesn't also get the deckTabActive class from a stale localStorage value. + useEffect(() => { + if (!isBazaarEnabled && standaloneDeckSource === 'bazaar') { + setStandaloneDeckSourceState('talishar'); + localStorage.setItem('quickJoin_deckSource', 'talishar'); + } + }, [isBazaarEnabled]); // eslint-disable-line react-hooks/exhaustive-deps + + const setStandaloneDeckSource = (src: 'talishar' | 'bazaar') => { + setStandaloneDeckSourceState(src); + localStorage.setItem('quickJoin_deckSource', src); + }; + const handleStandaloneSelectBazaarDeck = (deckId: string) => { + setStandaloneSelectedBazaarDeck(deckId); + localStorage.setItem('quickJoin_bazaarDeck', deckId); + setValue('fabdb', deckId ? `${FAB_BAZAAR_DECK_URL_BASE}${deckId}` : ''); + setValue('favoriteDecks', ''); + }; + // Initial stuff to allow the lang to change const { t, i18n, ready } = useTranslation(); @@ -180,10 +232,10 @@ const CreateGame = () => { // When inside QuickJoinProvider (main menu), sync deck values from the shared context React.useEffect(() => { if (isEmbedded) { - setValue('favoriteDecks', quickJoinCtx!.selectedFavoriteDeck); - setValue('fabdb', quickJoinCtx!.importDeckUrl); + setValue('favoriteDecks', quickJoinCtx!.effectiveFavoriteDecks); + setValue('fabdb', quickJoinCtx!.effectiveFabdb); } - }, [quickJoinCtx?.selectedFavoriteDeck, quickJoinCtx?.importDeckUrl, setValue]); + }, [quickJoinCtx?.effectiveFavoriteDecks, quickJoinCtx?.effectiveFabdb, setValue]); // Normalize localStorage on mount - extract base option from expanded descriptions React.useEffect(() => { @@ -419,10 +471,8 @@ const CreateGame = () => { setValue('fabdb', PRECON_DECKS.LINKS[0]); } else if (isEmbedded && quickJoinCtx) { // Re-apply context deck values that were cleared by reset(initialValues). - // This prevents a paste-then-submit failure when the query completes after - // the user has already typed a URL into the QuickJoin panel. - setValue('favoriteDecks', quickJoinCtx.selectedFavoriteDeck); - setValue('fabdb', quickJoinCtx.importDeckUrl); + setValue('favoriteDecks', quickJoinCtx.effectiveFavoriteDecks); + setValue('fabdb', quickJoinCtx.effectiveFabdb); } setIsInitialized(true); }, [initialValues, reset, setValue]); // eslint-disable-line react-hooks/exhaustive-deps @@ -457,10 +507,13 @@ const CreateGame = () => { // When inside QuickJoinProvider (main menu), use deck from shared context. // For precon formats, keep the precon deck URL already set in values.fabdb via the form. if (isEmbedded && !isPreconFormat(values.format)) { - values.favoriteDecks = quickJoinCtx!.selectedFavoriteDeck; - values.fabdb = quickJoinCtx!.importDeckUrl; - // Only save deck if "Save Deck" is checked and a new deck URL is being used (not a saved favorite) - values.favoriteDeck = quickJoinCtx!.saveDeck && quickJoinCtx!.importDeckUrl.trim() !== ''; + values.favoriteDecks = quickJoinCtx!.effectiveFavoriteDecks; + values.fabdb = quickJoinCtx!.effectiveFabdb; + // Only save deck if "Save Deck" is checked and a new deck URL is being used (not a saved favorite or bazaar) + values.favoriteDeck = + quickJoinCtx!.saveDeck && + quickJoinCtx!.deckSource === 'talishar' && + quickJoinCtx!.importDeckUrl.trim() !== ''; } // Extract base game description (remove hero/class names) @@ -568,9 +621,66 @@ const CreateGame = () => { )} + {/* Deck source tabs β€” standalone logged-in non-precon only */} + {!isEmbedded && + isLoggedIn && + !isPreconFormat(formFormat || selectedFormat) && ( +
    + + +
    + )} + {/* FaB Bazaar deck picker (standalone) */} + {!isEmbedded && + isLoggedIn && + standaloneDeckSource === 'bazaar' && + !isPreconFormat(formFormat || selectedFormat) && ( + metafyHash ? ( + + ) : ( +

    + Link your FaB Bazaar account in your{' '} + profile to see your decks + here. +

    + ) + )} {!isEmbedded && isLoggedIn && !isLoading && + standaloneDeckSource === 'talishar' && !isPreconFormat(formFormat || selectedFormat) && ( )} - {!isEmbedded && ( + {!isEmbedded && standaloneDeckSource === 'talishar' && ( { )} /> )} - {(!isEmbedded || isPreconFormat(formFormat || selectedFormat)) && ( + {(isPreconFormat(formFormat || selectedFormat) || + (!isEmbedded && standaloneDeckSource === 'talishar')) && (
    diff --git a/src/routes/index/Index.module.css b/src/routes/index/Index.module.css index b89089f44..5e470853c 100644 --- a/src/routes/index/Index.module.css +++ b/src/routes/index/Index.module.css @@ -341,31 +341,14 @@ h3 { border-radius: 12px; color: #aaa; font-size: 0.85rem; - cursor: pointer; - transition: all 0.2s ease; + cursor: default; + pointer-events: none; user-select: none; } -.reaction:hover { - background: rgba(100, 181, 246, 0.2); - border-color: rgba(100, 181, 246, 0.6); - transform: scale(1.05); - box-shadow: 0 2px 8px rgba(100, 181, 246, 0.2); -} - -.reaction:active { - transform: scale(0.98); - background: rgba(100, 181, 246, 0.3); -} - .reactionEmoji { width: 1em; height: 1em; - transition: transform 0.2s ease; -} - -.reaction:hover .reactionEmoji { - transform: scale(1.2); } .reactionCount { diff --git a/src/routes/index/Index.tsx b/src/routes/index/Index.tsx index 3dababca1..9ad3e0084 100644 --- a/src/routes/index/Index.tsx +++ b/src/routes/index/Index.tsx @@ -66,17 +66,6 @@ const Index = () => { {showAds && (