Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
Replaced ~100+ hardcoded French and English text strings across 30+ components with proper i18n t() calls. Added 57 new translation keys to all 15 locale files (ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh). Key changes: - contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders) - ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history) - note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist) - lab-skeleton.tsx: French loading text → t() - admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed - New AI chat component, agent cards, sidebar, settings panel i18n cleanup Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
436 lines
16 KiB
TypeScript
436 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetTrigger,
|
|
} from '@/components/ui/sheet'
|
|
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, Settings, LogOut, User, Shield, Coffee, MessageSquare, FlaskConical, Bot } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
import { cn } from '@/lib/utils'
|
|
import { useLabels } from '@/context/LabelContext'
|
|
import { LabelFilter } from './label-filter'
|
|
import { NotificationPanel } from './notification-panel'
|
|
import { updateTheme } from '@/app/actions/profile'
|
|
import { useDebounce } from '@/hooks/use-debounce'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
import { useSession, signOut } from 'next-auth/react'
|
|
|
|
interface HeaderProps {
|
|
selectedLabels?: string[]
|
|
selectedColor?: string | null
|
|
onLabelFilterChange?: (labels: string[]) => void
|
|
onColorFilterChange?: (color: string | null) => void
|
|
user?: any
|
|
}
|
|
|
|
export function Header({
|
|
selectedLabels = [],
|
|
selectedColor = null,
|
|
onLabelFilterChange,
|
|
onColorFilterChange,
|
|
user
|
|
}: HeaderProps = {}) {
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
|
const [isSemanticSearching, setIsSemanticSearching] = useState(false)
|
|
const pathname = usePathname()
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const { labels, setNotebookId } = useLabels()
|
|
const { t } = useLanguage()
|
|
const { data: session } = useSession()
|
|
|
|
const noSidebarMode = ['/agents', '/chat', '/lab'].some(r => pathname.startsWith(r))
|
|
|
|
|
|
// Track last pushed search to avoid infinite loops
|
|
const lastPushedSearch = useRef<string | null>(null)
|
|
|
|
const currentLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
|
const currentSearch = searchParams.get('search') || ''
|
|
const currentColor = searchParams.get('color') || ''
|
|
|
|
const currentUser = user || session?.user
|
|
|
|
// Initialize search query from URL ONLY on mount
|
|
useEffect(() => {
|
|
setSearchQuery(currentSearch)
|
|
lastPushedSearch.current = currentSearch
|
|
}, []) // Run only once on mount
|
|
|
|
// Sync LabelContext notebookId with URL notebook parameter
|
|
const currentNotebook = searchParams.get('notebook')
|
|
useEffect(() => {
|
|
setNotebookId(currentNotebook || null)
|
|
}, [currentNotebook, setNotebookId])
|
|
|
|
// Prevent body scroll when mobile menu is open
|
|
useEffect(() => {
|
|
if (isSidebarOpen) {
|
|
document.body.style.overflow = 'hidden'
|
|
document.body.style.position = 'fixed'
|
|
document.body.style.width = '100%'
|
|
} else {
|
|
document.body.style.overflow = ''
|
|
document.body.style.position = ''
|
|
document.body.style.width = ''
|
|
}
|
|
return () => {
|
|
document.body.style.overflow = ''
|
|
document.body.style.position = ''
|
|
document.body.style.width = ''
|
|
}
|
|
}, [isSidebarOpen])
|
|
|
|
// Close mobile menu on Esc key press
|
|
useEffect(() => {
|
|
const handleEscapeKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && isSidebarOpen) {
|
|
setIsSidebarOpen(false)
|
|
}
|
|
}
|
|
|
|
if (isSidebarOpen) {
|
|
document.addEventListener('keydown', handleEscapeKey)
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', handleEscapeKey)
|
|
}
|
|
}, [isSidebarOpen])
|
|
|
|
// Simple debounced search with URL update (150ms for more responsiveness)
|
|
const debouncedSearchQuery = useDebounce(searchQuery, 150)
|
|
|
|
useEffect(() => {
|
|
// Skip if search hasn't changed or if we already pushed this value
|
|
if (debouncedSearchQuery === lastPushedSearch.current) return
|
|
|
|
// Only trigger search navigation from the home page
|
|
if (pathname !== '/') {
|
|
lastPushedSearch.current = debouncedSearchQuery
|
|
return
|
|
}
|
|
|
|
// Build new params preserving other filters (notebook, labels, etc.)
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (debouncedSearchQuery.trim()) {
|
|
params.set('search', debouncedSearchQuery)
|
|
} else {
|
|
params.delete('search')
|
|
}
|
|
|
|
const newUrl = `/?${params.toString()}`
|
|
|
|
// Mark as pushed before calling router.push to prevent loops
|
|
lastPushedSearch.current = debouncedSearchQuery
|
|
router.push(newUrl)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [debouncedSearchQuery])
|
|
|
|
// Handle semantic search button click
|
|
const handleSemanticSearch = () => {
|
|
if (!searchQuery.trim()) return
|
|
|
|
// Add semantic flag to URL
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.set('search', searchQuery)
|
|
params.set('semantic', 'true')
|
|
router.push(`/?${params.toString()}`)
|
|
|
|
// Show loading state briefly
|
|
setIsSemanticSearching(true)
|
|
setTimeout(() => setIsSemanticSearching(false), 1500)
|
|
}
|
|
|
|
useEffect(() => {
|
|
// Use 'theme-preference' to match the unified theme system
|
|
const savedTheme = localStorage.getItem('theme-preference') || currentUser?.theme || 'light'
|
|
// Don't persist on initial load to avoid unnecessary DB calls
|
|
applyTheme(savedTheme, false)
|
|
}, [currentUser])
|
|
|
|
const applyTheme = async (newTheme: string, persist = true) => {
|
|
setTheme(newTheme as any)
|
|
localStorage.setItem('theme-preference', newTheme)
|
|
|
|
// Remove all theme classes first
|
|
document.documentElement.classList.remove('dark')
|
|
document.documentElement.removeAttribute('data-theme')
|
|
|
|
if (newTheme === 'dark') {
|
|
document.documentElement.classList.add('dark')
|
|
} else if (newTheme !== 'light') {
|
|
document.documentElement.setAttribute('data-theme', newTheme)
|
|
if (newTheme === 'midnight') {
|
|
document.documentElement.classList.add('dark')
|
|
}
|
|
}
|
|
|
|
if (persist && currentUser) {
|
|
await updateTheme(newTheme)
|
|
}
|
|
}
|
|
|
|
const handleSearch = (query: string) => {
|
|
setSearchQuery(query)
|
|
// URL update is now handled by the debounced useEffect
|
|
}
|
|
|
|
const removeLabelFilter = (labelToRemove: string) => {
|
|
const newLabels = currentLabels.filter(l => l !== labelToRemove)
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (newLabels.length > 0) {
|
|
params.set('labels', newLabels.join(','))
|
|
} else {
|
|
params.delete('labels')
|
|
}
|
|
router.push(`/?${params.toString()}`)
|
|
}
|
|
|
|
const removeColorFilter = () => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.delete('color')
|
|
router.push(`/?${params.toString()}`)
|
|
}
|
|
|
|
const clearAllFilters = () => {
|
|
// Clear only label and color filters, keep search
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.delete('labels')
|
|
params.delete('color')
|
|
router.push(`/?${params.toString()}`)
|
|
}
|
|
|
|
const handleFilterChange = (newLabels: string[]) => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (newLabels.length > 0) {
|
|
params.set('labels', newLabels.join(','))
|
|
} else {
|
|
params.delete('labels')
|
|
}
|
|
router.push(`/?${params.toString()}`)
|
|
}
|
|
|
|
const handleColorChange = (newColor: string | null) => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (newColor) {
|
|
params.set('color', newColor)
|
|
} else {
|
|
params.delete('color')
|
|
}
|
|
router.push(`/?${params.toString()}`)
|
|
}
|
|
|
|
const toggleLabelFilter = (labelName: string) => {
|
|
const newLabels = currentLabels.includes(labelName)
|
|
? currentLabels.filter(l => l !== labelName)
|
|
: [...currentLabels, labelName]
|
|
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (newLabels.length > 0) {
|
|
params.set('labels', newLabels.join(','))
|
|
} else {
|
|
params.delete('labels')
|
|
}
|
|
router.push(`/?${params.toString()}`)
|
|
}
|
|
|
|
const NavItem = ({ href, icon: Icon, label, active, onClick }: any) => {
|
|
const content = (
|
|
<>
|
|
<Icon className={cn("h-5 w-5", active && "fill-current text-primary")} />
|
|
{label}
|
|
</>
|
|
)
|
|
|
|
if (onClick) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
"w-full flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2 text-left",
|
|
active
|
|
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
|
|
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
|
|
)}
|
|
style={{ minHeight: '44px' }}
|
|
aria-pressed={active}
|
|
>
|
|
{content}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
href={href}
|
|
onClick={() => setIsSidebarOpen(false)}
|
|
className={cn(
|
|
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
|
|
active
|
|
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
|
|
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
|
|
)}
|
|
style={{ minHeight: '44px' }}
|
|
aria-current={active ? 'page' : undefined}
|
|
>
|
|
{content}
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
const hasActiveFilters = currentLabels.length > 0 || !!currentColor
|
|
|
|
return (
|
|
<>
|
|
{/* Top Navigation - Style Keep */}
|
|
<header className="flex-none flex items-center justify-between whitespace-nowrap border-b border-solid border-slate-200 dark:border-slate-800 bg-white dark:bg-[#1e2128] px-6 py-3 z-20">
|
|
<div className="flex items-center gap-8">
|
|
|
|
|
|
{/* Logo MEMENTO */}
|
|
<div className="flex items-center gap-3 text-slate-900 dark:text-white cursor-pointer group" onClick={() => router.push('/')}>
|
|
<div className="size-8 bg-primary rounded-lg flex items-center justify-center text-primary-foreground shadow-sm group-hover:shadow-md transition-all">
|
|
<StickyNote className="w-5 h-5" />
|
|
</div>
|
|
<h2 className="text-xl font-bold leading-tight tracking-tight">MEMENTO</h2>
|
|
</div>
|
|
|
|
{/* Search Bar */}
|
|
<label className="hidden md:flex flex-col min-w-40 w-96 !h-10">
|
|
<div className="flex w-full flex-1 items-stretch rounded-full h-full bg-slate-100 dark:bg-slate-800 border border-transparent focus-within:bg-white dark:focus-within:bg-slate-700 focus-within:border-primary/30 focus-within:shadow-md transition-all duration-200">
|
|
<div className="text-slate-400 dark:text-slate-400 flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
|
|
<Search className="w-4 h-4" />
|
|
</div>
|
|
<input
|
|
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 px-3 text-sm focus:ring-0 focus:outline-none"
|
|
placeholder={t('search.placeholder') }
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex flex-1 justify-end gap-2 items-center">
|
|
|
|
{/* Quick nav: Notes (hidden-sidebar only), Chat, Agents, Lab */}
|
|
<div className="hidden md:flex items-center gap-1 bg-slate-100 dark:bg-slate-800/60 rounded-full px-1.5 py-1">
|
|
{noSidebarMode && (
|
|
<Link
|
|
href="/"
|
|
className={cn(
|
|
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
|
pathname === '/'
|
|
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<StickyNote className="h-3.5 w-3.5" />
|
|
<span>{t('sidebar.notes') || 'Notes'}</span>
|
|
</Link>
|
|
)}
|
|
<Link
|
|
href="/agents"
|
|
className={cn(
|
|
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
|
pathname === '/agents'
|
|
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<Bot className="h-3.5 w-3.5" />
|
|
<span>{t('nav.agents')}</span>
|
|
</Link>
|
|
<Link
|
|
href="/lab"
|
|
className={cn(
|
|
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
|
pathname === '/lab'
|
|
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<FlaskConical className="h-3.5 w-3.5" />
|
|
<span>{t('nav.lab')}</span>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Notifications */}
|
|
<NotificationPanel />
|
|
|
|
{/* Settings Button */}
|
|
<Link
|
|
href="/settings"
|
|
className="flex items-center justify-center size-10 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 transition-colors"
|
|
>
|
|
<Settings className="w-5 h-5" />
|
|
</Link>
|
|
|
|
{/* User Avatar Menu */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<div className="flex items-center justify-center bg-center bg-no-repeat bg-cover rounded-full size-10 ring-2 ring-white dark:ring-slate-700 cursor-pointer shadow-sm hover:shadow-md transition-shadow bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
|
|
style={currentUser?.image ? { backgroundImage: `url(${currentUser?.image})` } : undefined}>
|
|
{!currentUser?.image && (
|
|
<span className="text-sm font-semibold">
|
|
{currentUser?.name ? currentUser.name.charAt(0).toUpperCase() : <User className="w-5 h-5" />}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
<div className="flex items-center justify-start gap-2 p-2">
|
|
<div className="flex flex-col space-y-1 leading-none">
|
|
{currentUser?.name && <p className="font-medium">{currentUser.name}</p>}
|
|
{currentUser?.email && <p className="w-[200px] truncate text-sm text-muted-foreground">{currentUser.email}</p>}
|
|
</div>
|
|
</div>
|
|
<DropdownMenuItem asChild className="cursor-pointer">
|
|
<Link href="/settings/profile">
|
|
<User className="mr-2 h-4 w-4" />
|
|
<span>{t('settings.profile') || 'Profile'}</span>
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
{(currentUser as any)?.role === 'ADMIN' && (
|
|
<DropdownMenuItem asChild className="cursor-pointer">
|
|
{/* Force hard reload: client-side navigation between (main) and (admin)
|
|
route groups triggers React #310 in Next.js 16.x (framework bug). */}
|
|
<a href="/admin">
|
|
<Shield className="mr-2 h-4 w-4" />
|
|
<span>{t('nav.adminDashboard')}</span>
|
|
</a>
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600">
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
<span>{t('auth.signOut') || 'Sign out'}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* User Avatar - Removed from here */}
|
|
</div>
|
|
</header>
|
|
</>
|
|
)
|
|
}
|