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:
2026-01-18 22:33:41 +01:00
parent ef60dafd73
commit ddb67ba9e5
306 changed files with 59580 additions and 6063 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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}

View File

@@ -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>
)}
</>
)
}

View File

@@ -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>
)}

View 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;
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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) => {

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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)}&notebook=${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>
)

View 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
}