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
parent
7e87afa3ec
commit
ea61bd5475
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue