Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom' | |
| import { useState, Fragment } from 'react' | |
| import { Menu, Transition } from '@headlessui/react' | |
| import { | |
| HomeIcon, | |
| MapIcon, | |
| DocumentTextIcon, | |
| BellAlertIcon, | |
| BuildingLibraryIcon, | |
| Cog6ToothIcon, | |
| ChartBarIcon, | |
| MagnifyingGlassIcon, | |
| BookOpenIcon, | |
| UserGroupIcon, | |
| AcademicCapIcon, | |
| Bars3Icon, | |
| XMarkIcon, | |
| UserCircleIcon, | |
| ArrowRightOnRectangleIcon, | |
| ChevronDownIcon, | |
| MapPinIcon, | |
| HeartIcon, | |
| CodeBracketIcon, | |
| XCircleIcon, | |
| } from '@heroicons/react/24/outline' | |
| import { useAuth } from '../contexts/AuthContext' | |
| import { useLocation as useLocationContext } from '../contexts/LocationContext' | |
| const navigation = [ | |
| { name: 'Home', href: '/', icon: HomeIcon }, | |
| { name: 'Explore Data', href: '/explore', icon: MagnifyingGlassIcon }, | |
| { name: 'Search', href: '/search', icon: MagnifyingGlassIcon }, | |
| { name: 'Jurisdictions', href: '/jurisdictions', icon: MapPinIcon }, | |
| { | |
| section: 'Families & Individuals', | |
| items: [ | |
| { name: 'Community Events', href: '/events', icon: BookOpenIcon }, | |
| { name: 'Services & Resources', href: '/services', icon: HeartIcon }, | |
| ] | |
| }, | |
| { | |
| section: 'Policy & Government', | |
| items: [ | |
| { name: 'Policy Decisions', href: '/documents', icon: DocumentTextIcon }, | |
| { name: 'Budget Analysis', href: '/analytics', icon: ChartBarIcon }, | |
| { name: 'Elected Officials', href: '/people', icon: UserGroupIcon }, | |
| { name: 'Policy Map', href: '/policy-map', icon: MapIcon }, | |
| ] | |
| }, | |
| { | |
| section: 'Community & Advocacy', | |
| items: [ | |
| { name: 'Nonprofits', href: '/nonprofits', icon: BuildingLibraryIcon }, | |
| { name: 'Advocacy Topics', href: '/advocacy-topics', icon: BellAlertIcon }, | |
| { name: 'Fact-Checking', href: '/fact-checking', icon: AcademicCapIcon }, | |
| ] | |
| }, | |
| { | |
| section: 'Developers', | |
| items: [ | |
| { name: 'Open Source', href: '/opensource', icon: CodeBracketIcon }, | |
| { name: 'Hackathons', href: '/hackathons', icon: AcademicCapIcon }, | |
| ] | |
| }, | |
| { name: 'Settings', href: '/settings', icon: Cog6ToothIcon }, | |
| ] | |
| export default function Layout() { | |
| const location = useLocation() | |
| const navigate = useNavigate() | |
| const [searchQuery, setSearchQuery] = useState('') | |
| const [mobileMenuOpen, setMobileMenuOpen] = useState(false) | |
| const [showLoginMenu, setShowLoginMenu] = useState(false) | |
| const { user, isAuthenticated, login, logout, isLoading, authError, clearAuthError } = useAuth() | |
| const { location: userLocation, hasLocation } = useLocationContext() | |
| // Environment-aware URLs | |
| const docsUrl = import.meta.env.PROD ? 'https://www.communityone.com/docs/intro' : 'http://localhost:3000/docs/intro' | |
| const apiDocsUrl = import.meta.env.PROD ? 'https://www.communityone.com/api/docs' : 'http://localhost:8000/docs' | |
| const handleSearch = (e: React.FormEvent) => { | |
| e.preventDefault() | |
| if (searchQuery.trim()) { | |
| navigate(`/search?q=${encodeURIComponent(searchQuery)}`) | |
| } | |
| } | |
| return ( | |
| <div className="min-h-screen" style={{ backgroundColor: '#F1F5F9' }}> | |
| {/* Top Header Bar */} | |
| <div className="fixed top-0 left-0 right-0 bg-white border-b border-gray-200 z-50"> | |
| <div className="flex items-center justify-between px-4 md:px-6 py-3"> | |
| <div className="flex items-center gap-3"> | |
| {/* Mobile menu button */} | |
| <button | |
| onClick={() => setMobileMenuOpen(!mobileMenuOpen)} | |
| className="md:hidden p-2 rounded-lg hover:bg-gray-100 text-gray-700" | |
| aria-label="Toggle menu" | |
| > | |
| {mobileMenuOpen ? ( | |
| <XMarkIcon className="h-6 w-6" /> | |
| ) : ( | |
| <Bars3Icon className="h-6 w-6" /> | |
| )} | |
| </button> | |
| <Link to="/" className="flex items-center gap-2 md:gap-3"> | |
| <img | |
| src="/communityone_logo.svg" | |
| alt="CommunityOne Logo" | |
| className="h-10 md:h-12" | |
| /> | |
| <h1 className="text-lg md:text-2xl font-bold" style={{ color: '#354F52' }}> | |
| Open Navigator | |
| </h1> | |
| </Link> | |
| </div> | |
| {/* Global Search - Hidden on home page and mobile */} | |
| {location.pathname !== '/' && ( | |
| <form onSubmit={handleSearch} className="hidden md:flex flex-1 max-w-2xl mx-8"> | |
| <div className="relative w-full"> | |
| <input | |
| type="text" | |
| placeholder="Search people, meetings, organizations, causes..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="w-full px-4 py-2 pl-10 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" | |
| /> | |
| <MagnifyingGlassIcon className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" /> | |
| </div> | |
| </form> | |
| )} | |
| {/* Header Actions */} | |
| <div className="flex items-center gap-2 md:gap-4"> | |
| {/* Location Banner - Compact */} | |
| {hasLocation && userLocation && ( | |
| <div className="hidden lg:flex items-center gap-2 px-3 py-1.5 bg-primary-50 border border-primary-200 rounded-lg"> | |
| <MapPinIcon className="h-4 w-4 text-primary-600 flex-shrink-0" /> | |
| <div className="text-xs"> | |
| <div className="font-semibold text-gray-900"> | |
| {userLocation.city}, {userLocation.state} | |
| </div> | |
| {userLocation.county && ( | |
| <div className="text-gray-700">{userLocation.county}</div> | |
| )} | |
| </div> | |
| <button | |
| onClick={() => navigate('/?tab=community')} | |
| className="text-xs text-primary-600 hover:text-primary-700 font-medium underline ml-2 flex-shrink-0" | |
| > | |
| Change | |
| </button> | |
| </div> | |
| )} | |
| {/* Authentication */} | |
| {isLoading ? ( | |
| <div className="px-3 py-2"> | |
| <div className="animate-spin h-8 w-8 border-3 border-gray-300 border-t-primary-600 rounded-full"></div> | |
| </div> | |
| ) : isAuthenticated && user ? ( | |
| <Menu as="div" className="relative"> | |
| <Menu.Button className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors"> | |
| {user.avatar_url ? ( | |
| <img | |
| src={user.avatar_url} | |
| alt={user.full_name || user.email} | |
| className="h-9 w-9 flex-shrink-0 rounded-full border-2 border-primary-500 shadow-sm object-cover" | |
| onError={(e) => { | |
| // If image fails to load, hide it and show fallback | |
| e.currentTarget.style.display = 'none'; | |
| const fallback = e.currentTarget.nextElementSibling as HTMLElement | null; | |
| if (fallback) fallback.style.display = 'flex'; | |
| }} | |
| /> | |
| ) : null} | |
| <div | |
| className="h-9 w-9 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white font-bold text-sm shadow-sm" | |
| style={{ display: user.avatar_url ? 'none' : 'flex' }} | |
| > | |
| {(user.full_name || user.username || user.email).charAt(0).toUpperCase()} | |
| </div> | |
| <span className="hidden md:inline text-sm font-medium text-gray-700"> | |
| {user.full_name || user.username || user.email.split('@')[0]} | |
| </span> | |
| <ChevronDownIcon className="hidden md:block h-4 w-4 text-gray-600" /> | |
| </Menu.Button> | |
| <Transition | |
| as={Fragment} | |
| enter="transition ease-out duration-100" | |
| enterFrom="transform opacity-0 scale-95" | |
| enterTo="transform opacity-100 scale-100" | |
| leave="transition ease-in duration-75" | |
| leaveFrom="transform opacity-100 scale-100" | |
| leaveTo="transform opacity-0 scale-95" | |
| > | |
| <Menu.Items className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 focus:outline-none z-50"> | |
| <div className="px-4 py-3 border-b border-gray-200"> | |
| <div className="flex items-center gap-3 mb-2"> | |
| {user.avatar_url ? ( | |
| <img | |
| src={user.avatar_url} | |
| alt={user.full_name || user.email} | |
| className="h-12 w-12 rounded-full border-2 border-primary-500" | |
| onError={(e) => { | |
| // If image fails to load, hide it and show fallback | |
| e.currentTarget.style.display = 'none'; | |
| const fallback = e.currentTarget.nextElementSibling as HTMLElement | null; | |
| if (fallback) fallback.style.display = 'flex'; | |
| }} | |
| /> | |
| ) : null} | |
| <div | |
| className="h-12 w-12 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white font-bold text-lg" | |
| style={{ display: user.avatar_url ? 'none' : 'flex' }} | |
| > | |
| {(user.full_name || user.username || user.email).charAt(0).toUpperCase()} | |
| </div> | |
| <div> | |
| <p className="text-sm font-semibold text-gray-900"> | |
| {user.full_name || user.username || user.email.split('@')[0]} | |
| </p> | |
| <p className="text-xs text-gray-500 truncate"> | |
| {user.email} | |
| </p> | |
| </div> | |
| </div> | |
| {user.oauth_provider && ( | |
| <div className="flex items-center gap-1 text-xs text-gray-400"> | |
| <span>Signed in via</span> | |
| <span className="font-medium capitalize">{user.oauth_provider}</span> | |
| </div> | |
| )} | |
| </div> | |
| <div className="py-1"> | |
| <Menu.Item> | |
| {({ active }) => ( | |
| <button | |
| onClick={() => navigate('/profile')} | |
| className={`${ | |
| active ? 'bg-gray-50' : '' | |
| } flex items-center gap-3 w-full px-4 py-2.5 text-sm text-gray-700 hover:text-gray-900`} | |
| > | |
| <UserCircleIcon className="h-5 w-5" /> | |
| <span>My Profile</span> | |
| </button> | |
| )} | |
| </Menu.Item> | |
| <Menu.Item> | |
| {({ active }) => ( | |
| <button | |
| onClick={() => navigate('/settings')} | |
| className={`${ | |
| active ? 'bg-gray-50' : '' | |
| } flex items-center gap-3 w-full px-4 py-2.5 text-sm text-gray-700 hover:text-gray-900`} | |
| > | |
| <Cog6ToothIcon className="h-5 w-5" /> | |
| <span>Settings</span> | |
| </button> | |
| )} | |
| </Menu.Item> | |
| <Menu.Item> | |
| {({ active }) => ( | |
| <button | |
| onClick={logout} | |
| className={`${ | |
| active ? 'bg-red-50' : '' | |
| } flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-600 hover:text-red-700 border-t border-gray-100 mt-1`} | |
| > | |
| <ArrowRightOnRectangleIcon className="h-5 w-5" /> | |
| <span className="font-medium">Sign out</span> | |
| </button> | |
| )} | |
| </Menu.Item> | |
| </div> | |
| </Menu.Items> | |
| </Transition> | |
| </Menu> | |
| ) : ( | |
| <div className="relative"> | |
| <button | |
| onClick={() => setShowLoginMenu(!showLoginMenu)} | |
| className="px-3 md:px-4 py-2 text-white rounded-lg transition-colors text-sm md:text-base font-medium flex items-center gap-2" | |
| style={{ backgroundColor: '#354F52' }} | |
| onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#2e4346'} | |
| onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#354F52'} | |
| > | |
| <UserCircleIcon className="h-5 w-5" /> | |
| <span className="hidden md:inline">Register</span> | |
| <ChevronDownIcon className="h-4 w-4" /> | |
| </button> | |
| {showLoginMenu && ( | |
| <div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50"> | |
| <div className="px-4 py-2 border-b border-gray-200"> | |
| <p className="text-sm font-medium text-gray-900">Sign in with:</p> | |
| </div> | |
| <button | |
| onClick={() => { login('google'); setShowLoginMenu(false); }} | |
| className="flex items-center gap-3 w-full px-4 py-3 hover:bg-gray-100 transition-colors" | |
| > | |
| <div className="w-6 h-6 flex items-center justify-center flex-shrink-0"> | |
| <svg viewBox="0 0 24 24" className="w-5 h-5" preserveAspectRatio="xMidYMid meet"> | |
| <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/> | |
| <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/> | |
| <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/> | |
| <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/> | |
| </svg> | |
| </div> | |
| <span className="text-sm font-medium text-gray-700">Google</span> | |
| </button> | |
| <button | |
| onClick={() => { login('facebook'); setShowLoginMenu(false); }} | |
| className="flex items-center gap-3 w-full px-4 py-3 hover:bg-gray-100 transition-colors" | |
| > | |
| <div className="w-6 h-6 flex items-center justify-center"> | |
| <svg viewBox="0 0 24 24" className="w-5 h-5" fill="#1877F2"> | |
| <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/> | |
| </svg> | |
| </div> | |
| <span className="text-sm font-medium text-gray-700">Facebook</span> | |
| </button> | |
| <button | |
| onClick={() => { login('github'); setShowLoginMenu(false); }} | |
| className="flex items-center gap-3 w-full px-4 py-3 hover:bg-gray-100 transition-colors" | |
| > | |
| <div className="w-6 h-6 flex items-center justify-center"> | |
| <svg viewBox="0 0 24 24" className="w-5 h-5" fill="#181717"> | |
| <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/> | |
| </svg> | |
| </div> | |
| <span className="text-sm font-medium text-gray-700">GitHub</span> | |
| </button> | |
| <div className="border-t border-gray-100 my-1"></div> | |
| <button | |
| onClick={() => { login('huggingface'); setShowLoginMenu(false); }} | |
| className="flex items-center gap-3 w-full px-4 py-3 hover:bg-gray-100 transition-colors" | |
| > | |
| <div className="w-6 h-6 flex items-center justify-center"> | |
| <span className="text-2xl">🤗</span> | |
| </div> | |
| <span className="text-sm font-medium text-gray-700">HuggingFace</span> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <a | |
| href={docsUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="flex items-center gap-1 md:gap-2 px-2 md:px-4 py-2 text-gray-700 hover:text-primary-600 transition-colors" | |
| > | |
| <BookOpenIcon className="h-5 w-5" /> | |
| <span className="hidden md:inline font-medium">Docs</span> | |
| </a> | |
| <a | |
| href={apiDocsUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="px-2 md:px-4 py-2 text-white rounded-lg transition-colors text-sm md:text-base" | |
| style={{ backgroundColor: '#354F52' }} | |
| onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#2e4346'} | |
| onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#354F52'} | |
| > | |
| API | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Sidebar */} | |
| <div className={` | |
| fixed top-16 inset-y-0 left-0 w-64 bg-white border-r border-gray-200 z-40 | |
| transform transition-transform duration-200 ease-in-out | |
| ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} | |
| `}> | |
| <nav className="mt-6 px-4 overflow-y-auto h-[calc(100vh-10rem)]"> | |
| {navigation.map((item, index) => { | |
| // Handle section headers with nested items | |
| if ('section' in item && item.section && item.items) { | |
| return ( | |
| <div key={index} className="mb-6"> | |
| <div className="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider"> | |
| {item.section} | |
| </div> | |
| {item.items.map((subItem) => { | |
| const isActive = location.pathname === subItem.href | |
| const isExternal = 'external' in subItem && subItem.external | |
| const linkClasses = ` | |
| flex items-center gap-3 px-4 py-3 mb-1 rounded-lg transition-colors | |
| ${ | |
| isActive | |
| ? 'bg-primary-50 text-primary-700 font-medium' | |
| : 'text-gray-700 hover:bg-gray-100' | |
| } | |
| ` | |
| if (isExternal) { | |
| return ( | |
| <a | |
| key={subItem.name} | |
| href={subItem.href} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className={linkClasses} | |
| > | |
| <subItem.icon className="h-5 w-5" /> | |
| <span className="text-sm">{subItem.name}</span> | |
| </a> | |
| ) | |
| } | |
| return ( | |
| <Link | |
| key={subItem.name} | |
| to={subItem.href} | |
| onClick={() => setMobileMenuOpen(false)} | |
| className={linkClasses} | |
| > | |
| <subItem.icon className="h-5 w-5" /> | |
| <span className="text-sm">{subItem.name}</span> | |
| </Link> | |
| ) | |
| })} | |
| </div> | |
| ) | |
| } | |
| // Handle regular navigation items | |
| if ('href' in item && item.href) { | |
| const isActive = location.pathname === item.href | |
| return ( | |
| <Link | |
| key={item.name} | |
| to={item.href} | |
| onClick={() => setMobileMenuOpen(false)} | |
| className={` | |
| flex items-center gap-3 px-4 py-3 mb-2 rounded-lg transition-colors | |
| ${ | |
| isActive | |
| ? 'bg-primary-50 text-primary-700 font-medium' | |
| : 'text-gray-700 hover:bg-gray-100' | |
| } | |
| `} | |
| > | |
| <item.icon className="h-6 w-6" /> | |
| <span>{item.name}</span> | |
| </Link> | |
| ) | |
| } | |
| return null | |
| })} | |
| </nav> | |
| {/* Sidebar Footer */} | |
| <div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200 bg-white"> | |
| <div className="text-sm text-gray-600"> | |
| <div className="font-medium mb-1">Open Data Sources</div> | |
| <div className="text-xs"> | |
| • <Link to="/jurisdictions" className="hover:text-primary-600 hover:underline">925 Jurisdictions</Link><br /> | |
| • <Link to="/search?types=organizations" className="hover:text-primary-600 hover:underline">43,726 Nonprofits</Link><br /> | |
| • 6,913 Meeting Pages<br /> | |
| • <Link to="/search?types=contacts" className="hover:text-primary-600 hover:underline">362 Officials</Link> | |
| </div> | |
| <div className="mt-3 pt-3 border-t border-gray-100"> | |
| <Link | |
| to="/#contact" | |
| className="text-xs text-primary-600 hover:text-primary-700 hover:underline font-medium" | |
| > | |
| 📍 Request Jurisdiction Coverage | |
| </Link> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Mobile menu overlay */} | |
| {mobileMenuOpen && ( | |
| <div | |
| className="fixed inset-0 bg-black bg-opacity-50 z-30 md:hidden" | |
| onClick={() => setMobileMenuOpen(false)} | |
| /> | |
| )} | |
| {/* Main content */} | |
| <div className="md:pl-64 pt-16"> | |
| {/* Auth Error Banner - Mobile Friendly */} | |
| {authError && ( | |
| <div className="bg-red-50 border-l-4 border-red-500 p-4 m-4"> | |
| <div className="flex items-start"> | |
| <div className="flex-shrink-0"> | |
| <XCircleIcon className="h-5 w-5 text-red-500" aria-hidden="true" /> | |
| </div> | |
| <div className="ml-3 flex-1"> | |
| <p className="text-sm font-medium text-red-800"> | |
| Login failed | |
| </p> | |
| <p className="mt-1 text-sm text-red-700"> | |
| {authError} | |
| </p> | |
| </div> | |
| <div className="ml-auto pl-3"> | |
| <button | |
| onClick={clearAuthError} | |
| className="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" | |
| > | |
| <span className="sr-only">Dismiss</span> | |
| <XMarkIcon className="h-5 w-5" aria-hidden="true" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <main> | |
| <Outlet /> | |
| </main> | |
| </div> | |
| </div> | |
| ) | |
| } | |