diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bf3045b..df072a9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; import { BaseRomProvider } from "@/contexts/BaseRomContext"; +import { AuthProvider } from "@/contexts/AuthContext"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -30,14 +31,16 @@ export default function RootLayout({ - -
-
-
-
-
{children}
-
- + + +
+
+
+
+
{children}
+
+ + ); diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts index be57e0f..d18e586 100644 --- a/src/app/login/actions.ts +++ b/src/app/login/actions.ts @@ -2,7 +2,7 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' -import { AuthError } from '@supabase/supabase-js' +import { AuthError, User } from '@supabase/supabase-js' import { createClient } from '@/utils/supabase/server' @@ -21,7 +21,10 @@ function getErrorMessage(error: AuthError): string { return error.message || 'Unable to log in. Please try again later.'; } -export type AuthActionState = { error?: string | null } +export type AuthActionState = | + { error: string, user: null, redirectTo: null } | + { error: null, user: User | null, redirectTo: string } | + null export async function login(state: AuthActionState, payload: FormData) { const supabase = await createClient() @@ -31,12 +34,20 @@ export async function login(state: AuthActionState, payload: FormData) { password: payload.get('password') as string, } - const { error } = await supabase.auth.signInWithPassword(data) + const { data: authData, error } = await supabase.auth.signInWithPassword(data) if (error) { - return { error: getErrorMessage(error) } + return { error: getErrorMessage(error), user: null, redirectTo: null } } revalidatePath('/', 'layout') - redirect('/account') + const redirectTo = (payload.get('redirectTo') as string | null) + const isValidInternalPath = redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//') + + return { + error: null, + user: authData.user, + redirectTo: isValidInternalPath ? redirectTo : '/account' + } + } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index f5e76b6..852eee2 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,6 +1,8 @@ import LoginForm from "@/components/Auth/LoginForm"; +import Link from "next/link"; -export default function LoginPage() { +export default function LoginPage({ searchParams }: { searchParams?: { redirectTo?: string } }) { + const redirectTo = searchParams?.redirectTo; return (
@@ -10,7 +12,8 @@ export default function LoginPage() {

- New here? Create an account + New here? + Create an account

diff --git a/src/app/page.tsx b/src/app/page.tsx index 458eed9..a29e2ba 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -26,6 +26,12 @@ export default function Home() { > Submit a patch + + Already a creator? Log in +
diff --git a/src/app/signup/actions.ts b/src/app/signup/actions.ts index 7b66873..72055f9 100644 --- a/src/app/signup/actions.ts +++ b/src/app/signup/actions.ts @@ -55,5 +55,7 @@ export async function signup(state: AuthActionState, payload: FormData) { } revalidatePath('/', 'layout'); - redirect('/account'); + const redirectTo = (payload.get('redirectTo') as string | null) || null + const isValidInternalPath = redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//') + redirect(isValidInternalPath ? redirectTo! : '/account'); } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index f8a8c78..12a5407 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,6 +1,8 @@ +import Link from "next/link"; import SignupForm from "@/components/Auth/SignupForm"; -export default function SignupPage() { +export default function SignupPage({ searchParams }: { searchParams?: { redirectTo?: string } }) { + const redirectTo = searchParams?.redirectTo; return (
@@ -10,7 +12,8 @@ export default function SignupPage() {

- Already have an account? Log in + Already have an account? + Log in

diff --git a/src/app/submit/page.tsx b/src/app/submit/page.tsx index 954af3a..b3f5af8 100644 --- a/src/app/submit/page.tsx +++ b/src/app/submit/page.tsx @@ -1,6 +1,11 @@ import SubmitForm from "@/components/Submit/SubmitForm"; +import { createClient } from "@/utils/supabase/server"; +import SubmitAuthOverlay from "@/components/Submit/SubmitAuthOverlay"; + +export default async function SubmitPage() { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); -export default function SubmitPage() { return (

Submit your ROM hack

@@ -8,6 +13,7 @@ export default function SubmitPage() {
+ {!user && }
); } diff --git a/src/components/Auth/LoginForm.tsx b/src/components/Auth/LoginForm.tsx index 32cbd5e..93c0991 100644 --- a/src/components/Auth/LoginForm.tsx +++ b/src/components/Auth/LoginForm.tsx @@ -1,28 +1,42 @@ "use client"; -import React, { useActionState} from "react"; +import React, { useActionState, useEffect} from "react"; import { FiEye, FiEyeOff } from "react-icons/fi"; import { AuthActionState, login } from "@/app/login/actions"; -import { useSearchParams } from "next/navigation"; +import { redirect, useSearchParams } from "next/navigation"; +import { useAuthContext } from "@/contexts/AuthContext"; export default function LoginForm() { + const { setUser } = useAuthContext(); const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [showPassword, setShowPassword] = React.useState(false); const searchParams = useSearchParams(); const urlError = searchParams.get("error"); - - const [state, formAction] = useActionState(login, { error: null }); + const [state, formAction] = useActionState(login, null); const errorMessage = urlError === "EMAIL_CONFIRMATION_ERROR" ? "Email verification failed. Try again or request a new link." : state?.error || null; + const redirectTo = searchParams.get("redirectTo"); const emailValid = /.+@.+\..+/.test(email); const passwordValid = password.length > 1; const isValid = emailValid && passwordValid; + useEffect(() => { + if (state && state.error === null) { + setUser(state.user); + if (state.redirectTo) { + redirect(state.redirectTo); + } + } + }, [state]); + return (
+ {redirectTo && ( + + )} {(errorMessage) && (
{errorMessage} diff --git a/src/components/Auth/SignupForm.tsx b/src/components/Auth/SignupForm.tsx index 878d162..646e6ab 100644 --- a/src/components/Auth/SignupForm.tsx +++ b/src/components/Auth/SignupForm.tsx @@ -1,11 +1,13 @@ "use client"; import React, { useActionState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; import { FiEye, FiEyeOff } from "react-icons/fi"; import { AuthActionState, signup } from "@/app/signup/actions"; import { validateEmail, validatePassword } from "@/utils/auth"; export default function SignupForm() { + const searchParams = useSearchParams(); const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [confirm, setConfirm] = React.useState(""); @@ -27,8 +29,13 @@ export default function SignupForm() { setPasswordError(error); }, [password]); + const redirectTo = searchParams.get("redirectTo"); + return ( + {redirectTo && ( + + )} {(state?.error) && (
{state?.error} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index b0f0881..50e7358 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -12,6 +12,9 @@ export default function Footer() { Submit + + Already a creator? Log in +
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index f994853..4cc5372 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -4,6 +4,9 @@ import Link from "next/link"; import React from "react"; import { usePathname } from "next/navigation"; import { useBaseRoms } from "@/contexts/BaseRomContext"; +import { useAuthContext } from "@/contexts/AuthContext"; +import { createClient } from "@/utils/supabase/client"; +import Avatar from "@/components/Account/Avatar"; function NavLink({ href, label, className = "" }: { href: string; label: React.ReactNode; className?: string }) { const pathname = usePathname(); @@ -23,7 +26,38 @@ function NavLink({ href, label, className = "" }: { href: string; label: React.R export default function Header() { const { countReady } = useBaseRoms(); + const { user } = useAuthContext(); const pathname = usePathname(); + const supabase = createClient(); + const [isAuthenticated, setIsAuthenticated] = React.useState(false); + const [userId, setUserId] = React.useState(null); + const [avatarUrl, setAvatarUrl] = React.useState(null); + + React.useEffect(() => { + let isMounted = true; + (async () => { + const { data } = await supabase.auth.getUser(); + if (!isMounted) return; + const authed = Boolean(data.user); + setIsAuthenticated(authed); + setUserId(data.user?.id ?? null); + // Best-effort fetch profile avatar_url for header avatar + if (authed && data.user?.id) { + const { data: profile } = await supabase + .from('profiles') + .select('avatar_url') + .eq('id', data.user.id) + .single(); + setAvatarUrl(profile?.avatar_url ?? null); + } else { + setAvatarUrl(null); + } + })(); + return () => { + isMounted = false; + }; + }, [supabase, user]); + return (
@@ -52,6 +86,16 @@ export default function Header() { > Submit + {isAuthenticated && ( + + + + )}
diff --git a/src/components/Submit/SubmitAuthOverlay.tsx b/src/components/Submit/SubmitAuthOverlay.tsx new file mode 100644 index 0000000..f3aa97a --- /dev/null +++ b/src/components/Submit/SubmitAuthOverlay.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React, { useEffect } from 'react' +import Link from 'next/link' + +const SubmitAuthOverlay: React.FC = () => { + useEffect(() => { + const html = document.documentElement; + const body = document.body; + const previousHtmlOverflow = html.style.overflow; + const previousBodyOverflow = body.style.overflow; + const previousBodyPaddingRight = body.style.paddingRight; + const scrollBarWidth = window.innerWidth - html.clientWidth; + + html.style.overflow = 'hidden'; + body.style.overflow = 'hidden'; + if (scrollBarWidth > 0) { + body.style.paddingRight = `${scrollBarWidth}px`; + } + + return () => { + html.style.overflow = previousHtmlOverflow; + body.style.overflow = previousBodyOverflow; + body.style.paddingRight = previousBodyPaddingRight; + }; + }, []); + + return ( +
+
+
+
+
+
Creators only
+

+ You need an account to submit new romhacks for others to play. It only takes a minute. +

+
+
+ + Create account + + + Log in + +
+
+
+
+ ) +} + +export default SubmitAuthOverlay; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..60b4b80 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React from "react"; +import { createClient } from "@/utils/supabase/client"; +import { User } from "@supabase/supabase-js"; + +interface AuthContextType { + user: User | null; + setUser: (user: User | null) => void; +} + +export const AuthContext = React.createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = React.useState(null); + const supabase = createClient(); + + React.useEffect(() => { + let isMounted = true; + + const fetchUser = async () => { + const { data } = await supabase.auth.getUser(); + if (!isMounted) return; + setUser(data.user ?? null); + }; + + fetchUser(); + + return () => { + isMounted = false; + }; + }, [supabase]); + + return ( + {children} + ); +} + +export function useAuthContext() { + const context = React.useContext(AuthContext); + if (!context) { + throw new Error("useAuthContext must be used within an AuthProvider"); + } + return context; +}