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
|
- 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
|
- 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
|
- 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
|
## 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);
|
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 ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.admin-dropdown {
|
.admin-dropdown {
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,38 @@ import AdminTechniquePages from "./pages/AdminTechniquePages";
|
||||||
import About from "./pages/About";
|
import About from "./pages/About";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Register from "./pages/Register";
|
import Register from "./pages/Register";
|
||||||
|
import CreatorDashboard from "./pages/CreatorDashboard";
|
||||||
|
import CreatorSettings from "./pages/CreatorSettings";
|
||||||
import AdminDropdown from "./components/AdminDropdown";
|
import AdminDropdown from "./components/AdminDropdown";
|
||||||
import AppFooter from "./components/AppFooter";
|
import AppFooter from "./components/AppFooter";
|
||||||
import SearchAutocomplete from "./components/SearchAutocomplete";
|
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 location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const showNavSearch = location.pathname !== "/";
|
const showNavSearch = location.pathname !== "/";
|
||||||
|
|
@ -61,7 +87,6 @@ export default function App() {
|
||||||
}, [menuOpen, handleOutsideClick]);
|
}, [menuOpen, handleOutsideClick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<a href="#main-content" className="skip-link">Skip to content</a>
|
<a href="#main-content" className="skip-link">Skip to content</a>
|
||||||
<header className="app-header" ref={headerRef}>
|
<header className="app-header" ref={headerRef}>
|
||||||
|
|
@ -108,6 +133,7 @@ export default function App() {
|
||||||
<Link to="/">Home</Link>
|
<Link to="/">Home</Link>
|
||||||
<Link to="/topics">Topics</Link>
|
<Link to="/topics">Topics</Link>
|
||||||
<Link to="/creators">Creators</Link>
|
<Link to="/creators">Creators</Link>
|
||||||
|
<AuthNav />
|
||||||
<AdminDropdown />
|
<AdminDropdown />
|
||||||
{/* Mobile-only: search bar inside menu when not shown in header */}
|
{/* Mobile-only: search bar inside menu when not shown in header */}
|
||||||
{menuOpen && showNavSearch && (
|
{menuOpen && showNavSearch && (
|
||||||
|
|
@ -148,6 +174,10 @@ export default function App() {
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<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 */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
@ -155,6 +185,13 @@ export default function App() {
|
||||||
|
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppShell />
|
||||||
</AuthProvider>
|
</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
|
// Check for success message from registration redirect
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const registered = params.get("registered") === "1";
|
const registered = params.get("registered") === "1";
|
||||||
|
const returnTo = params.get("returnTo");
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -31,7 +32,7 @@ export default function Login() {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await login(email.trim(), password);
|
await login(email.trim(), password);
|
||||||
navigate("/creator/dashboard", { replace: true });
|
navigate(returnTo || "/creator/dashboard", { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.detail);
|
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