From 511bbb87b46ba265d41f413f1e506d345c0c98af Mon Sep 17 00:00:00 2001 From: Arjun S Date: Sun, 3 Aug 2025 23:18:49 +0530 Subject: [PATCH] adminpage --- package-lock.json | 32 ++++++++- package.json | 3 +- party/index.ts | 51 +++++++++++++- src/App.tsx | 54 ++++++++++++-- src/components/AdminPage.tsx | 133 +++++++++++++++++++++++++++++++++++ src/hooks/usePartyKit.ts | 15 +++- 6 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 src/components/AdminPage.tsx diff --git a/package-lock.json b/package-lock.json index f390696..0d3cc27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "partykit": "^0.0.115", "partysocket": "^1.1.4", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "wouter": "^3.7.1" }, "devDependencies": { "@eslint/js": "^9.9.1", @@ -3314,6 +3315,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mlly": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", @@ -3910,6 +3917,15 @@ "node": ">=8.10.0" } }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -4649,6 +4665,20 @@ "@cloudflare/workerd-windows-64": "1.20240718.0" } }, + "node_modules/wouter": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.7.1.tgz", + "integrity": "sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==", + "license": "Unlicense", + "dependencies": { + "mitt": "^3.0.1", + "regexparam": "^3.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/package.json b/package.json index 28a89d4..ea3923f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "partykit": "^0.0.115", "partysocket": "^1.1.4", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "wouter": "^3.7.1" }, "devDependencies": { "@eslint/js": "^9.9.1", diff --git a/party/index.ts b/party/index.ts index 19bd723..9b3ab8f 100644 --- a/party/index.ts +++ b/party/index.ts @@ -33,11 +33,17 @@ interface ApplyMultiplierBonusMessage extends AuthenticatedMessage { // New mess multiplierBonus: number; } +interface AdminBroadcastMessage extends AuthenticatedMessage { + type: 'admin-broadcast'; + message: string; + targetUserId?: string; // Optional: if broadcasting to a specific user +} + interface UserJoinMessage extends AuthenticatedMessage { type: 'user-join'; } -type Message = ClickMessage | PurchaseUpgradeMessage | ApplyMultiplierBonusMessage | UserJoinMessage; // Updated Message type +type Message = ClickMessage | PurchaseUpgradeMessage | ApplyMultiplierBonusMessage | UserJoinMessage | AdminBroadcastMessage; // Updated Message type const UPGRADES = { clickMultiplier: { baseCost: 10, multiplier: 1.5, clickBonus: 1 }, @@ -59,6 +65,7 @@ const MILESTONES = [ export default class GameServer implements Party.Server { clerkClient: ReturnType; + private userConnections: Map = new Map(); // Map userId to connection constructor(readonly party: Party.Party) { this.clerkClient = createClerkClient({ @@ -86,6 +93,16 @@ export default class GameServer implements Party.Server { conn.send(JSON.stringify({ type: 'game-state', state: this.gameState })); } + onClose(conn: Party.Connection) { + // Remove the connection from the map when it closes + for (const [userId, connection] of this.userConnections.entries()) { + if (connection === conn) { + this.userConnections.delete(userId); + break; + } + } + } + async onMessage(message: string, sender: Party.Connection) { const data = JSON.parse(message) as Message; @@ -145,6 +162,8 @@ export default class GameServer implements Party.Server { if (this.gameState.users[currentUserId].bonusMultiplier === undefined) { this.gameState.users[currentUserId].bonusMultiplier = 1; } + // Store the connection for targeted broadcasts + this.userConnections.set(currentUserId, sender); } switch (data.type) { @@ -173,6 +192,13 @@ export default class GameServer implements Party.Server { console.warn(`Unauthenticated apply-multiplier-bonus from ${currentUserId} ignored.`); } break; + case 'admin-broadcast': + if (isAuthenticated && currentUserId === this.party.env.CLERK_ADMIN_USERID) { + this.handleAdminBroadcast(data); + } else { + console.warn(`Unauthorized admin broadcast attempt from ${currentUserId}.`); + } + break; } this.broadcast(); @@ -209,6 +235,29 @@ export default class GameServer implements Party.Server { this.checkMilestones(); } + handleAdminBroadcast(data: AdminBroadcastMessage) { + const broadcastPayload = { + type: 'admin-message', + message: data.message, + sender: 'Admin' + }; + + if (data.targetUserId) { + // Send to a specific user using the stored connection + const targetConn = this.userConnections.get(data.targetUserId); + if (targetConn) { + targetConn.send(JSON.stringify(broadcastPayload)); + console.log(`Admin broadcasted to user ${data.targetUserId}: ${data.message}`); + } else { + console.warn(`Target user ${data.targetUserId} not found or not connected.`); + } + } else { + // Broadcast to all connections + this.party.broadcast(JSON.stringify(broadcastPayload)); + console.log(`Admin broadcasted to all users: ${data.message}`); + } + } + handleApplyMultiplierBonus(data: ApplyMultiplierBonusMessage, authenticatedUserId: string) { if (!this.gameState.users[authenticatedUserId]) { console.warn(`User ${authenticatedUserId} not found for multiplier bonus application.`); diff --git a/src/App.tsx b/src/App.tsx index d6db817..9cc0ae0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,14 +12,22 @@ import { useAuth } from '@clerk/clerk-react'; import { ClickableMascot } from './components/ClickableMascot'; // Import ClickableMascot component import { ClickableMascot as ClickableMascotType, Upgrade } from './types'; // Import ClickableMascotType and Upgrade import { UPGRADES } from './config/upgrades'; // Import UPGRADES for upgrade config +import { useLocation, Link } from 'wouter'; // Import wouter hooks +import AdminPage from './components/AdminPage'; // Import AdminPage function App() { - const { isSignedIn, isLoaded } = useAuth(); - const { gameState, sendClick, purchaseUpgrade, userId, sendMascotClickBonus } = usePartyKit(); // Renamed sendShoominionClickBonus + const { isSignedIn, isLoaded, userId: clerkUserId } = useAuth(); // Get clerkUserId from useAuth + const { gameState, sendClick, purchaseUpgrade, userId, sendMascotClickBonus, lastMessage } = usePartyKit(); // Renamed sendShoominionClickBonus const [celebrationMessage, setCelebrationMessage] = useState(null); const [previousMilestones, setPreviousMilestones] = useState>({}); const [mascotEntities, setMascotEntities] = useState([]); const timeoutRef = useRef(null); + const [location] = useLocation(); // Get current location from wouter + const [adminBroadcastMessage, setAdminBroadcastMessage] = useState(null); // New state for admin messages + + // Admin user ID from .env + const CLERK_ADMIN_USERID = import.meta.env.VITE_CLERK_ADMIN_USERID; + const isAdmin = clerkUserId === CLERK_ADMIN_USERID; // Effect for milestone celebrations useEffect(() => { @@ -42,6 +50,21 @@ function App() { } }, [gameState, previousMilestones]); + // Effect for receiving admin broadcast messages + useEffect(() => { + if (lastMessage) { + try { + const parsedMessage = JSON.parse(lastMessage); + if (parsedMessage.type === 'admin-message') { + setAdminBroadcastMessage(parsedMessage.message); + setTimeout(() => setAdminBroadcastMessage(null), 7000); // Message disappears after 7 seconds + } + } catch (error) { + console.error('Failed to parse last message in App.tsx:', error); + } + } + }, [lastMessage]); + // Effect for spawning mascots useEffect(() => { if (!gameState) return; @@ -152,12 +175,24 @@ function App() { const userBonusMultiplier = isSignedIn && userId ? gameState.users[userId]?.bonusMultiplier || 1 : 1; const effectiveClickMultiplier = gameState.clickMultiplier * userBonusMultiplier; + // Render the AdminPage if the current location is /admin + if (location === '/admin') { + return ; + } + return (
- - {/* User Button */} -
+ + {/* User Button and Admin Link */} +
+ {isAdmin && ( + + + Admin Page + + + )} @@ -172,6 +207,15 @@ function App() {
)} + {/* Admin Broadcast Message */} + {adminBroadcastMessage && ( +
+
+ 📢 Admin Message: {adminBroadcastMessage} 📢 +
+
+ )} +
{/* Header */}
diff --git a/src/components/AdminPage.tsx b/src/components/AdminPage.tsx new file mode 100644 index 0000000..4097afd --- /dev/null +++ b/src/components/AdminPage.tsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from 'react'; +import { useUser } from '@clerk/clerk-react'; +import { usePartyKit } from '../hooks/usePartyKit'; + +interface AdminMessage { + type: 'admin-message'; + message: string; + sender: string; +} + +const AdminPage: React.FC = () => { + const { user } = useUser(); + const { sendMessage, lastMessage, getToken } = usePartyKit(); + const [broadcastMessage, setBroadcastMessage] = useState(''); + const [targetUserId, setTargetUserId] = useState(''); + const [receivedMessages, setReceivedMessages] = useState([]); + const [isAdmin, setIsAdmin] = useState(false); + + // Replace with your actual admin user ID from .env or similar + const CLERK_ADMIN_USERID = import.meta.env.VITE_CLERK_ADMIN_USERID; + + useEffect(() => { + if (user?.id === CLERK_ADMIN_USERID) { + setIsAdmin(true); + } else { + setIsAdmin(false); + } + }, [user, CLERK_ADMIN_USERID]); + + useEffect(() => { + if (lastMessage) { + try { + const parsedMessage = JSON.parse(lastMessage); + if (parsedMessage.type === 'admin-message') { + setReceivedMessages((prev) => [...prev, parsedMessage]); + } + } catch (error) { + console.error('Failed to parse last message:', error); + } + } + }, [lastMessage]); + + const handleBroadcast = async () => { + if (!user || !isAdmin) { + alert('You are not authorized to send admin messages.'); + return; + } + + if (broadcastMessage.trim()) { + const token = await getToken(); // Get the token using the exposed getToken from usePartyKit + const messagePayload = { + type: 'admin-broadcast', + token: token, // Send the Clerk token + userId: user.id, + userName: user.fullName || user.id, + message: broadcastMessage.trim(), + ...(targetUserId.trim() && { targetUserId: targetUserId.trim() }), + }; + sendMessage(JSON.stringify(messagePayload)); + setBroadcastMessage(''); + setTargetUserId(''); + } + }; + + if (!isAdmin) { + return ( +
+

Access Denied: You are not an administrator.

+
+ ); + } + + return ( +
+

Admin Broadcast Page

+ +
+

Send Broadcast Message

+
+ + +
+
+ + setTargetUserId(e.target.value)} + placeholder="e.g., user_123abc" + /> +
+ +
+ +
+

Received Admin Messages

+
+ {receivedMessages.length === 0 ? ( +

No admin messages received yet.

+ ) : ( +
    + {receivedMessages.map((msg, index) => ( +
  • + {msg.sender}: {msg.message} +
  • + ))} +
+ )} +
+
+
+ ); +}; + +export default AdminPage; diff --git a/src/hooks/usePartyKit.ts b/src/hooks/usePartyKit.ts index 4ced641..01ee7d5 100644 --- a/src/hooks/usePartyKit.ts +++ b/src/hooks/usePartyKit.ts @@ -10,6 +10,7 @@ export function usePartyKit() { const { user } = useUser(); const [gameState, setGameState] = useState(null); const [socket, setSocket] = useState(null); + const [lastMessage, setLastMessage] = useState(null); // New state for last message // Generate a persistent guest ID if the user is not signed in const [guestId] = useState(() => { let storedGuestId = localStorage.getItem('bozo_guest_id'); @@ -59,6 +60,7 @@ export function usePartyKit() { if (data.type === 'game-state') { setGameState(data.state); } + setLastMessage(event.data); // Store the raw last message }; setSocket(ws); @@ -105,6 +107,14 @@ export function usePartyKit() { } }, [socket, isSignedIn, user, getToken]); + // Determine the userId and userName to expose based on sign-in status + // Only expose Clerk user ID/name if signed in, otherwise undefined + const sendMessage = useCallback((message: string) => { + if (socket) { + socket.send(message); + } + }, [socket]); + // Determine the userId and userName to expose based on sign-in status // Only expose Clerk user ID/name if signed in, otherwise undefined const currentUserId = isSignedIn && user ? user.id : undefined; @@ -115,7 +125,10 @@ export function usePartyKit() { sendClick, purchaseUpgrade, sendMascotClickBonus, + sendMessage, // Expose sendMessage + lastMessage, // Expose lastMessage userId: currentUserId, - userName: currentUserName + userName: currentUserName, + getToken // Expose getToken }; }