feat: Added ProtectedRoute component, CreatorDashboard with sidebar nav…
- "frontend/src/components/ProtectedRoute.tsx" - "frontend/src/pages/CreatorDashboard.tsx" - "frontend/src/pages/CreatorDashboard.module.css" - "frontend/src/pages/CreatorSettings.tsx" - "frontend/src/pages/CreatorSettings.module.css" - "frontend/src/App.tsx" - "frontend/src/App.css" - "frontend/src/pages/Login.tsx" GSD-Task: S02/T04
This commit is contained in:
parent
b344307a89
commit
c60fc8c3b3
12 changed files with 749 additions and 6 deletions
|
|
@ -148,7 +148,7 @@
|
|||
- Estimate: 1.5h
|
||||
- Files: frontend/src/context/AuthContext.tsx, frontend/src/api/public-client.ts, frontend/src/pages/Login.tsx, frontend/src/pages/Login.module.css, frontend/src/pages/Register.tsx, frontend/src/pages/Register.module.css, frontend/src/App.tsx
|
||||
- Verify: cd frontend && npm run build && test -f src/context/AuthContext.tsx && test -f src/pages/Login.tsx && test -f src/pages/Register.tsx
|
||||
- [ ] **T04: Build dashboard shell, profile settings, ProtectedRoute, and nav auth state** — Create the creator dashboard shell page with sidebar navigation, profile settings page, ProtectedRoute wrapper component, and update the main nav to show authenticated user state.
|
||||
- [x] **T04: Added ProtectedRoute component, CreatorDashboard with sidebar nav, CreatorSettings with profile/password forms, protected routes in App.tsx, and auth-aware header nav** — Create the creator dashboard shell page with sidebar navigation, profile settings page, ProtectedRoute wrapper component, and update the main nav to show authenticated user state.
|
||||
|
||||
## Steps
|
||||
|
||||
|
|
|
|||
42
.gsd/milestones/M019/slices/S02/tasks/T03-VERIFY.json
Normal file
42
.gsd/milestones/M019/slices/S02/tasks/T03-VERIFY.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T03",
|
||||
"unitId": "M019/S02/T03",
|
||||
"timestamp": 1775253488528,
|
||||
"passed": false,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd frontend",
|
||||
"exitCode": 0,
|
||||
"durationMs": 4,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "npm run build",
|
||||
"exitCode": 254,
|
||||
"durationMs": 90,
|
||||
"verdict": "fail"
|
||||
},
|
||||
{
|
||||
"command": "test -f src/context/AuthContext.tsx",
|
||||
"exitCode": 1,
|
||||
"durationMs": 5,
|
||||
"verdict": "fail"
|
||||
},
|
||||
{
|
||||
"command": "test -f src/pages/Login.tsx",
|
||||
"exitCode": 1,
|
||||
"durationMs": 5,
|
||||
"verdict": "fail"
|
||||
},
|
||||
{
|
||||
"command": "test -f src/pages/Register.tsx",
|
||||
"exitCode": 1,
|
||||
"durationMs": 5,
|
||||
"verdict": "fail"
|
||||
}
|
||||
],
|
||||
"retryAttempt": 1,
|
||||
"maxRetries": 2
|
||||
}
|
||||
97
.gsd/milestones/M019/slices/S02/tasks/T04-SUMMARY.md
Normal file
97
.gsd/milestones/M019/slices/S02/tasks/T04-SUMMARY.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
---
|
||||
id: T04
|
||||
parent: S02
|
||||
milestone: M019
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["frontend/src/components/ProtectedRoute.tsx", "frontend/src/pages/CreatorDashboard.tsx", "frontend/src/pages/CreatorDashboard.module.css", "frontend/src/pages/CreatorSettings.tsx", "frontend/src/pages/CreatorSettings.module.css", "frontend/src/App.tsx", "frontend/src/App.css", "frontend/src/pages/Login.tsx"]
|
||||
key_decisions: ["Extracted AppShell from App to allow useAuth() inside AuthProvider", "Login page honors returnTo query param from ProtectedRoute redirect", "SidebarNav exported from CreatorDashboard for reuse in CreatorSettings"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "All task and slice verification checks pass: npm run build exits 0 with zero TypeScript errors, all required files exist, ProtectedRoute and useAuth grep checks pass in App.tsx, backend model/auth/schema imports succeed."
|
||||
completed_at: 2026-04-03T22:02:01.042Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T04: Added ProtectedRoute component, CreatorDashboard with sidebar nav, CreatorSettings with profile/password forms, protected routes in App.tsx, and auth-aware header nav
|
||||
|
||||
> Added ProtectedRoute component, CreatorDashboard with sidebar nav, CreatorSettings with profile/password forms, protected routes in App.tsx, and auth-aware header nav
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T04
|
||||
parent: S02
|
||||
milestone: M019
|
||||
key_files:
|
||||
- frontend/src/components/ProtectedRoute.tsx
|
||||
- frontend/src/pages/CreatorDashboard.tsx
|
||||
- frontend/src/pages/CreatorDashboard.module.css
|
||||
- frontend/src/pages/CreatorSettings.tsx
|
||||
- frontend/src/pages/CreatorSettings.module.css
|
||||
- frontend/src/App.tsx
|
||||
- frontend/src/App.css
|
||||
- frontend/src/pages/Login.tsx
|
||||
key_decisions:
|
||||
- Extracted AppShell from App to allow useAuth() inside AuthProvider
|
||||
- Login page honors returnTo query param from ProtectedRoute redirect
|
||||
- SidebarNav exported from CreatorDashboard for reuse in CreatorSettings
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-03T22:02:01.043Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T04: Added ProtectedRoute component, CreatorDashboard with sidebar nav, CreatorSettings with profile/password forms, protected routes in App.tsx, and auth-aware header nav
|
||||
|
||||
**Added ProtectedRoute component, CreatorDashboard with sidebar nav, CreatorSettings with profile/password forms, protected routes in App.tsx, and auth-aware header nav**
|
||||
|
||||
## What Happened
|
||||
|
||||
Created ProtectedRoute that checks isAuthenticated and redirects to /login?returnTo=... when unauthenticated. Built CreatorDashboard with two-column layout (sidebar + content) using NavLink for active state, with placeholder cards for future analytics. Built CreatorSettings reusing the same SidebarNav, with profile (display_name editable, email read-only) and password change sections calling authUpdateProfile(). Restructured App.tsx by extracting AppShell so useAuth() works inside AuthProvider, added AuthNav component showing user name + dashboard link + logout when authenticated or login link when not. Updated Login.tsx to honor returnTo param. All CSS uses modules with existing dark theme tokens.
|
||||
|
||||
## Verification
|
||||
|
||||
All task and slice verification checks pass: npm run build exits 0 with zero TypeScript errors, all required files exist, ProtectedRoute and useAuth grep checks pass in App.tsx, backend model/auth/schema imports succeed.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3300ms |
|
||||
| 2 | `test -f src/components/ProtectedRoute.tsx` | 0 | ✅ pass | 50ms |
|
||||
| 3 | `test -f src/pages/CreatorDashboard.tsx && test -f src/pages/CreatorSettings.tsx` | 0 | ✅ pass | 50ms |
|
||||
| 4 | `grep -q 'ProtectedRoute' src/App.tsx` | 0 | ✅ pass | 50ms |
|
||||
| 5 | `grep -q 'useAuth' src/App.tsx` | 0 | ✅ pass | 50ms |
|
||||
| 6 | `cd backend && python -c "from models import User, InviteCode, UserRole; print('OK')"` | 0 | ✅ pass | 500ms |
|
||||
| 7 | `cd backend && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('OK')"` | 0 | ✅ pass | 500ms |
|
||||
| 8 | `cd backend && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest; print('OK')"` | 0 | ✅ pass | 500ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Extracted AppShell from App to allow useAuth() inside AuthProvider. AuthNav added as separate component rather than inline.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `frontend/src/components/ProtectedRoute.tsx`
|
||||
- `frontend/src/pages/CreatorDashboard.tsx`
|
||||
- `frontend/src/pages/CreatorDashboard.module.css`
|
||||
- `frontend/src/pages/CreatorSettings.tsx`
|
||||
- `frontend/src/pages/CreatorSettings.module.css`
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/App.css`
|
||||
- `frontend/src/pages/Login.tsx`
|
||||
|
||||
|
||||
## Deviations
|
||||
Extracted AppShell from App to allow useAuth() inside AuthProvider. AuthNav added as separate component rather than inline.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
|
|
@ -778,6 +778,48 @@ a.app-footer__repo:hover {
|
|||
color: var(--color-text-on-header-hover);
|
||||
}
|
||||
|
||||
/* ── Auth nav ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.auth-nav__user {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.auth-nav__user:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.auth-nav__logout {
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.625rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.auth-nav__logout:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.auth-nav__login {
|
||||
color: var(--color-text-on-header);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.auth-nav__login:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── Admin dropdown ───────────────────────────────────────────────────────── */
|
||||
|
||||
.admin-dropdown {
|
||||
|
|
|
|||
|
|
@ -13,12 +13,38 @@ import AdminTechniquePages from "./pages/AdminTechniquePages";
|
|||
import About from "./pages/About";
|
||||
import Login from "./pages/Login";
|
||||
import Register from "./pages/Register";
|
||||
import CreatorDashboard from "./pages/CreatorDashboard";
|
||||
import CreatorSettings from "./pages/CreatorSettings";
|
||||
import AdminDropdown from "./components/AdminDropdown";
|
||||
import AppFooter from "./components/AppFooter";
|
||||
import SearchAutocomplete from "./components/SearchAutocomplete";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { AuthProvider, useAuth } from "./context/AuthContext";
|
||||
|
||||
export default function App() {
|
||||
function AuthNav() {
|
||||
const { isAuthenticated, user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<Link to="/creator/dashboard" className="auth-nav__user">
|
||||
{user?.display_name ?? "Dashboard"}
|
||||
</Link>
|
||||
<button
|
||||
className="auth-nav__logout"
|
||||
onClick={() => { logout(); navigate("/"); }}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <Link to="/login" className="auth-nav__login">Login</Link>;
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const showNavSearch = location.pathname !== "/";
|
||||
|
|
@ -61,7 +87,6 @@ export default function App() {
|
|||
}, [menuOpen, handleOutsideClick]);
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<div className="app">
|
||||
<a href="#main-content" className="skip-link">Skip to content</a>
|
||||
<header className="app-header" ref={headerRef}>
|
||||
|
|
@ -108,6 +133,7 @@ export default function App() {
|
|||
<Link to="/">Home</Link>
|
||||
<Link to="/topics">Topics</Link>
|
||||
<Link to="/creators">Creators</Link>
|
||||
<AuthNav />
|
||||
<AdminDropdown />
|
||||
{/* Mobile-only: search bar inside menu when not shown in header */}
|
||||
{menuOpen && showNavSearch && (
|
||||
|
|
@ -148,6 +174,10 @@ export default function App() {
|
|||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
|
||||
{/* Creator routes (protected) */}
|
||||
<Route path="/creator/dashboard" element={<ProtectedRoute><CreatorDashboard /></ProtectedRoute>} />
|
||||
<Route path="/creator/settings" element={<ProtectedRoute><CreatorSettings /></ProtectedRoute>} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
|
@ -155,6 +185,13 @@ export default function App() {
|
|||
|
||||
<AppFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppShell />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
24
frontend/src/components/ProtectedRoute.tsx
Normal file
24
frontend/src/components/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Wraps routes that require authentication.
|
||||
* Redirects to /login with a returnTo param if not authenticated.
|
||||
* Shows nothing while the auth state is still loading (token rehydration).
|
||||
*/
|
||||
export default function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return null; // Wait for token rehydration before redirecting
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
const returnTo = location.pathname + location.search;
|
||||
return <Navigate to={`/login?returnTo=${encodeURIComponent(returnTo)}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
132
frontend/src/pages/CreatorDashboard.module.css
Normal file
132
frontend/src/pages/CreatorDashboard.module.css
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
.layout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
/* ── Sidebar ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-radius: 12px 0 0 12px;
|
||||
padding: 1.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sidebarLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebarLink:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-surface-hover);
|
||||
}
|
||||
|
||||
.sidebarLinkActive {
|
||||
color: var(--color-accent) !important;
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.sidebarLinkDisabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sidebarLinkDisabled:hover {
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.sidebarIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Content area ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Mobile ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 0.75rem 0.5rem;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebarLink {
|
||||
padding: 0.5rem 1rem;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
79
frontend/src/pages/CreatorDashboard.tsx
Normal file
79
frontend/src/pages/CreatorDashboard.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { NavLink } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||
import styles from "./CreatorDashboard.module.css";
|
||||
|
||||
function SidebarNav() {
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`${styles.sidebarLink}${isActive ? ` ${styles.sidebarLinkActive}` : ""}`;
|
||||
|
||||
return (
|
||||
<nav className={styles.sidebar}>
|
||||
<NavLink to="/creator/dashboard" className={linkClass} end>
|
||||
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<span
|
||||
className={`${styles.sidebarLink} ${styles.sidebarLinkDisabled}`}
|
||||
title="Coming soon"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
Content
|
||||
</span>
|
||||
<NavLink to="/creator/settings" className={linkClass}>
|
||||
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export { SidebarNav };
|
||||
|
||||
export default function CreatorDashboard() {
|
||||
useDocumentTitle("Creator Dashboard");
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<SidebarNav />
|
||||
<div className={styles.content}>
|
||||
<h1 className={styles.welcome}>
|
||||
Welcome back{user?.display_name ? `, ${user.display_name}` : ""}
|
||||
</h1>
|
||||
<div className={styles.cards}>
|
||||
<div className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>Content Stats</h2>
|
||||
<p className={styles.cardBody}>
|
||||
Content analytics coming in M020. You'll see views, engagement, and technique performance here.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>Recent Activity</h2>
|
||||
<p className={styles.cardBody}>
|
||||
Activity feed coming soon. Track updates to your technique pages.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>Quick Actions</h2>
|
||||
<p className={styles.cardBody}>
|
||||
Content management tools will appear here once the creator content module is live.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
frontend/src/pages/CreatorSettings.module.css
Normal file
115
frontend/src/pages/CreatorSettings.module.css
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
.heading {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Sections ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
/* ── Form elements ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.fieldGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--color-bg-input);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9375rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-focus);
|
||||
}
|
||||
|
||||
.inputReadonly {
|
||||
composes: input;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Button ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.button {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: var(--color-accent);
|
||||
color: #0f0f14;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Feedback ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
174
frontend/src/pages/CreatorSettings.tsx
Normal file
174
frontend/src/pages/CreatorSettings.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { useState, type FormEvent } from "react";
|
||||
import { useAuth, ApiError } from "../context/AuthContext";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||
import { authUpdateProfile } from "../api/public-client";
|
||||
import { SidebarNav } from "./CreatorDashboard";
|
||||
import dashStyles from "./CreatorDashboard.module.css";
|
||||
import styles from "./CreatorSettings.module.css";
|
||||
|
||||
export default function CreatorSettings() {
|
||||
useDocumentTitle("Settings");
|
||||
const { user, token } = useAuth();
|
||||
|
||||
// Profile fields
|
||||
const [displayName, setDisplayName] = useState(user?.display_name ?? "");
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [profileSubmitting, setProfileSubmitting] = useState(false);
|
||||
|
||||
// Password fields
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [passwordMsg, setPasswordMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
|
||||
|
||||
const handleProfileSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProfileMsg(null);
|
||||
|
||||
if (!displayName.trim()) {
|
||||
setProfileMsg({ type: "error", text: "Display name cannot be empty." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) return;
|
||||
setProfileSubmitting(true);
|
||||
try {
|
||||
await authUpdateProfile(token, { display_name: displayName.trim() });
|
||||
setProfileMsg({ type: "success", text: "Profile updated." });
|
||||
} catch (err) {
|
||||
setProfileMsg({
|
||||
type: "error",
|
||||
text: err instanceof ApiError ? err.detail : "Failed to update profile.",
|
||||
});
|
||||
} finally {
|
||||
setProfileSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordMsg(null);
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
setPasswordMsg({ type: "error", text: "Current and new password are required." });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordMsg({ type: "error", text: "New password must be at least 8 characters." });
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMsg({ type: "error", text: "New passwords don't match." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) return;
|
||||
setPasswordSubmitting(true);
|
||||
try {
|
||||
await authUpdateProfile(token, {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
setPasswordMsg({ type: "success", text: "Password changed successfully." });
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (err) {
|
||||
setPasswordMsg({
|
||||
type: "error",
|
||||
text: err instanceof ApiError ? err.detail : "Failed to change password.",
|
||||
});
|
||||
} finally {
|
||||
setPasswordSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={dashStyles.layout}>
|
||||
<SidebarNav />
|
||||
<div className={dashStyles.content}>
|
||||
<h1 className={styles.heading}>Settings</h1>
|
||||
|
||||
{/* Profile section */}
|
||||
<div className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Profile</h2>
|
||||
{profileMsg && (
|
||||
<div className={profileMsg.type === "error" ? styles.error : styles.success}>
|
||||
{profileMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleProfileSubmit} className={styles.fieldGroup}>
|
||||
<label className={styles.label}>
|
||||
Display Name
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.label}>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
className={styles.inputReadonly}
|
||||
value={user?.email ?? ""}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className={styles.button} disabled={profileSubmitting}>
|
||||
{profileSubmitting ? "Saving…" : "Save Profile"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Password section */}
|
||||
<div className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Change Password</h2>
|
||||
{passwordMsg && (
|
||||
<div className={passwordMsg.type === "error" ? styles.error : styles.success}>
|
||||
{passwordMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handlePasswordSubmit} className={styles.fieldGroup}>
|
||||
<label className={styles.label}>
|
||||
Current Password
|
||||
<input
|
||||
type="password"
|
||||
className={styles.input}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.label}>
|
||||
New Password
|
||||
<input
|
||||
type="password"
|
||||
className={styles.input}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.label}>
|
||||
Confirm New Password
|
||||
<input
|
||||
type="password"
|
||||
className={styles.input}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className={styles.button} disabled={passwordSubmitting}>
|
||||
{passwordSubmitting ? "Changing…" : "Change Password"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ export default function Login() {
|
|||
// Check for success message from registration redirect
|
||||
const params = new URLSearchParams(location.search);
|
||||
const registered = params.get("registered") === "1";
|
||||
const returnTo = params.get("returnTo");
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -31,7 +32,7 @@ export default function Login() {
|
|||
setSubmitting(true);
|
||||
try {
|
||||
await login(email.trim(), password);
|
||||
navigate("/creator/dashboard", { replace: true });
|
||||
navigate(returnTo || "/creator/dashboard", { replace: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.detail);
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
|
||||
Loading…
Add table
Reference in a new issue