Session 2026-05-28: profile page overhaul, nav fixes, dashboard activity links

- Fix nav links not working from profile page (useEffect infinite re-render via unstable profile object ref)
- Fix nav hover/active: gold icon highlight, no background change; active links non-clickable
- Fix hover layout shift: add border: 1px solid transparent to all interactive elements
- Header icon buttons (search, theme toggle) now highlight gold on hover
- Profile page: replace calendar with activity feed (60/40 grid), add stat cards (tasks completed, active projects, revision requests, submissions)
- Profile card: title field, icon rows for location/email/linkedin, member since + role bottom-right, edit button top-right
- Profile portrait: remove wrapper column, fix left-gap alignment
- Add profiles.title migration
- Dashboard recent activity: name → /profile/{id}, task → /requests/{id} (clickable links)
- Icon-only sidebar with gold active/hover state, pointer-events: none on active links
- layout.md updated with profile page geometry rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-28 15:32:46 -04:00
parent 565d2ed4bc
commit 283511bf3a
48 changed files with 4151 additions and 1889 deletions
+186 -95
View File
@@ -1,24 +1,42 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const ICONS = {
dashboard: <svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5"/><rect x="1" y="9" width="6" height="6" rx="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5"/></svg>,
requests: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="2" width="12" height="12" rx="1.5"/><line x1="5" y1="5.5" x2="11" y2="5.5"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="5" y1="10.5" x2="8" y2="10.5"/></svg>,
projects: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M1.5 5.5C1.5 4.67 2.17 4 3 4h2.5l1.5 2H13c.83 0 1.5.67 1.5 1.5v5.5c0 .83-.67 1.5-1.5 1.5H3c-.83 0-1.5-.67-1.5-1.5V5.5z"/></svg>,
fileSharing: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6L9 2z"/><polyline points="9,2 9,6 13.5,6"/></svg>,
invoices: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="1.5" width="12" height="13" rx="1"/><line x1="8" y1="4" x2="8" y2="12"/><path d="M10 5.5H7a1.5 1.5 0 000 3h2a1.5 1.5 0 010 3H5.5"/></svg>,
notes: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="1.5" width="10" height="13" rx="1"/><line x1="5.5" y1="5" x2="10.5" y2="5"/><line x1="5.5" y1="7.5" x2="10.5" y2="7.5"/><line x1="5.5" y1="10" x2="8.5" y2="10"/></svg>,
survey: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="9.5" width="3" height="5" rx="0.5"/><rect x="6.5" y="6" width="3" height="8.5" rx="0.5"/><rect x="11.5" y="2.5" width="3" height="12" rx="0.5"/></svg>,
brandBook: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 2.5h4a2 2 0 012 2v9a2 2 0 00-2-2h-4V2.5z"/><path d="M13.5 2.5h-4a2 2 0 00-2 2v9a2 2 0 012-2h4V2.5z"/></svg>,
converter: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><circle cx="5.5" cy="5.5" r="1.5"/><path d="M1.5 11.5l3.5-3.5 3 3L11 7.5l3 3"/></svg>,
passwords: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="7" width="10" height="7.5" rx="1"/><path d="M5 7V5.5a3 3 0 016 0V7"/><circle cx="8" cy="10.5" r="1" fill="currentColor" stroke="none"/></svg>,
companies: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="5" width="13" height="9.5" rx="1"/><path d="M5.5 5V3a1 1 0 011-1h3a1 1 0 011 1v2"/><line x1="8" y1="5" x2="8" y2="14.5"/><line x1="1.5" y1="9" x2="14.5" y2="9"/></svg>,
users: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6" cy="5" r="2.5"/><path d="M1 14c0-2.76 2.24-5 5-5s5 2.24 5 5"/><circle cx="12.5" cy="5.5" r="2"/><path d="M12.5 10c1.93 0 3.5 1.57 3.5 3.5"/></svg>,
};
function NI({ icon }) {
return <span className="nav-icon">{icon}</span>;
}
function TeamNav({ onNav }) {
const location = useLocation();
const primaryLinks = [
{ to: '/dashboard', label: 'Dashboard' },
{ to: '/projects', label: 'Projects' },
{ to: '/requests', label: 'Requests' },
{ to: '/file-sharing', label: 'File Sharing' },
{ to: '/team/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
{ to: '/invoices', label: 'Finances', icon: ICONS.invoices },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
];
const utilityLinks = [
{ to: '/meeting-notes', label: 'Meeting Notes' },
{ to: '/invoices', label: 'Invoices & Expenses' },
{ to: '/survey-maker', label: 'Survey Maker' },
{ to: '/brand-book', label: 'Brand Book Maker' },
{ to: '/converters', label: 'Image Converter' },
{ to: '/fourge-passwords', label: 'Fourge Passwords' },
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
{ to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords },
];
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
@@ -26,51 +44,44 @@ function TeamNav({ onNav }) {
return (
<div className="sidebar-section">
{primaryLinks.map(({ to, label }) => (
{primaryLinks.map(({ to, label, icon }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label}
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
))}
<div style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
<div style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
Team Tools
<div className="sidebar-tools-divider" style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
<div className="sidebar-tools-label" style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 400, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
Tools
</div>
{utilityLinks.map(({ to, label }) => (
{utilityLinks.map(({ to, label, icon }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label}
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
))}
<NavLink to="/company" onClick={onNav} className={() => `sidebar-link${isCompaniesActive ? ' active' : ''}`}>
Companies
<NI icon={ICONS.companies} /><span className="nav-label">Companies</span>
</NavLink>
<NavLink to="/company?tab=users" onClick={onNav} className={() => `sidebar-link${isUsersActive ? ' active' : ''}`}>
Users
<NI icon={ICONS.users} /><span className="nav-label">Users</span>
</NavLink>
</div>
);
}
function ClientNav({ onNav }) {
const { currentUser } = useAuth();
const companies = currentUser?.companies?.length
? currentUser.companies
: currentUser?.company ? [currentUser.company] : [];
const companyTo = companies.length === 1 ? `/company/${companies[0].id}` : '/company';
const links = [
{ to: '/dashboard', label: 'Dashboard' },
{ to: '/projects', label: 'Projects' },
{ to: '/requests', label: 'Requests' },
{ to: '/file-sharing', label: 'File Sharing' },
{ to: '/my-invoices', label: 'Invoices' },
{ to: companyTo, label: 'Company' },
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
{ to: '/my-invoices', label: 'Invoices', icon: ICONS.invoices },
];
return (
<div className="sidebar-section">
{links.map(({ to, label }) => (
{links.map(({ to, label, icon }) => (
<NavLink key={label} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label}
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
))}
</div>
@@ -79,30 +90,30 @@ function ClientNav({ onNav }) {
function ExternalNav({ onNav }) {
const links = [
{ to: '/dashboard', label: 'Dashboard' },
{ to: '/requests', label: 'Requests' },
{ to: '/projects', label: 'Projects' },
{ to: '/my-invoices-sub', label: 'Invoices' },
{ to: '/file-sharing', label: 'File Sharing' },
{ to: '/survey-maker', label: 'Survey Maker' },
{ to: '/brand-book', label: 'Brand Book Maker' },
{ to: '/converters', label: 'Image Converter' },
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
{ to: '/my-invoices-sub', label: 'Invoices', icon: ICONS.invoices },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
];
return (
<div className="sidebar-section">
{links.map(({ to, label }, index) => (
{links.map(({ to, label, icon }, index) => (
<div key={to}>
{index === 4 && (
<>
<div style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
<div style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
Team Tools
<div className="sidebar-tools-divider" style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
<div className="sidebar-tools-label" style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 400, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
Tools
</div>
</>
)}
<NavLink to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label}
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
</div>
))}
@@ -116,7 +127,22 @@ export default function Layout({ children }) {
const location = useLocation();
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark');
const [menuOpen, setMenuOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem('sidebarCollapsed') === 'true');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState({ projects: [], tasks: [] });
const [searchOpen, setSearchOpen] = useState(false);
const [searchExpanded, setSearchExpanded] = useState(false);
const searchInputRef = useRef(null);
const [avatarOpen, setAvatarOpen] = useState(false);
const avatarRef = useRef(null);
const hour = new Date().getHours();
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
const firstName = currentUser?.name?.split(' ')[0] || '';
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
const headerTitle = isProfileRoute ? 'Profile' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`;
const headerSubtitle = isProfileRoute
? 'Account details and security settings.'
: "Here's what's happening today.";
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
@@ -124,81 +150,146 @@ export default function Layout({ children }) {
}, [theme]);
useEffect(() => {
localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed));
}, [sidebarCollapsed]);
if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [] }); return; }
const t = setTimeout(async () => {
const { supabase } = await import('../lib/supabase');
const [{ data: projects }, { data: tasks }] = await Promise.all([
supabase.from('projects').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
supabase.from('tasks').select('id, title').ilike('title', `%${searchQuery}%`).limit(6),
]);
setSearchResults({ projects: projects || [], tasks: tasks || [] });
}, 280);
return () => clearTimeout(t);
}, [searchQuery]);
useEffect(() => {
if (!avatarOpen) return;
function handler(e) {
if (avatarRef.current && !avatarRef.current.contains(e.target)) setAvatarOpen(false);
}
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [avatarOpen]);
// Close menu on route change (derived-state pattern, no effect needed)
const [lastPathname, setLastPathname] = useState(location.pathname);
if (lastPathname !== location.pathname) {
setLastPathname(location.pathname);
setMenuOpen(false);
setAvatarOpen(false);
}
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
const handleLogout = async () => {
await logout();
navigate('/');
};
const handleLogout = async () => { await logout(); navigate('/'); };
const initials = currentUser?.name
?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
const hasResults = searchResults.projects.length > 0 || searchResults.tasks.length > 0;
return (
<div className={`app-layout${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
{/* Overlay */}
<div className="app-layout">
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
<div className="sidebar-logo">
<img className="brand-logo brand-logo-sidebar" src="/fourge-logo.png" alt="Fourge Branding" />
<button
className="sidebar-pin-toggle"
onClick={() => setSidebarCollapsed(current => !current)}
title={sidebarCollapsed ? 'Pin sidebar open' : 'Collapse sidebar'}
aria-label={sidebarCollapsed ? 'Pin sidebar open' : 'Collapse sidebar'}
>
{sidebarCollapsed ? '' : ''}
</button>
<img className="brand-logo brand-logo-sidebar" src="/logonowordmark.png" alt="Fourge Branding" />
</div>
{!sidebarCollapsed && (
currentUser?.role === 'team'
? <TeamNav onNav={() => setMenuOpen(false)} />
: currentUser?.role === 'external'
? <ExternalNav onNav={() => setMenuOpen(false)} />
: <ClientNav onNav={() => setMenuOpen(false)} />
)}
{currentUser?.role === 'team'
? <TeamNav onNav={() => setMenuOpen(false)} />
: currentUser?.role === 'external'
? <ExternalNav onNav={() => setMenuOpen(false)} />
: <ClientNav onNav={() => setMenuOpen(false)} />
}
<div className="sidebar-bottom">
<NavLink to="/settings" onClick={() => setMenuOpen(false)} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<div className="sidebar-avatar" style={{ width: 28, height: 28, fontSize: 11, flexShrink: 0 }}>{initials}</div>
<div className="sidebar-user-info">
<div className="sidebar-user-name">{currentUser?.name || 'Set your name'}</div>
<div className="sidebar-user-role">{currentUser?.role === 'external' ? 'Team' : currentUser?.role}</div>
</div>
</NavLink>
<div className="sidebar-bottom-actions">
{!sidebarCollapsed && <button className="sidebar-link" style={{ flex: 1 }} onClick={handleLogout}>Sign Out</button>}
<button
onClick={toggleTheme}
className="sidebar-theme-toggle"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? '☀' : '☾'}
</button>
</div>
</div>
</aside>
<div className="main-wrapper">
{/* Mobile top bar inside main wrapper so it sits at the top */}
<div className="mobile-topbar">
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
<span /><span /><span />
</button>
<img className="brand-logo brand-logo-mobile" src="/fourge-logo.png" alt="Fourge Branding" />
</div>
<main className="main-content">
<div className="site-header">
<div>
<div className="site-header-greeting">{headerTitle}</div>
<div className="site-header-sub">{headerSubtitle}</div>
</div>
<div className="site-header-right">
<div className="site-header-search-wrap">
{!searchExpanded ? (
<button
className="site-header-search-btn"
onClick={() => { setSearchExpanded(true); setTimeout(() => searchInputRef.current?.focus(), 50); }}
aria-label="Search"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6.5" cy="6.5" r="4"/><line x1="10" y1="10" x2="14" y2="14"/></svg>
</button>
) : (
<>
<input
ref={searchInputRef}
className="site-header-search"
type="text"
placeholder="Search projects & tasks..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onFocus={() => setSearchOpen(true)}
onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); }, 150)}
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); } }}
/>
</>
)}
{searchOpen && searchQuery.trim() && (
<div className="site-header-dropdown">
{!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>}
{searchResults.projects.length > 0 && (
<>
<div className="site-header-dropdown-group">Projects</div>
{searchResults.projects.map(p => (
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span>
{p.name}
</div>
))}
</>
)}
{searchResults.tasks.length > 0 && (
<>
<div className="site-header-dropdown-group">Tasks</div>
{searchResults.tasks.map(r => (
<div key={r.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/requests/${r.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.requests}</span>
{r.title}
</div>
))}
</>
)}
</div>
)}
</div>
<button onClick={toggleTheme} className="site-header-theme-toggle" title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
{theme === 'dark'
? <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="2.5"/><line x1="8" y1="13.5" x2="8" y2="15"/><line x1="1" y1="8" x2="2.5" y2="8"/><line x1="13.5" y1="8" x2="15" y2="8"/><line x1="3" y1="3" x2="4.1" y2="4.1"/><line x1="11.9" y1="11.9" x2="13" y2="13"/><line x1="13" y1="3" x2="11.9" y2="4.1"/><line x1="4.1" y1="11.9" x2="3" y2="13"/></svg>
: <svg viewBox="0 0 16 16" fill="currentColor" stroke="none"><path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z"/></svg>
}
</button>
<div className="site-header-avatar-wrap" ref={avatarRef}>
<button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" fill="currentColor"/></svg>
</button>
{avatarOpen && (
<div className="site-header-avatar-menu">
<button className="site-header-avatar-item" onClick={() => { navigate('/profile'); setAvatarOpen(false); }}>Settings</button>
<div className="site-header-avatar-divider" />
<button className="site-header-avatar-item" onClick={handleLogout}>Sign Out</button>
</div>
)}
</div>
</div>
</div>
{children}
</main>
</div>