fix: unify theme system - fix theme switching persistence
- Unified localStorage key to 'theme-preference' across all components
- Fixed header.tsx using wrong localStorage key ('theme' instead of 'theme-preference')
- Added localStorage hybrid persistence for instant theme changes
- Removed router.refresh() which was causing stale data revert
- Replaced Blue theme with Sepia
- Consolidated auth() calls to prevent race conditions
- Updated UserSettingsData types to include all themes
This commit is contained in:
19
keep-notes/components/admin-content-area.tsx
Normal file
19
keep-notes/components/admin-content-area.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface AdminContentAreaProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AdminContentArea({ children, className }: AdminContentAreaProps) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'flex-1 bg-gray-50 dark:bg-zinc-950 p-6 overflow-auto',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
70
keep-notes/components/admin-metrics.tsx
Normal file
70
keep-notes/components/admin-metrics.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface MetricItem {
|
||||
title: string
|
||||
value: string | number
|
||||
trend?: {
|
||||
value: number
|
||||
isPositive: boolean
|
||||
}
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export interface AdminMetricsProps {
|
||||
metrics: MetricItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{metrics.map((metric, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="p-6 bg-white dark:bg-zinc-900 border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||
{metric.title}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{metric.value}
|
||||
</p>
|
||||
{metric.trend && (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
metric.trend.isPositive
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{metric.trend.isPositive ? '↑' : '↓'} {Math.abs(metric.trend.value)}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
vs last period
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{metric.icon && (
|
||||
<div className="p-2 bg-gray-100 dark:bg-zinc-800 rounded-lg">
|
||||
{metric.icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
keep-notes/components/admin-sidebar.tsx
Normal file
75
keep-notes/components/admin-sidebar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { LayoutDashboard, Users, Brain, Settings } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface AdminSidebarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
title: string
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: '/admin',
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
href: '/admin/users',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'AI Management',
|
||||
href: '/admin/ai',
|
||||
icon: <Brain className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
href: '/admin/settings',
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
},
|
||||
]
|
||||
|
||||
export function AdminSidebar({ className }: AdminSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'w-64 bg-white dark:bg-zinc-900 border-r border-gray-200 dark:border-gray-800 p-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href))
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
|
||||
'hover:bg-gray-100 dark:hover:bg-zinc-800',
|
||||
isActive
|
||||
? 'bg-gray-100 dark:bg-zinc-800 text-gray-900 dark:text-white font-semibold'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
9
keep-notes/components/debug-theme.tsx
Normal file
9
keep-notes/components/debug-theme.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
|
||||
export function DebugTheme({ theme }: { theme: string }) {
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 z-50 bg-black text-white p-2 rounded text-xs opacity-80 pointer-events-none">
|
||||
Debug Theme: {theme}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function FavoritesSection({ pinnedNotes, onEdit }: FavoritesSectionProps)
|
||||
|
||||
{/* Collapsible Content */}
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pinnedNotes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
|
||||
@@ -150,14 +150,15 @@ export function Header({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = currentUser?.theme || localStorage.getItem('theme') || 'light'
|
||||
// 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', newTheme)
|
||||
localStorage.setItem('theme-preference', newTheme)
|
||||
|
||||
// Remove all theme classes first
|
||||
document.documentElement.classList.remove('dark')
|
||||
@@ -168,7 +169,7 @@ export function Header({
|
||||
} else if (newTheme !== 'light') {
|
||||
document.documentElement.setAttribute('data-theme', newTheme)
|
||||
if (newTheme === 'midnight') {
|
||||
document.documentElement.classList.add('dark')
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +245,7 @@ export function Header({
|
||||
const NavItem = ({ href, icon: Icon, label, active, onClick }: any) => {
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn("h-5 w-5", active && "fill-current text-amber-900")} />
|
||||
<Icon className={cn("h-5 w-5", active && "fill-current text-blue-900")} />
|
||||
{label}
|
||||
</>
|
||||
)
|
||||
@@ -256,7 +257,7 @@ export function Header({
|
||||
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-[#EFB162] text-amber-900"
|
||||
? "bg-blue-100 text-blue-900"
|
||||
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
|
||||
)}
|
||||
style={{ minHeight: '44px' }}
|
||||
@@ -274,7 +275,7 @@ export function Header({
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
|
||||
active
|
||||
? "bg-[#EFB162] text-amber-900"
|
||||
? "bg-blue-100 text-blue-900"
|
||||
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
|
||||
)}
|
||||
style={{ minHeight: '44px' }}
|
||||
@@ -289,188 +290,83 @@ export function Header({
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="h-20 bg-background/90 backdrop-blur-sm border-b border-transparent flex items-center justify-between px-6 lg:px-12 flex-shrink-0 z-30 sticky top-0">
|
||||
{/* Mobile Menu Button */}
|
||||
<Sheet open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden mr-4 text-muted-foreground"
|
||||
aria-label="Open menu"
|
||||
aria-expanded={isSidebarOpen}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[280px] sm:w-[320px] p-0 pt-4">
|
||||
<SheetHeader className="px-4 mb-4 flex items-center justify-between">
|
||||
<SheetTitle className="flex items-center gap-2 text-xl font-normal">
|
||||
<StickyNote className="h-6 w-6 text-primary" />
|
||||
{t('nav.workspace')}
|
||||
</SheetTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close menu"
|
||||
style={{ width: '44px', height: '44px' }}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-1 py-2">
|
||||
<NavItem
|
||||
href="/"
|
||||
icon={StickyNote}
|
||||
label={t('nav.notes')}
|
||||
active={pathname === '/' && !hasActiveFilters}
|
||||
/>
|
||||
<NavItem
|
||||
href="/reminders"
|
||||
icon={Bell}
|
||||
label={t('reminder.title')}
|
||||
active={pathname === '/reminders'}
|
||||
/>
|
||||
{/* 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">
|
||||
|
||||
<div className="my-2 px-4 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('labels.title')}</span>
|
||||
|
||||
{/* 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-blue-500 rounded-lg flex items-center justify-center text-white 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 - Style Keep */}
|
||||
<label className="hidden md:flex flex-col min-w-40 w-96 !h-10">
|
||||
<div className="flex w-full flex-1 items-stretch rounded-xl h-full bg-slate-100 dark:bg-slate-800 focus-within:ring-2 focus-within:ring-primary/20 transition-all">
|
||||
<div className="text-slate-500 dark:text-slate-400 flex items-center justify-center pl-4">
|
||||
<Search className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
{labels.map(label => (
|
||||
<NavItem
|
||||
key={label.id}
|
||||
icon={Tag}
|
||||
label={label.name}
|
||||
active={currentLabels.includes(label.name)}
|
||||
onClick={() => toggleLabelFilter(label.name)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="my-2 border-t border-gray-200 dark:border-zinc-800" />
|
||||
|
||||
<NavItem
|
||||
href="/archive"
|
||||
icon={Settings}
|
||||
label={t('nav.archive')}
|
||||
active={pathname === '/archive'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/trash"
|
||||
icon={Tag}
|
||||
label={t('nav.trash')}
|
||||
active={pathname === '/trash'}
|
||||
<input
|
||||
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-xl bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-500 dark:placeholder:text-slate-400 px-3 text-sm font-medium focus:ring-0"
|
||||
placeholder={t('search.placeholder') || "Search notes, labels, and more..."}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="flex-1 max-w-2xl flex items-center bg-card rounded-lg px-4 py-3 shadow-sm border border-transparent focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10 transition-all">
|
||||
<Search className="text-muted-foreground text-xl" />
|
||||
<input
|
||||
className="bg-transparent border-none outline-none focus:ring-0 w-full text-sm text-foreground ml-3 placeholder-muted-foreground"
|
||||
placeholder={t('search.placeholder') || "Search notes, tags, or notebooks..."}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* IA Search Button */}
|
||||
<button
|
||||
onClick={handleSemanticSearch}
|
||||
disabled={!searchQuery.trim() || isSemanticSearching}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors min-h-[36px]",
|
||||
"hover:bg-accent",
|
||||
searchParams.get('semantic') === 'true'
|
||||
? "bg-primary/20 text-primary"
|
||||
: "text-muted-foreground hover:text-primary",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
title={t('search.semanticTooltip')}
|
||||
>
|
||||
<Sparkles className={cn("h-3.5 w-3.5", isSemanticSearching && "animate-spin")} />
|
||||
</button>
|
||||
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => handleSearch('')}
|
||||
className="ml-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Right Side Actions */}
|
||||
<div className="flex items-center space-x-3 ml-6">
|
||||
{/* Label Filter */}
|
||||
<LabelFilter
|
||||
selectedLabels={currentLabels}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
<div className="flex flex-1 justify-end gap-4 items-center">
|
||||
|
||||
{/* Grid View Button */}
|
||||
<button className="p-2.5 text-muted-foreground hover:bg-accent rounded-lg transition-colors duration-200 min-h-[44px] min-w-[44px]">
|
||||
<Grid3x3 className="text-xl" />
|
||||
{/* Settings Button */}
|
||||
<button
|
||||
onClick={() => router.push('/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" />
|
||||
</button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
{/* User Avatar Menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="p-2.5 text-muted-foreground hover:bg-accent rounded-lg transition-colors duration-200 min-h-[44px] min-w-[44px]">
|
||||
{theme === 'light' ? <Sun className="text-xl" /> : <Moon className="text-xl" />}
|
||||
</button>
|
||||
<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-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-200"
|
||||
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">
|
||||
<DropdownMenuItem onClick={() => applyTheme('light')}>{t('settings.themeLight')}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => applyTheme('dark')}>{t('settings.themeDark')}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => applyTheme('midnight')}>Midnight</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => applyTheme('sepia')}>Sepia</DropdownMenuItem>
|
||||
<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 onClick={() => router.push('/settings/profile')} className="cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>{t('settings.profile') || 'Profile'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push('/admin')} className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
<span>Admin</span>
|
||||
</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>
|
||||
|
||||
{/* Notifications */}
|
||||
<NotificationPanel />
|
||||
{/* User Avatar - Removed from here */}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Active Filters Bar */}
|
||||
{hasActiveFilters && (
|
||||
<div className="px-6 lg:px-12 pb-3 flex items-center gap-2 overflow-x-auto border-t border-border pt-2 bg-background/50 backdrop-blur-sm animate-in slide-in-from-top-2">
|
||||
{currentColor && (
|
||||
<Badge variant="secondary" className="flex items-center gap-1 h-7 whitespace-nowrap pl-2 pr-1">
|
||||
<div className={cn("w-3 h-3 rounded-full border border-black/10", `bg-${currentColor}-500`)} />
|
||||
{t('notes.color')}: {currentColor}
|
||||
<button onClick={removeColorFilter} className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5 min-h-[24px] min-w-[24px]">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{currentLabels.map(label => (
|
||||
<Badge key={label} variant="secondary" className="flex items-center gap-1 h-7 whitespace-nowrap pl-2 pr-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
{label}
|
||||
<button onClick={() => removeLabelFilter(label)} className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{(currentLabels.length > 0 || currentColor) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllFilters}
|
||||
className="h-7 text-xs text-primary hover:text-primary hover:bg-accent whitespace-nowrap ml-auto"
|
||||
>
|
||||
{t('labels.clearAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@ import { useLanguage } from '@/lib/i18n'
|
||||
interface LabelFilterProps {
|
||||
selectedLabels: string[]
|
||||
onFilterChange: (labels: string[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps) {
|
||||
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
|
||||
const { labels, loading } = useLabels()
|
||||
const { t } = useLanguage()
|
||||
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
|
||||
@@ -46,14 +47,21 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
|
||||
if (loading || allLabelNames.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("flex items-center gap-2", className ? "" : "")}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-9">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
{t('labels.filter')}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-10 gap-2 rounded-full border border-gray-200 bg-white hover:bg-gray-50 text-gray-700 shadow-sm font-medium",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{t('labels.filter') || 'Filter'}
|
||||
{selectedLabels.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 h-5 min-w-5 px-1.5">
|
||||
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1.5 rounded-full bg-gray-100">
|
||||
{selectedLabels.length}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
230
keep-notes/components/masonry-grid.css
Normal file
230
keep-notes/components/masonry-grid.css
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Masonry Grid Styles
|
||||
*
|
||||
* Styles for responsive masonry layout similar to Google Keep
|
||||
* Handles note sizes, drag states, and responsive breakpoints
|
||||
*/
|
||||
|
||||
/* Masonry Container */
|
||||
.masonry-container {
|
||||
width: 100%;
|
||||
padding: 0 16px 24px 16px;
|
||||
}
|
||||
|
||||
/* Grid containers for pinned and others sections */
|
||||
.masonry-container > div > div[ref*="GridRef"] {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Masonry Item Base Styles - Width is managed by Muuri */
|
||||
.masonry-item {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 0;
|
||||
width: auto; /* Width will be set by JS based on container */
|
||||
}
|
||||
|
||||
/* Masonry Item Content Wrapper */
|
||||
.masonry-item-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Ensure proper box-sizing for all elements in the grid */
|
||||
.masonry-item *,
|
||||
.masonry-item-content * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Note Card - Base styles */
|
||||
.note-card {
|
||||
width: 100% !important; /* Force full width within grid cell */
|
||||
min-width: 0; /* Prevent overflow */
|
||||
height: auto !important; /* Let content determine height like Google Keep */
|
||||
max-height: none !important; /* No max-height restriction */
|
||||
}
|
||||
|
||||
/* Note Size Styles - Desktop Default */
|
||||
.note-card[data-size="small"] {
|
||||
min-height: 150px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.note-card[data-size="medium"] {
|
||||
min-height: 200px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.note-card[data-size="large"] {
|
||||
min-height: 300px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Drag State Styles - Improved for Google Keep-like behavior */
|
||||
.masonry-item.muuri-item-dragging {
|
||||
z-index: 1000;
|
||||
opacity: 0.6;
|
||||
transition: none; /* No transition during drag for better performance */
|
||||
}
|
||||
|
||||
.masonry-item.muuri-item-dragging .note-card {
|
||||
transform: scale(1.05) rotate(2deg);
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
|
||||
transition: none; /* No transition during drag */
|
||||
}
|
||||
|
||||
.masonry-item.muuri-item-releasing {
|
||||
z-index: 2;
|
||||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.masonry-item.muuri-item-releasing .note-card {
|
||||
transform: scale(1) rotate(0deg);
|
||||
box-shadow: none;
|
||||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.masonry-item.muuri-item-hidden {
|
||||
z-index: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Drag Placeholder - More visible and styled like Google Keep */
|
||||
.muuri-item-placeholder {
|
||||
opacity: 0.3;
|
||||
background: rgba(100, 100, 255, 0.05);
|
||||
border: 2px dashed rgba(100, 100, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease-out;
|
||||
min-height: 150px !important;
|
||||
min-width: 100px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.muuri-item-placeholder::before {
|
||||
content: '';
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(100, 100, 255, 0.1);
|
||||
border: 2px dashed rgba(100, 100, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Mobile Styles (< 640px) */
|
||||
@media (max-width: 639px) {
|
||||
.masonry-container {
|
||||
padding: 0 12px 16px 12px;
|
||||
}
|
||||
|
||||
.masonry-item {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* Smaller note sizes on mobile - keep same ratio */
|
||||
.note-card[data-size="small"] {
|
||||
min-height: 120px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.note-card[data-size="medium"] {
|
||||
min-height: 160px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.note-card[data-size="large"] {
|
||||
min-height: 240px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Reduced drag effect on mobile */
|
||||
.masonry-item.muuri-item-dragging .note-card {
|
||||
transform: scale(1.01);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet Styles (640px - 1023px) */
|
||||
@media (min-width: 640px) and (max-width: 1023px) {
|
||||
.masonry-container {
|
||||
padding: 0 16px 20px 16px;
|
||||
}
|
||||
|
||||
.masonry-item {
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop Styles (1024px - 1279px) */
|
||||
@media (min-width: 1024px) and (max-width: 1279px) {
|
||||
.masonry-container {
|
||||
padding: 0 20px 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large Desktop Styles (1280px+) */
|
||||
@media (min-width: 1280px) {
|
||||
.masonry-container {
|
||||
padding: 0 24px 32px 24px;
|
||||
/* max-width removed for infinite columns */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.masonry-item {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transition for layout changes */
|
||||
.masonry-item,
|
||||
.masonry-item-content,
|
||||
.note-card {
|
||||
transition-property: transform, box-shadow, opacity;
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
/* Prevent layout shift during animations */
|
||||
.masonry-item.muuri-item-positioning {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Hide scrollbars during drag to prevent jitter */
|
||||
body.muuri-dragging {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Optimize for reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.masonry-item,
|
||||
.masonry-item-content,
|
||||
.note-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.masonry-item.muuri-item-dragging .note-card {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.masonry-item.muuri-item-dragging,
|
||||
.muuri-item-placeholder {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.masonry-item {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer';
|
||||
import { useNotebookDrag } from '@/context/notebook-drag-context';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { DEFAULT_LAYOUT, calculateColumns, calculateItemWidth, isMobileViewport } from '@/config/masonry-layout';
|
||||
import './masonry-grid.css';
|
||||
|
||||
interface MasonryGridProps {
|
||||
notes: Note[];
|
||||
@@ -20,30 +22,17 @@ interface MasonryItemProps {
|
||||
onResize: () => void;
|
||||
onDragStart?: (noteId: string) => void;
|
||||
onDragEnd?: () => void;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
function getSizeClasses(size: string = 'small') {
|
||||
switch (size) {
|
||||
case 'medium':
|
||||
return 'w-full sm:w-full lg:w-2/3 xl:w-2/4 2xl:w-2/5';
|
||||
case 'large':
|
||||
return 'w-full';
|
||||
case 'small':
|
||||
default:
|
||||
return 'w-full sm:w-1/2 lg:w-1/3 xl:w-1/4 2xl:w-1/5';
|
||||
}
|
||||
}
|
||||
|
||||
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragStart, onDragEnd, isDragging }: MasonryItemProps) {
|
||||
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragStart, onDragEnd }: MasonryItemProps) {
|
||||
const resizeRef = useResizeObserver(onResize);
|
||||
|
||||
const sizeClasses = getSizeClasses(note.size);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`masonry-item absolute p-2 ${sizeClasses}`}
|
||||
className="masonry-item absolute py-1"
|
||||
data-id={note.id}
|
||||
data-size={note.size}
|
||||
data-draggable="true"
|
||||
ref={resizeRef as any}
|
||||
>
|
||||
<div className="masonry-item-content relative">
|
||||
@@ -52,14 +41,13 @@ const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onDragSt
|
||||
onEdit={onEdit}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Custom comparison to avoid re-render on function prop changes if note data is same
|
||||
return prev.note.id === next.note.id && prev.note.order === next.note.order && prev.isDragging === next.isDragging;
|
||||
return prev.note.id === next.note.id; // Removed isDragging comparison
|
||||
});
|
||||
|
||||
export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
@@ -67,8 +55,14 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
||||
|
||||
// Use external onEdit if provided, otherwise use internal state
|
||||
const lastDragEndTime = useRef<number>(0);
|
||||
|
||||
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
|
||||
// Prevent opening note if it was just dragged (within 200ms)
|
||||
if (Date.now() - lastDragEndTime.current < 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onEdit) {
|
||||
onEdit(note, readOnly);
|
||||
} else {
|
||||
@@ -81,36 +75,20 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
const pinnedMuuri = useRef<any>(null);
|
||||
const othersMuuri = useRef<any>(null);
|
||||
|
||||
// Memoize filtered and sorted notes to avoid recalculation on every render
|
||||
// Memoize filtered notes (order comes from array)
|
||||
const pinnedNotes = useMemo(
|
||||
() => notes.filter(n => n.isPinned).sort((a, b) => a.order - b.order),
|
||||
() => notes.filter(n => n.isPinned),
|
||||
[notes]
|
||||
);
|
||||
const othersNotes = useMemo(
|
||||
() => notes.filter(n => !n.isPinned).sort((a, b) => a.order - b.order),
|
||||
() => notes.filter(n => !n.isPinned),
|
||||
[notes]
|
||||
);
|
||||
|
||||
// CRITICAL: Sync editingNote when underlying note changes (e.g., after moving to notebook)
|
||||
// This ensures the NoteEditor gets the updated note with the new notebookId
|
||||
useEffect(() => {
|
||||
if (!editingNote) return;
|
||||
|
||||
// Find the updated version of the currently edited note in the notes array
|
||||
const updatedNote = notes.find(n => n.id === editingNote.note.id);
|
||||
|
||||
if (updatedNote) {
|
||||
// Check if any key properties changed (especially notebookId)
|
||||
const notebookIdChanged = updatedNote.notebookId !== editingNote.note.notebookId;
|
||||
|
||||
if (notebookIdChanged) {
|
||||
// Update the editingNote with the new data
|
||||
setEditingNote(prev => prev ? { ...prev, note: updatedNote } : null);
|
||||
}
|
||||
}
|
||||
}, [notes, editingNote]);
|
||||
|
||||
const handleDragEnd = useCallback(async (grid: any) => {
|
||||
// Record drag end time to prevent accidental clicks
|
||||
lastDragEndTime.current = Date.now();
|
||||
|
||||
if (!grid) return;
|
||||
|
||||
const items = grid.getItems();
|
||||
@@ -119,27 +97,53 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
.filter((id: any): id is string => !!id);
|
||||
|
||||
try {
|
||||
// Save order to database WITHOUT revalidating the page
|
||||
// Muuri has already updated the visual layout, so we don't need to reload
|
||||
// Save order to database WITHOUT triggering a full page refresh
|
||||
// Muuri has already updated the visual layout
|
||||
await updateFullOrderWithoutRevalidation(ids);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist order:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshLayout = useCallback(() => {
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
requestAnimationFrame(() => {
|
||||
if (pinnedMuuri.current) {
|
||||
pinnedMuuri.current.refreshItems().layout();
|
||||
}
|
||||
if (othersMuuri.current) {
|
||||
othersMuuri.current.refreshItems().layout();
|
||||
const layoutTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const refreshLayout = useCallback((_?: any) => {
|
||||
if (layoutTimeoutRef.current) {
|
||||
clearTimeout(layoutTimeoutRef.current);
|
||||
}
|
||||
|
||||
layoutTimeoutRef.current = setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (pinnedMuuri.current) {
|
||||
pinnedMuuri.current.refreshItems().layout();
|
||||
}
|
||||
if (othersMuuri.current) {
|
||||
othersMuuri.current.refreshItems().layout();
|
||||
}
|
||||
});
|
||||
}, 100); // 100ms debounce
|
||||
}, []);
|
||||
|
||||
// Ref for container to use with ResizeObserver
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Centralized function to apply item dimensions based on container width
|
||||
const applyItemDimensions = useCallback((grid: any, containerWidth: number) => {
|
||||
if (!grid) return;
|
||||
|
||||
const columns = calculateColumns(containerWidth);
|
||||
const itemWidth = calculateItemWidth(containerWidth, columns);
|
||||
|
||||
const items = grid.getItems();
|
||||
items.forEach((item: any) => {
|
||||
const el = item.getElement();
|
||||
if (el) {
|
||||
el.style.width = `${itemWidth}px`;
|
||||
// Height is auto - determined by content (Google Keep style)
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initialize Muuri grids once on mount and sync when needed
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
let muuriInitialized = false;
|
||||
@@ -161,12 +165,41 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
const isMobileWidth = window.innerWidth < 768;
|
||||
const isMobile = isTouchDevice || isMobileWidth;
|
||||
|
||||
// Get container width for responsive calculation
|
||||
const containerWidth = window.innerWidth - 32; // Subtract padding
|
||||
const columns = calculateColumns(containerWidth);
|
||||
const itemWidth = calculateItemWidth(containerWidth, columns);
|
||||
|
||||
console.log(`[Masonry] Container width: ${containerWidth}px, Columns: ${columns}, Item width: ${itemWidth}px`);
|
||||
|
||||
// Calculate item dimensions based on note size
|
||||
const getItemDimensions = (note: Note) => {
|
||||
const baseWidth = itemWidth;
|
||||
let baseHeight = 200; // Default medium height
|
||||
|
||||
switch (note.size) {
|
||||
case 'small':
|
||||
baseHeight = 150;
|
||||
break;
|
||||
case 'medium':
|
||||
baseHeight = 200;
|
||||
break;
|
||||
case 'large':
|
||||
baseHeight = 300;
|
||||
break;
|
||||
default:
|
||||
baseHeight = 200;
|
||||
}
|
||||
|
||||
return { width: baseWidth, height: baseHeight };
|
||||
};
|
||||
|
||||
const layoutOptions = {
|
||||
dragEnabled: true,
|
||||
// Use drag handle for mobile devices to allow smooth scrolling
|
||||
// On desktop, whole card is draggable (no handle needed)
|
||||
dragHandle: isMobile ? '.muuri-drag-handle' : undefined,
|
||||
dragContainer: document.body,
|
||||
// dragContainer: document.body, // REMOVED: Keep item in grid to prevent React conflict
|
||||
dragStartPredicate: {
|
||||
distance: 10,
|
||||
delay: 0,
|
||||
@@ -175,28 +208,128 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
enabled: true,
|
||||
createElement: (item: any) => {
|
||||
const el = item.getElement().cloneNode(true);
|
||||
el.style.opacity = '0.5';
|
||||
el.style.opacity = '0.4';
|
||||
el.style.transform = 'scale(1.05)';
|
||||
el.style.boxShadow = '0 20px 40px rgba(0, 0, 0, 0.3)';
|
||||
el.classList.add('muuri-item-placeholder');
|
||||
return el;
|
||||
},
|
||||
},
|
||||
dragAutoScroll: {
|
||||
targets: [window],
|
||||
speed: (item: any, target: any, intersection: any) => {
|
||||
return intersection * 20;
|
||||
return intersection * 30; // Faster auto-scroll for better UX
|
||||
},
|
||||
threshold: 50, // Start auto-scroll earlier (50px from edge)
|
||||
smoothStop: true, // Smooth deceleration
|
||||
},
|
||||
// IMPROVED: Configuration for drag release handling
|
||||
dragRelease: {
|
||||
duration: 300,
|
||||
easing: 'cubic-bezier(0.25, 1, 0.5, 1)',
|
||||
useDragContainer: false, // REMOVED: Keep item in grid
|
||||
},
|
||||
dragCssProps: {
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
userDrag: 'none',
|
||||
tapHighlightColor: 'rgba(0, 0, 0, 0)',
|
||||
touchCallout: 'none',
|
||||
contentZooming: 'none',
|
||||
},
|
||||
// CRITICAL: Grid layout configuration for fixed width items
|
||||
layoutDuration: 30, // Much faster layout for responsiveness
|
||||
layoutEasing: 'ease-out',
|
||||
// Use grid layout for better control over item placement
|
||||
layout: {
|
||||
// Enable true masonry layout - items can be different heights
|
||||
fillGaps: true,
|
||||
horizontal: false,
|
||||
alignRight: false,
|
||||
alignBottom: false,
|
||||
rounding: false,
|
||||
// Set fixed width and let height be determined by content
|
||||
// This creates a Google Keep-like masonry layout
|
||||
itemPositioning: {
|
||||
onLayout: true,
|
||||
onResize: true,
|
||||
onInit: true,
|
||||
},
|
||||
},
|
||||
dragSort: true,
|
||||
dragSortInterval: 50,
|
||||
// Enable drag and drop with proper drag handling
|
||||
dragSortHeuristics: {
|
||||
sortInterval: 0, // Zero interval for immediate sorting
|
||||
minDragDistance: 5,
|
||||
minBounceBackAngle: 1,
|
||||
},
|
||||
// Grid configuration for responsive columns
|
||||
visibleStyles: {
|
||||
opacity: '1',
|
||||
transform: 'scale(1)',
|
||||
},
|
||||
hiddenStyles: {
|
||||
opacity: '0',
|
||||
transform: 'scale(0.5)',
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize pinned grid
|
||||
if (pinnedGridRef.current && !pinnedMuuri.current) {
|
||||
// Set container width explicitly
|
||||
pinnedGridRef.current.style.width = '100%';
|
||||
pinnedGridRef.current.style.position = 'relative';
|
||||
|
||||
// Get all items in the pinned grid and set their dimensions
|
||||
const pinnedItems = Array.from(pinnedGridRef.current.children);
|
||||
pinnedItems.forEach((item) => {
|
||||
const noteId = item.getAttribute('data-id');
|
||||
const note = pinnedNotes.find(n => n.id === noteId);
|
||||
if (note) {
|
||||
const dims = getItemDimensions(note);
|
||||
(item as HTMLElement).style.width = `${dims.width}px`;
|
||||
// Don't set height - let content determine it like Google Keep
|
||||
}
|
||||
});
|
||||
|
||||
pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions)
|
||||
.on('dragEnd', () => handleDragEnd(pinnedMuuri.current));
|
||||
.on('dragEnd', () => handleDragEnd(pinnedMuuri.current))
|
||||
.on('dragStart', () => {
|
||||
// Optional: visual feedback or state update
|
||||
});
|
||||
|
||||
// Initial layout
|
||||
requestAnimationFrame(() => {
|
||||
pinnedMuuri.current?.refreshItems().layout();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize others grid
|
||||
if (othersGridRef.current && !othersMuuri.current) {
|
||||
// Set container width explicitly
|
||||
othersGridRef.current.style.width = '100%';
|
||||
othersGridRef.current.style.position = 'relative';
|
||||
|
||||
// Get all items in the others grid and set their dimensions
|
||||
const othersItems = Array.from(othersGridRef.current.children);
|
||||
othersItems.forEach((item) => {
|
||||
const noteId = item.getAttribute('data-id');
|
||||
const note = othersNotes.find(n => n.id === noteId);
|
||||
if (note) {
|
||||
const dims = getItemDimensions(note);
|
||||
(item as HTMLElement).style.width = `${dims.width}px`;
|
||||
// Don't set height - let content determine it like Google Keep
|
||||
}
|
||||
});
|
||||
|
||||
othersMuuri.current = new MuuriClass(othersGridRef.current, layoutOptions)
|
||||
.on('dragEnd', () => handleDragEnd(othersMuuri.current));
|
||||
|
||||
// Initial layout
|
||||
requestAnimationFrame(() => {
|
||||
othersMuuri.current?.refreshItems().layout();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -213,20 +346,121 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Synchronize items when notes change (e.g. searching, adding)
|
||||
// Synchronize items when notes change (e.g. searching, adding, removing)
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (pinnedMuuri.current) {
|
||||
pinnedMuuri.current.refreshItems().layout();
|
||||
const syncGridItems = (
|
||||
grid: any,
|
||||
gridRef: React.RefObject<HTMLDivElement | null>,
|
||||
notesArray: Note[]
|
||||
) => {
|
||||
if (!grid || !gridRef.current) return;
|
||||
|
||||
// Get container width for dimension calculation
|
||||
const containerWidth = containerRef.current?.getBoundingClientRect().width || window.innerWidth - 32;
|
||||
const columns = calculateColumns(containerWidth);
|
||||
const itemWidth = calculateItemWidth(containerWidth, columns);
|
||||
|
||||
// Get current DOM elements and Muuri items
|
||||
const domElements = Array.from(gridRef.current.children) as HTMLElement[];
|
||||
const muuriItems = grid.getItems();
|
||||
const muuriElements = muuriItems.map((item: any) => item.getElement());
|
||||
|
||||
// Find new elements to add (in DOM but not in Muuri)
|
||||
const newElements = domElements.filter(el => !muuriElements.includes(el));
|
||||
|
||||
// Find removed items (in Muuri but not in DOM)
|
||||
const removedItems = muuriItems.filter((item: any) =>
|
||||
!domElements.includes(item.getElement())
|
||||
);
|
||||
|
||||
// Remove old items from Muuri
|
||||
if (removedItems.length > 0) {
|
||||
console.log(`[Masonry Sync] Removing ${removedItems.length} items`);
|
||||
grid.remove(removedItems, { layout: false });
|
||||
}
|
||||
if (othersMuuri.current) {
|
||||
othersMuuri.current.refreshItems().layout();
|
||||
|
||||
// Add new items to Muuri with correct width
|
||||
if (newElements.length > 0) {
|
||||
console.log(`[Masonry Sync] Adding ${newElements.length} new items`);
|
||||
newElements.forEach(el => {
|
||||
el.style.width = `${itemWidth}px`;
|
||||
});
|
||||
grid.add(newElements, { layout: false });
|
||||
}
|
||||
});
|
||||
}, [notes]);
|
||||
|
||||
// Update all existing item widths
|
||||
domElements.forEach(el => {
|
||||
el.style.width = `${itemWidth}px`;
|
||||
});
|
||||
|
||||
// Refresh and layout - CRITICAL: Always refresh items to catch content size changes
|
||||
// Use requestAnimationFrame to ensure DOM has painted
|
||||
requestAnimationFrame(() => {
|
||||
grid.refreshItems().layout();
|
||||
});
|
||||
};
|
||||
|
||||
// Use setTimeout to ensure React has finished rendering DOM elements
|
||||
const timeoutId = setTimeout(() => {
|
||||
syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes);
|
||||
syncGridItems(othersMuuri.current, othersGridRef, othersNotes);
|
||||
}, 50); // Increased timeout slightly to ensure DOM stability
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [pinnedNotes, othersNotes]);
|
||||
|
||||
// Handle container resize with ResizeObserver for responsive layout
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout;
|
||||
|
||||
const handleResize = (entries: ResizeObserverEntry[]) => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
const containerWidth = entries[0]?.contentRect.width || window.innerWidth - 32;
|
||||
const columns = calculateColumns(containerWidth);
|
||||
|
||||
console.log(`[Masonry Resize] Width: ${containerWidth}px, Columns: ${columns}`);
|
||||
|
||||
// Apply dimensions to both grids using centralized function
|
||||
applyItemDimensions(pinnedMuuri.current, containerWidth);
|
||||
applyItemDimensions(othersMuuri.current, containerWidth);
|
||||
|
||||
// Refresh both grids with new layout
|
||||
requestAnimationFrame(() => {
|
||||
if (pinnedMuuri.current) {
|
||||
pinnedMuuri.current.refreshItems().layout();
|
||||
}
|
||||
if (othersMuuri.current) {
|
||||
othersMuuri.current.refreshItems().layout();
|
||||
}
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(handleResize);
|
||||
observer.observe(containerRef.current);
|
||||
|
||||
// Initial layout calculation
|
||||
if (containerRef.current) {
|
||||
const initialWidth = containerRef.current.getBoundingClientRect().width || window.innerWidth - 32;
|
||||
applyItemDimensions(pinnedMuuri.current, initialWidth);
|
||||
applyItemDimensions(othersMuuri.current, initialWidth);
|
||||
requestAnimationFrame(() => {
|
||||
pinnedMuuri.current?.refreshItems().layout();
|
||||
othersMuuri.current?.refreshItems().layout();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [applyItemDimensions]);
|
||||
|
||||
return (
|
||||
<div className="masonry-container">
|
||||
<div ref={containerRef} className="masonry-container">
|
||||
{pinnedNotes.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">{t('notes.pinned')}</h2>
|
||||
@@ -239,7 +473,6 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
onResize={refreshLayout}
|
||||
onDragStart={startDrag}
|
||||
onDragEnd={endDrag}
|
||||
isDragging={draggedNoteId === note.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -260,7 +493,6 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
onResize={refreshLayout}
|
||||
onDragStart={startDrag}
|
||||
onDragEnd={endDrag}
|
||||
isDragging={draggedNoteId === note.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -276,13 +508,19 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
)}
|
||||
|
||||
<style jsx global>{`
|
||||
.masonry-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.masonry-item {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.masonry-item.muuri-item-dragging {
|
||||
z-index: 3;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.masonry-item.muuri-item-releasing {
|
||||
z-index: 2;
|
||||
@@ -294,6 +532,12 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* Ensure proper box-sizing for all elements in the grid */
|
||||
.masonry-item *,
|
||||
.masonry-item-content * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2 } from 'lucide-react'
|
||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Tag } from 'lucide-react'
|
||||
import { useState, useEffect, useTransition, useOptimistic } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
@@ -270,12 +270,25 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
|
||||
return (
|
||||
<Card
|
||||
data-testid="note-card"
|
||||
data-draggable="true"
|
||||
data-note-id={note.id}
|
||||
data-size={note.size}
|
||||
draggable={true}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', note.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
|
||||
onDragStart?.(note.id)
|
||||
}}
|
||||
onDragEnd={() => onDragEnd?.()}
|
||||
className={cn(
|
||||
'note-card group relative rounded-lg p-4 transition-all duration-200 border shadow-sm hover:shadow-md',
|
||||
'note-card group relative rounded-2xl overflow-hidden p-5 border shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:shadow-xl hover:-translate-y-1',
|
||||
colorClasses.bg,
|
||||
colorClasses.card,
|
||||
colorClasses.hover,
|
||||
isDragging && 'opacity-30'
|
||||
isDragging && 'opacity-60 scale-105 shadow-2xl rotate-1'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
// Only trigger edit if not clicking on buttons
|
||||
@@ -350,6 +363,8 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
|
||||
/>
|
||||
</Button>
|
||||
|
||||
|
||||
|
||||
{/* Reminder Icon - Move slightly if pin button is there */}
|
||||
{note.reminder && new Date(note.reminder) > new Date() && (
|
||||
<Bell
|
||||
@@ -437,11 +452,11 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
|
||||
{optimisticNote.links && optimisticNote.links.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{optimisticNote.links.map((link, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<a
|
||||
key={idx}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block border rounded-md overflow-hidden bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -475,9 +490,44 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
|
||||
{/* Labels - ONLY show if note belongs to a notebook (labels are contextual per PRD) */}
|
||||
{optimisticNote.notebookId && optimisticNote.labels && optimisticNote.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{optimisticNote.labels.map((label) => (
|
||||
<LabelBadge key={label} label={label} />
|
||||
))}
|
||||
{optimisticNote.labels.map((label) => {
|
||||
// Map label names to Keep style colors
|
||||
const getLabelColor = (labelName: string) => {
|
||||
if (labelName.includes('hôtels') || labelName.includes('réservations')) {
|
||||
return 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300'
|
||||
} else if (labelName.includes('vols') || labelName.includes('flight')) {
|
||||
return 'bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300'
|
||||
} else if (labelName.includes('restos') || labelName.includes('restaurant')) {
|
||||
return 'bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300'
|
||||
} else {
|
||||
return 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
}
|
||||
}
|
||||
|
||||
// Map label names to Keep style icons
|
||||
const getLabelIcon = (labelName: string) => {
|
||||
if (labelName.includes('hôtels')) return 'label'
|
||||
else if (labelName.includes('vols')) return 'flight'
|
||||
else if (labelName.includes('restos')) return 'restaurant'
|
||||
else return 'label'
|
||||
}
|
||||
|
||||
const icon = getLabelIcon(label)
|
||||
const colorClass = getLabelColor(label)
|
||||
|
||||
return (
|
||||
<span
|
||||
key={label}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold",
|
||||
colorClass
|
||||
)}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { StickyNote, Plus, Tag as TagIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LucideIcon } from 'lucide-react'
|
||||
import { StickyNote, Plus, Tag, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LucideIcon, Plane, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useNotebookDrag } from '@/context/notebook-drag-context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -13,6 +14,7 @@ import { DeleteNotebookDialog } from './delete-notebook-dialog'
|
||||
import { EditNotebookDialog } from './edit-notebook-dialog'
|
||||
import { NotebookSummaryDialog } from './notebook-summary-dialog'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
|
||||
// Map icon names to lucide-react components
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
@@ -28,6 +30,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
'flight_takeoff': Plane,
|
||||
}
|
||||
|
||||
// Function to get icon component by name
|
||||
@@ -43,23 +46,24 @@ export function NotebooksList() {
|
||||
const { t } = useLanguage()
|
||||
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, isLoading } = useNotebooks()
|
||||
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
|
||||
const { labels } = useLabels()
|
||||
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [editingNotebook, setEditingNotebook] = useState<any>(null)
|
||||
const [deletingNotebook, setDeletingNotebook] = useState<any>(null)
|
||||
const [summaryNotebook, setSummaryNotebook] = useState<any>(null) // NEW: Summary dialog state (IA6)
|
||||
const [summaryNotebook, setSummaryNotebook] = useState<any>(null)
|
||||
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
|
||||
|
||||
const currentNotebookId = searchParams.get('notebook')
|
||||
|
||||
// Handle drop on a notebook
|
||||
const handleDrop = useCallback(async (e: React.DragEvent, notebookId: string | null) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation() // Prevent triggering notebook click
|
||||
e.stopPropagation()
|
||||
const noteId = e.dataTransfer.getData('text/plain')
|
||||
|
||||
if (noteId) {
|
||||
await moveNoteToNotebookOptimistic(noteId, notebookId)
|
||||
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
|
||||
}
|
||||
|
||||
dragOver(null)
|
||||
@@ -92,14 +96,27 @@ export function NotebooksList() {
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleToggleExpand = (notebookId: string) => {
|
||||
setExpandedNotebook(expandedNotebook === notebookId ? null : notebookId)
|
||||
}
|
||||
|
||||
const handleLabelFilter = (labelName: string, notebookId: string) => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
const currentLabels = params.get('labels')?.split(',').filter(Boolean) || []
|
||||
|
||||
if (currentLabels.includes(labelName)) {
|
||||
params.set('labels', currentLabels.filter((l: string) => l !== labelName).join(','))
|
||||
} else {
|
||||
params.set('labels', [...currentLabels, labelName].join(','))
|
||||
}
|
||||
|
||||
params.set('notebook', notebookId)
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="my-2">
|
||||
<div className="px-4 mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('nav.notebooks')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2">
|
||||
<div className="text-xs text-gray-500">{t('common.loading')}</div>
|
||||
</div>
|
||||
@@ -109,93 +126,143 @@ export function NotebooksList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Notebooks Section */}
|
||||
<div className="my-2">
|
||||
{/* Section Header */}
|
||||
<div className="px-4 flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
{t('nav.notebooks')}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
<div className="flex flex-col pt-1">
|
||||
{/* Header with Add Button */}
|
||||
<div className="flex items-center justify-between px-6 py-2 mt-2 group cursor-pointer text-gray-500 hover:text-gray-800 dark:hover:text-gray-300">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">{t('nav.notebooks') || 'NOTEBOOKS'}</span>
|
||||
<button
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full transition-colors"
|
||||
title={t('notebooks.create') || 'Create notebook'}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* "Notes générales" (Inbox) */}
|
||||
<button
|
||||
onClick={() => handleSelectNotebook(null)}
|
||||
onDrop={(e) => handleDrop(e, null)}
|
||||
onDragOver={(e) => handleDragOver(e, null)}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200",
|
||||
!currentNotebookId && pathname === '/' && !searchParams.get('search')
|
||||
? "bg-[#FEF3C6] text-amber-900 shadow-lg"
|
||||
: "text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800/50 hover:translate-x-1"
|
||||
)}
|
||||
>
|
||||
<StickyNote className="h-5 w-5" />
|
||||
<span className={cn("text-sm font-medium", !currentNotebookId && pathname === '/' && !searchParams.get('search') && "font-semibold")}>{t('nav.generalNotes')}</span>
|
||||
</button>
|
||||
|
||||
{/* Notebooks List */}
|
||||
{/* Notebooks Loop */}
|
||||
{notebooks.map((notebook: any) => {
|
||||
const isActive = currentNotebookId === notebook.id
|
||||
const isExpanded = expandedNotebook === notebook.id
|
||||
const isDragOver = dragOverNotebookId === notebook.id
|
||||
|
||||
// Get the icon component
|
||||
// Get icon component
|
||||
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
||||
|
||||
return (
|
||||
<div key={notebook.id} className="group flex items-center">
|
||||
<button
|
||||
onClick={() => handleSelectNotebook(notebook.id)}
|
||||
onDrop={(e) => handleDrop(e, notebook.id)}
|
||||
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200",
|
||||
isActive
|
||||
? "bg-[#FEF3C6] text-amber-900 shadow-lg"
|
||||
: "text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800/50 hover:translate-x-1",
|
||||
isDragOver && "ring-2 ring-blue-500 ring-dashed"
|
||||
)}
|
||||
>
|
||||
{/* Icon with notebook color */}
|
||||
<div key={notebook.id} className="group flex flex-col">
|
||||
{isActive ? (
|
||||
// Active notebook with expanded labels - STYLE MATCH Sidebar
|
||||
<div
|
||||
className="h-5 w-5 rounded flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: isActive ? 'white' : notebook.color || '#6B7280',
|
||||
color: isActive ? (notebook.color || '#6B7280') : 'white'
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, notebook.id)}
|
||||
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"flex flex-col mr-2 rounded-r-full overflow-hidden transition-all",
|
||||
!notebook.color && "bg-blue-50 dark:bg-blue-900/20",
|
||||
isDragOver && "ring-2 ring-blue-500 ring-dashed"
|
||||
)}
|
||||
style={notebook.color ? { backgroundColor: `${notebook.color}20` } : undefined}
|
||||
>
|
||||
<NotebookIcon className="h-3 w-3" />
|
||||
</div>
|
||||
<span className={cn("truncate flex-1 text-left text-sm", isActive && "font-semibold")}>{notebook.name}</span>
|
||||
{notebook.notesCount > 0 && (
|
||||
<span className={cn(
|
||||
"ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded",
|
||||
isActive
|
||||
? "bg-amber-900/20 text-amber-900"
|
||||
: "text-gray-500"
|
||||
)}>
|
||||
{notebook.notesCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Header - allow pointer events for expand button */}
|
||||
<div className="pointer-events-auto flex items-center justify-between px-6 py-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<NotebookIcon
|
||||
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-blue-700 dark:text-blue-100")}
|
||||
style={notebook.color ? { color: notebook.color } : undefined}
|
||||
/>
|
||||
<span
|
||||
className={cn("text-sm font-medium tracking-wide truncate max-w-[120px]", !notebook.color && "text-blue-700 dark:text-blue-100")}
|
||||
style={notebook.color ? { color: notebook.color } : undefined}
|
||||
>
|
||||
{notebook.name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleExpand(notebook.id)}
|
||||
className={cn("transition-colors p-1 flex-shrink-0", !notebook.color && "text-blue-600 hover:text-blue-800 dark:text-blue-200 dark:hover:text-blue-100")}
|
||||
style={notebook.color ? { color: notebook.color } : undefined}
|
||||
>
|
||||
<ChevronDown className={cn("w-4 h-4 transition-transform", isExpanded && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions (visible on hover) */}
|
||||
<NotebookActions
|
||||
notebook={notebook}
|
||||
onEdit={() => setEditingNotebook(notebook)}
|
||||
onDelete={() => setDeletingNotebook(notebook)}
|
||||
onSummary={() => setSummaryNotebook(notebook)} // NEW: Summary action (IA6)
|
||||
/>
|
||||
{/* Contextual Labels Tree */}
|
||||
{isExpanded && labels.length > 0 && (
|
||||
<div className="flex flex-col pb-2">
|
||||
{labels.map((label: any) => (
|
||||
<button
|
||||
key={label.id}
|
||||
onClick={() => handleLabelFilter(label.name, notebook.id)}
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-center gap-4 pl-12 pr-4 py-2 hover:bg-black/5 dark:hover:bg-white/10 transition-colors rounded-r-full mr-2",
|
||||
searchParams.get('labels')?.includes(label.name) && "font-bold text-gray-900 dark:text-white"
|
||||
)}
|
||||
>
|
||||
<Tag className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{label.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => router.push('/settings/labels')}
|
||||
className="pointer-events-auto flex items-center gap-2 pl-12 pr-4 py-2 mt-1 text-gray-500 hover:text-gray-800 hover:bg-black/5 dark:hover:bg-white/10 rounded-r-full mr-2 transition-colors group/label"
|
||||
>
|
||||
<Plus className="w-3 h-3 group-hover/label:scale-110 transition-transform" />
|
||||
<span className="text-xs font-medium opacity-80">{t('sidebar.editLabels') || 'Edit Labels'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Inactive notebook
|
||||
<div
|
||||
onDrop={(e) => handleDrop(e, notebook.id)}
|
||||
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"flex items-center group relative",
|
||||
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-r-full mr-2"
|
||||
)}
|
||||
>
|
||||
<div className="w-full flex">
|
||||
<button
|
||||
onClick={() => handleSelectNotebook(notebook.id)}
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-r-full mr-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pr-10",
|
||||
isDragOver && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm font-medium tracking-wide truncate flex-1 text-left">{notebook.name}</span>
|
||||
{notebook.notesCount > 0 && (
|
||||
<span className="text-xs text-gray-400 ml-2 flex-shrink-0">({notebook.notesCount})</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expand button separate from main click */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleExpand(notebook.id); }}
|
||||
className={cn(
|
||||
"absolute right-4 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 opacity-0 group-hover:opacity-100",
|
||||
expandedNotebook === notebook.id && "opacity-100 rotate-180"
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions (visible on hover) */}
|
||||
<div className="absolute right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10" style={{ right: '40px' }}>
|
||||
<NotebookActions
|
||||
notebook={notebook}
|
||||
onEdit={() => setEditingNotebook(notebook)}
|
||||
onDelete={() => setDeletingNotebook(notebook)}
|
||||
onSummary={() => setSummaryNotebook(notebook)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -229,7 +296,7 @@ export function NotebooksList() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notebook Summary Dialog (IA6) */}
|
||||
{/* Notebook Summary Dialog */}
|
||||
<NotebookSummaryDialog
|
||||
open={!!summaryNotebook}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
29
keep-notes/components/providers-wrapper.tsx
Normal file
29
keep-notes/components/providers-wrapper.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { LanguageProvider } from '@/lib/i18n/LanguageProvider'
|
||||
import { LabelProvider } from '@/context/LabelContext'
|
||||
import { NotebooksProvider } from '@/context/notebooks-context'
|
||||
import { NotebookDragProvider } from '@/context/notebook-drag-context'
|
||||
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface ProvidersWrapperProps {
|
||||
children: ReactNode
|
||||
initialLanguage?: string
|
||||
}
|
||||
|
||||
export function ProvidersWrapper({ children, initialLanguage = 'en' }: ProvidersWrapperProps) {
|
||||
return (
|
||||
<NoteRefreshProvider>
|
||||
<LabelProvider>
|
||||
<NotebooksProvider>
|
||||
<NotebookDragProvider>
|
||||
<LanguageProvider initialLanguage={initialLanguage as any}>
|
||||
{children}
|
||||
</LanguageProvider>
|
||||
</NotebookDragProvider>
|
||||
</NotebooksProvider>
|
||||
</LabelProvider>
|
||||
</NoteRefreshProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface Section {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
href: string
|
||||
}
|
||||
|
||||
interface SettingsSearchProps {
|
||||
onSearch: (query: string) => void
|
||||
sections: Section[]
|
||||
onFilter: (filteredSections: Section[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsSearch({
|
||||
onSearch,
|
||||
sections,
|
||||
onFilter,
|
||||
placeholder = 'Search settings...',
|
||||
className
|
||||
}: SettingsSearchProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [filteredSections, setFilteredSections] = useState<Section[]>(sections)
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setQuery(value)
|
||||
onSearch(value)
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setFilteredSections(sections)
|
||||
return
|
||||
}
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
const filtered = sections.filter(section => {
|
||||
const labelMatch = section.label.toLowerCase().includes(queryLower)
|
||||
const descMatch = section.description.toLowerCase().includes(queryLower)
|
||||
return labelMatch || descMatch
|
||||
})
|
||||
setFilteredSections(filtered)
|
||||
}, [query, sections])
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setQuery('')
|
||||
setFilteredSections(sections)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClearSearch()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setQuery(value)
|
||||
}
|
||||
|
||||
const hasResults = query.trim() && filteredSections.length < sections.length
|
||||
const isEmptySearch = query.trim() && filteredSections.length === 0
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="pl-10"
|
||||
autoFocus
|
||||
/>
|
||||
{hasResults && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-2 top-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{isEmptySearch && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 p-2 bg-white rounded-lg shadow-lg border z-50">
|
||||
<p className="text-sm text-gray-600">No settings found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,232 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { StickyNote, Bell, Archive, Trash2, Tag, Settings, User, Shield, LogOut, Heart, Clock, Sparkles, X } from 'lucide-react'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { NotebooksList } from './notebooks-list'
|
||||
import { useSession, signOut } from 'next-auth/react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
Lightbulb,
|
||||
Bell,
|
||||
Tag,
|
||||
Archive,
|
||||
Trash2,
|
||||
Pencil,
|
||||
ChevronRight,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { LABEL_COLORS } from '@/lib/types'
|
||||
import { NotebooksList } from './notebooks-list'
|
||||
|
||||
export function Sidebar({ className, user }: { className?: string, user?: any }) {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { labels, getLabelColor } = useLabels()
|
||||
const [isLabelsExpanded, setIsLabelsExpanded] = useState(false)
|
||||
const { data: session } = useSession()
|
||||
const { labels } = useLabels()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const currentUser = user || session?.user
|
||||
// Helper to determine if a link is active
|
||||
const isActive = (href: string, exact = false) => {
|
||||
if (href === '/') {
|
||||
// Home is active only if no special filters are applied
|
||||
return pathname === '/' &&
|
||||
!searchParams.get('label') &&
|
||||
!searchParams.get('archived') &&
|
||||
!searchParams.get('trashed')
|
||||
}
|
||||
|
||||
const currentLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const currentSearch = searchParams.get('search')
|
||||
const currentNotebookId = searchParams.get('notebook')
|
||||
// For labels
|
||||
if (href.startsWith('/?label=')) {
|
||||
const labelParam = searchParams.get('label')
|
||||
// Extract label from href
|
||||
const labelFromHref = href.split('=')[1]
|
||||
return labelParam === labelFromHref
|
||||
}
|
||||
|
||||
// Show first 5 labels by default, or all if expanded
|
||||
const displayedLabels = isLabelsExpanded ? labels : labels.slice(0, 5)
|
||||
const hasMoreLabels = labels.length > 5
|
||||
// For other routes
|
||||
return pathname === href
|
||||
}
|
||||
|
||||
const userRole = (currentUser as any)?.role || 'USER'
|
||||
const userInitials = currentUser?.name
|
||||
? currentUser.name.split(' ').map((n: string) => n[0]).join('').toUpperCase().substring(0, 2)
|
||||
: 'U'
|
||||
|
||||
const NavItem = ({ href, icon: Icon, label, active, onClick, iconColorClass, count }: any) => (
|
||||
const NavItem = ({ href, icon: Icon, label, active }: any) => (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200",
|
||||
"flex items-center gap-4 px-6 py-3 rounded-r-full mr-2 transition-colors",
|
||||
"text-sm font-medium tracking-wide",
|
||||
active
|
||||
? "bg-[#EFB162] text-amber-900 shadow-lg shadow-amber-500/20"
|
||||
: "text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800/50 hover:translate-x-1"
|
||||
? "bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-100"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-5 w-5", active && "text-amber-900", !active && "group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors", !active && iconColorClass)} />
|
||||
<span className={cn("text-sm font-medium", active && "font-semibold")}>{label}</span>
|
||||
{count && (
|
||||
<span className="ml-auto text-[10px] font-medium bg-amber-900/20 px-1.5 py-0.5 rounded text-amber-900">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
<Icon className={cn("w-5 h-5", active ? "fill-current" : "")} />
|
||||
<span className="truncate">{label}</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<aside className={cn(
|
||||
"w-72 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border-r border-white/20 dark:border-slate-700/50 flex-shrink-0 hidden lg:flex flex-col h-full z-20 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.1)] relative transition-all duration-300",
|
||||
"w-[280px] flex-none flex-col bg-white dark:bg-[#1e2128] overflow-y-auto hidden md:flex py-2",
|
||||
className
|
||||
)}>
|
||||
{/* Logo Section */}
|
||||
<div className="h-20 flex items-center px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-yellow-400 to-amber-500 flex items-center justify-center text-white shadow-lg shadow-yellow-500/30 transform hover:rotate-6 transition-transform duration-300">
|
||||
<StickyNote className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-bold tracking-tight text-slate-900 dark:text-white leading-none">Memento</span>
|
||||
<span className="text-[10px] font-medium text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-1">{t('nav.workspace')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Main Navigation */}
|
||||
<div className="flex flex-col">
|
||||
<NavItem
|
||||
href="/"
|
||||
icon={Lightbulb}
|
||||
label={t('sidebar.notes') || 'Notes'}
|
||||
active={isActive('/')}
|
||||
/>
|
||||
<NavItem
|
||||
href="/reminders"
|
||||
icon={Bell}
|
||||
label={t('sidebar.reminders') || 'Rappels'}
|
||||
active={isActive('/reminders')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-2 space-y-8 scroll-smooth">
|
||||
{/* Quick Access Section */}
|
||||
<div>
|
||||
<p className="px-2 text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">{t('nav.quickAccess')}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Favorites - Coming Soon */}
|
||||
<button className="flex flex-col items-start p-3 rounded-2xl bg-white dark:bg-slate-800 shadow-sm hover:shadow-md border border-slate-100 dark:border-slate-700/50 group transition-all duration-200 hover:-translate-y-1 opacity-60 cursor-not-allowed">
|
||||
<div className="w-8 h-8 rounded-lg bg-rose-50 dark:bg-rose-900/20 text-rose-500 flex items-center justify-center mb-2">
|
||||
<Heart className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-600 dark:text-slate-300">{t('nav.favorites') || 'Favorites'}</span>
|
||||
</button>
|
||||
|
||||
{/* Recent - Coming Soon */}
|
||||
<button className="flex flex-col items-start p-3 rounded-2xl bg-white dark:bg-slate-800 shadow-sm hover:shadow-md border border-slate-100 dark:border-slate-700/50 group transition-all duration-200 hover:-translate-y-1 opacity-60 cursor-not-allowed">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-500 flex items-center justify-center mb-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-600 dark:text-slate-300">{t('nav.recent') || 'Recent'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My Library Section */}
|
||||
<nav className="space-y-1">
|
||||
<p className="px-2 text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">{t('nav.myLibrary') || 'My Library'}</p>
|
||||
<NavItem
|
||||
href="/"
|
||||
icon={StickyNote}
|
||||
label={t('nav.notes')}
|
||||
active={pathname === '/' && currentLabels.length === 0 && !currentSearch && !currentNotebookId}
|
||||
/>
|
||||
<NavItem
|
||||
href="/reminders"
|
||||
icon={Bell}
|
||||
label={t('nav.reminders')}
|
||||
active={pathname === '/reminders'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/archive"
|
||||
icon={Archive}
|
||||
label={t('nav.archive')}
|
||||
active={pathname === '/archive'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/trash"
|
||||
icon={Trash2}
|
||||
label={t('nav.trash')}
|
||||
active={pathname === '/trash'}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{/* Notebooks Section */}
|
||||
{/* Notebooks Section */}
|
||||
<div className="flex flex-col mt-2">
|
||||
<NotebooksList />
|
||||
|
||||
{/* Labels Section - Contextual per notebook */}
|
||||
{currentNotebookId && (
|
||||
<nav className="space-y-1">
|
||||
<p className="px-2 text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">{t('labels.title')}</p>
|
||||
{displayedLabels.map(label => {
|
||||
const colorName = getLabelColor(label.name)
|
||||
const colorClass = LABEL_COLORS[colorName]?.icon
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
key={label.id}
|
||||
href={`/?labels=${encodeURIComponent(label.name)}¬ebook=${encodeURIComponent(currentNotebookId)}`}
|
||||
icon={Tag}
|
||||
label={label.name}
|
||||
active={currentLabels.includes(label.name)}
|
||||
iconColorClass={colorClass}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{hasMoreLabels && (
|
||||
<button
|
||||
onClick={() => setIsLabelsExpanded(!isLabelsExpanded)}
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800/50 rounded-xl transition-all duration-200 hover:translate-x-1 w-full"
|
||||
>
|
||||
<Tag className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
{isLabelsExpanded ? t('labels.showLess') : t('labels.showMore')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Profile Section */}
|
||||
<div className="p-4 mt-auto bg-white/50 dark:bg-slate-800/30 backdrop-blur-sm border-t border-slate-200 dark:border-slate-800">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 cursor-pointer transition-colors group w-full">
|
||||
<div className="relative">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={currentUser?.image || ''} alt={currentUser?.name || ''} />
|
||||
<AvatarFallback className="bg-gradient-to-tr from-amber-400 to-orange-500 text-white text-xs font-bold shadow-sm">
|
||||
{userInitials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white truncate">{currentUser?.name}</p>
|
||||
<p className="text-[10px] text-slate-500 truncate">{t('nav.proPlan') || 'Pro Plan'}</p>
|
||||
</div>
|
||||
<Settings className="text-slate-400 group-hover:text-indigo-600 transition-colors h-5 w-5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{currentUser?.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{currentUser?.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => router.push('/settings/profile')}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>{t('nav.profile')}</span>
|
||||
</DropdownMenuItem>
|
||||
{userRole === 'ADMIN' && (
|
||||
<DropdownMenuItem onClick={() => router.push('/admin')}>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
<span>{t('nav.adminDashboard')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => router.push('/settings')}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>{t('nav.diagnostics')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => signOut({ callbackUrl: '/login' })}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{t('nav.logout')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
{/* Archive & Trash */}
|
||||
<div className="flex flex-col mt-2 border-t border-transparent">
|
||||
<NavItem
|
||||
href="/archive"
|
||||
icon={Archive}
|
||||
label={t('sidebar.archive') || 'Archives'}
|
||||
active={pathname === '/archive'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/trash"
|
||||
icon={Trash2}
|
||||
label={t('sidebar.trash') || 'Corbeille'}
|
||||
active={pathname === '/trash'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer / Copyright / Terms */}
|
||||
<div className="mt-auto px-6 py-4 text-[10px] text-gray-400">
|
||||
<div className="flex gap-2 mb-1">
|
||||
<Link href="#" className="hover:underline">Confidentialité</Link>
|
||||
<span>•</span>
|
||||
<Link href="#" className="hover:underline">Conditions</Link>
|
||||
</div>
|
||||
<p>Open Source Clone</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
83
keep-notes/components/theme-initializer.tsx
Normal file
83
keep-notes/components/theme-initializer.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface ThemeInitializerProps {
|
||||
theme?: string
|
||||
fontSize?: string
|
||||
}
|
||||
|
||||
export function ThemeInitializer({ theme, fontSize }: ThemeInitializerProps) {
|
||||
useEffect(() => {
|
||||
console.log('[ThemeInitializer] Received theme:', theme)
|
||||
// Helper to apply theme
|
||||
const applyTheme = (t?: string) => {
|
||||
console.log('[ThemeInitializer] Applying theme:', t)
|
||||
if (!t) return
|
||||
|
||||
const root = document.documentElement
|
||||
|
||||
// Reset
|
||||
root.removeAttribute('data-theme')
|
||||
root.classList.remove('dark')
|
||||
|
||||
if (t === 'auto') {
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
if (systemDark) root.classList.add('dark')
|
||||
} else if (t === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else if (t === 'light') {
|
||||
// Default, nothing needed usually if light is default, but ensuring no 'dark' class
|
||||
} else {
|
||||
// Named theme
|
||||
root.setAttribute('data-theme', t)
|
||||
// Check if theme implies dark mode (e.g. midnight)
|
||||
if (['midnight'].includes(t)) {
|
||||
root.classList.add('dark')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to apply font size
|
||||
const applyFontSize = (s?: string) => {
|
||||
const size = s || 'medium'
|
||||
|
||||
const fontSizeMap: Record<string, string> = {
|
||||
'small': '14px',
|
||||
'medium': '16px',
|
||||
'large': '18px',
|
||||
'extra-large': '20px'
|
||||
}
|
||||
|
||||
const fontSizeFactorMap: Record<string, number> = {
|
||||
'small': 0.95,
|
||||
'medium': 1.0,
|
||||
'large': 1.1,
|
||||
'extra-large': 1.25
|
||||
}
|
||||
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--user-font-size', fontSizeMap[size] || '16px')
|
||||
root.style.setProperty('--user-font-size-factor', (fontSizeFactorMap[size] || 1).toString())
|
||||
}
|
||||
|
||||
// CRITICAL: Use localStorage as the source of truth (it's always fresh)
|
||||
// Server prop may be stale due to caching.
|
||||
const localTheme = localStorage.getItem('theme-preference')
|
||||
const effectiveTheme = localTheme || theme
|
||||
|
||||
console.log('[ThemeInitializer] Local theme:', localTheme, '| Server theme:', theme, '| Using:', effectiveTheme)
|
||||
|
||||
applyTheme(effectiveTheme)
|
||||
|
||||
// Only sync to localStorage if it was empty (first visit after login)
|
||||
// NEVER overwrite with server value if localStorage already has a value
|
||||
if (!localTheme && theme) {
|
||||
localStorage.setItem('theme-preference', theme)
|
||||
}
|
||||
|
||||
applyFontSize(fontSize)
|
||||
}, [theme, fontSize])
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user