Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import SettingsButton from './components/SettingsButton';
import SettingsPage from './components/SettingsPage';
import GameModeScreen from './components/GameModeScreen';
import Leaderboard from './components/Leaderboard';
import { UserMenu } from './components/UserMenu';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserMenu is imported here but never rendered in the component's JSX. The sign-in/sign-out UI won't be visible to users, which defeats the purpose of this PR. You likely need to add <UserMenu /> somewhere in the returned markup (e.g. alongside the other top-level UI elements).

Fix it with Roo Code or mention @roomote and request a fix.

import { playMatchSound, playMismatchSound, playVictorySound } from './utils/sounds';
import { buildSettingsHash } from './utils/settingsHash';

Expand Down Expand Up @@ -404,6 +405,7 @@ const App: React.FC = () => {
return (
<div className="flex flex-col p-2 min-h-screen">
<MuteButton isMuted={isMuted} onToggle={handleMuteToggle} />
<UserMenu />
{gameState === 'playing' && gameMode === 'Custom' && <SettingsButton onClick={() => {
setPreviousGameState(gameState);
setGameState('configuringSettings');
Expand Down
54 changes: 54 additions & 0 deletions components/UserMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from "react";
import { useAuthActions } from "@convex-dev/auth/react";
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";

export function UserMenu() {
const { signIn, signOut } = useAuthActions();
// We use ts-ignore because we don't have a configured project for codegen.
// @ts-ignore
const userDetails = useQuery(api.users.currentUserDetails);

return (
<div className="absolute top-4 right-4 flex items-center gap-2 z-50">
{userDetails !== undefined ? (
userDetails === null ? (
<button
onClick={() => void signIn("google")}
className="text-xs text-[var(--secondary-text-color)] hover:text-[var(--text-color)] opacity-70 hover:opacity-100 transition-opacity bg-transparent border-none"
style={{ padding: '0.25rem 0.5rem' }}
>
Sign In
</button>
) : (
<div className="flex items-center gap-4 bg-black/20 rounded-full pl-2 pr-4 py-1.5 shadow-sm border border-white/5">
<a
href="/profile"
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
title="Go to profile"
>
{userDetails.image && (
<img
src={userDetails.image}
alt={userDetails.name || "User"}
className="w-6 h-6 rounded-full object-cover border border-white/20"
/>
)}
<span className="text-sm text-[var(--text-color)] font-medium">
{userDetails.name || "Player"}
</span>
</a>
<button
onClick={() => void signOut()}
className="text-xs text-[var(--secondary-text-color)] hover:text-red-400 transition"
>
Sign Out
</button>
</div>
)
) : (
<div className="w-24 h-10 bg-gray-700 animate-pulse rounded"></div>
)}
</div>
);
}
72 changes: 68 additions & 4 deletions components/VictoryModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@

import React, { useEffect, useState } from 'react';
import confetti from 'canvas-confetti';
import { useQuery, useMutation } from 'convex/react';
import { useQuery, useMutation, useConvexAuth } from 'convex/react';
import { api } from '../convex/_generated/api';
import { useAuthActions } from "@convex-dev/auth/react";

interface VictoryModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -43,7 +44,17 @@ const VictoryModal: React.FC<VictoryModalProps> = ({
const [playerName, setPlayerName] = useState('');
const [hasSubmitted, setHasSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const submitScore = useMutation(api.leaderboard.submitScore);
const { signIn } = useAuthActions();
const { isAuthenticated } = useConvexAuth();
// @ts-ignore
const claimName = useMutation(api.users.claimName);

// @ts-ignore
const userDetails = useQuery(api.users.currentUserDetails);
const userName = userDetails?.name;

const scores = useQuery(
api.leaderboard.getTopScores,
isOpen ? { settingsHash, limit: 10 } : 'skip',
Expand Down Expand Up @@ -85,13 +96,30 @@ const VictoryModal: React.FC<VictoryModalProps> = ({
}
}, [isOpen, isPerfectScore, isTimeUp]);

useEffect(() => {
if (userName) {
setPlayerName(userName);
}
}, [userName]);

const handleSubmitScore = async () => {
const trimmedName = playerName.trim();
if (!trimmedName || isSubmitting) return;

setIsSubmitting(true);
setSubmitError(null);
try {
localStorage.setItem('matchfield-player-name', trimmedName);

// If user is logged in but hasn't claimed a name or is changing it
if (isAuthenticated && userName !== undefined && userName !== trimmedName) {
try {
await claimName({ name: trimmedName });
} catch (claimErr: any) {
throw new Error(claimErr.message || "Failed to claim name");
}
}

await submitScore({
playerName: trimmedName,
gameMode,
Expand All @@ -100,8 +128,16 @@ const VictoryModal: React.FC<VictoryModalProps> = ({
isPerfectScore,
});
setHasSubmitted(true);
} catch (err) {
} catch (err: any) {
console.error('Failed to submit score:', err);
// Determine error message safely
let errorMsg = "An error occurred submitting your score.";
if (err instanceof Error) {
// Convex passes the server throw Error message in the Error object message property
const match = err.message.match(/Uncaught Error: (.*)/);
errorMsg = match ? match[1] : err.message;
}
setSubmitError(errorMsg);
} finally {
setIsSubmitting(false);
}
Expand Down Expand Up @@ -144,6 +180,22 @@ const VictoryModal: React.FC<VictoryModalProps> = ({
<p className="text-[var(--secondary-text-color)] text-sm mb-2">
Submit to the {gameMode === 'Classic' ? 'Classic' : 'Custom'} leaderboard
</p>
{submitError && (
<div className="mb-2 text-red-500 text-sm">
{submitError}
{submitError.includes("Please sign in") && (
<span>
{" "}
<button
onClick={() => void signIn("google")}
className="underline text-blue-400 hover:text-blue-300"
>
Sign in with Google
</button> to claim this name.
</span>
)}
</div>
)}
<div className="flex gap-2">
<input
type="text"
Expand All @@ -166,10 +218,22 @@ const VictoryModal: React.FC<VictoryModalProps> = ({
)}

{canSubmit && hasSubmitted && (
<div className="mb-4 bg-black/20 rounded-lg p-2">
<p className="text-[var(--accent-color)] text-sm font-semibold">
<div className="mb-4 bg-black/20 rounded-lg p-3">
<p className="text-[var(--accent-color)] text-sm font-semibold mb-2">
Score submitted!
</p>
{/* If they submitted successfully but aren't signed in, prompt them to claim it */}
{!isAuthenticated && (
<div className="mt-2 text-sm text-[var(--secondary-text-color)]">
<p className="mb-2">Want to claim "{playerName}" permanently?</p>
<button
onClick={() => void signIn("google")}
className="px-4 py-2 bg-white text-black font-semibold rounded hover:bg-gray-200 transition"
>
Sign in with Google
</button>
</div>
)}
</div>
)}

Expand Down
8 changes: 8 additions & 0 deletions convex/auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
},
],
};
6 changes: 6 additions & 0 deletions convex/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Google from "@auth/core/providers/google";
import { convexAuth } from "@convex-dev/auth/server";

export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [Google],
});
8 changes: 8 additions & 0 deletions convex/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { httpRouter } from "convex/server";
import { auth } from "./auth";

const http = httpRouter();

auth.addHttpRoutes(http);

export default http;
23 changes: 23 additions & 0 deletions convex/leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export const listBoards = query({
},
});

import { getAuthUserId } from "@convex-dev/auth/server";

export const submitScore = mutation({
args: {
playerName: v.string(),
Expand All @@ -86,8 +88,29 @@ export const submitScore = mutation({
throw new Error("Player name cannot be empty");
}

const userId = await getAuthUserId(ctx);

// If they are not logged in, ensure the name is not claimed
if (userId === null) {
const existingUser = await ctx.db
.query("users")
.withIndex("customDisplayName", (q) => q.eq("customDisplayName", trimmedName))
.first();

if (existingUser !== null) {
throw new Error("This name is already claimed by a registered user. Please sign in or choose another name.");
}
} else {
// If logged in, they should only be submitting scores under their claimed name
const user = await ctx.db.get(userId);
if (user && user.customDisplayName && user.customDisplayName !== trimmedName) {
throw new Error("You must submit scores under your claimed name, or change your name.");
}
}

await ctx.db.insert("leaderboard", {
playerName: trimmedName,
userId: userId !== null ? userId : undefined,
gameMode: args.gameMode,
settingsHash: args.settingsHash,
longestCombo: args.longestCombo,
Expand Down
17 changes: 16 additions & 1 deletion convex/schema.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { authTables } from "@convex-dev/auth/server";

export default defineSchema({
...authTables,
users: defineTable({
name: v.optional(v.string()),
image: v.optional(v.string()),
email: v.optional(v.string()),
emailVerificationTime: v.optional(v.number()),
phone: v.optional(v.string()),
phoneVerificationTime: v.optional(v.number()),
isAnonymous: v.optional(v.boolean()),
customDisplayName: v.optional(v.string()),
}).index("email", ["email"]).index("customDisplayName", ["customDisplayName"]),
leaderboard: defineTable({
playerName: v.string(),
userId: v.optional(v.id("users")),
gameMode: v.string(), // "Classic" or "Custom"
settingsHash: v.string(), // "classic" for Classic, deterministic key for Custom
longestCombo: v.number(),
isPerfectScore: v.boolean(),
completedAt: v.number(), // timestamp
})
.index("by_settings", ["settingsHash", "longestCombo"])
.index("by_mode", ["gameMode", "longestCombo"]),
.index("by_mode", ["gameMode", "longestCombo"])
.index("by_userId", ["userId"])
.index("by_playerName", ["playerName"]),
});
88 changes: 88 additions & 0 deletions convex/users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";

export const checkNameClaimed = query({
args: {
name: v.string(),
},
handler: async (ctx, args) => {
const trimmedName = args.name.trim().slice(0, 20);
const existingUser = await ctx.db
.query("users")
.withIndex("customDisplayName", (q) => q.eq("customDisplayName", trimmedName))
.first();

return existingUser !== null;
},
});

export const claimName = mutation({
args: {
name: v.string(),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (userId === null) {
throw new Error("User must be authenticated to claim a name.");
}

const trimmedName = args.name.trim().slice(0, 20);
if (trimmedName.length === 0) {
throw new Error("Name cannot be empty");
}

// Check if another user already claimed this name
const existingUser = await ctx.db
.query("users")
.withIndex("customDisplayName", (q) => q.eq("customDisplayName", trimmedName))
.first();

if (existingUser !== null && existingUser._id !== userId) {
throw new Error("This name is already claimed by someone else.");
}

// Update the user's customDisplayName
await ctx.db.patch(userId, { customDisplayName: trimmedName, isAnonymous: false });

// Migrate existing anonymous scores under this name to the user
const allScoresForName = await ctx.db
.query("leaderboard")
.withIndex("by_playerName", (q) => q.eq("playerName", trimmedName))
.collect();
Comment on lines +49 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This .filter().collect() does a full table scan of the leaderboard since there's no index on playerName. If the leaderboard grows, this will get increasingly expensive. Consider adding a by_playerName index to the leaderboard table in the schema and using .withIndex() here instead.

Fix it with Roo Code or mention @roomote and request a fix.


for (const score of allScoresForName) {
if (score.userId === undefined) {
await ctx.db.patch(score._id, { userId });
}
}

// If the user previously had a different customDisplayName, do we migrate those scores?
// Actually, scores submitted when authenticated are stored with userId.
// The playername stored on the record will be historical or we can update it.
// Let's update the playername on all their past scores to the new name.
const allUserScores = await ctx.db
.query("leaderboard")
.withIndex("by_userId", (q) => q.eq("userId", userId))
.collect();

for (const score of allUserScores) {
if (score.playerName !== trimmedName) {
await ctx.db.patch(score._id, { playerName: trimmedName });
}
}
},
});

export const currentUserDetails = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (userId === null) return null;
const user = await ctx.db.get(userId);
return {
name: user?.customDisplayName || null,
image: user?.image || null,
};
}
});
Loading