feat: user nav dropdown in landing page + redirect to editor after OAuth login

- Google OAuth callback now redirects to /editor instead of landing page
- Landing page nav shows avatar, username and dropdown when logged in
  (My Projects, Open Editor, Sign out)
- Skeleton placeholder during checkSession so login button never flickers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pull/10/head
David Montero Crespo 2026-03-06 21:50:35 -03:00
parent 7e87afa3ec
commit ea61bd5475
3 changed files with 224 additions and 4 deletions

View File

@ -168,6 +168,7 @@ async def google_callback(code: str, response: Response, db: AsyncSession = Depe
await db.refresh(user)
jwt_token = create_access_token({"sub": user.id}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
redirect = RedirectResponse(url=settings.FRONTEND_URL)
# Send the user straight to the editor after OAuth login
redirect = RedirectResponse(url=f"{settings.FRONTEND_URL}/editor")
_set_auth_cookie(redirect, jwt_token)
return redirect

View File

@ -97,6 +97,149 @@
background: #1a8fd1;
}
/* ── Nav auth skeleton ────────────────────────────────── */
.nav-auth-skeleton {
width: 80px;
height: 28px;
background: var(--border);
border-radius: var(--radius);
animation: skeleton-pulse 1.4s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── User menu ────────────────────────────────────────── */
.user-menu {
position: relative;
}
.user-menu-trigger {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: 1px solid var(--border-hi);
border-radius: 20px;
padding: 3px 10px 3px 4px;
cursor: pointer;
color: var(--text);
font-size: 13px;
transition: border-color 0.15s, background 0.15s;
}
.user-menu-trigger:hover {
border-color: #333;
background: rgba(255,255,255,0.04);
}
.user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.user-avatar-initials {
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: #fff;
font-size: 11px;
font-weight: 700;
}
.user-avatar-lg {
width: 36px;
height: 36px;
font-size: 15px;
}
.user-menu-name {
font-size: 13px;
font-weight: 500;
color: var(--text);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-menu-dropdown {
position: absolute;
right: 0;
top: calc(100% + 6px);
background: #131318;
border: 1px solid var(--border-hi);
border-radius: 6px;
min-width: 200px;
z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
overflow: hidden;
}
.user-menu-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 14px 12px;
}
.user-menu-uname {
font-size: 13.5px;
font-weight: 600;
color: var(--text);
}
.user-menu-email {
font-size: 11.5px;
color: var(--text-muted);
margin-top: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 130px;
}
.user-menu-divider {
height: 1px;
background: var(--border);
}
.user-menu-item {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 9px 14px;
color: var(--text-muted);
font-size: 13px;
text-decoration: none;
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.user-menu-item:hover {
background: rgba(255,255,255,0.05);
color: var(--text);
}
.user-menu-signout {
color: #e06c75;
}
.user-menu-signout:hover {
background: rgba(224, 108, 117, 0.08);
color: #e06c75;
}
/* ── Hero ─────────────────────────────────────────────── */
.landing-hero {
display: grid;

View File

@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore';
import './LandingPage.css';
@ -330,9 +331,82 @@ const IcoSponsor = () => (
</svg>
);
/* ── User nav dropdown ────────────────────────────────── */
const UserMenu: React.FC = () => {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
if (!user) return null;
const initials = user.username[0].toUpperCase();
return (
<div className="user-menu" ref={ref}>
<button className="user-menu-trigger" onClick={() => setOpen((v) => !v)}>
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="user-avatar" />
) : (
<span className="user-avatar user-avatar-initials">{initials}</span>
)}
<span className="user-menu-name">{user.username}</span>
<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10" style={{ opacity: 0.5 }}>
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round"/>
</svg>
</button>
{open && (
<div className="user-menu-dropdown">
<div className="user-menu-header">
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="user-avatar user-avatar-lg" />
) : (
<span className="user-avatar user-avatar-initials user-avatar-lg">{initials}</span>
)}
<div>
<div className="user-menu-uname">{user.username}</div>
<div className="user-menu-email">{user.email}</div>
</div>
</div>
<div className="user-menu-divider" />
<Link to="/editor" className="user-menu-item" onClick={() => setOpen(false)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" width="15" height="15">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
Open Editor
</Link>
<Link to={`/${user.username}`} className="user-menu-item" onClick={() => setOpen(false)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" width="15" height="15">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
My Projects
</Link>
<div className="user-menu-divider" />
<button className="user-menu-item user-menu-signout" onClick={async () => { setOpen(false); await logout(); navigate('/'); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" width="15" height="15">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Sign out
</button>
</div>
)}
</div>
);
};
/* ── Component ────────────────────────────────────────── */
export const LandingPage: React.FC = () => {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
return (
<div className="landing">
@ -347,8 +421,10 @@ export const LandingPage: React.FC = () => {
<IcoGitHub /> GitHub
</a>
<Link to="/examples" className="nav-link">Examples</Link>
{user ? (
<Link to="/editor" className="nav-btn-primary">Open Editor</Link>
{isLoading ? (
<div className="nav-auth-skeleton" />
) : user ? (
<UserMenu />
) : (
<>
<Link to="/login" className="nav-link">Sign in</Link>