Files
fourge-portal/src/components/Layout.jsx
T
Krao Hasanee 5b3060e190 Session 2026-05-31: tasks table overhaul, FileBrowser redesign, folder path fix
- Tasks table: added R#, Assigned To (avatar), Priority (HOT/NO) columns; removed Status column; sortable columns; adjustable widths
- FileBrowser: full visual rewrite to match FileSharing page (table layout, colored ext badges, SVG folder icon, sortable columns, multi-select, context menu, drag-drop)
- TaskDetail folder tab: fix path with safeName(), rootPath set to project folder level
- filebrowserFolders: export safeName for reuse

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:02:40 -04:00

357 lines
20 KiB
React

import { useState, useEffect, useRef } from 'react';
import PageLoader from './PageLoader';
import { NavLink, Link, 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', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/invoices', label: 'Finances', icon: ICONS.invoices },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
];
const utilityLinks = [
{ 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');
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
return (
<div className="sidebar-section">
{primaryLinks.map(({ to, label, icon }) => (
<NavLink key={to} to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
))}
<div className="sidebar-tools-divider" />
<div className="sidebar-tools-label">Tools</div>
{utilityLinks.map(({ to, label, icon }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
))}
<NavLink to="/company" onClick={onNav} className={() => `sidebar-link${isCompaniesActive ? ' active' : ''}`}>
<NI icon={ICONS.companies} /><span className="nav-label">Companies</span>
</NavLink>
<NavLink to="/company?tab=users" onClick={onNav} className={() => `sidebar-link${isUsersActive ? ' active' : ''}`}>
<NI icon={ICONS.users} /><span className="nav-label">Users</span>
</NavLink>
</div>
);
}
function ClientNav({ onNav }) {
const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ 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, icon }) => (
<NavLink key={label} to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
))}
</div>
);
}
function ExternalNav({ onNav }) {
const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ 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, icon }, index) => (
<div key={to}>
{index === 4 && (
<>
<div className="sidebar-tools-divider" />
<div className="sidebar-tools-label">Tools</div>
</>
)}
<NavLink to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
</div>
))}
</div>
);
}
function getInitialTheme() {
if (typeof document !== 'undefined') {
return document.documentElement.getAttribute('data-theme') || localStorage.getItem('theme') || 'dark';
}
return 'dark';
}
export default function Layout({ children }) {
const { currentUser, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [theme, setTheme] = useState(getInitialTheme);
const [menuOpen, setMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState({ projects: [], tasks: [], companies: [], people: [] });
const [searchOpen, setSearchOpen] = 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 isFileSharingRoute = location.pathname === '/file-sharing';
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks' || isTaskDetailRoute;
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
const headerSubtitle = isProfileRoute
? 'Account details and security settings.'
: isFileSharingRoute
? 'Browse, share and manage files.'
: isRequestsRoute && !isTaskDetailRoute
? 'Browse and manage all tasks and projects.'
: isTaskDetailRoute ? null : "Here's what's happening today.";
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
}, [theme]);
useEffect(() => {
if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [], companies: [], people: [] }); return; }
const t = setTimeout(async () => {
const { supabase } = await import('../lib/supabase');
const [{ data: projects }, { data: tasks }, { data: companies }, { data: people }] = 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),
supabase.from('companies').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
supabase.from('profiles').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
]);
setSearchResults({ projects: projects || [], tasks: tasks || [], companies: companies || [], people: people || [] });
}, 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]);
const [navigating, setNavigating] = useState(false);
const navTimerRef = useRef(null);
const [lastPathname, setLastPathname] = useState(location.pathname);
if (lastPathname !== location.pathname) {
setLastPathname(location.pathname);
setMenuOpen(false);
setAvatarOpen(false);
}
useEffect(() => {
setNavigating(true);
clearTimeout(navTimerRef.current);
navTimerRef.current = setTimeout(() => setNavigating(false), 500);
return () => clearTimeout(navTimerRef.current);
}, [location.pathname]);
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
const handleLogout = async () => { await logout(); navigate('/'); };
const hasResults =
searchResults.projects.length > 0 ||
searchResults.tasks.length > 0 ||
searchResults.companies.length > 0 ||
searchResults.people.length > 0;
return (
<div className="app-layout">
{navigating && <PageLoader />}
{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="/logonowordmark.png" alt="Fourge Branding" />
</div>
{currentUser?.role === 'team'
? <TeamNav onNav={() => setMenuOpen(false)} />
: currentUser?.role === 'external'
? <ExternalNav onNav={() => setMenuOpen(false)} />
: <ClientNav onNav={() => setMenuOpen(false)} />
}
</aside>
<div className="main-wrapper">
<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>
{isTaskDetailRoute ? (
<div>
<Link to="/tasks" className="dashboard-inline-link" style={{ display: 'inline-flex', alignItems: 'center', gap: 1, color: 'var(--text-muted)', textDecoration: 'none' }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 24, height: 24, flexShrink: 0 }}><polyline points="10 4 6 8 10 12"/></svg>
<span style={{ fontSize: 24, fontWeight: 500, lineHeight: 1.2, letterSpacing: '-0.3px', position: 'relative', top: 2 }}>Tasks &amp; Projects</span>
</Link>
<div className="site-header-sub" style={{ paddingLeft: 25 }}>Return back to previous page</div>
</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">
<span className="site-header-search-icon" aria-hidden="true">
<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>
</span>
<input
ref={searchInputRef}
className="site-header-search"
type="text"
placeholder="Search"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onFocus={() => setSearchOpen(true)}
onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchQuery(''); }, 150)}
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(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); }}>
<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(`/tasks/${r.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.requests}</span>
{r.title}
</div>
))}
</>
)}
{searchResults.companies.length > 0 && (
<>
<div className="site-header-dropdown-group">Companies</div>
{searchResults.companies.map(c => (
<div key={c.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/company/${c.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.companies}</span>
{c.name}
</div>
))}
</>
)}
{searchResults.people.length > 0 && (
<>
<div className="site-header-dropdown-group">People</div>
{searchResults.people.map(p => (
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/profile/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.users}</span>
{p.name || 'Unknown User'}
</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" style={{ overflow: 'hidden' }}>
{currentUser?.avatar_url
? <img src={currentUser.avatar_url} alt={currentUser.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
: <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); }}>Profile</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>
</div>
);
}