feat: rename keep-notes to memento-note, migrate to PostgreSQL, fix MCP bugs
- Rename directory keep-notes -> memento-note with all code references - Prisma: SQLite -> PostgreSQL (both app and MCP server schemas) - Sync MCP schema with main app (add missing fields, relations, indexes) - Delete 17 SQLite migrations (clean slate for PostgreSQL) - Remove SQLite dependencies (@libsql/client, better-sqlite3, etc.) - Fix MCP server: hardcoded Windows DB paths -> DATABASE_URL env var - Fix MCP server: .dockerignore excluded index-sse.js (SSE mode broken) - MCP Dockerfile: node:20 -> node:22 - Docker Compose: add postgres service, remove SQLite volume - Generate favicon.ico, icon-192.png, icon-512.png, apple-icon.png - Update layout.tsx icons and manifest.json for PNG icons - Update all .env files for PostgreSQL - Rewrite README.md with updated sections - Remove mcp-server/node_modules and prisma/client-generated from git tracking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
19
memento-note/components/admin-content-area.tsx
Normal file
19
memento-note/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
memento-note/components/admin-metrics.tsx
Normal file
70
memento-note/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>
|
||||
)
|
||||
}
|
||||
17
memento-note/components/admin-page-header.tsx
Normal file
17
memento-note/components/admin-page-header.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function AdminPageHeader() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<h1 className="text-3xl font-bold">{t('nav.userManagement')}</h1>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsButton() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return t('settings.title')
|
||||
}
|
||||
75
memento-note/components/admin-sidebar.tsx
Normal file
75
memento-note/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>
|
||||
)
|
||||
}
|
||||
149
memento-note/components/ai-assistant-action-bar.tsx
Normal file
149
memento-note/components/ai-assistant-action-bar.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sparkles, Lightbulb, Scissors, Wand2, FileText, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
|
||||
interface AIAssistantActionBarProps {
|
||||
onClarify?: () => void
|
||||
onShorten?: () => void
|
||||
onImprove?: () => void
|
||||
onTransformMarkdown?: () => void
|
||||
isMarkdownMode?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AIAssistantActionBar({
|
||||
onClarify,
|
||||
onShorten,
|
||||
onImprove,
|
||||
onTransformMarkdown,
|
||||
isMarkdownMode = false,
|
||||
disabled = false,
|
||||
className
|
||||
}: AIAssistantActionBarProps) {
|
||||
const { t } = useLanguage()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const handleAction = async (action: () => void) => {
|
||||
if (!disabled) {
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'ai-action-bar',
|
||||
'bg-amber-50 dark:bg-amber-950/20',
|
||||
'border border-amber-200 dark:border-amber-800',
|
||||
'rounded-lg shadow-md',
|
||||
'transition-all duration-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header with toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 cursor-pointer select-none hover:bg-amber-100/50 dark:hover:bg-amber-900/30 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Sparkles className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-amber-700 dark:text-amber-300">
|
||||
{t('ai.assistant')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 hover:bg-amber-200/50 dark:hover:bg-amber-800/30"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsExpanded(!isExpanded)
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3 w-3 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 flex flex-wrap gap-2">
|
||||
{/* Clarify */}
|
||||
{onClarify && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
onClick={() => handleAction(onClarify)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Lightbulb className="h-3 w-3 mr-1 text-amber-600 dark:text-amber-400" />
|
||||
{t('ai.clarify')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Shorten */}
|
||||
{onShorten && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
onClick={() => handleAction(onShorten)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Scissors className="h-3 w-3 mr-1 text-primary dark:text-primary-foreground" />
|
||||
{t('ai.shorten')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Improve Style */}
|
||||
{onImprove && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
onClick={() => handleAction(onImprove)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Wand2 className="h-3 w-3 mr-1 text-purple-600 dark:text-purple-400" />
|
||||
{t('ai.improveStyle')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Transform to Markdown */}
|
||||
{onTransformMarkdown && !isMarkdownMode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-white dark:bg-zinc-800 hover:bg-emerald-50 dark:hover:bg-emerald-950/20 border-emerald-300 dark:border-emerald-700 hover:border-emerald-400 dark:hover:border-emerald-600 text-emerald-700 dark:text-emerald-400 transition-colors font-medium"
|
||||
onClick={() => handleAction(onTransformMarkdown)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
{t('ai.transformMarkdown') || 'Transformer en Markdown'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Already in markdown mode indicator */}
|
||||
{isMarkdownMode && (
|
||||
<div className="h-7 px-2 text-xs flex items-center bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 rounded border border-emerald-200 dark:border-emerald-800 font-medium">
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Markdown
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
266
memento-note/components/ai/ai-settings-panel.tsx
Normal file
266
memento-note/components/ai/ai-settings-panel.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { DemoModeToggle } from '@/components/demo-mode-toggle'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AISettingsPanelProps {
|
||||
initialSettings: {
|
||||
titleSuggestions: boolean
|
||||
semanticSearch: boolean
|
||||
paragraphRefactor: boolean
|
||||
memoryEcho: boolean
|
||||
memoryEchoFrequency: 'daily' | 'weekly' | 'custom'
|
||||
aiProvider: 'auto' | 'openai' | 'ollama'
|
||||
preferredLanguage: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
|
||||
demoMode: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
const [settings, setSettings] = useState(initialSettings)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleToggle = async (feature: string, value: boolean) => {
|
||||
// Optimistic update
|
||||
setSettings(prev => ({ ...prev, [feature]: value }))
|
||||
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ [feature]: value })
|
||||
toast.success(t('aiSettings.saved'))
|
||||
} catch (error) {
|
||||
console.error('Error updating setting:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
// Revert on error
|
||||
setSettings(initialSettings)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
|
||||
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
|
||||
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ memoryEchoFrequency: value })
|
||||
toast.success(t('aiSettings.saved'))
|
||||
} catch (error) {
|
||||
console.error('Error updating frequency:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(initialSettings)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProviderChange = async (value: 'auto' | 'openai' | 'ollama') => {
|
||||
setSettings(prev => ({ ...prev, aiProvider: value }))
|
||||
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ aiProvider: value })
|
||||
toast.success(t('aiSettings.saved'))
|
||||
} catch (error) {
|
||||
console.error('Error updating provider:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(initialSettings)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLanguageChange = async (value: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl') => {
|
||||
setSettings(prev => ({ ...prev, preferredLanguage: value }))
|
||||
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ preferredLanguage: value })
|
||||
toast.success(t('aiSettings.saved'))
|
||||
} catch (error) {
|
||||
console.error('Error updating language:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(initialSettings)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDemoModeToggle = async (enabled: boolean) => {
|
||||
setSettings(prev => ({ ...prev, demoMode: enabled }))
|
||||
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ demoMode: enabled })
|
||||
} catch (error) {
|
||||
console.error('Error toggling demo mode:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(initialSettings)
|
||||
throw error
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('aiSettings.saving')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature Toggles */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">{t('aiSettings.features')}</h2>
|
||||
|
||||
<FeatureToggle
|
||||
name={t('titleSuggestions.available').replace('💡 ', '')}
|
||||
description={t('aiSettings.titleSuggestionsDesc')}
|
||||
checked={settings.titleSuggestions}
|
||||
onChange={(checked) => handleToggle('titleSuggestions', checked)}
|
||||
/>
|
||||
|
||||
<FeatureToggle
|
||||
name={t('semanticSearch.exactMatch')}
|
||||
description={t('semanticSearch.searching')}
|
||||
checked={settings.semanticSearch}
|
||||
onChange={(checked) => handleToggle('semanticSearch', checked)}
|
||||
/>
|
||||
|
||||
<FeatureToggle
|
||||
name={t('paragraphRefactor.title')}
|
||||
description={t('aiSettings.paragraphRefactorDesc')}
|
||||
checked={settings.paragraphRefactor}
|
||||
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
||||
/>
|
||||
|
||||
<FeatureToggle
|
||||
name={t('memoryEcho.title')}
|
||||
description={t('memoryEcho.dailyInsight')}
|
||||
checked={settings.memoryEcho}
|
||||
onChange={(checked) => handleToggle('memoryEcho', checked)}
|
||||
/>
|
||||
|
||||
{settings.memoryEcho && (
|
||||
<Card className="p-4 ml-6">
|
||||
<Label htmlFor="frequency" className="text-sm font-medium">
|
||||
{t('aiSettings.frequency')}
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{t('aiSettings.frequencyDesc')}
|
||||
</p>
|
||||
<RadioGroup
|
||||
value={settings.memoryEchoFrequency}
|
||||
onValueChange={handleFrequencyChange}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="daily" id="daily" />
|
||||
<Label htmlFor="daily" className="font-normal">
|
||||
{t('aiSettings.frequencyDaily')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="weekly" id="weekly" />
|
||||
<Label htmlFor="weekly" className="font-normal">
|
||||
{t('aiSettings.frequencyWeekly')}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Demo Mode Toggle */}
|
||||
<DemoModeToggle
|
||||
demoMode={settings.demoMode}
|
||||
onToggle={handleDemoModeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI Provider Selection */}
|
||||
<Card className="p-4">
|
||||
<Label className="text-base font-medium mb-1">{t('aiSettings.provider')}</Label>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{t('aiSettings.providerDesc')}
|
||||
</p>
|
||||
|
||||
<RadioGroup
|
||||
value={settings.aiProvider}
|
||||
onValueChange={handleProviderChange}
|
||||
>
|
||||
<div className="flex items-start space-x-2 py-2">
|
||||
<RadioGroupItem value="auto" id="auto" />
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="auto" className="font-medium">
|
||||
{t('aiSettings.providerAuto')}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('aiSettings.providerAutoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2 py-2">
|
||||
<RadioGroupItem value="ollama" id="ollama" />
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="ollama" className="font-medium">
|
||||
{t('aiSettings.providerOllama')}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('aiSettings.providerOllamaDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2 py-2">
|
||||
<RadioGroupItem value="openai" id="openai" />
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="openai" className="font-medium">
|
||||
{t('aiSettings.providerOpenAI')}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('aiSettings.providerOpenAIDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FeatureToggleProps {
|
||||
name: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
function FeatureToggle({ name, description, checked, onChange }: FeatureToggleProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-medium">{name}</Label>
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
11
memento-note/components/archive-header.tsx
Normal file
11
memento-note/components/archive-header.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function ArchiveHeader() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<h1 className="text-3xl font-bold mb-8">{t('nav.archive')}</h1>
|
||||
)
|
||||
}
|
||||
227
memento-note/components/auto-label-suggestion-dialog.tsx
Normal file
227
memento-note/components/auto-label-suggestion-dialog.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from './ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog'
|
||||
import { Checkbox } from './ui/checkbox'
|
||||
import { Tag, Loader2, Sparkles, CheckCircle2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { AutoLabelSuggestion, SuggestedLabel } from '@/lib/ai/services'
|
||||
|
||||
interface AutoLabelSuggestionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
notebookId: string | null
|
||||
onLabelsCreated: () => void
|
||||
}
|
||||
|
||||
export function AutoLabelSuggestionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
notebookId,
|
||||
onLabelsCreated,
|
||||
}: AutoLabelSuggestionDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [suggestions, setSuggestions] = useState<AutoLabelSuggestion | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [selectedLabels, setSelectedLabels] = useState<Set<string>>(new Set())
|
||||
|
||||
// Fetch suggestions when dialog opens with a notebook
|
||||
useEffect(() => {
|
||||
if (open && notebookId) {
|
||||
fetchSuggestions()
|
||||
} else {
|
||||
// Reset state when closing
|
||||
setSuggestions(null)
|
||||
setSelectedLabels(new Set())
|
||||
}
|
||||
}, [open, notebookId])
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
if (!notebookId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/auto-labels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
notebookId,
|
||||
language: document.documentElement.lang || 'en',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.data) {
|
||||
setSuggestions(data.data)
|
||||
// Select all labels by default
|
||||
const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string))
|
||||
setSelectedLabels(allLabelNames)
|
||||
} else {
|
||||
// No suggestions is not an error - just close the dialog
|
||||
if (data.message) {
|
||||
}
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch label suggestions:', error)
|
||||
toast.error('Failed to fetch label suggestions')
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLabelSelection = (labelName: string) => {
|
||||
const newSelected = new Set(selectedLabels)
|
||||
if (newSelected.has(labelName)) {
|
||||
newSelected.delete(labelName)
|
||||
} else {
|
||||
newSelected.add(labelName)
|
||||
}
|
||||
setSelectedLabels(newSelected)
|
||||
}
|
||||
|
||||
const handleCreateLabels = async () => {
|
||||
if (!suggestions || selectedLabels.size === 0) {
|
||||
toast.error('No labels selected')
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/auto-labels', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
suggestions,
|
||||
selectedLabels: Array.from(selectedLabels),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
t('ai.autoLabels.created', { count: data.data.createdCount }) ||
|
||||
`${data.data.createdCount} labels created successfully`
|
||||
)
|
||||
onLabelsCreated()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to create labels')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create labels:', error)
|
||||
toast.error('Failed to create labels')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{t('ai.autoLabels.analyzing')}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (!suggestions) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
{t('ai.autoLabels.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('ai.autoLabels.description', {
|
||||
notebook: suggestions.notebookName,
|
||||
count: suggestions.totalNotes,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-4">
|
||||
{suggestions.suggestedLabels.map((label) => (
|
||||
<div
|
||||
key={label.name}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => toggleLabelSelection(label.name)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedLabels.has(label.name)}
|
||||
onCheckedChange={() => toggleLabelSelection(label.name)}
|
||||
aria-label={`Select label: ${label.name}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{label.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('ai.autoLabels.notesCount', { count: label.count })}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{Math.round(label.confidence * 100)}% confidence
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={creating}
|
||||
>
|
||||
{t('general.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateLabels}
|
||||
disabled={selectedLabels.size === 0 || creating}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('ai.autoLabels.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
{t('ai.autoLabels.create')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
321
memento-note/components/batch-organization-dialog.tsx
Normal file
321
memento-note/components/batch-organization-dialog.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from './ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog'
|
||||
import { Checkbox } from './ui/checkbox'
|
||||
import { Wand2, Loader2, ChevronRight, CheckCircle2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { OrganizationPlan, NotebookOrganization } from '@/lib/ai/services'
|
||||
|
||||
interface BatchOrganizationDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onNotesMoved: () => void
|
||||
}
|
||||
|
||||
export function BatchOrganizationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onNotesMoved,
|
||||
}: BatchOrganizationDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [plan, setPlan] = useState<OrganizationPlan | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [selectedNotes, setSelectedNotes] = useState<Set<string>>(new Set())
|
||||
|
||||
const fetchOrganizationPlan = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/batch-organize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
language: document.documentElement.lang || 'en'
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.data) {
|
||||
setPlan(data.data)
|
||||
// Select all notes by default
|
||||
const allNoteIds = new Set<string>()
|
||||
data.data.notebooks.forEach((nb: NotebookOrganization) => {
|
||||
nb.notes.forEach(note => allNoteIds.add(note.noteId))
|
||||
})
|
||||
setSelectedNotes(allNoteIds)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to create organization plan')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create organization plan:', error)
|
||||
toast.error('Failed to create organization plan')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
// Reset state when closing
|
||||
setPlan(null)
|
||||
setSelectedNotes(new Set())
|
||||
} else {
|
||||
// Fetch plan when opening
|
||||
fetchOrganizationPlan()
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
const toggleNoteSelection = (noteId: string) => {
|
||||
const newSelected = new Set(selectedNotes)
|
||||
if (newSelected.has(noteId)) {
|
||||
newSelected.delete(noteId)
|
||||
} else {
|
||||
newSelected.add(noteId)
|
||||
}
|
||||
setSelectedNotes(newSelected)
|
||||
}
|
||||
|
||||
const toggleNotebookSelection = (notebook: NotebookOrganization) => {
|
||||
const newSelected = new Set(selectedNotes)
|
||||
const allNoteIds = notebook.notes.map(n => n.noteId)
|
||||
|
||||
// Check if all notes in this notebook are already selected
|
||||
const allSelected = allNoteIds.every(id => newSelected.has(id))
|
||||
|
||||
if (allSelected) {
|
||||
// Deselect all
|
||||
allNoteIds.forEach(id => newSelected.delete(id))
|
||||
} else {
|
||||
// Select all
|
||||
allNoteIds.forEach(id => newSelected.add(id))
|
||||
}
|
||||
|
||||
setSelectedNotes(newSelected)
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!plan || selectedNotes.size === 0) {
|
||||
toast.error('No notes selected')
|
||||
return
|
||||
}
|
||||
|
||||
setApplying(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/batch-organize', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
selectedNoteIds: Array.from(selectedNotes),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
t('ai.batchOrganization.success', { count: data.data.movedCount }) ||
|
||||
`${data.data.movedCount} notes moved successfully`
|
||||
)
|
||||
onNotesMoved()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to apply organization plan')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to apply organization plan:', error)
|
||||
toast.error('Failed to apply organization plan')
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectedCountForNotebook = (notebook: NotebookOrganization) => {
|
||||
return notebook.notes.filter(n => selectedNotes.has(n.noteId)).length
|
||||
}
|
||||
|
||||
const getAllSelectedCount = () => {
|
||||
if (!plan) return 0
|
||||
return plan.notebooks.reduce(
|
||||
(acc, nb) => acc + getSelectedCountForNotebook(nb),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wand2 className="h-5 w-5" />
|
||||
{t('ai.batchOrganization.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('ai.batchOrganization.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{t('ai.batchOrganization.analyzing')}
|
||||
</p>
|
||||
</div>
|
||||
) : plan ? (
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Summary */}
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t('ai.batchOrganization.notesToOrganize', {
|
||||
count: plan.totalNotes,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('ai.batchOrganization.selected', {
|
||||
count: getAllSelectedCount(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No notebooks available */}
|
||||
{plan.notebooks.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
{plan.unorganizedNotes === plan.totalNotes
|
||||
? t('ai.batchOrganization.noNotebooks')
|
||||
: t('ai.batchOrganization.noSuggestions')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Organization plan by notebook */}
|
||||
{plan.notebooks.map((notebook) => {
|
||||
const selectedCount = getSelectedCountForNotebook(notebook)
|
||||
const allSelected =
|
||||
selectedCount === notebook.notes.length && selectedCount > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notebook.notebookId}
|
||||
className="border rounded-lg p-4 space-y-3"
|
||||
>
|
||||
{/* Notebook header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={() => toggleNotebookSelection(notebook)}
|
||||
aria-label={`Select all notes in ${notebook.notebookName}`}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{notebook.notebookIcon}</span>
|
||||
<span className="font-semibold">
|
||||
{notebook.notebookName}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({selectedCount}/{notebook.notes.length})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes in this notebook */}
|
||||
<div className="space-y-2 pl-11">
|
||||
{notebook.notes.map((note) => (
|
||||
<div
|
||||
key={note.noteId}
|
||||
className="flex items-start gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => toggleNoteSelection(note.noteId)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedNotes.has(note.noteId)}
|
||||
onCheckedChange={() => toggleNoteSelection(note.noteId)}
|
||||
aria-label={`Select note: ${note.title || 'Untitled'}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{note.title || t('notes.untitled') || 'Untitled'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{note.content}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{Math.round(note.confidence * 100)}% confidence
|
||||
</span>
|
||||
{note.reason && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{note.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Unorganized notes warning */}
|
||||
{plan.unorganizedNotes > 0 && (
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-900">
|
||||
<ChevronRight className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{t('ai.batchOrganization.unorganized', {
|
||||
count: plan.unorganizedNotes,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={applying}
|
||||
>
|
||||
{t('general.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={!plan || selectedNotes.size === 0 || applying}
|
||||
>
|
||||
{applying ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('ai.batchOrganization.applying')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
{t('ai.batchOrganization.apply', { count: selectedNotes.size })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
73
memento-note/components/collaborator-avatars.tsx
Normal file
73
memento-note/components/collaborator-avatars.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface Collaborator {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
interface CollaboratorAvatarsProps {
|
||||
collaborators: Collaborator[]
|
||||
ownerId: string
|
||||
maxDisplay?: number
|
||||
}
|
||||
|
||||
export function CollaboratorAvatars({ collaborators, ownerId, maxDisplay = 5 }: CollaboratorAvatarsProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
if (collaborators.length === 0) return null
|
||||
|
||||
const displayCollaborators = collaborators.slice(0, maxDisplay)
|
||||
const remainingCount = collaborators.length - maxDisplay
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<TooltipProvider>
|
||||
{displayCollaborators.map((collaborator) => (
|
||||
<Tooltip key={collaborator.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="relative group">
|
||||
<Avatar className="h-6 w-6 border-2 border-background">
|
||||
<AvatarImage src={collaborator.image || undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{collaborator.name?.charAt(0).toUpperCase() || collaborator.email.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{collaborator.id === ownerId && (
|
||||
<div className="absolute -bottom-1 -right-1">
|
||||
<Badge variant="secondary" className="text-[8px] h-3 px-1 min-w-0">
|
||||
{t('collaboration.owner')}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">{collaborator.name || t('collaboration.unnamedUser')}</p>
|
||||
<p className="text-xs text-muted-foreground">{collaborator.email}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{remainingCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs border-2 border-background">
|
||||
+{remainingCount}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{remainingCount} {t('collaboration.canEdit')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
332
memento-note/components/collaborator-dialog.tsx
Normal file
332
memento-note/components/collaborator-dialog.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition, useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { X, Loader2, Mail } from "lucide-react"
|
||||
import { addCollaborator, removeCollaborator, getNoteCollaborators } from "@/app/actions/notes"
|
||||
import { toast } from "sonner"
|
||||
import { useLanguage } from "@/lib/i18n"
|
||||
|
||||
interface Collaborator {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
interface CollaboratorDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
noteId: string
|
||||
noteOwnerId: string
|
||||
currentUserId: string
|
||||
onCollaboratorsChange?: (collaboratorIds: string[]) => void
|
||||
initialCollaborators?: string[]
|
||||
}
|
||||
|
||||
export function CollaboratorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
noteId,
|
||||
noteOwnerId,
|
||||
currentUserId,
|
||||
onCollaboratorsChange,
|
||||
initialCollaborators = []
|
||||
}: CollaboratorDialogProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
|
||||
const [localCollaboratorIds, setLocalCollaboratorIds] = useState<string[]>(initialCollaborators)
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [justAddedCollaborator, setJustAddedCollaborator] = useState(false)
|
||||
const isOwner = currentUserId === noteOwnerId
|
||||
const isCreationMode = !noteId
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
// Load collaborators when dialog opens (only for existing notes)
|
||||
const loadCollaborators = async () => {
|
||||
if (isCreationMode) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await getNoteCollaborators(noteId)
|
||||
setCollaborators(result)
|
||||
hasLoadedRef.current = true
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('collaboration.errorLoading'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load collaborators when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && !isCreationMode && !hasLoadedRef.current && !isLoading) {
|
||||
loadCollaborators()
|
||||
}
|
||||
// Reset when dialog closes
|
||||
if (!open) {
|
||||
hasLoadedRef.current = false
|
||||
}
|
||||
}, [open, isCreationMode])
|
||||
|
||||
// Sync initial collaborators when prop changes (creation mode)
|
||||
useEffect(() => {
|
||||
if (isCreationMode) {
|
||||
setLocalCollaboratorIds(initialCollaborators)
|
||||
}
|
||||
}, [initialCollaborators, isCreationMode])
|
||||
|
||||
// Handle adding a collaborator
|
||||
const handleAddCollaborator = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!email.trim()) return
|
||||
|
||||
if (isCreationMode) {
|
||||
// Creation mode: just add email as placeholder, will be resolved on note creation
|
||||
if (!localCollaboratorIds.includes(email)) {
|
||||
const newIds = [...localCollaboratorIds, email]
|
||||
setLocalCollaboratorIds(newIds)
|
||||
onCollaboratorsChange?.(newIds)
|
||||
setEmail('')
|
||||
toast.success(t('collaboration.willBeAdded', { email }))
|
||||
} else {
|
||||
toast.warning(t('collaboration.alreadyInList'))
|
||||
}
|
||||
} else {
|
||||
// Existing note mode: use server action
|
||||
setJustAddedCollaborator(true)
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await addCollaborator(noteId, email)
|
||||
|
||||
if (result.success) {
|
||||
setCollaborators([...collaborators, result.user])
|
||||
setEmail('')
|
||||
toast.success(t('collaboration.nowHasAccess', { name: result.user.name || result.user.email }))
|
||||
// Don't refresh here - it would close the dialog!
|
||||
// The collaborator list is already updated in local state
|
||||
setJustAddedCollaborator(false)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('collaboration.failedToAdd'))
|
||||
setJustAddedCollaborator(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle removing a collaborator
|
||||
const handleRemoveCollaborator = async (userId: string) => {
|
||||
if (isCreationMode) {
|
||||
// Creation mode: remove from local list
|
||||
const newIds = localCollaboratorIds.filter(id => id !== userId)
|
||||
setLocalCollaboratorIds(newIds)
|
||||
onCollaboratorsChange?.(newIds)
|
||||
} else {
|
||||
// Existing note mode: use server action
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await removeCollaborator(noteId, userId)
|
||||
setCollaborators(collaborators.filter(c => c.id !== userId))
|
||||
toast.success(t('collaboration.accessRevoked'))
|
||||
// Don't refresh here - it would close the dialog!
|
||||
// The collaborator list is already updated in local state
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('collaboration.failedToRemove'))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onInteractOutside={(event) => {
|
||||
// Prevent dialog from closing when interacting with Sonner toasts
|
||||
// Sonner uses data-sonner-toast NOT data-toast
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const isSonnerElement =
|
||||
target.closest('[data-sonner-toast]') ||
|
||||
target.closest('[data-sonner-toaster]') ||
|
||||
target.closest('[data-icon]') ||
|
||||
target.closest('[data-content]') ||
|
||||
target.closest('[data-description]') ||
|
||||
target.closest('[data-title]') ||
|
||||
target.closest('[data-button]');
|
||||
|
||||
if (isSonnerElement) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Also prevent if target is the toast container itself
|
||||
if (target.getAttribute('data-sonner-toaster') !== null) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('collaboration.shareWithCollaborators')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isOwner
|
||||
? t('collaboration.addCollaboratorDescription')
|
||||
: t('collaboration.viewerDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{isOwner && (
|
||||
<form onSubmit={handleAddCollaborator} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="email" className="sr-only">{t('collaboration.emailAddress')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={t('collaboration.enterEmailAddress')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isPending || !email.trim()}>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
{t('collaboration.invite')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('collaboration.peopleWithAccess')}</Label>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : isCreationMode ? (
|
||||
// Creation mode: show emails
|
||||
localCollaboratorIds.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{t('collaboration.noCollaborators')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{localCollaboratorIds.map((emailOrId, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
data-testid="collaborator-item"
|
||||
className="flex items-center justify-between p-2 rounded-lg border bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
{emailOrId.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{t('collaboration.pendingInvite')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{emailOrId}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="ml-2">{t('collaboration.pending')}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleRemoveCollaborator(emailOrId)}
|
||||
disabled={isPending}
|
||||
aria-label={t('collaboration.remove')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : collaborators.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{t('collaboration.noCollaboratorsViewer')} {isOwner && t('collaboration.noCollaborators').split('.')[1]}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{collaborators.map((collaborator) => (
|
||||
<div
|
||||
key={collaborator.id}
|
||||
data-testid="collaborator-item"
|
||||
className="flex items-center justify-between p-2 rounded-lg border bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={collaborator.image || undefined} />
|
||||
<AvatarFallback>
|
||||
{collaborator.name?.charAt(0).toUpperCase() || collaborator.email.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{collaborator.name || t('collaboration.unnamedUser')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{collaborator.email}
|
||||
</p>
|
||||
</div>
|
||||
{collaborator.id === noteOwnerId && (
|
||||
<Badge variant="secondary" className="ml-2">{t('collaboration.owner')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isOwner && collaborator.id !== noteOwnerId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleRemoveCollaborator(collaborator.id)}
|
||||
disabled={isPending}
|
||||
aria-label={t('collaboration.remove')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('collaboration.done')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
175
memento-note/components/comparison-modal.tsx
Normal file
175
memento-note/components/comparison-modal.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Sparkles, ThumbsUp, ThumbsDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Note } from '@/lib/types'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
|
||||
interface ComparisonModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
notes: Array<Partial<Note>>
|
||||
similarity?: number
|
||||
onOpenNote?: (noteId: string) => void
|
||||
}
|
||||
|
||||
export function ComparisonModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
notes,
|
||||
similarity,
|
||||
onOpenNote
|
||||
}: ComparisonModalProps) {
|
||||
const { t } = useLanguage()
|
||||
const [feedback, setFeedback] = useState<'thumbs_up' | 'thumbs_down' | null>(null)
|
||||
|
||||
const handleFeedback = async (type: 'thumbs_up' | 'thumbs_down') => {
|
||||
setFeedback(type)
|
||||
// TODO: Send feedback to backend
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const getNoteColor = (index: number) => {
|
||||
const colors = [
|
||||
'border-primary/20 dark:border-primary/30 hover:border-primary/30 dark:hover:border-primary/40',
|
||||
'border-purple-200 dark:border-purple-800 hover:border-purple-300 dark:hover:border-purple-700',
|
||||
'border-green-200 dark:border-green-800 hover:border-green-300 dark:hover:border-green-700'
|
||||
]
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
const getTitleColor = (index: number) => {
|
||||
const colors = [
|
||||
'text-primary dark:text-primary-foreground',
|
||||
'text-purple-600 dark:text-purple-400',
|
||||
'text-green-600 dark:text-green-400'
|
||||
]
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
const maxModalWidth = notes.length === 2 ? 'max-w-6xl' : 'max-w-7xl'
|
||||
const similarityPercentage = similarity ? Math.round(similarity * 100) : 0
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className={cn(
|
||||
"max-h-[90vh] overflow-hidden flex flex-col p-0",
|
||||
maxModalWidth
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('memoryEcho.comparison.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('memoryEcho.comparison.similarityInfo', { similarity: similarityPercentage })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Insight Section - Optional for now */}
|
||||
{similarityPercentage >= 80 && (
|
||||
<div className="px-6 py-4 bg-amber-50 dark:bg-amber-950/20 border-b dark:border-zinc-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('memoryEcho.comparison.highSimilarityInsight')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes Grid */}
|
||||
<div className={cn(
|
||||
"flex-1 overflow-y-auto p-6",
|
||||
notes.length === 2 ? "grid grid-cols-2 gap-6" : "grid grid-cols-3 gap-4"
|
||||
)}>
|
||||
{notes.map((note, index) => {
|
||||
const title = note.title || t('memoryEcho.comparison.untitled')
|
||||
const noteColor = getNoteColor(index)
|
||||
const titleColor = getTitleColor(index)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={note.id || index}
|
||||
onClick={() => {
|
||||
if (onOpenNote && note.id) {
|
||||
onOpenNote(note.id)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer border dark:border-zinc-700 rounded-lg p-4 transition-all hover:shadow-md",
|
||||
noteColor
|
||||
)}
|
||||
>
|
||||
<h3 className={cn("font-semibold text-lg mb-3", titleColor)}>
|
||||
{title}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-8 whitespace-pre-wrap">
|
||||
{note.content}
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t dark:border-zinc-700">
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
{t('memoryEcho.comparison.clickToView')}
|
||||
<span className="transform rotate-[-45deg]">→</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer - Feedback */}
|
||||
<div className="px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('memoryEcho.comparison.helpfulQuestion')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={feedback === 'thumbs_up' ? 'default' : 'outline'}
|
||||
onClick={() => handleFeedback('thumbs_up')}
|
||||
className={cn(
|
||||
feedback === 'thumbs_up' && "bg-green-600 hover:bg-green-700 text-white"
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.comparison.helpful')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={feedback === 'thumbs_down' ? 'default' : 'outline'}
|
||||
onClick={() => handleFeedback('thumbs_down')}
|
||||
className={cn(
|
||||
feedback === 'thumbs_down' && "bg-red-600 hover:bg-red-700 text-white"
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.comparison.notHelpful')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
94
memento-note/components/connections-badge.tsx
Normal file
94
memento-note/components/connections-badge.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
|
||||
interface ConnectionsBadgeProps {
|
||||
noteId: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ConnectionData {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
createdAt: Date
|
||||
similarity: number
|
||||
daysApart: number
|
||||
}
|
||||
|
||||
interface ConnectionsResponse {
|
||||
connections: ConnectionData[]
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) {
|
||||
const { t } = useLanguage()
|
||||
const [connectionCount, setConnectionCount] = useState<number>(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConnections = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=1`)
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch connections')
|
||||
}
|
||||
|
||||
const data: ConnectionsResponse = await res.json()
|
||||
setConnectionCount(data.pagination.total || 0)
|
||||
} catch (error) {
|
||||
console.error('[ConnectionsBadge] Failed to fetch connections:', error)
|
||||
setConnectionCount(0)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchConnections()
|
||||
}, [noteId])
|
||||
|
||||
// Don't render if no connections or still loading
|
||||
if (connectionCount === 0 || isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
const plural = connectionCount > 1 ? 's' : ''
|
||||
const badgeText = t('memoryEcho.connectionsBadge', { count: connectionCount, plural })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded',
|
||||
'bg-amber-100 dark:bg-amber-900/30',
|
||||
'text-amber-700 dark:text-amber-400',
|
||||
'text-[10px] font-medium',
|
||||
'border border-amber-200 dark:border-amber-800',
|
||||
'cursor-pointer',
|
||||
'transition-all duration-150 ease-out',
|
||||
'hover:bg-amber-200 dark:hover:bg-amber-800/50',
|
||||
isHovered && 'scale-105',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
title={badgeText}
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5 inline-block mr-1" />
|
||||
{badgeText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
315
memento-note/components/connections-overlay.tsx
Normal file
315
memento-note/components/connections-overlay.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Sparkles, X, Search, ArrowRight, Eye } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
|
||||
interface ConnectionData {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
createdAt: Date
|
||||
similarity: number
|
||||
daysApart: number
|
||||
}
|
||||
|
||||
interface ConnectionsResponse {
|
||||
connections: ConnectionData[]
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface ConnectionsOverlayProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
noteId: string
|
||||
onOpenNote?: (noteId: string) => void
|
||||
onCompareNotes?: (noteIds: string[]) => void
|
||||
}
|
||||
|
||||
export function ConnectionsOverlay({
|
||||
isOpen,
|
||||
onClose,
|
||||
noteId,
|
||||
onOpenNote,
|
||||
onCompareNotes
|
||||
}: ConnectionsOverlayProps) {
|
||||
const { t } = useLanguage()
|
||||
const [connections, setConnections] = useState<ConnectionData[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters and sorting
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'similarity' | 'recent' | 'oldest'>('similarity')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
// Pagination
|
||||
const [pagination, setPagination] = useState({
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
hasNext: false,
|
||||
hasPrev: false
|
||||
})
|
||||
|
||||
// Fetch connections when overlay opens
|
||||
useEffect(() => {
|
||||
if (isOpen && noteId) {
|
||||
fetchConnections(1)
|
||||
}
|
||||
}, [isOpen, noteId])
|
||||
|
||||
const fetchConnections = async (page: number = 1) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&page=${page}&limit=10`)
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch connections')
|
||||
}
|
||||
|
||||
const data: ConnectionsResponse = await res.json()
|
||||
setConnections(data.connections)
|
||||
setPagination(data.pagination)
|
||||
setCurrentPage(data.pagination.page)
|
||||
} catch (err) {
|
||||
console.error('[ConnectionsOverlay] Failed to fetch:', err)
|
||||
setError(t('memoryEcho.overlay.error'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and sort connections
|
||||
const filteredConnections = connections
|
||||
.filter(conn => {
|
||||
if (!searchQuery) return true
|
||||
const query = searchQuery.toLowerCase()
|
||||
const title = conn.title?.toLowerCase() || ''
|
||||
const content = conn.content.toLowerCase()
|
||||
return title.includes(query) || content.includes(query)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'similarity':
|
||||
return b.similarity - a.similarity
|
||||
case 'recent':
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
case 'oldest':
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (pagination.hasPrev) {
|
||||
fetchConnections(currentPage - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (pagination.hasNext) {
|
||||
fetchConnections(currentPage + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenNote = (connNoteId: string) => {
|
||||
onOpenNote?.(connNoteId)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col p-0"
|
||||
showCloseButton={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('memoryEcho.editorSection.title', { count: pagination.total })}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('memoryEcho.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search - Show if 7+ connections */}
|
||||
{pagination.total >= 7 && (
|
||||
<div className="px-6 py-3 border-b dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('memoryEcho.overlay.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="px-3 py-2 rounded-md border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-sm"
|
||||
>
|
||||
<option value="similarity">{t('memoryEcho.overlay.sortSimilarity')}</option>
|
||||
<option value="recent">{t('memoryEcho.overlay.sortRecent')}</option>
|
||||
<option value="oldest">{t('memoryEcho.overlay.sortOldest')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-500">{t('memoryEcho.overlay.loading')}</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
) : filteredConnections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<Search className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p>{t('memoryEcho.overlay.noConnections')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-2">
|
||||
{filteredConnections.map((conn) => {
|
||||
const similarityPercentage = Math.round(conn.similarity * 100)
|
||||
const title = conn.title || t('memoryEcho.comparison.untitled')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conn.noteId}
|
||||
className="border dark:border-zinc-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-all hover:border-l-4 hover:border-l-amber-500 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="font-semibold text-base text-gray-900 dark:text-gray-100 flex-1">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">
|
||||
{similarityPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-3">
|
||||
{conn.content}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenNote(conn.noteId)}
|
||||
className="flex-1 justify-start"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.editorSection.view')}
|
||||
</Button>
|
||||
|
||||
{onCompareNotes && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onCompareNotes([noteId, conn.noteId])
|
||||
onClose()
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.editorSection.compare')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handlePrevPage}
|
||||
disabled={!pagination.hasPrev}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('pagination.pageInfo', { current: currentPage, total: pagination.totalPages })}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleNextPage}
|
||||
disabled={!pagination.hasNext}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer - Action */}
|
||||
<div className="px-6 py-4 border-t dark:border-zinc-700">
|
||||
<Button
|
||||
className="w-full bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onClick={() => {
|
||||
if (onCompareNotes && connections.length > 0) {
|
||||
const noteIds = connections.slice(0, Math.min(3, connections.length)).map(c => c.noteId)
|
||||
onCompareNotes([noteId, ...noteIds])
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
disabled={connections.length === 0}
|
||||
>
|
||||
{t('memoryEcho.overlay.viewAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
225
memento-note/components/create-notebook-dialog.tsx
Normal file
225
memento-note/components/create-notebook-dialog.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Plus, X, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
const NOTEBOOK_ICONS = [
|
||||
{ icon: Folder, name: 'folder' },
|
||||
{ icon: Briefcase, name: 'briefcase' },
|
||||
{ icon: FileText, name: 'document' },
|
||||
{ icon: Zap, name: 'lightning' },
|
||||
{ icon: BarChart3, name: 'chart' },
|
||||
{ icon: Globe, name: 'globe' },
|
||||
{ icon: Sparkles, name: 'sparkle' },
|
||||
{ icon: Book, name: 'book' },
|
||||
{ icon: Heart, name: 'heart' },
|
||||
{ icon: Crown, name: 'crown' },
|
||||
{ icon: Music, name: 'music' },
|
||||
{ icon: Building2, name: 'building' },
|
||||
]
|
||||
|
||||
const NOTEBOOK_COLORS = [
|
||||
{ name: 'Slate', value: '#64748B', bg: 'bg-slate-500' },
|
||||
{ name: 'Purple', value: '#8B5CF6', bg: 'bg-purple-500' },
|
||||
{ name: 'Red', value: '#EF4444', bg: 'bg-red-500' },
|
||||
{ name: 'Orange', value: '#F59E0B', bg: 'bg-orange-500' },
|
||||
{ name: 'Green', value: '#10B981', bg: 'bg-green-500' },
|
||||
{ name: 'Teal', value: '#14B8A6', bg: 'bg-teal-500' },
|
||||
{ name: 'Gray', value: '#6B7280', bg: 'bg-gray-500' },
|
||||
]
|
||||
|
||||
interface CreateNotebookDialogProps {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialogProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const [name, setName] = useState('')
|
||||
const [selectedIcon, setSelectedIcon] = useState('folder')
|
||||
const [selectedColor, setSelectedColor] = useState('#3B82F6')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/notebooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
icon: selectedIcon,
|
||||
color: selectedColor,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Close dialog and reload
|
||||
onOpenChange?.(false)
|
||||
window.location.reload()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
console.error('Failed to create notebook:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create notebook:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setName('')
|
||||
setSelectedIcon('folder')
|
||||
setSelectedColor('#3B82F6')
|
||||
}
|
||||
|
||||
const SelectedIconComponent = NOTEBOOK_ICONS.find(i => i.name === selectedIcon)?.icon || Folder
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(val) => {
|
||||
onOpenChange?.(val)
|
||||
if (!val) handleReset()
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[500px] p-0">
|
||||
<button
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors z-10"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<DialogHeader className="px-8 pt-8 pb-4">
|
||||
<DialogTitle className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t('notebook.createNew')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('notebook.createDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-8 pb-8">
|
||||
<div className="space-y-6">
|
||||
{/* Notebook Name */}
|
||||
<div>
|
||||
<label className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2 block">
|
||||
{t('notebook.name')}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Q4 Marketing Strategy"
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Icon Selection */}
|
||||
<div>
|
||||
<label className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 block">
|
||||
{t('notebook.selectIcon')}
|
||||
</label>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
{NOTEBOOK_ICONS.map((item) => {
|
||||
const IconComponent = item.icon
|
||||
const isSelected = selectedIcon === item.name
|
||||
return (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={() => setSelectedIcon(item.name)}
|
||||
className={cn(
|
||||
"h-14 w-full rounded-xl border-2 flex items-center justify-center transition-all duration-200",
|
||||
isSelected
|
||||
? 'border-indigo-600 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600'
|
||||
: 'border-gray-200 dark:border-gray-700 text-gray-400 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Selection */}
|
||||
<div>
|
||||
<label className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 block">
|
||||
{t('notebook.selectColor')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{NOTEBOOK_COLORS.map((color) => {
|
||||
const isSelected = selectedColor === color.value
|
||||
return (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color.value)}
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-full border-2 transition-all duration-200",
|
||||
isSelected
|
||||
? 'border-white scale-110 shadow-lg'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:scale-105'
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{name.trim() && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-white shadow-md"
|
||||
style={{ backgroundColor: selectedColor }}
|
||||
>
|
||||
<SelectedIconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{name.trim()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
{t('notebook.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6"
|
||||
>
|
||||
{isSubmitting ? t('notebook.creating') : t('notebook.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
9
memento-note/components/debug-theme.tsx
Normal file
9
memento-note/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>
|
||||
)
|
||||
}
|
||||
55
memento-note/components/delete-notebook-dialog.tsx
Normal file
55
memento-note/components/delete-notebook-dialog.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
|
||||
interface DeleteNotebookDialogProps {
|
||||
notebook: any
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function DeleteNotebookDialog({ notebook, open, onOpenChange }: DeleteNotebookDialogProps) {
|
||||
const { deleteNotebook } = useNotebooks()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteNotebook(notebook.id)
|
||||
onOpenChange(false)
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
// Error already handled in UI
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('notebook.delete')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('notebook.deleteWarning', { notebookName: notebook?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('general.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
{t('notebook.deleteConfirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
101
memento-note/components/demo-mode-toggle.tsx
Normal file
101
memento-note/components/demo-mode-toggle.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { FlaskConical, Zap, Target, Lightbulb } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface DemoModeToggleProps {
|
||||
demoMode: boolean
|
||||
onToggle: (enabled: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
setIsPending(true)
|
||||
try {
|
||||
await onToggle(checked)
|
||||
if (checked) {
|
||||
toast.success(t('demoMode.activated'))
|
||||
} else {
|
||||
toast.success(t('demoMode.deactivated'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling demo mode:', error)
|
||||
toast.error(t('demoMode.toggleFailed'))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`border-2 transition-all ${
|
||||
demoMode
|
||||
? 'border-amber-300 bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/30 dark:to-background'
|
||||
: 'border-amber-100 dark:border-amber-900/30'
|
||||
}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-full transition-colors ${
|
||||
demoMode
|
||||
? 'bg-amber-200 dark:bg-amber-900/50'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}>
|
||||
<FlaskConical className={`h-5 w-5 ${
|
||||
demoMode ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
🧪 {t('demoMode.title')}
|
||||
{demoMode && <Zap className="h-4 w-4 text-amber-500 animate-pulse" />}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{t('demoMode.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={demoMode}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isPending}
|
||||
className="data-[state=checked]:bg-amber-600"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{demoMode && (
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<div className="rounded-lg bg-white dark:bg-zinc-900 border border-amber-200 dark:border-amber-900/30 p-3">
|
||||
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('demoMode.parametersActive')}
|
||||
</p>
|
||||
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-start gap-2">
|
||||
<Target className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<span>{t('demoMode.similarityThreshold')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Zap className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<span>{t('demoMode.delayBetweenNotes')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
|
||||
<span>{t('demoMode.unlimitedInsights')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 text-center">
|
||||
💡 {t('demoMode.createNotesTip')}
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
103
memento-note/components/edit-notebook-dialog.tsx
Normal file
103
memento-note/components/edit-notebook-dialog.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface EditNotebookDialogProps {
|
||||
notebook: any
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EditNotebookDialog({ notebook, open, onOpenChange }: EditNotebookDialogProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const [name, setName] = useState(notebook?.name || '')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/notebooks/${notebook.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim() }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
onOpenChange(false)
|
||||
window.location.reload()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
}
|
||||
} catch (error) {
|
||||
// Error already handled in UI
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('notebook.edit')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('notebook.editDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Notebook"
|
||||
className="col-span-3"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('general.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : t('general.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
255
memento-note/components/editor-connections-section.tsx
Normal file
255
memento-note/components/editor-connections-section.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ChevronDown, ChevronUp, Sparkles, Eye, ArrowRight, Link2, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
|
||||
interface ConnectionData {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
createdAt: Date
|
||||
similarity: number
|
||||
daysApart: number
|
||||
}
|
||||
|
||||
interface ConnectionsResponse {
|
||||
connections: ConnectionData[]
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface EditorConnectionsSectionProps {
|
||||
noteId: string
|
||||
onOpenNote?: (noteId: string) => void
|
||||
onCompareNotes?: (noteIds: string[]) => void
|
||||
onMergeNotes?: (noteIds: string[]) => void
|
||||
}
|
||||
|
||||
export function EditorConnectionsSection({
|
||||
noteId,
|
||||
onOpenNote,
|
||||
onCompareNotes,
|
||||
onMergeNotes
|
||||
}: EditorConnectionsSectionProps) {
|
||||
const { t } = useLanguage()
|
||||
const [connections, setConnections] = useState<ConnectionData[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConnections = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=10`)
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch connections')
|
||||
}
|
||||
|
||||
const data: ConnectionsResponse = await res.json()
|
||||
setConnections(data.connections)
|
||||
|
||||
// Show section if there are connections
|
||||
if (data.connections.length > 0) {
|
||||
setIsVisible(true)
|
||||
} else {
|
||||
setIsVisible(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EditorConnectionsSection] Failed to fetch:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchConnections()
|
||||
}, [noteId])
|
||||
|
||||
// Don't render if no connections or if dismissed
|
||||
if (!isVisible || (connections.length === 0 && !isLoading)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t dark:border-zinc-700 pt-4">
|
||||
{/* Header with toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer select-none group"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Sparkles className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('memoryEcho.editorSection.title', { count: connections.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Dismiss all connections for this note
|
||||
try {
|
||||
await Promise.all(
|
||||
connections.map(conn =>
|
||||
fetch('/api/ai/echo/dismiss', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
noteId: noteId,
|
||||
connectedNoteId: conn.noteId
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
setIsVisible(false)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to dismiss connections:', error)
|
||||
}
|
||||
}}
|
||||
title={t('memoryEcho.editorSection.close') || 'Fermer'}
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsExpanded(!isExpanded)
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections list */}
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4 text-sm text-gray-500">
|
||||
{t('memoryEcho.editorSection.loading')}
|
||||
</div>
|
||||
) : (
|
||||
connections.map((conn) => {
|
||||
const similarityPercentage = Math.round(conn.similarity * 100)
|
||||
const title = conn.title || t('memoryEcho.comparison.untitled')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conn.noteId}
|
||||
className="border dark:border-zinc-700 rounded-lg p-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
|
||||
{title}
|
||||
</h4>
|
||||
<span className="ml-2 text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">
|
||||
{similarityPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">
|
||||
{conn.content}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={() => onOpenNote?.(conn.noteId)}
|
||||
>
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
{t('memoryEcho.editorSection.view')}
|
||||
</Button>
|
||||
|
||||
{onCompareNotes && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={() => onCompareNotes([noteId, conn.noteId])}
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 mr-1" />
|
||||
{t('memoryEcho.editorSection.compare')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onMergeNotes && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={() => onMergeNotes([noteId, conn.noteId])}
|
||||
>
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
{t('memoryEcho.editorSection.merge')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer actions */}
|
||||
{isExpanded && connections.length > 1 && (
|
||||
<div className="mt-3 flex items-center gap-2 pt-2 border-t dark:border-zinc-700">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => {
|
||||
if (onCompareNotes) {
|
||||
const allIds = connections.slice(0, Math.min(3, connections.length)).map(c => c.noteId)
|
||||
onCompareNotes([noteId, ...allIds])
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('memoryEcho.editorSection.compareAll')}
|
||||
</Button>
|
||||
|
||||
{onMergeNotes && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => {
|
||||
const allIds = connections.map(c => c.noteId)
|
||||
onMergeNotes([noteId, ...allIds])
|
||||
}}
|
||||
>
|
||||
{t('memoryEcho.editorSection.mergeAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
memento-note/components/editor-images.tsx
Normal file
33
memento-note/components/editor-images.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
interface EditorImagesProps {
|
||||
images: string[]
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
|
||||
export function EditorImages({ images, onRemove }: EditorImagesProps) {
|
||||
if (!images || images.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
{images.map((img, idx) => (
|
||||
<div key={idx} className="relative group">
|
||||
<img
|
||||
src={img}
|
||||
alt=""
|
||||
className="h-auto rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 h-7 w-7 p-0 bg-white/90 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => onRemove(idx)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
memento-note/components/favorites-section.tsx
Normal file
83
memento-note/components/favorites-section.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Note } from '@/lib/types'
|
||||
import { NoteCard } from './note-card'
|
||||
import { ChevronDown, ChevronUp, Pin } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface FavoritesSectionProps {
|
||||
pinnedNotes: Note[]
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function FavoritesSection({ pinnedNotes, onEdit, isLoading }: FavoritesSectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section data-testid="favorites-section" className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4 px-2 py-2">
|
||||
<Pin className="w-5 h-5 text-muted-foreground animate-pulse" />
|
||||
<div className="h-6 w-32 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-40 bg-muted rounded-2xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (pinnedNotes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-testid="favorites-section" className="mb-8">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center justify-between gap-2 mb-4 px-2 py-2 hover:bg-accent rounded-lg transition-colors min-h-[44px]"
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={t('favorites.toggleSection') || 'Toggle pinned notes section'}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">📌</span>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{t('notes.pinnedNotes')}
|
||||
<span className="text-sm font-medium text-muted-foreground ml-2">
|
||||
({pinnedNotes.length})
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Collapsible Content */}
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pinnedNotes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
376
memento-note/components/fusion-modal.tsx
Normal file
376
memento-note/components/fusion-modal.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { X, Link2, Sparkles, Edit, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Note } from '@/lib/types'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
|
||||
interface FusionModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
notes: Array<Partial<Note>>
|
||||
onConfirmFusion: (mergedNote: { title: string; content: string }, options: FusionOptions) => Promise<void>
|
||||
}
|
||||
|
||||
interface FusionOptions {
|
||||
archiveOriginals: boolean
|
||||
keepAllTags: boolean
|
||||
useLatestTitle: boolean
|
||||
createBacklinks: boolean
|
||||
}
|
||||
|
||||
export function FusionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
notes,
|
||||
onConfirmFusion
|
||||
}: FusionModalProps) {
|
||||
const { t } = useLanguage()
|
||||
const [selectedNoteIds, setSelectedNoteIds] = useState<string[]>(notes.filter(n => n.id).map(n => n.id!))
|
||||
const [customPrompt, setCustomPrompt] = useState('')
|
||||
const [fusionPreview, setFusionPreview] = useState('')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [generationError, setGenerationError] = useState<string | null>(null)
|
||||
const hasGeneratedRef = useRef(false)
|
||||
|
||||
const [options, setOptions] = useState<FusionOptions>({
|
||||
archiveOriginals: true,
|
||||
keepAllTags: true,
|
||||
useLatestTitle: false,
|
||||
createBacklinks: false
|
||||
})
|
||||
|
||||
const handleGenerateFusion = useCallback(async () => {
|
||||
setIsGenerating(true)
|
||||
setGenerationError(null)
|
||||
setFusionPreview('')
|
||||
|
||||
try {
|
||||
|
||||
// Call AI API to generate fusion
|
||||
const res = await fetch('/api/ai/echo/fusion', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
noteIds: selectedNoteIds,
|
||||
prompt: customPrompt
|
||||
})
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to generate fusion')
|
||||
}
|
||||
|
||||
if (!data.fusedNote) {
|
||||
throw new Error('No fusion content returned from API')
|
||||
}
|
||||
|
||||
setFusionPreview(data.fusedNote)
|
||||
} catch (error) {
|
||||
console.error('[FusionModal] Failed to generate:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : t('memoryEcho.fusion.generateError')
|
||||
setGenerationError(errorMessage)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}, [selectedNoteIds, customPrompt])
|
||||
|
||||
// Auto-generate fusion preview when modal opens with selected notes
|
||||
useEffect(() => {
|
||||
// Reset generation state when modal closes
|
||||
if (!isOpen) {
|
||||
hasGeneratedRef.current = false
|
||||
setGenerationError(null)
|
||||
setFusionPreview('')
|
||||
return
|
||||
}
|
||||
|
||||
// Generate only once when modal opens and we have 2+ notes
|
||||
if (isOpen && selectedNoteIds.length >= 2 && !hasGeneratedRef.current && !isGenerating) {
|
||||
hasGeneratedRef.current = true
|
||||
handleGenerateFusion()
|
||||
}
|
||||
}, [isOpen, selectedNoteIds.length, isGenerating, handleGenerateFusion])
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isGenerating) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!fusionPreview) {
|
||||
await handleGenerateFusion()
|
||||
return
|
||||
}
|
||||
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
// Parse the preview into title and content
|
||||
const lines = fusionPreview.split('\n')
|
||||
const title = lines[0].replace(/^#+\s*/, '').trim()
|
||||
const content = lines.slice(1).join('\n').trim()
|
||||
|
||||
await onConfirmFusion(
|
||||
{ title, content },
|
||||
options
|
||||
)
|
||||
|
||||
onClose()
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedNotes = notes.filter(n => n.id && selectedNoteIds.includes(n.id))
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
||||
<Link2 className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('memoryEcho.fusion.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('memoryEcho.fusion.mergeNotes', { count: selectedNoteIds.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Section 1: Note Selection */}
|
||||
<div className="p-6 border-b dark:border-zinc-700">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
{t('memoryEcho.fusion.notesToMerge')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{notes.filter(n => n.id).map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-lg border transition-colors",
|
||||
selectedNoteIds.includes(note.id!)
|
||||
? "border-purple-200 bg-purple-50 dark:bg-purple-950/20 dark:border-purple-800"
|
||||
: "border-gray-200 dark:border-zinc-700 opacity-50"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`note-${note.id}`}
|
||||
checked={selectedNoteIds.includes(note.id!)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked && note.id) {
|
||||
setSelectedNoteIds([...selectedNoteIds, note.id])
|
||||
} else if (note.id) {
|
||||
setSelectedNoteIds(selectedNoteIds.filter(id => id !== note.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`note-${note.id}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||
{note.title || t('memoryEcho.comparison.untitled')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{note.createdAt ? new Date(note.createdAt).toLocaleDateString() : t('memoryEcho.fusion.unknownDate')}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Custom Prompt (Optional) */}
|
||||
<div className="p-6 border-b dark:border-zinc-700">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
{t('memoryEcho.fusion.optionalPrompt')}
|
||||
</h3>
|
||||
<Textarea
|
||||
placeholder={t('memoryEcho.fusion.promptPlaceholder')}
|
||||
value={customPrompt}
|
||||
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={handleGenerateFusion}
|
||||
disabled={isGenerating || selectedNoteIds.length < 2}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('memoryEcho.fusion.generating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.fusion.generateFusion')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{generationError && (
|
||||
<div className="mx-6 mt-4 p-4 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">
|
||||
{t('memoryEcho.fusion.error')}: {generationError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 3: Preview */}
|
||||
{fusionPreview && (
|
||||
<div className="p-6 border-b dark:border-zinc-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
{t('memoryEcho.fusion.previewTitle')}
|
||||
</h3>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.fusion.modify')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
value={fusionPreview}
|
||||
onChange={(e) => setFusionPreview(e.target.value)}
|
||||
rows={10}
|
||||
className="resize-none font-mono text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="border dark:border-zinc-700 rounded-lg p-4 bg-white dark:bg-zinc-900">
|
||||
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-sans">
|
||||
{fusionPreview}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 4: Options */}
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-3">{t('memoryEcho.fusion.optionsTitle')}</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={options.archiveOriginals}
|
||||
onCheckedChange={(checked) =>
|
||||
setOptions({ ...options, archiveOriginals: !!checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('memoryEcho.fusion.archiveOriginals')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={options.keepAllTags}
|
||||
onCheckedChange={(checked) =>
|
||||
setOptions({ ...options, keepAllTags: !!checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('memoryEcho.fusion.keepAllTags')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={options.useLatestTitle}
|
||||
onCheckedChange={(checked) =>
|
||||
setOptions({ ...options, useLatestTitle: !!checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('memoryEcho.fusion.useLatestTitle')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={options.createBacklinks}
|
||||
onCheckedChange={(checked) =>
|
||||
setOptions({ ...options, createBacklinks: !!checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('memoryEcho.fusion.createBacklinks')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('memoryEcho.fusion.cancel')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
{t('memoryEcho.fusion.finishEditing')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedNoteIds.length < 2 || isGenerating}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('memoryEcho.fusion.generating')}
|
||||
</>
|
||||
) : (
|
||||
t('memoryEcho.fusion.confirmFusion')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
107
memento-note/components/ghost-tags.tsx
Normal file
107
memento-note/components/ghost-tags.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { TagSuggestion } from '@/lib/ai/types';
|
||||
import { Loader2, Sparkles, X, CheckCircle, Plus } from 'lucide-react';
|
||||
import { cn, getHashColor } from '@/lib/utils';
|
||||
import { LABEL_COLORS } from '@/lib/types';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
interface GhostTagsProps {
|
||||
suggestions: TagSuggestion[];
|
||||
addedTags: string[]; // Nouveauté : tags déjà présents sur la note
|
||||
isAnalyzing: boolean;
|
||||
onSelectTag: (tag: string) => void;
|
||||
onDismissTag: (tag: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
|
||||
// On filtre pour l'affichage conditionnel global, mais on garde les tags ajoutés pour l'affichage visuel "validé"
|
||||
const visibleSuggestions = suggestions;
|
||||
|
||||
// Show help message if not analyzing and no suggestions (but don't return null)
|
||||
const isEmpty = !isAnalyzing && visibleSuggestions.length === 0;
|
||||
|
||||
// FIX: Never return null, always show something (either tags, analyzer, or help message)
|
||||
// This ensures the help message "Tapez du contenu..." is always shown when needed
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
|
||||
|
||||
{isAnalyzing && (
|
||||
<div className="flex items-center text-purple-500 animate-pulse" title={t('ai.analyzing')}>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show message when no labels suggested */}
|
||||
{!isAnalyzing && visibleSuggestions.length === 0 && (
|
||||
<div className="text-xs text-gray-500 italic">
|
||||
{t('ai.autoLabels.typeForSuggestions')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAnalyzing && visibleSuggestions.map((suggestion) => {
|
||||
const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase());
|
||||
const colorName = getHashColor(suggestion.tag);
|
||||
const colorClasses = LABEL_COLORS[colorName];
|
||||
const isNewLabel = (suggestion as any).isNewLabel; // Check if this is a new label suggestion
|
||||
|
||||
if (isAdded) {
|
||||
// Tag déjà ajouté : on l'affiche en mode "confirmé" statique pour ne pas perdre le focus
|
||||
return (
|
||||
<div key={suggestion.tag} className={cn("flex items-center px-3 py-1 text-xs font-medium border rounded-full opacity-50 cursor-default", colorClasses.bg, colorClasses.text, colorClasses.border)}>
|
||||
<CheckCircle className="w-3 h-3 mr-1.5" />
|
||||
{suggestion.tag}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={suggestion.tag}
|
||||
className={cn(
|
||||
"group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100",
|
||||
colorClasses.bg,
|
||||
colorClasses.border
|
||||
)}
|
||||
>
|
||||
{/* Zone de validation (Clic principal) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectTag(suggestion.tag);
|
||||
}}
|
||||
className={cn("flex items-center px-3 py-1 text-xs font-medium", colorClasses.text)}
|
||||
title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')}
|
||||
>
|
||||
{isNewLabel && <Plus className="w-3 h-3 mr-1" />}
|
||||
{!isNewLabel && <Sparkles className="w-3 h-3 mr-1.5 opacity-50" />}
|
||||
{suggestion.tag}
|
||||
{isNewLabel && <span className="ml-1 opacity-60">{t('ai.autoLabels.new')}</span>}
|
||||
</button>
|
||||
|
||||
{/* Zone de refus (Croix) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDismissTag(suggestion.tag);
|
||||
}}
|
||||
className={cn("pr-2 pl-1 hover:text-red-500 transition-colors", colorClasses.text)}
|
||||
title={t('ai.ignoreSuggestion')}
|
||||
>
|
||||
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
memento-note/components/header-wrapper.tsx
Normal file
63
memento-note/components/header-wrapper.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { Header } from './header'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
|
||||
interface HeaderWrapperProps {
|
||||
onColorFilterChange?: (color: string | null) => void
|
||||
user?: any
|
||||
}
|
||||
|
||||
function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { labels } = useLabels()
|
||||
|
||||
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const selectedColor = searchParams.get('color') || null
|
||||
|
||||
const handleLabelFilterChange = (labels: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
|
||||
if (labels.length > 0) {
|
||||
params.set('labels', labels.join(','))
|
||||
} else {
|
||||
params.delete('labels')
|
||||
}
|
||||
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleColorFilterChange = (color: string | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
|
||||
if (color) {
|
||||
params.set('color', color)
|
||||
} else {
|
||||
params.delete('color')
|
||||
}
|
||||
|
||||
router.push(`/?${params.toString()}`)
|
||||
onColorFilterChange?.(color)
|
||||
}
|
||||
|
||||
return (
|
||||
<Header
|
||||
selectedLabels={selectedLabels}
|
||||
selectedColor={selectedColor}
|
||||
onLabelFilterChange={handleLabelFilterChange}
|
||||
onColorFilterChange={handleColorFilterChange}
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function HeaderWrapper(props: HeaderWrapperProps) {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-16 border-b bg-background/95" />}>
|
||||
<HeaderContent {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
372
memento-note/components/header.tsx
Normal file
372
memento-note/components/header.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, Settings, LogOut, User, Shield, Coffee } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { LabelFilter } from './label-filter'
|
||||
import { NotificationPanel } from './notification-panel'
|
||||
import { updateTheme } from '@/app/actions/profile'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useSession, signOut } from 'next-auth/react'
|
||||
|
||||
interface HeaderProps {
|
||||
selectedLabels?: string[]
|
||||
selectedColor?: string | null
|
||||
onLabelFilterChange?: (labels: string[]) => void
|
||||
onColorFilterChange?: (color: string | null) => void
|
||||
user?: any
|
||||
}
|
||||
|
||||
export function Header({
|
||||
selectedLabels = [],
|
||||
selectedColor = null,
|
||||
onLabelFilterChange,
|
||||
onColorFilterChange,
|
||||
user
|
||||
}: HeaderProps = {}) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const [isSemanticSearching, setIsSemanticSearching] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { labels, setNotebookId } = useLabels()
|
||||
const { t } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
|
||||
// Track last pushed search to avoid infinite loops
|
||||
const lastPushedSearch = useRef<string | null>(null)
|
||||
|
||||
const currentLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const currentSearch = searchParams.get('search') || ''
|
||||
const currentColor = searchParams.get('color') || ''
|
||||
|
||||
const currentUser = user || session?.user
|
||||
|
||||
// Initialize search query from URL ONLY on mount
|
||||
useEffect(() => {
|
||||
setSearchQuery(currentSearch)
|
||||
lastPushedSearch.current = currentSearch
|
||||
}, []) // Run only once on mount
|
||||
|
||||
// Sync LabelContext notebookId with URL notebook parameter
|
||||
const currentNotebook = searchParams.get('notebook')
|
||||
useEffect(() => {
|
||||
setNotebookId(currentNotebook || null)
|
||||
}, [currentNotebook, setNotebookId])
|
||||
|
||||
// Prevent body scroll when mobile menu is open
|
||||
useEffect(() => {
|
||||
if (isSidebarOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.body.style.position = 'fixed'
|
||||
document.body.style.width = '100%'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
document.body.style.position = ''
|
||||
document.body.style.width = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
document.body.style.position = ''
|
||||
document.body.style.width = ''
|
||||
}
|
||||
}, [isSidebarOpen])
|
||||
|
||||
// Close mobile menu on Esc key press
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isSidebarOpen) {
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSidebarOpen) {
|
||||
document.addEventListener('keydown', handleEscapeKey)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscapeKey)
|
||||
}
|
||||
}, [isSidebarOpen])
|
||||
|
||||
// Simple debounced search with URL update (150ms for more responsiveness)
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 150)
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if search hasn't changed or if we already pushed this value
|
||||
if (debouncedSearchQuery === lastPushedSearch.current) return
|
||||
|
||||
// Build new params preserving other filters
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
params.set('search', debouncedSearchQuery)
|
||||
} else {
|
||||
params.delete('search')
|
||||
}
|
||||
|
||||
const newUrl = `/?${params.toString()}`
|
||||
|
||||
// Mark as pushed before calling router.push to prevent loops
|
||||
lastPushedSearch.current = debouncedSearchQuery
|
||||
router.push(newUrl)
|
||||
}, [debouncedSearchQuery])
|
||||
|
||||
// Handle semantic search button click
|
||||
const handleSemanticSearch = () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
// Add semantic flag to URL
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('search', searchQuery)
|
||||
params.set('semantic', 'true')
|
||||
router.push(`/?${params.toString()}`)
|
||||
|
||||
// Show loading state briefly
|
||||
setIsSemanticSearching(true)
|
||||
setTimeout(() => setIsSemanticSearching(false), 1500)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Use 'theme-preference' to match the unified theme system
|
||||
const savedTheme = localStorage.getItem('theme-preference') || currentUser?.theme || 'light'
|
||||
// Don't persist on initial load to avoid unnecessary DB calls
|
||||
applyTheme(savedTheme, false)
|
||||
}, [currentUser])
|
||||
|
||||
const applyTheme = async (newTheme: string, persist = true) => {
|
||||
setTheme(newTheme as any)
|
||||
localStorage.setItem('theme-preference', newTheme)
|
||||
|
||||
// Remove all theme classes first
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (newTheme !== 'light') {
|
||||
document.documentElement.setAttribute('data-theme', newTheme)
|
||||
if (newTheme === 'midnight') {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
}
|
||||
|
||||
if (persist && currentUser) {
|
||||
await updateTheme(newTheme)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query)
|
||||
// URL update is now handled by the debounced useEffect
|
||||
}
|
||||
|
||||
const removeLabelFilter = (labelToRemove: string) => {
|
||||
const newLabels = currentLabels.filter(l => l !== labelToRemove)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) {
|
||||
params.set('labels', newLabels.join(','))
|
||||
} else {
|
||||
params.delete('labels')
|
||||
}
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
const removeColorFilter = () => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('color')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
// Clear only label and color filters, keep search
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('labels')
|
||||
params.delete('color')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleFilterChange = (newLabels: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) {
|
||||
params.set('labels', newLabels.join(','))
|
||||
} else {
|
||||
params.delete('labels')
|
||||
}
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleColorChange = (newColor: string | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newColor) {
|
||||
params.set('color', newColor)
|
||||
} else {
|
||||
params.delete('color')
|
||||
}
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
const toggleLabelFilter = (labelName: string) => {
|
||||
const newLabels = currentLabels.includes(labelName)
|
||||
? currentLabels.filter(l => l !== labelName)
|
||||
: [...currentLabels, labelName]
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) {
|
||||
params.set('labels', newLabels.join(','))
|
||||
} else {
|
||||
params.delete('labels')
|
||||
}
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
const NavItem = ({ href, icon: Icon, label, active, onClick }: any) => {
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn("h-5 w-5", active && "fill-current text-primary")} />
|
||||
{label}
|
||||
</>
|
||||
)
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2 text-left",
|
||||
active
|
||||
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
|
||||
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
|
||||
)}
|
||||
style={{ minHeight: '44px' }}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
|
||||
active
|
||||
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
|
||||
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
|
||||
)}
|
||||
style={{ minHeight: '44px' }}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const hasActiveFilters = currentLabels.length > 0 || !!currentColor
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top Navigation - Style Keep */}
|
||||
<header className="flex-none flex items-center justify-between whitespace-nowrap border-b border-solid border-slate-200 dark:border-slate-800 bg-white dark:bg-[#1e2128] px-6 py-3 z-20">
|
||||
<div className="flex items-center gap-8">
|
||||
|
||||
|
||||
{/* Logo MEMENTO */}
|
||||
<div className="flex items-center gap-3 text-slate-900 dark:text-white cursor-pointer group" onClick={() => router.push('/')}>
|
||||
<div className="size-8 bg-primary rounded-lg flex items-center justify-center text-primary-foreground shadow-sm group-hover:shadow-md transition-all">
|
||||
<StickyNote className="w-5 h-5" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold leading-tight tracking-tight">MEMENTO</h2>
|
||||
</div>
|
||||
|
||||
{/* Search Bar - 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>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 justify-end gap-4 items-center">
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* User Avatar Menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center justify-center bg-center bg-no-repeat bg-cover rounded-full size-10 ring-2 ring-white dark:ring-slate-700 cursor-pointer shadow-sm hover:shadow-md transition-shadow bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
|
||||
style={currentUser?.image ? { backgroundImage: `url(${currentUser?.image})` } : undefined}>
|
||||
{!currentUser?.image && (
|
||||
<span className="text-sm font-semibold">
|
||||
{currentUser?.name ? currentUser.name.charAt(0).toUpperCase() : <User className="w-5 h-5" />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center justify-start gap-2 p-2">
|
||||
<div className="flex flex-col space-y-1 leading-none">
|
||||
{currentUser?.name && <p className="font-medium">{currentUser.name}</p>}
|
||||
{currentUser?.email && <p className="w-[200px] truncate text-sm text-muted-foreground">{currentUser.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuItem 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>{t('nav.adminDashboard')}</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>
|
||||
|
||||
{/* User Avatar - Removed from here */}
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
58
memento-note/components/label-badge.tsx
Normal file
58
memento-note/components/label-badge.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LABEL_COLORS } from '@/lib/types'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
|
||||
interface LabelBadgeProps {
|
||||
label: string
|
||||
onRemove?: () => void
|
||||
variant?: 'default' | 'filter' | 'clickable'
|
||||
onClick?: () => void
|
||||
isSelected?: boolean
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export function LabelBadge({
|
||||
label,
|
||||
onRemove,
|
||||
variant = 'default',
|
||||
onClick,
|
||||
isSelected = false,
|
||||
isDisabled = false,
|
||||
}: LabelBadgeProps) {
|
||||
const { getLabelColor } = useLabels()
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs border gap-1',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border,
|
||||
variant === 'filter' && 'cursor-pointer hover:opacity-80',
|
||||
variant === 'clickable' && 'cursor-pointer',
|
||||
isDisabled && 'opacity-50',
|
||||
isSelected && 'ring-2 ring-blue-500'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
className="hover:text-red-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
133
memento-note/components/label-filter.tsx
Normal file
133
memento-note/components/label-filter.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Filter, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface LabelFilterProps {
|
||||
selectedLabels: string[]
|
||||
onFilterChange: (labels: string[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
|
||||
const { labels, loading } = useLabels()
|
||||
const { t } = useLanguage()
|
||||
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
// Extract label names from labels array
|
||||
setAllLabelNames(labels.map((l: any) => l.name).sort())
|
||||
}, [labels])
|
||||
|
||||
const handleToggleLabel = (label: string) => {
|
||||
if (selectedLabels.includes(label)) {
|
||||
onFilterChange(selectedLabels.filter((l: string) => l !== label))
|
||||
} else {
|
||||
onFilterChange([...selectedLabels, label])
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
onFilterChange([])
|
||||
}
|
||||
|
||||
if (loading || allLabelNames.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className ? "" : "")}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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-1 h-5 min-w-5 px-1.5 rounded-full bg-gray-100">
|
||||
{selectedLabels.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span>{t('labels.title')}</span>
|
||||
{selectedLabels.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
{t('general.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Label Filters */}
|
||||
<div className="max-h-64 overflow-y-auto px-1 pb-1">
|
||||
{!loading && allLabelNames.map((labelName: string) => {
|
||||
const isSelected = selectedLabels.includes(labelName)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={labelName}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleToggleLabel(labelName)
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded border border-primary",
|
||||
isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
<LabelBadge
|
||||
label={labelName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Active filters display */}
|
||||
{!loading && selectedLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedLabels.map((labelName: string) => (
|
||||
<LabelBadge
|
||||
key={labelName}
|
||||
label={labelName}
|
||||
variant="filter"
|
||||
onClick={() => handleToggleLabel(labelName)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
187
memento-note/components/label-management-dialog.tsx
Normal file
187
memento-note/components/label-management-dialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './ui/dialog'
|
||||
import { Badge } from './ui/badge'
|
||||
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
|
||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function LabelManagementDialog() {
|
||||
const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
|
||||
const { t } = useLanguage()
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const [editingColorId, setEditingColorId] = useState<string | null>(null)
|
||||
|
||||
const handleAddLabel = async () => {
|
||||
const trimmed = newLabel.trim()
|
||||
if (trimmed) {
|
||||
try {
|
||||
await addLabel(trimmed, 'gray')
|
||||
setNewLabel('')
|
||||
} catch (error) {
|
||||
console.error('Failed to add label:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteLabel = async (id: string) => {
|
||||
if (confirm(t('labels.confirmDelete'))) {
|
||||
try {
|
||||
await deleteLabel(id)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete label:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeColor = async (id: string, color: LabelColorName) => {
|
||||
try {
|
||||
await updateLabel(id, { color })
|
||||
setEditingColorId(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to update label color:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t('labels.manage')}>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onInteractOutside={(event) => {
|
||||
// Prevent dialog from closing when interacting with Sonner toasts
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const isSonnerElement =
|
||||
target.closest('[data-sonner-toast]') ||
|
||||
target.closest('[data-sonner-toaster]') ||
|
||||
target.closest('[data-icon]') ||
|
||||
target.closest('[data-content]') ||
|
||||
target.closest('[data-description]') ||
|
||||
target.closest('[data-title]') ||
|
||||
target.closest('[data-button]');
|
||||
|
||||
if (isSonnerElement) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.getAttribute('data-sonner-toaster') !== null) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('labels.editLabels')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('labels.editLabelsDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Add new label */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('labels.newLabelPlaceholder')}
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddLabel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleAddLabel} size="icon">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List labels */}
|
||||
<div className="max-h-[60vh] overflow-y-auto space-y-2">
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">{t('labels.loading')}</p>
|
||||
) : labels.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">{t('labels.noLabelsFound')}</p>
|
||||
) : (
|
||||
labels.map((label) => {
|
||||
const colorClasses = LABEL_COLORS[label.color]
|
||||
const isEditing = editingColorId === label.id
|
||||
|
||||
return (
|
||||
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-gray-100 dark:hover:bg-zinc-800/50 group">
|
||||
<div className="flex items-center gap-3 flex-1 relative">
|
||||
<Tag className={cn("h-4 w-4", colorClasses.text)} />
|
||||
<span className="font-medium text-sm">{label.name}</span>
|
||||
|
||||
{/* Color Picker Popover */}
|
||||
{isEditing && (
|
||||
<div className="absolute z-20 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
||||
const classes = LABEL_COLORS[color]
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full border-2 transition-all hover:scale-110',
|
||||
classes.bg,
|
||||
label.color === color ? 'border-gray-900 dark:border-gray-100 ring-2 ring-offset-1' : 'border-transparent'
|
||||
)}
|
||||
onClick={() => handleChangeColor(label.id, color)}
|
||||
title={color}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
onClick={() => setEditingColorId(isEditing ? null : label.id)}
|
||||
title={t('labels.changeColor')}
|
||||
>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={() => handleDeleteLabel(label.id)}
|
||||
title={t('labels.deleteTooltip')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
284
memento-note/components/label-manager.tsx
Normal file
284
memento-note/components/label-manager.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './ui/dialog'
|
||||
import { Badge } from './ui/badge'
|
||||
import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react'
|
||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLabels, Label } from '@/context/LabelContext'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface LabelManagerProps {
|
||||
existingLabels: string[]
|
||||
notebookId?: string | null
|
||||
onUpdate: (labels: string[]) => void
|
||||
}
|
||||
|
||||
export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelManagerProps) {
|
||||
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useLabels()
|
||||
const { t } = useLanguage()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const [selectedLabels, setSelectedLabels] = useState<string[]>(existingLabels)
|
||||
const [editingColor, setEditingColor] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
// Sync selected labels with existingLabels prop
|
||||
useEffect(() => {
|
||||
setSelectedLabels(existingLabels)
|
||||
}, [existingLabels])
|
||||
|
||||
const handleAddLabel = async () => {
|
||||
const trimmed = newLabel.trim()
|
||||
setErrorMessage(null) // Clear previous error
|
||||
|
||||
if (trimmed && !selectedLabels.includes(trimmed)) {
|
||||
try {
|
||||
// NotebookId is REQUIRED for label creation (PRD R2)
|
||||
if (!notebookId) {
|
||||
setErrorMessage(t('labels.notebookRequired'))
|
||||
console.error(t('labels.notebookRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing label color or use random
|
||||
const existingLabel = labels.find(l => l.name === trimmed)
|
||||
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
|
||||
|
||||
await addLabel(trimmed, color, notebookId)
|
||||
const updated = [...selectedLabels, trimmed]
|
||||
setSelectedLabels(updated)
|
||||
setNewLabel('')
|
||||
} catch (error) {
|
||||
console.error('Failed to add label:', error)
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to add label'
|
||||
setErrorMessage(errorMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLabel = (label: string) => {
|
||||
setSelectedLabels(selectedLabels.filter(l => l !== label))
|
||||
}
|
||||
|
||||
const handleSelectExisting = (label: string) => {
|
||||
if (!selectedLabels.includes(label)) {
|
||||
setSelectedLabels([...selectedLabels, label])
|
||||
} else {
|
||||
setSelectedLabels(selectedLabels.filter(l => l !== label))
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeColor = async (label: string, color: LabelColorName) => {
|
||||
const labelObj = labels.find(l => l.name === label)
|
||||
if (labelObj) {
|
||||
try {
|
||||
await updateLabel(labelObj.id, { color })
|
||||
setEditingColor(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to update label color:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(selectedLabels)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedLabels(existingLabels)
|
||||
setEditingColor(null)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
handleCancel()
|
||||
} else {
|
||||
setOpen(true)
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Tag className="h-4 w-4 mr-2" />
|
||||
{t('labels.title')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onInteractOutside={(event) => {
|
||||
// Prevent dialog from closing when interacting with Sonner toasts
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const isSonnerElement =
|
||||
target.closest('[data-sonner-toast]') ||
|
||||
target.closest('[data-sonner-toaster]') ||
|
||||
target.closest('[data-icon]') ||
|
||||
target.closest('[data-content]') ||
|
||||
target.closest('[data-description]') ||
|
||||
target.closest('[data-title]') ||
|
||||
target.closest('[data-button]');
|
||||
|
||||
if (isSonnerElement) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.getAttribute('data-sonner-toaster') !== null) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('labels.manageLabels')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('labels.manageLabelsDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Error message */}
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-900">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new label */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('labels.newLabelPlaceholder')}
|
||||
value={newLabel}
|
||||
onChange={(e) => {
|
||||
setNewLabel(e.target.value)
|
||||
setErrorMessage(null) // Clear error when typing
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddLabel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleAddLabel} size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Selected labels */}
|
||||
{selectedLabels.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">{t('labels.selectedLabels')}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedLabels.map((label) => {
|
||||
const labelObj = labels.find(l => l.name === label)
|
||||
const colorClasses = labelObj ? LABEL_COLORS[labelObj.color] : LABEL_COLORS.gray
|
||||
const isEditing = editingColor === label
|
||||
|
||||
return (
|
||||
<div key={label} className="relative">
|
||||
{isEditing && labelObj ? (
|
||||
<div className="absolute z-10 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg p-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
||||
const classes = LABEL_COLORS[color]
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||
classes.bg,
|
||||
labelObj.color === color ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-600'
|
||||
)}
|
||||
onClick={() => handleChangeColor(label, color)}
|
||||
title={color}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs border cursor-pointer pr-1 flex items-center gap-1',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
)}
|
||||
onClick={() => setEditingColor(isEditing ? null : label)}
|
||||
>
|
||||
<Palette className="h-3 w-3" />
|
||||
{label}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveLabel(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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available labels from context */}
|
||||
{!loading && labels.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">{t('labels.allLabels')}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{labels
|
||||
.filter(label => !selectedLabels.includes(label.name))
|
||||
.map((label) => {
|
||||
const colorClasses = LABEL_COLORS[label.color]
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={label.id}
|
||||
className={cn(
|
||||
'text-xs border cursor-pointer',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border,
|
||||
'hover:opacity-80'
|
||||
)}
|
||||
onClick={() => handleSelectExisting(label.name)}
|
||||
>
|
||||
{label.name}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t('general.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{t('general.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
134
memento-note/components/label-selector.tsx
Normal file
134
memento-note/components/label-selector.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tag, Plus, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface LabelSelectorProps {
|
||||
selectedLabels: string[]
|
||||
onLabelsChange: (labels: string[]) => void
|
||||
variant?: 'default' | 'compact'
|
||||
triggerLabel?: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}
|
||||
|
||||
export function LabelSelector({
|
||||
selectedLabels,
|
||||
onLabelsChange,
|
||||
variant = 'default',
|
||||
triggerLabel,
|
||||
align = 'start',
|
||||
}: LabelSelectorProps) {
|
||||
const { labels, loading, addLabel } = useLabels()
|
||||
const { t } = useLanguage()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filteredLabels = labels.filter(l =>
|
||||
l.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const handleToggleLabel = (labelName: string) => {
|
||||
if (selectedLabels.includes(labelName)) {
|
||||
onLabelsChange(selectedLabels.filter((l) => l !== labelName))
|
||||
} else {
|
||||
onLabelsChange([...selectedLabels, labelName])
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateLabel = async () => {
|
||||
const trimmed = search.trim()
|
||||
if (trimmed) {
|
||||
await addLabel(trimmed) // Let backend assign random color
|
||||
onLabelsChange([...selectedLabels, trimmed])
|
||||
setSearch('')
|
||||
}
|
||||
}
|
||||
|
||||
const showCreateOption = search.trim() && !labels.some(l => l.name.toLowerCase() === search.trim().toLowerCase())
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 px-2">
|
||||
<Tag className={cn("h-4 w-4", triggerLabel && "mr-2")} />
|
||||
{triggerLabel || t('labels.title')}
|
||||
{selectedLabels.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 h-5 min-w-5 px-1.5 bg-gray-200 text-gray-800 dark:bg-zinc-700 dark:text-zinc-300">
|
||||
{selectedLabels.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} className="w-64 p-0">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder={t('labels.namePlaceholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (showCreateOption) handleCreateLabel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto px-1 pb-1">
|
||||
{loading ? (
|
||||
<div className="p-2 text-sm text-gray-500 text-center">{t('general.loading')}</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredLabels.map((label) => {
|
||||
const isSelected = selectedLabels.includes(label.name)
|
||||
return (
|
||||
<div
|
||||
key={label.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleToggleLabel(label.name)
|
||||
}}
|
||||
className="flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<div className={cn(
|
||||
"h-4 w-4 border rounded flex items-center justify-center transition-colors",
|
||||
isSelected ? "bg-primary border-primary text-primary-foreground" : "border-gray-400"
|
||||
)}>
|
||||
{isSelected && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
<LabelBadge label={label.name} variant="clickable" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{showCreateOption && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleCreateLabel()
|
||||
}}
|
||||
className="flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm border-t mt-1 font-medium hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>{t('labels.createLabel', { name: search })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredLabels.length === 0 && !showCreateOption && (
|
||||
<div className="p-2 text-sm text-gray-500 text-center">{t('labels.noLabelsFound')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
99
memento-note/components/login-form.tsx
Normal file
99
memento-note/components/login-form.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
import { authenticate } from '@/app/actions/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
function LoginButton() {
|
||||
const { pending } = useFormStatus();
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<Button className="w-full mt-4" aria-disabled={pending}>
|
||||
{t('auth.signIn')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginForm({ allowRegister = true }: { allowRegister?: boolean }) {
|
||||
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<form action={dispatch} className="space-y-3">
|
||||
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
|
||||
<h1 className="mb-3 text-2xl font-bold">
|
||||
{t('auth.signInToAccount')}
|
||||
</h1>
|
||||
<div className="w-full">
|
||||
<div>
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
|
||||
htmlFor="email"
|
||||
>
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
|
||||
htmlFor="password"
|
||||
>
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-2">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-xs text-gray-500 hover:text-gray-900 underline"
|
||||
>
|
||||
{t('auth.forgotPassword')}
|
||||
</Link>
|
||||
</div>
|
||||
<LoginButton />
|
||||
<div
|
||||
className="flex h-8 items-end space-x-1"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
{allowRegister && (
|
||||
<div className="mt-4 text-center text-sm">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Link href="/register" className="underline">
|
||||
{t('auth.signUp')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
30
memento-note/components/markdown-content.tsx
Normal file
30
memento-note/components/markdown-content.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
a: ({ node, ...props }) => (
|
||||
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />
|
||||
)
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
234
memento-note/components/masonry-grid.css
Normal file
234
memento-note/components/masonry-grid.css
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 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%;
|
||||
/* Reduced to compensate for item padding */
|
||||
padding: 0 20px 40px 20px;
|
||||
}
|
||||
|
||||
/* Masonry Item Base Styles - Width is managed by Muuri */
|
||||
.masonry-item {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
/* 8px * 2 = 16px gap (Tighter spacing) */
|
||||
}
|
||||
|
||||
/* Masonry Item Content Wrapper */
|
||||
.masonry-item-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* height: auto - let content determine height */
|
||||
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 */
|
||||
}
|
||||
|
||||
/* Note Size Styles - Desktop Default */
|
||||
.masonry-item[data-size="small"],
|
||||
.note-card[data-size="small"] {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.masonry-item[data-size="medium"],
|
||||
.note-card[data-size="medium"] {
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.masonry-item[data-size="large"],
|
||||
.note-card[data-size="large"] {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* Drag State Styles - Clean and flat behavior requested by user */
|
||||
.masonry-item.muuri-item-dragging {
|
||||
z-index: 1000;
|
||||
opacity: 1 !important;
|
||||
/* Force opacity to 100% */
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.masonry-item.muuri-item-dragging .note-card {
|
||||
transform: none !important;
|
||||
/* Force "straight" - no rotation, no scale */
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.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 20px 16px 20px;
|
||||
}
|
||||
|
||||
.masonry-item {
|
||||
padding: 8px;
|
||||
/* 16px gap on mobile */
|
||||
}
|
||||
|
||||
/* Smaller note sizes on mobile */
|
||||
.masonry-item[data-size="small"],
|
||||
.masonry-item-content .note-card[data-size="small"] {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.masonry-item[data-size="medium"],
|
||||
.masonry-item-content .note-card[data-size="medium"] {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.masonry-item[data-size="large"],
|
||||
.masonry-item-content .note-card[data-size="large"] {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 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 24px 20px 24px;
|
||||
}
|
||||
|
||||
.masonry-item {
|
||||
padding: 8px;
|
||||
/* 16px gap */
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop Styles (1024px - 1279px) */
|
||||
@media (min-width: 1024px) and (max-width: 1279px) {
|
||||
.masonry-container {
|
||||
padding: 0 28px 24px 28px;
|
||||
}
|
||||
|
||||
.masonry-item {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large Desktop Styles (1280px+) */
|
||||
@media (min-width: 1280px) {
|
||||
.masonry-container {
|
||||
padding: 0 28px 32px 28px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.masonry-item {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transition for layout changes */
|
||||
.masonry-item,
|
||||
.masonry-item-content,
|
||||
.note-card {
|
||||
transition-property: 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;
|
||||
}
|
||||
}
|
||||
437
memento-note/components/masonry-grid.tsx
Normal file
437
memento-note/components/masonry-grid.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, memo, useMemo } from 'react';
|
||||
import { Note } from '@/lib/types';
|
||||
import { NoteCard } from './note-card';
|
||||
import { NoteEditor } from './note-editor';
|
||||
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'; // Force rebuild: Spacing update verification
|
||||
|
||||
interface MasonryGridProps {
|
||||
notes: Note[];
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void;
|
||||
}
|
||||
|
||||
interface MasonryItemProps {
|
||||
note: Note;
|
||||
onEdit: (note: Note, readOnly?: boolean) => void;
|
||||
onResize: () => void;
|
||||
onNoteSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
||||
onDragStart?: (noteId: string) => void;
|
||||
onDragEnd?: () => void;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
const MasonryItem = function MasonryItem({ note, onEdit, onResize, onNoteSizeChange, onDragStart, onDragEnd, isDragging }: MasonryItemProps) {
|
||||
const resizeRef = useResizeObserver(onResize);
|
||||
|
||||
useEffect(() => {
|
||||
onResize();
|
||||
const timer = setTimeout(onResize, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [note.size, onResize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="masonry-item absolute py-1"
|
||||
data-id={note.id}
|
||||
data-size={note.size}
|
||||
data-draggable="true"
|
||||
>
|
||||
<div className="masonry-item-content relative" ref={resizeRef as any}>
|
||||
<NoteCard
|
||||
note={note}
|
||||
onEdit={onEdit}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
isDragging={isDragging}
|
||||
onResize={onResize}
|
||||
onSizeChange={(newSize) => onNoteSizeChange(note.id, newSize)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
const { t } = useLanguage();
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
||||
const [muuriReady, setMuuriReady] = useState(false);
|
||||
|
||||
// Local state for notes with dynamic size updates
|
||||
// This allows size changes to propagate immediately without waiting for server
|
||||
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
|
||||
|
||||
// Sync localNotes when parent notes prop changes
|
||||
useEffect(() => {
|
||||
setLocalNotes(notes);
|
||||
}, [notes]);
|
||||
|
||||
// Callback for when a note's size changes - update local state immediately
|
||||
const handleNoteSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
|
||||
setLocalNotes(prevNotes =>
|
||||
prevNotes.map(n => n.id === noteId ? { ...n, size: newSize } : n)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
|
||||
if (onEdit) {
|
||||
onEdit(note, readOnly);
|
||||
} else {
|
||||
setEditingNote({ note, readOnly });
|
||||
}
|
||||
}, [onEdit]);
|
||||
|
||||
const pinnedGridRef = useRef<HTMLDivElement>(null);
|
||||
const othersGridRef = useRef<HTMLDivElement>(null);
|
||||
const pinnedMuuri = useRef<any>(null);
|
||||
const othersMuuri = useRef<any>(null);
|
||||
|
||||
// Memoize filtered notes from localNotes (which includes dynamic size updates)
|
||||
const pinnedNotes = useMemo(
|
||||
() => localNotes.filter(n => n.isPinned),
|
||||
[localNotes]
|
||||
);
|
||||
const othersNotes = useMemo(
|
||||
() => localNotes.filter(n => !n.isPinned),
|
||||
[localNotes]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(async (grid: any) => {
|
||||
if (!grid) return;
|
||||
|
||||
const items = grid.getItems();
|
||||
const ids = items
|
||||
.map((item: any) => item.getElement()?.getAttribute('data-id'))
|
||||
.filter((id: any): id is string => !!id);
|
||||
|
||||
try {
|
||||
// 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(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (pinnedMuuri.current) {
|
||||
pinnedMuuri.current.refreshItems().layout();
|
||||
}
|
||||
if (othersMuuri.current) {
|
||||
othersMuuri.current.refreshItems().layout();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyItemDimensions = useCallback((grid: any, containerWidth: number) => {
|
||||
if (!grid) return;
|
||||
|
||||
// Calculate columns and item width based on container width
|
||||
const columns = calculateColumns(containerWidth);
|
||||
const baseItemWidth = calculateItemWidth(containerWidth, columns);
|
||||
|
||||
const items = grid.getItems();
|
||||
items.forEach((item: any) => {
|
||||
const el = item.getElement();
|
||||
if (el) {
|
||||
const size = el.getAttribute('data-size') || 'small';
|
||||
let width = baseItemWidth;
|
||||
if (columns >= 2 && size === 'medium') {
|
||||
width = Math.min(baseItemWidth * 1.5, containerWidth);
|
||||
} else if (columns >= 2 && size === 'large') {
|
||||
width = Math.min(baseItemWidth * 2, containerWidth);
|
||||
}
|
||||
el.style.width = `${width}px`;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initialize Muuri grids once on mount and sync when needed
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
let muuriInitialized = false;
|
||||
|
||||
const initMuuri = async () => {
|
||||
// Prevent duplicate initialization
|
||||
if (muuriInitialized) return;
|
||||
muuriInitialized = true;
|
||||
|
||||
// Import web-animations-js polyfill
|
||||
await import('web-animations-js');
|
||||
// Dynamic import of Muuri to avoid SSR window error
|
||||
const MuuriClass = (await import('muuri')).default;
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
// Detect if we are on a touch device (mobile behavior)
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
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);
|
||||
|
||||
|
||||
|
||||
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,
|
||||
dragStartPredicate: {
|
||||
distance: 10,
|
||||
delay: 0,
|
||||
},
|
||||
dragPlaceholder: {
|
||||
enabled: true,
|
||||
createElement: (item: any) => {
|
||||
const el = item.getElement().cloneNode(true);
|
||||
// Styles are now handled purely by CSS (.muuri-item-placeholder)
|
||||
// to avoid inline style conflicts and "grayed out/tilted" look
|
||||
return el;
|
||||
},
|
||||
},
|
||||
dragAutoScroll: {
|
||||
targets: [window],
|
||||
speed: (item: any, target: any, intersection: any) => {
|
||||
return intersection * 30; // Faster auto-scroll for better UX
|
||||
},
|
||||
threshold: 50, // Start auto-scroll earlier (50px from edge)
|
||||
smoothStop: true, // Smooth deceleration
|
||||
},
|
||||
// LAYOUT OPTIONS - Configure masonry grid behavior
|
||||
// These options are critical for proper masonry layout with different item sizes
|
||||
layoutDuration: 300,
|
||||
layoutEasing: 'cubic-bezier(0.25, 1, 0.5, 1)',
|
||||
fillGaps: true,
|
||||
horizontal: false,
|
||||
alignRight: false,
|
||||
alignBottom: false,
|
||||
rounding: false,
|
||||
// CRITICAL: Enable true masonry layout for different item sizes
|
||||
layout: {
|
||||
fillGaps: true,
|
||||
horizontal: false,
|
||||
alignRight: false,
|
||||
alignBottom: false,
|
||||
rounding: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize pinned grid
|
||||
if (pinnedGridRef.current && !pinnedMuuri.current) {
|
||||
pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions)
|
||||
.on('dragEnd', () => handleDragEnd(pinnedMuuri.current));
|
||||
applyItemDimensions(pinnedMuuri.current, containerWidth);
|
||||
pinnedMuuri.current.refreshItems().layout();
|
||||
}
|
||||
|
||||
// Initialize others grid
|
||||
if (othersGridRef.current && !othersMuuri.current) {
|
||||
othersMuuri.current = new MuuriClass(othersGridRef.current, layoutOptions)
|
||||
.on('dragEnd', () => handleDragEnd(othersMuuri.current));
|
||||
applyItemDimensions(othersMuuri.current, containerWidth);
|
||||
othersMuuri.current.refreshItems().layout();
|
||||
}
|
||||
|
||||
// Signal that Muuri is ready so sync/resize effects can run
|
||||
setMuuriReady(true);
|
||||
};
|
||||
|
||||
initMuuri();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
pinnedMuuri.current?.destroy();
|
||||
othersMuuri.current?.destroy();
|
||||
pinnedMuuri.current = null;
|
||||
othersMuuri.current = null;
|
||||
};
|
||||
// Only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Container ref for ResizeObserver
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Synchronize items when notes change (e.g. searching, adding)
|
||||
useEffect(() => {
|
||||
if (!muuriReady) return;
|
||||
const syncGridItems = (grid: any, gridRef: React.RefObject<HTMLDivElement | null>, notesArray: Note[]) => {
|
||||
if (!grid || !gridRef.current) return;
|
||||
|
||||
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();
|
||||
|
||||
// Map Muuri items to their elements for comparison
|
||||
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 elements to remove (in Muuri but not in DOM)
|
||||
const removedItems = muuriItems.filter((item: any) =>
|
||||
!domElements.includes(item.getElement())
|
||||
);
|
||||
|
||||
// Remove old items
|
||||
if (removedItems.length > 0) {
|
||||
grid.remove(removedItems, { layout: false });
|
||||
}
|
||||
|
||||
// Add new items with correct width based on size
|
||||
if (newElements.length > 0) {
|
||||
newElements.forEach(el => {
|
||||
const size = el.getAttribute('data-size') || 'small';
|
||||
let width = itemWidth;
|
||||
if (columns >= 2 && size === 'medium') {
|
||||
width = Math.min(itemWidth * 1.5, containerWidth);
|
||||
} else if (columns >= 2 && size === 'large') {
|
||||
width = Math.min(itemWidth * 2, containerWidth);
|
||||
}
|
||||
el.style.width = `${width}px`;
|
||||
});
|
||||
grid.add(newElements, { layout: false });
|
||||
}
|
||||
|
||||
// Update all item widths to ensure consistency (size-aware)
|
||||
domElements.forEach(el => {
|
||||
const size = el.getAttribute('data-size') || 'small';
|
||||
let width = itemWidth;
|
||||
if (columns >= 2 && size === 'medium') {
|
||||
width = Math.min(itemWidth * 1.5, containerWidth);
|
||||
} else if (columns >= 2 && size === 'large') {
|
||||
width = Math.min(itemWidth * 2, containerWidth);
|
||||
}
|
||||
el.style.width = `${width}px`;
|
||||
});
|
||||
|
||||
// Refresh and layout
|
||||
grid.refreshItems().layout();
|
||||
};
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM is updated before syncing
|
||||
requestAnimationFrame(() => {
|
||||
syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes);
|
||||
syncGridItems(othersMuuri.current, othersGridRef, othersNotes);
|
||||
|
||||
// CRITICAL: Force a second layout after CSS transitions (padding/height changes) complete
|
||||
// NoteCard has a 200ms transition. We wait 300ms to be safe.
|
||||
setTimeout(() => {
|
||||
if (pinnedMuuri.current) pinnedMuuri.current.refreshItems().layout();
|
||||
if (othersMuuri.current) othersMuuri.current.refreshItems().layout();
|
||||
}, 300);
|
||||
});
|
||||
}, [pinnedNotes, othersNotes, muuriReady]); // Re-run when notes change or Muuri becomes ready
|
||||
|
||||
// Handle container resize to update responsive layout
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || (!pinnedMuuri.current && !othersMuuri.current)) return;
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout;
|
||||
|
||||
const handleResize = (entries: ResizeObserverEntry[]) => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
// Get precise width from ResizeObserver
|
||||
const containerWidth = entries[0]?.contentRect.width || window.innerWidth - 32;
|
||||
const columns = calculateColumns(containerWidth);
|
||||
|
||||
|
||||
|
||||
// Apply dimensions to both grids
|
||||
applyItemDimensions(pinnedMuuri.current, containerWidth);
|
||||
applyItemDimensions(othersMuuri.current, containerWidth);
|
||||
|
||||
// Refresh layouts
|
||||
requestAnimationFrame(() => {
|
||||
pinnedMuuri.current?.refreshItems().layout();
|
||||
othersMuuri.current?.refreshItems().layout();
|
||||
});
|
||||
}, 150); // Debounce
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(handleResize);
|
||||
observer.observe(containerRef.current);
|
||||
|
||||
// Initial layout check
|
||||
if (containerRef.current) {
|
||||
handleResize([{ contentRect: containerRef.current.getBoundingClientRect() } as ResizeObserverEntry]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [applyItemDimensions, muuriReady]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div ref={pinnedGridRef} className="relative min-h-[100px]">
|
||||
{pinnedNotes.map(note => (
|
||||
<MasonryItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={handleEdit}
|
||||
onResize={refreshLayout}
|
||||
onNoteSizeChange={handleNoteSizeChange}
|
||||
onDragStart={startDrag}
|
||||
onDragEnd={endDrag}
|
||||
isDragging={draggedNoteId === note.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{othersNotes.length > 0 && (
|
||||
<div>
|
||||
{pinnedNotes.length > 0 && (
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">{t('notes.others')}</h2>
|
||||
)}
|
||||
<div ref={othersGridRef} className="relative min-h-[100px]">
|
||||
{othersNotes.map(note => (
|
||||
<MasonryItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={handleEdit}
|
||||
onResize={refreshLayout}
|
||||
onNoteSizeChange={handleNoteSizeChange}
|
||||
onDragStart={startDrag}
|
||||
onDragEnd={endDrag}
|
||||
isDragging={draggedNoteId === note.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingNote && (
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={() => setEditingNote(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
337
memento-note/components/memory-echo-notification.tsx
Normal file
337
memento-note/components/memory-echo-notification.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Lightbulb, ThumbsUp, ThumbsDown, X, Sparkles, ArrowRight } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MemoryEchoInsight {
|
||||
id: string
|
||||
note1Id: string
|
||||
note2Id: string
|
||||
note1: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
}
|
||||
note2: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
}
|
||||
similarityScore: number
|
||||
insight: string
|
||||
insightDate: Date
|
||||
viewed: boolean
|
||||
feedback: string | null
|
||||
}
|
||||
|
||||
interface MemoryEchoNotificationProps {
|
||||
onOpenNote?: (noteId: string) => void
|
||||
}
|
||||
|
||||
export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationProps) {
|
||||
const { t } = useLanguage()
|
||||
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
// Fetch insight on mount
|
||||
useEffect(() => {
|
||||
fetchInsight()
|
||||
}, [])
|
||||
|
||||
const fetchInsight = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/echo')
|
||||
const data = await res.json()
|
||||
|
||||
if (data.insight) {
|
||||
setInsight(data.insight)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to fetch insight:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleView = async () => {
|
||||
if (!insight) return
|
||||
|
||||
try {
|
||||
// Mark as viewed
|
||||
await fetch('/api/ai/echo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'view',
|
||||
insightId: insight.id
|
||||
})
|
||||
})
|
||||
|
||||
// Show success message and open modal
|
||||
toast.success('Opening connection...')
|
||||
setShowModal(true)
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to view connection:', error)
|
||||
toast.error('Failed to open connection')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFeedback = async (feedback: 'thumbs_up' | 'thumbs_down') => {
|
||||
if (!insight) return
|
||||
|
||||
try {
|
||||
await fetch('/api/ai/echo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'feedback',
|
||||
insightId: insight.id,
|
||||
feedback
|
||||
})
|
||||
})
|
||||
|
||||
// Show feedback toast
|
||||
if (feedback === 'thumbs_up') {
|
||||
toast.success('Thanks for your feedback!')
|
||||
} else {
|
||||
toast.success('Thanks! We\'ll use this to improve.')
|
||||
}
|
||||
|
||||
// Dismiss notification
|
||||
setIsDismissed(true)
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to submit feedback:', error)
|
||||
toast.error('Failed to submit feedback')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true)
|
||||
}
|
||||
|
||||
// Don't render notification if dismissed, loading, or no insight
|
||||
if (isDismissed || isLoading || !insight) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate values for both notification and modal
|
||||
const note1Title = insight.note1.title || 'Untitled'
|
||||
const note2Title = insight.note2.title || 'Untitled'
|
||||
const similarityPercentage = Math.round(insight.similarityScore * 100)
|
||||
|
||||
// Render modal if requested
|
||||
if (showModal && insight) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('memoryEcho.title')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('connection.similarityInfo', { similarity: similarityPercentage })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModal(false)
|
||||
setIsDismissed(true)
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI-generated insight */}
|
||||
<div className="px-6 py-4 bg-amber-50 dark:bg-amber-950/20 border-b dark:border-zinc-700">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{insight.insight}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes Grid */}
|
||||
<div className="grid grid-cols-2 gap-6 p-6">
|
||||
{/* Note 1 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (onOpenNote) {
|
||||
onOpenNote(insight.note1.id)
|
||||
setShowModal(false)
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer border dark:border-zinc-700 rounded-lg p-4 hover:border-amber-300 dark:hover:border-amber-700 transition-colors"
|
||||
>
|
||||
<h3 className="font-semibold text-primary dark:text-primary-foreground mb-2">
|
||||
{note1Title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
|
||||
{insight.note1.content}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{t('memoryEcho.clickToView')}</p>
|
||||
</div>
|
||||
|
||||
{/* Note 2 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (onOpenNote) {
|
||||
onOpenNote(insight.note2.id)
|
||||
setShowModal(false)
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer border dark:border-zinc-700 rounded-lg p-4 hover:border-purple-300 dark:hover:border-purple-700 transition-colors"
|
||||
>
|
||||
<h3 className="font-semibold text-purple-600 dark:text-purple-400 mb-2">
|
||||
{note2Title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
|
||||
{insight.note2.content}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{t('memoryEcho.clickToView')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Section */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('connection.isHelpful')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleFeedback('thumbs_up')}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_up'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'hover:bg-green-50 text-green-600 dark:hover:bg-green-950/20'
|
||||
}`}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
{t('memoryEcho.helpful')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFeedback('thumbs_down')}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_down'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'hover:bg-red-50 text-red-600 dark:hover:bg-red-950/20'
|
||||
}`}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
{t('memoryEcho.notHelpful')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-md w-full animate-in slide-in-from-bottom-4 fade-in duration-500">
|
||||
<Card className="border-amber-200 dark:border-amber-900 shadow-lg bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/20 dark:to-background">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Lightbulb className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{t('memoryEcho.title')}
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{t('memoryEcho.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 -mr-2 -mt-2"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* AI-generated insight */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg p-3 border border-amber-100 dark:border-amber-900/30">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{insight.insight}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connected notes */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="border-primary/20 text-primary dark:border-primary/30 dark:text-primary-foreground">
|
||||
{note1Title}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-gray-400" />
|
||||
<Badge variant="outline" className="border-purple-200 text-purple-700 dark:border-purple-900 dark:text-purple-300">
|
||||
{note2Title}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{similarityPercentage}% match
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onClick={handleView}
|
||||
>
|
||||
{t('memoryEcho.viewConnection')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 border-l pl-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
|
||||
onClick={() => handleFeedback('thumbs_up')}
|
||||
title={t('memoryEcho.helpful')}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={() => handleFeedback('thumbs_down')}
|
||||
title={t('memoryEcho.notHelpful')}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss link */}
|
||||
<button
|
||||
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
{t('memoryEcho.dismiss')}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
memento-note/components/note-actions.tsx
Normal file
154
memento-note/components/note-actions.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
MoreVertical,
|
||||
Palette,
|
||||
Pin,
|
||||
Trash2,
|
||||
Users,
|
||||
Maximize2,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { NOTE_COLORS } from "@/lib/types"
|
||||
import { useLanguage } from "@/lib/i18n"
|
||||
|
||||
interface NoteActionsProps {
|
||||
isPinned: boolean
|
||||
isArchived: boolean
|
||||
currentColor: string
|
||||
currentSize?: 'small' | 'medium' | 'large'
|
||||
onTogglePin: () => void
|
||||
onToggleArchive: () => void
|
||||
onColorChange: (color: string) => void
|
||||
onSizeChange?: (size: 'small' | 'medium' | 'large') => void
|
||||
onDelete: () => void
|
||||
onShareCollaborators?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NoteActions({
|
||||
isPinned,
|
||||
isArchived,
|
||||
currentColor,
|
||||
currentSize = 'small',
|
||||
onTogglePin,
|
||||
onToggleArchive,
|
||||
onColorChange,
|
||||
onSizeChange,
|
||||
onDelete,
|
||||
onShareCollaborators,
|
||||
className
|
||||
}: NoteActionsProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-1", className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Color Palette */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.changeColor')}>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<div className="grid grid-cols-5 gap-2 p-2">
|
||||
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
|
||||
<button
|
||||
key={colorName}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||
classes.bg,
|
||||
currentColor === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
|
||||
)}
|
||||
onClick={() => onColorChange(colorName)}
|
||||
title={colorName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* More Options */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" aria-label={t('notes.moreOptions')}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onToggleArchive}>
|
||||
{isArchived ? (
|
||||
<>
|
||||
<ArchiveRestore className="h-4 w-4 mr-2" />
|
||||
{t('notes.unarchive')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
{t('notes.archive')}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Size Selector */}
|
||||
{onSizeChange && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
{t('notes.size')}
|
||||
</div>
|
||||
{(['small', 'medium', 'large'] as const).map((size) => (
|
||||
<DropdownMenuItem
|
||||
key={size}
|
||||
onSelect={(e) => {
|
||||
onSizeChange?.(size);
|
||||
}}
|
||||
className={cn(
|
||||
"capitalize",
|
||||
currentSize === size && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4 mr-2" />
|
||||
{t(`notes.${size}` as const)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Collaborators */}
|
||||
{onShareCollaborators && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShareCollaborators()
|
||||
}}
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
{t('notes.shareWithCollaborators')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onDelete} className="text-red-600 dark:text-red-400">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('notes.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
658
memento-note/components/note-card.tsx
Normal file
658
memento-note/components/note-card.tsx
Normal file
@@ -0,0 +1,658 @@
|
||||
'use client'
|
||||
|
||||
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
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, Tag } from 'lucide-react'
|
||||
import { useState, useEffect, useTransition, useOptimistic } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow, Locale } from 'date-fns'
|
||||
import * as dateFnsLocales from 'date-fns/locale'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { NoteImages } from './note-images'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
import { NoteActions } from './note-actions'
|
||||
import { CollaboratorDialog } from './collaborator-dialog'
|
||||
import { CollaboratorAvatars } from './collaborator-avatars'
|
||||
import { ConnectionsBadge } from './connections-badge'
|
||||
import { ConnectionsOverlay } from './connections-overlay'
|
||||
import { ComparisonModal } from './comparison-modal'
|
||||
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Mapping of supported languages to date-fns locales
|
||||
const localeMap: Record<string, Locale> = {
|
||||
en: dateFnsLocales.enUS,
|
||||
fr: dateFnsLocales.fr,
|
||||
es: dateFnsLocales.es,
|
||||
de: dateFnsLocales.de,
|
||||
fa: dateFnsLocales.faIR,
|
||||
it: dateFnsLocales.it,
|
||||
pt: dateFnsLocales.pt,
|
||||
ru: dateFnsLocales.ru,
|
||||
zh: dateFnsLocales.zhCN,
|
||||
ja: dateFnsLocales.ja,
|
||||
ko: dateFnsLocales.ko,
|
||||
ar: dateFnsLocales.ar,
|
||||
hi: dateFnsLocales.hi,
|
||||
nl: dateFnsLocales.nl,
|
||||
pl: dateFnsLocales.pl,
|
||||
}
|
||||
|
||||
function getDateLocale(language: string): Locale {
|
||||
return localeMap[language] || dateFnsLocales.enUS
|
||||
}
|
||||
|
||||
// Map icon names to lucide-react components
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
}
|
||||
|
||||
// Function to get icon component by name
|
||||
function getNotebookIcon(iconName: string): LucideIcon {
|
||||
const IconComponent = ICON_MAP[iconName] || Folder
|
||||
return IconComponent
|
||||
}
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
isDragging?: boolean
|
||||
isDragOver?: boolean
|
||||
onDragStart?: (noteId: string) => void
|
||||
onDragEnd?: () => void
|
||||
onResize?: () => void
|
||||
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
|
||||
}
|
||||
|
||||
// Helper function to get initials from name
|
||||
function getInitials(name: string): string {
|
||||
if (!name) return '??'
|
||||
const trimmedName = name.trim()
|
||||
const parts = trimmedName.split(' ')
|
||||
if (parts.length === 1) {
|
||||
return trimmedName.substring(0, 2).toUpperCase()
|
||||
}
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
// Helper function to get avatar color based on name hash
|
||||
function getAvatarColor(name: string): string {
|
||||
const colors = [
|
||||
'bg-primary',
|
||||
'bg-purple-500',
|
||||
'bg-green-500',
|
||||
'bg-orange-500',
|
||||
'bg-pink-500',
|
||||
'bg-teal-500',
|
||||
'bg-red-500',
|
||||
'bg-indigo-500',
|
||||
]
|
||||
|
||||
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
return colors[hash % colors.length]
|
||||
}
|
||||
|
||||
export function NoteCard({
|
||||
note,
|
||||
onEdit,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
isDragging,
|
||||
onResize,
|
||||
onSizeChange
|
||||
}: NoteCardProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { refreshLabels } = useLabels()
|
||||
const { data: session } = useSession()
|
||||
const { t, language } = useLanguage()
|
||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
||||
const [collaborators, setCollaborators] = useState<any[]>([])
|
||||
const [owner, setOwner] = useState<any>(null)
|
||||
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
|
||||
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
|
||||
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
||||
|
||||
// Move note to a notebook
|
||||
const handleMoveToNotebook = async (notebookId: string | null) => {
|
||||
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
||||
setShowNotebookMenu(false)
|
||||
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
|
||||
}
|
||||
|
||||
// Optimistic UI state for instant feedback
|
||||
const [optimisticNote, addOptimisticNote] = useOptimistic(
|
||||
note,
|
||||
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
|
||||
)
|
||||
|
||||
const colorClasses = NOTE_COLORS[optimisticNote.color as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
// Check if this note is currently open in the editor
|
||||
const isNoteOpenInEditor = searchParams.get('note') === note.id
|
||||
|
||||
// Only fetch comparison notes when we have IDs to compare
|
||||
const { notes: comparisonNotesData, isLoading: isLoadingComparison } = useConnectionsCompare(
|
||||
comparisonNotes && comparisonNotes.length > 0 ? comparisonNotes : null
|
||||
)
|
||||
|
||||
const currentUserId = session?.user?.id
|
||||
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
|
||||
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
|
||||
const isOwner = currentUserId && note.userId && currentUserId === note.userId
|
||||
|
||||
// Load collaborators when note changes
|
||||
useEffect(() => {
|
||||
const loadCollaborators = async () => {
|
||||
if (note.userId) {
|
||||
try {
|
||||
const users = await getNoteAllUsers(note.id)
|
||||
setCollaborators(users)
|
||||
// Owner is always first in the list
|
||||
if (users.length > 0) {
|
||||
setOwner(users[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load collaborators:', error)
|
||||
setCollaborators([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCollaborators()
|
||||
}, [note.id, note.userId])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm(t('notes.confirmDelete'))) {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
// Refresh global labels to reflect garbage collection
|
||||
await refreshLabels()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ isPinned: !note.isPinned })
|
||||
await togglePin(note.id, !note.isPinned)
|
||||
router.refresh()
|
||||
|
||||
if (!note.isPinned) {
|
||||
toast.success(t('notes.pinned') || 'Note pinned')
|
||||
} else {
|
||||
toast.info(t('notes.unpinned') || 'Note unpinned')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleArchive = async () => {
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ isArchived: !note.isArchived })
|
||||
await toggleArchive(note.id, !note.isArchived)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
const handleColorChange = async (color: string) => {
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ color })
|
||||
await updateColor(note.id, color)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSizeChange = async (size: 'small' | 'medium' | 'large') => {
|
||||
startTransition(async () => {
|
||||
// Instant visual feedback for the card itself
|
||||
addOptimisticNote({ size })
|
||||
|
||||
// Notify parent so it can update its local state
|
||||
onSizeChange?.(size)
|
||||
|
||||
// Trigger layout refresh
|
||||
onResize?.()
|
||||
setTimeout(() => onResize?.(), 300)
|
||||
|
||||
// Update server in background
|
||||
|
||||
try {
|
||||
await updateSize(note.id, size);
|
||||
} catch (error) {
|
||||
console.error('Failed to update note size:', error);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCheckItem = async (checkItemId: string) => {
|
||||
if (note.type === 'checklist' && note.checkItems) {
|
||||
const updatedItems = note.checkItems.map(item =>
|
||||
item.id === checkItemId ? { ...item, checked: !item.checked } : item
|
||||
)
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ checkItems: updatedItems })
|
||||
await updateNote(note.id, { checkItems: updatedItems })
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeaveShare = async () => {
|
||||
if (confirm(t('notes.confirmLeaveShare'))) {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
setIsDeleting(true) // Hide the note from view
|
||||
} catch (error) {
|
||||
console.error('Failed to leave share:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent opening the note editor
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ autoGenerated: null })
|
||||
await removeFusedBadge(note.id)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
if (isDeleting) return null
|
||||
|
||||
const getMinHeight = (size?: string) => {
|
||||
switch (size) {
|
||||
case 'medium': return '350px'
|
||||
case 'large': return '500px'
|
||||
default: return '150px' // small
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-testid="note-card"
|
||||
data-draggable="true"
|
||||
data-note-id={note.id}
|
||||
data-size={optimisticNote.size}
|
||||
style={{ minHeight: getMinHeight(optimisticNote.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-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,
|
||||
colorClasses.hover,
|
||||
isDragging && 'shadow-2xl' // Removed opacity, scale, and rotation for clean drag
|
||||
)}
|
||||
onClick={(e) => {
|
||||
// Only trigger edit if not clicking on buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.muuri-drag-handle') && !target.closest('.drag-handle')) {
|
||||
// For shared notes, pass readOnly flag
|
||||
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag Handle - Only visible on mobile/touch devices */}
|
||||
<div
|
||||
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
|
||||
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
|
||||
title={t('notes.dragToReorder') || 'Drag to reorder'}
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Move to Notebook Dropdown Menu */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
|
||||
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 bg-primary/10 dark:bg-primary/20 hover:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary-foreground"
|
||||
title={t('notebookSuggestion.moveToNotebook')}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
{t('notebookSuggestion.moveToNotebook')}
|
||||
</div>
|
||||
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
|
||||
<StickyNote className="h-4 w-4 mr-2" />
|
||||
{t('notebookSuggestion.generalNotes')}
|
||||
</DropdownMenuItem>
|
||||
{notebooks.map((notebook: any) => {
|
||||
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={notebook.id}
|
||||
onClick={() => handleMoveToNotebook(notebook.id)}
|
||||
>
|
||||
<NotebookIcon className="h-4 w-4 mr-2" />
|
||||
{notebook.name}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Pin Button - Visible on hover or if pinned */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="pin-button"
|
||||
className={cn(
|
||||
"absolute top-2 right-12 z-20 min-h-[44px] min-w-[44px] h-8 w-8 p-0 rounded-md transition-opacity",
|
||||
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleTogglePin()
|
||||
}}
|
||||
>
|
||||
<Pin
|
||||
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
|
||||
|
||||
{/* Reminder Icon - Move slightly if pin button is there */}
|
||||
{note.reminder && new Date(note.reminder) > new Date() && (
|
||||
<Bell
|
||||
className="absolute top-3 right-10 h-4 w-4 text-primary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Memory Echo Badges - Fusion + Connections (BEFORE Title) */}
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{/* Fusion Badge with remove button */}
|
||||
{note.autoGenerated && (
|
||||
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative">
|
||||
<Link2 className="h-2.5 w-2.5" />
|
||||
{t('memoryEcho.fused')}
|
||||
<button
|
||||
onClick={handleRemoveFusedBadge}
|
||||
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
|
||||
title={t('notes.remove') || 'Remove'}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connections Badge */}
|
||||
<ConnectionsBadge
|
||||
noteId={note.id}
|
||||
onClick={() => {
|
||||
// Only open overlay if note is NOT open in editor
|
||||
// (to avoid having 2 Dialogs with 2 close buttons)
|
||||
if (!isNoteOpenInEditor) {
|
||||
setShowConnectionsOverlay(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{optimisticNote.title && (
|
||||
<h3 className="text-base font-medium mb-2 pr-10 text-foreground">
|
||||
{optimisticNote.title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Search Match Type Badge */}
|
||||
{optimisticNote.matchType && (
|
||||
<Badge
|
||||
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
'mb-2 text-xs',
|
||||
optimisticNote.matchType === 'exact'
|
||||
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
|
||||
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Shared badge */}
|
||||
{isSharedNote && owner && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-primary dark:text-primary-foreground font-medium">
|
||||
{t('notes.sharedBy')} {owner.name || owner.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleLeaveShare()
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{t('notes.leaveShare')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images Component */}
|
||||
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
|
||||
|
||||
{/* Link Previews */}
|
||||
{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"
|
||||
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()}
|
||||
>
|
||||
{link.imageUrl && (
|
||||
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
||||
)}
|
||||
<div className="p-2">
|
||||
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
|
||||
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
|
||||
<span className="text-[10px] text-primary mt-1 block">
|
||||
{new URL(link.url).hostname}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{optimisticNote.type === 'text' ? (
|
||||
<div className="text-sm text-foreground line-clamp-10">
|
||||
<MarkdownContent content={optimisticNote.content} />
|
||||
</div>
|
||||
) : (
|
||||
<NoteChecklist
|
||||
items={optimisticNote.checkItems || []}
|
||||
onToggleItem={handleCheckItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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) => {
|
||||
// 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>
|
||||
)}
|
||||
|
||||
{/* Footer with Date only */}
|
||||
<div className="mt-3 flex items-center justify-end">
|
||||
{/* Creation Date */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Owner Avatar - Aligned with action buttons at bottom */}
|
||||
{owner && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-2 left-2 z-20",
|
||||
"w-6 h-6 rounded-full text-white text-[10px] font-semibold flex items-center justify-center",
|
||||
getAvatarColor(owner.name || owner.email || 'Unknown')
|
||||
)}
|
||||
title={owner.name || owner.email || 'Unknown'}
|
||||
>
|
||||
{getInitials(owner.name || owner.email || '??')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Bar Component - Always show for now to fix regression */}
|
||||
{true && (
|
||||
<NoteActions
|
||||
isPinned={optimisticNote.isPinned}
|
||||
isArchived={optimisticNote.isArchived}
|
||||
currentColor={optimisticNote.color}
|
||||
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
|
||||
onTogglePin={handleTogglePin}
|
||||
onToggleArchive={handleToggleArchive}
|
||||
onColorChange={handleColorChange}
|
||||
onSizeChange={handleSizeChange}
|
||||
onDelete={handleDelete}
|
||||
onShareCollaborators={() => setShowCollaboratorDialog(true)}
|
||||
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Collaborator Dialog */}
|
||||
{currentUserId && note.userId && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<CollaboratorDialog
|
||||
open={showCollaboratorDialog}
|
||||
onOpenChange={setShowCollaboratorDialog}
|
||||
noteId={note.id}
|
||||
noteOwnerId={note.userId}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connections Overlay */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ConnectionsOverlay
|
||||
isOpen={showConnectionsOverlay}
|
||||
onClose={() => setShowConnectionsOverlay(false)}
|
||||
noteId={note.id}
|
||||
onOpenNote={(noteId) => {
|
||||
// Find the note and open it
|
||||
onEdit?.(note, false)
|
||||
}}
|
||||
onCompareNotes={(noteIds) => {
|
||||
setComparisonNotes(noteIds)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comparison Modal */}
|
||||
{comparisonNotes && comparisonNotesData.length > 0 && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ComparisonModal
|
||||
isOpen={!!comparisonNotes}
|
||||
onClose={() => setComparisonNotes(null)}
|
||||
notes={comparisonNotesData}
|
||||
onOpenNote={(noteId) => {
|
||||
const foundNote = comparisonNotesData.find(n => n.id === noteId)
|
||||
if (foundNote) {
|
||||
onEdit?.(foundNote, false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
42
memento-note/components/note-checklist.tsx
Normal file
42
memento-note/components/note-checklist.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { CheckItem } from "@/lib/types"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface NoteChecklistProps {
|
||||
items: CheckItem[]
|
||||
onToggleItem: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function NoteChecklist({ items, onToggleItem }: NoteChecklistProps) {
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleItem(item.id)
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
item.checked
|
||||
? 'line-through text-gray-500 dark:text-gray-500'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1133
memento-note/components/note-editor.tsx
Normal file
1133
memento-note/components/note-editor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
68
memento-note/components/note-images.tsx
Normal file
68
memento-note/components/note-images.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface NoteImagesProps {
|
||||
images: string[]
|
||||
title?: string | null
|
||||
}
|
||||
|
||||
export function NoteImages({ images, title }: NoteImagesProps) {
|
||||
if (!images || images.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"mb-3 -mx-4",
|
||||
!title && "-mt-4"
|
||||
)}>
|
||||
{images.length === 1 ? (
|
||||
<img
|
||||
src={images[0]}
|
||||
alt=""
|
||||
className="w-full h-auto rounded-lg"
|
||||
/>
|
||||
) : images.length === 2 ? (
|
||||
<div className="grid grid-cols-2 gap-2 px-4">
|
||||
{images.map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-full h-auto rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : images.length === 3 ? (
|
||||
<div className="grid grid-cols-2 gap-2 px-4">
|
||||
<img
|
||||
src={images[0]}
|
||||
alt=""
|
||||
className="col-span-2 w-full h-auto rounded-lg"
|
||||
/>
|
||||
{images.slice(1).map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-full h-auto rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 px-4">
|
||||
{images.slice(0, 4).map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-full h-auto rounded-lg"
|
||||
/>
|
||||
))}
|
||||
{images.length > 4 && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs">
|
||||
+{images.length - 4}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1115
memento-note/components/note-input.tsx
Normal file
1115
memento-note/components/note-input.tsx
Normal file
File diff suppressed because it is too large
Load Diff
55
memento-note/components/notebook-actions.tsx
Normal file
55
memento-note/components/notebook-actions.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { Edit2, MoreVertical, FileText } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
interface NotebookActionsProps {
|
||||
notebook: any
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onSummary?: () => void // NEW: Summary action callback (IA6)
|
||||
}
|
||||
|
||||
export function NotebookActions({ notebook, onEdit, onDelete, onSummary }: NotebookActionsProps) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSummary && (
|
||||
<DropdownMenuItem onClick={onSummary}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{t('notebook.summary')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
{t('notebook.edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onDelete}
|
||||
className="text-red-600"
|
||||
>
|
||||
{t('notebook.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
memento-note/components/notebook-suggestion-toast.tsx
Normal file
154
memento-note/components/notebook-suggestion-toast.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { X, FolderOpen } from 'lucide-react'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface NotebookSuggestionToastProps {
|
||||
noteId: string
|
||||
noteContent: string
|
||||
onDismiss: () => void
|
||||
onMoveToNotebook?: (notebookId: string) => void
|
||||
}
|
||||
|
||||
export function NotebookSuggestionToast({
|
||||
noteId,
|
||||
noteContent,
|
||||
onDismiss,
|
||||
onMoveToNotebook
|
||||
}: NotebookSuggestionToastProps) {
|
||||
const { t } = useLanguage()
|
||||
const [suggestion, setSuggestion] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [visible, setVisible] = useState(true)
|
||||
const [timeLeft, setTimeLeft] = useState(30) // 30 second countdown
|
||||
const router = useRouter()
|
||||
const { moveNoteToNotebookOptimistic } = useNotebooks()
|
||||
|
||||
// Auto-dismiss after 30 seconds
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
handleDismiss()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// Fetch suggestion when component mounts
|
||||
useEffect(() => {
|
||||
const fetchSuggestion = async () => {
|
||||
// Only suggest if content is long enough (> 20 words)
|
||||
const wordCount = noteContent.trim().split(/\s+/).length
|
||||
if (wordCount < 20) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/suggest-notebook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
noteContent,
|
||||
language: document.documentElement.lang || 'en',
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (response.ok) {
|
||||
if (data.suggestion && data.confidence > 0.7) {
|
||||
setSuggestion(data.suggestion)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Error fetching notebook suggestion
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSuggestion()
|
||||
}, [noteContent])
|
||||
|
||||
const handleDismiss = () => {
|
||||
setVisible(false)
|
||||
setTimeout(() => onDismiss(), 300) // Wait for animation
|
||||
}
|
||||
|
||||
const handleMoveToNotebook = async () => {
|
||||
if (!suggestion) return
|
||||
|
||||
try {
|
||||
// Move note to suggested notebook
|
||||
await moveNoteToNotebookOptimistic(noteId, suggestion.id)
|
||||
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
|
||||
handleDismiss()
|
||||
} catch (error) {
|
||||
console.error('Failed to move note to notebook:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't render if no suggestion or loading or dismissed
|
||||
if (!visible || isLoading || !suggestion) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 right-4 z-50 max-w-md bg-white dark:bg-zinc-800',
|
||||
'border border-border dark:border-border rounded-lg shadow-lg',
|
||||
'p-4 animate-in slide-in-from-bottom-4 fade-in duration-300',
|
||||
'transition-all duration-300'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 dark:bg-primary/20 flex items-center justify-center">
|
||||
<FolderOpen className="w-5 h-5 text-primary dark:text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t('notebookSuggestion.title', { icon: suggestion.icon, name: suggestion.name })}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('notebookSuggestion.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Move button */}
|
||||
<button
|
||||
onClick={handleMoveToNotebook}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t('notebookSuggestion.move')}
|
||||
</button>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
|
||||
title={t('notebookSuggestion.dismissIn', { timeLeft })}
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
163
memento-note/components/notebook-summary-dialog.tsx
Normal file
163
memento-note/components/notebook-summary-dialog.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from './ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog'
|
||||
import { Loader2, FileText, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { NotebookSummary } from '@/lib/ai/services'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
interface NotebookSummaryDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
notebookId: string | null
|
||||
notebookName?: string
|
||||
}
|
||||
|
||||
export function NotebookSummaryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
notebookId,
|
||||
notebookName,
|
||||
}: NotebookSummaryDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [summary, setSummary] = useState<NotebookSummary | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [regenerating, setRegenerating] = useState(false)
|
||||
|
||||
// Fetch summary when dialog opens with a notebook
|
||||
useEffect(() => {
|
||||
if (open && notebookId) {
|
||||
fetchSummary()
|
||||
} else {
|
||||
// Reset state when closing
|
||||
setSummary(null)
|
||||
}
|
||||
}, [open, notebookId])
|
||||
|
||||
const fetchSummary = async () => {
|
||||
if (!notebookId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/notebook-summary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
notebookId,
|
||||
language: document.documentElement.lang || 'en',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.data) {
|
||||
setSummary(data.data)
|
||||
} else {
|
||||
toast.error(data.error || t('notebook.summaryError'))
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('notebook.summaryError'))
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!notebookId) return
|
||||
setRegenerating(true)
|
||||
await fetchSummary()
|
||||
setRegenerating(false)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{t('notebook.generating')}</DialogTitle>
|
||||
<DialogDescription>{t('notebook.generatingDescription') || 'Please wait...'}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{t('notebook.generating')}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
{t('notebook.summary')}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${regenerating ? 'animate-spin' : ''}`} />
|
||||
{regenerating
|
||||
? (t('ai.notebookSummary.regenerating') || 'Regenerating...')
|
||||
: (t('ai.notebookSummary.regenerate') || 'Regenerate')}
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('notebook.summaryDescription', {
|
||||
notebook: summary.notebookName,
|
||||
count: summary.stats.totalNotes,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{summary.stats.totalNotes} {summary.stats.totalNotes === 1 ? 'note' : 'notes'}
|
||||
</span>
|
||||
</div>
|
||||
{summary.stats.labelsUsed.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Labels:</span>
|
||||
<span className="text-sm">{summary.stats.labelsUsed.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{new Date(summary.generatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Content */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown>{summary.summary}</ReactMarkdown>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
310
memento-note/components/notebooks-list.tsx
Normal file
310
memento-note/components/notebooks-list.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
'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, 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'
|
||||
import { CreateNotebookDialog } from './create-notebook-dialog'
|
||||
import { NotebookActions } from './notebook-actions'
|
||||
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> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
'flight_takeoff': Plane,
|
||||
}
|
||||
|
||||
// Function to get icon component by name
|
||||
const getNotebookIcon = (iconName: string) => {
|
||||
const IconComponent = ICON_MAP[iconName] || Folder
|
||||
return IconComponent
|
||||
}
|
||||
|
||||
export function NotebooksList() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
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)
|
||||
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()
|
||||
const noteId = e.dataTransfer.getData('text/plain')
|
||||
|
||||
if (noteId) {
|
||||
await moveNoteToNotebookOptimistic(noteId, notebookId)
|
||||
}
|
||||
|
||||
dragOver(null)
|
||||
}, [moveNoteToNotebookOptimistic, dragOver])
|
||||
|
||||
// Handle drag over a notebook
|
||||
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
|
||||
e.preventDefault()
|
||||
dragOver(notebookId)
|
||||
}, [dragOver])
|
||||
|
||||
// Handle drag leave
|
||||
const handleDragLeave = useCallback(() => {
|
||||
dragOver(null)
|
||||
}, [dragOver])
|
||||
|
||||
const handleSelectNotebook = (notebookId: string | null) => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
|
||||
if (notebookId) {
|
||||
params.set('notebook', notebookId)
|
||||
} else {
|
||||
params.delete('notebook')
|
||||
}
|
||||
|
||||
// Clear other filters
|
||||
params.delete('labels')
|
||||
params.delete('search')
|
||||
|
||||
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 py-2">
|
||||
<div className="text-xs text-gray-500">{t('common.loading')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notebooks Loop */}
|
||||
{notebooks.map((notebook: any) => {
|
||||
const isActive = currentNotebookId === notebook.id
|
||||
const isExpanded = expandedNotebook === notebook.id
|
||||
const isDragOver = dragOverNotebookId === notebook.id
|
||||
|
||||
// Get icon component
|
||||
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
||||
|
||||
return (
|
||||
<div key={notebook.id} className="group flex flex-col">
|
||||
{isActive ? (
|
||||
// Active notebook with expanded labels - STYLE MATCH Sidebar
|
||||
<div
|
||||
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-primary/10 dark:bg-primary/20",
|
||||
isDragOver && "ring-2 ring-primary ring-dashed"
|
||||
)}
|
||||
style={notebook.color ? { backgroundColor: `${notebook.color}20` } : undefined}
|
||||
>
|
||||
{/* 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-primary dark:text-primary-foreground")}
|
||||
style={notebook.color ? { color: notebook.color } : undefined}
|
||||
/>
|
||||
<span
|
||||
className={cn("text-sm font-medium tracking-wide truncate max-w-[120px]", !notebook.color && "text-primary dark:text-primary-foreground")}
|
||||
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-primary hover:text-primary/80 dark:text-primary-foreground dark:hover:text-primary-foreground/80")}
|
||||
style={notebook.color ? { color: notebook.color } : undefined}
|
||||
>
|
||||
<ChevronDown className={cn("w-4 h-4 transition-transform", isExpanded && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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-20",
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Create Notebook Dialog */}
|
||||
<CreateNotebookDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Edit Notebook Dialog */}
|
||||
{editingNotebook && (
|
||||
<EditNotebookDialog
|
||||
notebook={editingNotebook}
|
||||
open={!!editingNotebook}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingNotebook(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{deletingNotebook && (
|
||||
<DeleteNotebookDialog
|
||||
notebook={deletingNotebook}
|
||||
open={!!deletingNotebook}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeletingNotebook(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notebook Summary Dialog */}
|
||||
<NotebookSummaryDialog
|
||||
open={!!summaryNotebook}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSummaryNotebook(null)
|
||||
}}
|
||||
notebookId={summaryNotebook?.id}
|
||||
notebookName={summaryNotebook?.name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
231
memento-note/components/notification-panel.tsx
Normal file
231
memento-note/components/notification-panel.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Bell, Check, X, Clock, User } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { getPendingShareRequests, respondToShareRequest, removeSharedNoteFromView } from '@/app/actions/notes'
|
||||
import { toast } from 'sonner'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ShareRequest {
|
||||
id: string
|
||||
status: string
|
||||
permission: string
|
||||
createdAt: Date
|
||||
note: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
color: string
|
||||
createdAt: Date
|
||||
}
|
||||
sharer: {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
image: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export function NotificationPanel() {
|
||||
const router = useRouter()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
const { t } = useLanguage()
|
||||
const [requests, setRequests] = useState<ShareRequest[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
|
||||
const loadRequests = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await getPendingShareRequests()
|
||||
setRequests(data)
|
||||
setPendingCount(data.length)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load share requests:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests()
|
||||
const interval = setInterval(loadRequests, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const handleAccept = async (shareId: string) => {
|
||||
try {
|
||||
await respondToShareRequest(shareId, 'accept')
|
||||
router.refresh()
|
||||
triggerRefresh()
|
||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||
setPendingCount(prev => prev - 1)
|
||||
toast.success(t('notes.noteCreated'), {
|
||||
description: t('collaboration.nowHasAccess', { name: 'Note' }),
|
||||
duration: 3000,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('[NOTIFICATION] Error:', error)
|
||||
toast.error(error.message || t('general.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDecline = async (shareId: string) => {
|
||||
try {
|
||||
await respondToShareRequest(shareId, 'decline')
|
||||
router.refresh()
|
||||
triggerRefresh()
|
||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||
setPendingCount(prev => prev - 1)
|
||||
toast.info(t('general.operationFailed'))
|
||||
} catch (error: any) {
|
||||
console.error('[NOTIFICATION] Error:', error)
|
||||
toast.error(error.message || t('general.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (shareId: string) => {
|
||||
try {
|
||||
await removeSharedNoteFromView(shareId)
|
||||
router.refresh()
|
||||
triggerRefresh()
|
||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||
toast.info(t('general.operationFailed'))
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('general.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-9 w-9 p-0 hover:bg-accent/50 transition-all duration-200"
|
||||
>
|
||||
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
|
||||
{pendingCount > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs animate-pulse shadow-lg"
|
||||
>
|
||||
{pendingCount > 9 ? '9+' : pendingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<div className="px-4 py-3 border-b bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
|
||||
<span className="font-semibold text-sm">{t('nav.aiSettings')}</span>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
|
||||
{pendingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
|
||||
{t('general.loading')}
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
<Bell className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">{t('search.noResults')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{requests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="p-4 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-xs shadow-md">
|
||||
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{request.sharer.name || request.sharer.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
shared "{request.note.title || 'Untitled'}"
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs capitalize bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground border-0"
|
||||
>
|
||||
{request.permission}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleAccept(request.id)}
|
||||
className={cn(
|
||||
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
|
||||
"bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700",
|
||||
"text-white shadow-md hover:shadow-lg",
|
||||
"transition-all duration-200",
|
||||
"flex items-center justify-center gap-1.5",
|
||||
"active:scale-95"
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('general.confirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDecline(request.id)}
|
||||
className={cn(
|
||||
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
|
||||
"bg-white dark:bg-gray-800",
|
||||
"border-2 border-gray-200 dark:border-gray-700",
|
||||
"text-gray-700 dark:text-gray-300",
|
||||
"hover:bg-gray-50 dark:hover:bg-gray-700",
|
||||
"hover:border-gray-300 dark:hover:border-gray-600",
|
||||
"transition-all duration-200",
|
||||
"flex items-center justify-center gap-1.5",
|
||||
"active:scale-95"
|
||||
)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
{t('general.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{new Date(request.createdAt).toLocaleDateString()}</span>
|
||||
<button
|
||||
onClick={() => handleRemove(request.id)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground transition-colors duration-150"
|
||||
>
|
||||
{t('general.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
28
memento-note/components/profile-page-header.tsx
Normal file
28
memento-note/components/profile-page-header.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function ProfilePageHeader() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<h1 className="text-3xl font-bold mb-8">{t('nav.accountSettings')}</h1>
|
||||
)
|
||||
}
|
||||
|
||||
export function AISettingsCard() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">✨</span>
|
||||
{t('nav.aiSettings')}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('nav.configureAI')}
|
||||
</p>
|
||||
<span>{t('nav.manageAISettings')}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
29
memento-note/components/providers-wrapper.tsx
Normal file
29
memento-note/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>
|
||||
)
|
||||
}
|
||||
126
memento-note/components/recent-notes-section.tsx
Normal file
126
memento-note/components/recent-notes-section.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { Note } from '@/lib/types'
|
||||
import { Clock } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface RecentNotesSectionProps {
|
||||
recentNotes: Note[]
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
}
|
||||
|
||||
export function RecentNotesSection({ recentNotes, onEdit }: RecentNotesSectionProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
const topThree = recentNotes.slice(0, 3)
|
||||
|
||||
if (topThree.length === 0) return null
|
||||
|
||||
return (
|
||||
<section data-testid="recent-notes-section" className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('notes.recent')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· {topThree.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{topThree.map((note, index) => (
|
||||
<CompactCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
index={index}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactCard({
|
||||
note,
|
||||
index,
|
||||
onEdit
|
||||
}: {
|
||||
note: Note
|
||||
index: number
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const timeAgo = getCompactTime(note.contentUpdatedAt || note.updatedAt, t)
|
||||
const isFirstNote = index === 0
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onEdit?.(note)}
|
||||
className={cn(
|
||||
"group relative text-left p-4 bg-card border rounded-xl shadow-sm hover:shadow-md transition-all duration-200 min-h-[44px]",
|
||||
isFirstNote && "ring-2 ring-primary/20"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"absolute left-0 top-0 bottom-0 w-1 rounded-l-xl",
|
||||
isFirstNote
|
||||
? "bg-gradient-to-b from-primary to-primary/70"
|
||||
: index === 1
|
||||
? "bg-primary/80 dark:bg-primary/70"
|
||||
: "bg-muted dark:bg-muted/60"
|
||||
)} />
|
||||
|
||||
<div className="pl-2">
|
||||
<h3 className="text-sm font-semibold text-foreground line-clamp-1 mb-2">
|
||||
{note.title || t('notes.untitled')}
|
||||
</h3>
|
||||
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2.5rem]">
|
||||
{note.content?.substring(0, 80) || ''}
|
||||
{note.content && note.content.length > 80 && '...'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span className="font-medium">{timeAgo}</span>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{note.notebookId && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary dark:bg-primary/70" title={t('notes.inNotebook')} />
|
||||
)}
|
||||
{note.labels && note.labels.length > 0 && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={t('labels.count', { count: note.labels.length })} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-3 right-3 w-2 h-2 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function getCompactTime(date: Date | string, t: (key: string, params?: Record<string, any>) => string): string {
|
||||
const now = new Date()
|
||||
const then = date instanceof Date ? date : new Date(date)
|
||||
|
||||
if (isNaN(then.getTime())) {
|
||||
console.warn('Invalid date provided to getCompactTime:', date)
|
||||
return t('common.error')
|
||||
}
|
||||
|
||||
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (seconds < 60) return t('time.justNow')
|
||||
if (minutes < 60) return t('time.minutesAgo', { count: minutes })
|
||||
if (hours < 24) return t('time.hoursAgo', { count: hours })
|
||||
const days = Math.floor(hours / 24)
|
||||
return t('time.daysAgo', { count: days })
|
||||
}
|
||||
107
memento-note/components/register-form.tsx
Normal file
107
memento-note/components/register-form.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
import { register } from '@/app/actions/register';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
function RegisterButton() {
|
||||
const { pending } = useFormStatus();
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<Button className="w-full mt-4" aria-disabled={pending}>
|
||||
{t('auth.signUp')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterForm() {
|
||||
const [errorMessage, dispatch] = useActionState(register, undefined);
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<form action={dispatch} className="space-y-3">
|
||||
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
|
||||
<h1 className="mb-3 text-2xl font-bold">
|
||||
{t('auth.createAccount')}
|
||||
</h1>
|
||||
<div className="w-full">
|
||||
<div>
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
|
||||
htmlFor="name"
|
||||
>
|
||||
{t('auth.name')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
id="name"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
|
||||
htmlFor="email"
|
||||
>
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
|
||||
htmlFor="password"
|
||||
>
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={t('auth.passwordMinChars')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RegisterButton />
|
||||
<div
|
||||
className="flex h-8 items-end space-x-1"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
{t('auth.hasAccount')}{' '}
|
||||
<Link href="/login" className="underline">
|
||||
{t('auth.signIn')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
129
memento-note/components/reminder-dialog.tsx
Normal file
129
memento-note/components/reminder-dialog.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ReminderDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentReminder: Date | null
|
||||
onSave: (date: Date) => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export function ReminderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentReminder,
|
||||
onSave,
|
||||
onRemove
|
||||
}: ReminderDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [reminderDate, setReminderDate] = useState('')
|
||||
const [reminderTime, setReminderTime] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (currentReminder) {
|
||||
const date = new Date(currentReminder)
|
||||
setReminderDate(date.toISOString().split('T')[0])
|
||||
setReminderTime(date.toTimeString().slice(0, 5))
|
||||
} else {
|
||||
const tomorrow = new Date(Date.now() + 86400000)
|
||||
setReminderDate(tomorrow.toISOString().split('T')[0])
|
||||
setReminderTime('09:00')
|
||||
}
|
||||
}
|
||||
}, [open, currentReminder])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!reminderDate || !reminderTime) return
|
||||
|
||||
const dateTimeString = `${reminderDate}T${reminderTime}`
|
||||
const date = new Date(dateTimeString)
|
||||
|
||||
if (!isNaN(date.getTime())) {
|
||||
onSave(date)
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const isSonnerElement =
|
||||
target.closest('[data-sonner-toast]') ||
|
||||
target.closest('[data-sonner-toaster]') ||
|
||||
target.closest('[data-icon]') ||
|
||||
target.closest('[data-content]') ||
|
||||
target.closest('[data-description]') ||
|
||||
target.closest('[data-title]') ||
|
||||
target.closest('[data-button]');
|
||||
|
||||
if (isSonnerElement) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.getAttribute('data-sonner-toaster') !== null) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('reminder.setReminder')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-date" className="text-sm font-medium">
|
||||
{t('reminder.reminderDate')}
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-date"
|
||||
type="date"
|
||||
value={reminderDate}
|
||||
onChange={(e) => setReminderDate(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-time" className="text-sm font-medium">
|
||||
{t('reminder.reminderTime')}
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-time"
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
{currentReminder && (
|
||||
<Button variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
|
||||
{t('reminder.removeReminder')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t('reminder.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{t('reminder.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
7
memento-note/components/session-provider-wrapper.tsx
Normal file
7
memento-note/components/session-provider-wrapper.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
|
||||
export function SessionProviderWrapper({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>
|
||||
}
|
||||
87
memento-note/components/settings/SettingInput.tsx
Normal file
87
memento-note/components/settings/SettingInput.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingInputProps {
|
||||
label: string
|
||||
description?: string
|
||||
value: string
|
||||
type?: 'text' | 'password' | 'email' | 'url'
|
||||
onChange: (value: string) => Promise<void>
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SettingInput({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
type = 'text',
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled
|
||||
}: SettingInputProps) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSaved, setIsSaved] = useState(false)
|
||||
|
||||
const handleChange = async (newValue: string) => {
|
||||
setIsLoading(true)
|
||||
setIsSaved(false)
|
||||
|
||||
try {
|
||||
await onChange(newValue)
|
||||
setIsSaved(true)
|
||||
toast.success(t('toast.saved'))
|
||||
|
||||
setTimeout(() => setIsSaved(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err)
|
||||
toast.error(t('toast.saveFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
|
||||
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 border rounded-lg',
|
||||
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'bg-white dark:bg-gray-900',
|
||||
'border-gray-300 dark:border-gray-700',
|
||||
'text-gray-900 dark:text-gray-100',
|
||||
'placeholder:text-gray-400 dark:placeholder:text-gray-600'
|
||||
)}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
|
||||
)}
|
||||
{isSaved && !isLoading && (
|
||||
<Check className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
memento-note/components/settings/SettingSelect.tsx
Normal file
86
memento-note/components/settings/SettingSelect.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface SettingSelectProps {
|
||||
label: string
|
||||
description?: string
|
||||
value: string
|
||||
options: SelectOption[]
|
||||
onChange: (value: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SettingSelect({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled
|
||||
}: SettingSelectProps) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleChange = async (newValue: string) => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await onChange(newValue)
|
||||
toast.success(t('toast.saved'))
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err)
|
||||
toast.error(t('toast.saveFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
|
||||
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 border rounded-lg',
|
||||
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'appearance-none bg-white dark:bg-gray-900',
|
||||
'border-gray-300 dark:border-gray-700',
|
||||
'text-gray-900 dark:text-gray-100'
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
memento-note/components/settings/SettingToggle.tsx
Normal file
73
memento-note/components/settings/SettingToggle.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Check, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingToggleProps {
|
||||
label: string
|
||||
description?: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => Promise<void>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SettingToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled
|
||||
}: SettingToggleProps) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const handleChange = async (newChecked: boolean) => {
|
||||
setIsLoading(true)
|
||||
setError(false)
|
||||
|
||||
try {
|
||||
await onChange(newChecked)
|
||||
toast.success(t('toast.saved'))
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err)
|
||||
setError(true)
|
||||
toast.error(t('toast.saveFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center justify-between py-4',
|
||||
'border-b last:border-0 dark:border-gray-800'
|
||||
)}>
|
||||
<div className="flex-1 pr-4">
|
||||
<Label className="font-medium text-gray-900 dark:text-gray-100 cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-gray-500" />}
|
||||
{!isLoading && !error && checked && <Check className="h-4 w-4 text-green-500" />}
|
||||
{!isLoading && !error && !checked && <X className="h-4 w-4 text-gray-400" />}
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={disabled || isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
memento-note/components/settings/SettingsNav.tsx
Normal file
91
memento-note/components/settings/SettingsNav.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingsSection {
|
||||
id: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
href: string
|
||||
}
|
||||
|
||||
interface SettingsNavProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsNav({ className }: SettingsNavProps) {
|
||||
const pathname = usePathname()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: t('generalSettings.title'),
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
href: '/settings/general'
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
label: t('aiSettings.title'),
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
href: '/settings/ai'
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
label: t('appearance.title'),
|
||||
icon: <Palette className="h-5 w-5" />,
|
||||
href: '/settings/appearance'
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
label: t('profile.title'),
|
||||
icon: <User className="h-5 w-5" />,
|
||||
href: '/settings/profile'
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
label: t('dataManagement.title'),
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
href: '/settings/data'
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: t('about.title'),
|
||||
icon: <Info className="h-5 w-5" />,
|
||||
href: '/settings/about'
|
||||
}
|
||||
]
|
||||
|
||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
||||
|
||||
return (
|
||||
<nav className={cn('space-y-1', className)}>
|
||||
{sections.map((section) => (
|
||||
<Link
|
||||
key={section.id}
|
||||
href={section.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors',
|
||||
'hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
isActive(section.href)
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-primary'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
)}
|
||||
>
|
||||
{isActive(section.href) && (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
{!isActive(section.href) && (
|
||||
<div className="w-4" />
|
||||
)}
|
||||
{section.icon}
|
||||
<span className="font-medium">{section.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
101
memento-note/components/settings/SettingsSearch.tsx
Normal file
101
memento-note/components/settings/SettingsSearch.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
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 {
|
||||
sections: Section[]
|
||||
onFilter: (filteredSections: Section[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsSearch({
|
||||
sections,
|
||||
onFilter,
|
||||
placeholder = 'Search settings...',
|
||||
className
|
||||
}: SettingsSearchProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [filteredSections, setFilteredSections] = useState<Section[]>(sections)
|
||||
|
||||
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) => 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>
|
||||
)
|
||||
}
|
||||
36
memento-note/components/settings/SettingsSection.tsx
Normal file
36
memento-note/components/settings/SettingsSection.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
className
|
||||
}: SettingsSectionProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
6
memento-note/components/settings/index.ts
Normal file
6
memento-note/components/settings/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { SettingsNav } from './SettingsNav'
|
||||
export { SettingsSection } from './SettingsSection'
|
||||
export { SettingToggle } from './SettingToggle'
|
||||
export { SettingSelect } from './SettingSelect'
|
||||
export { SettingInput } from './SettingInput'
|
||||
export { SettingsSearch } from './SettingsSearch'
|
||||
120
memento-note/components/sidebar.tsx
Normal file
120
memento-note/components/sidebar.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Lightbulb,
|
||||
Bell,
|
||||
Tag,
|
||||
Archive,
|
||||
Trash2,
|
||||
Pencil,
|
||||
ChevronRight,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NotebooksList } from './notebooks-list'
|
||||
|
||||
export function Sidebar({ className, user }: { className?: string, user?: any }) {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const { labels } = useLabels()
|
||||
const { t } = useLanguage()
|
||||
|
||||
// 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')
|
||||
}
|
||||
|
||||
// For labels
|
||||
if (href.startsWith('/?label=')) {
|
||||
const labelParam = searchParams.get('label')
|
||||
// Extract label from href
|
||||
const labelFromHref = href.split('=')[1]
|
||||
return labelParam === labelFromHref
|
||||
}
|
||||
|
||||
// For other routes
|
||||
return pathname === href
|
||||
}
|
||||
|
||||
const NavItem = ({ href, icon: Icon, label, active }: any) => (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-6 py-3 rounded-r-full mr-2 transition-colors",
|
||||
"text-sm font-medium tracking-wide",
|
||||
active
|
||||
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 dark:hover:bg-muted/30"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-5 h-5", active ? "fill-current" : "")} />
|
||||
<span className="truncate">{label}</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<aside className={cn(
|
||||
"w-[280px] flex-none flex-col bg-white dark:bg-[#1e2128] overflow-y-auto hidden md:flex py-2",
|
||||
className
|
||||
)}>
|
||||
{/* 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>
|
||||
|
||||
{/* Notebooks Section */}
|
||||
<div className="flex flex-col mt-2">
|
||||
<NotebooksList />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* 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">{t('footer.privacy')}</Link>
|
||||
<span>•</span>
|
||||
<Link href="#" className="hover:underline">{t('footer.terms')}</Link>
|
||||
</div>
|
||||
<p>{t('footer.openSource')}</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
83
memento-note/components/theme-initializer.tsx
Normal file
83
memento-note/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(() => {
|
||||
|
||||
// Helper to apply theme
|
||||
const applyTheme = (t?: string) => {
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
60
memento-note/components/title-suggestions.tsx
Normal file
60
memento-note/components/title-suggestions.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface TitleSuggestionsProps {
|
||||
suggestions: TitleSuggestion[]
|
||||
onSelect: (title: string) => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSuggestionsProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
if (suggestions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-2 p-3 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>{t('titleSuggestions.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onSelect(suggestion.title)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-md transition-all",
|
||||
"hover:bg-amber-100 dark:hover:bg-amber-900",
|
||||
"text-sm text-amber-900 dark:text-amber-100",
|
||||
"border border-transparent hover:border-amber-300 dark:hover:border-amber-700"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-medium">{suggestion.title}</span>
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
||||
{suggestion.confidence}%
|
||||
</span>
|
||||
</div>
|
||||
{suggestion.reasoning && (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
||||
{suggestion.reasoning}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
memento-note/components/ui/avatar.tsx
Normal file
50
memento-note/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
memento-note/components/ui/badge.tsx
Normal file
46
memento-note/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
62
memento-note/components/ui/button.tsx
Normal file
62
memento-note/components/ui/button.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
memento-note/components/ui/card.tsx
Normal file
92
memento-note/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
32
memento-note/components/ui/checkbox.tsx
Normal file
32
memento-note/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
143
memento-note/components/ui/dialog.tsx
Normal file
143
memento-note/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
257
memento-note/components/ui/dropdown-menu.tsx
Normal file
257
memento-note/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
21
memento-note/components/ui/input.tsx
Normal file
21
memento-note/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
16
memento-note/components/ui/label.tsx
Normal file
16
memento-note/components/ui/label.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
export function Label({ className, ...props }: LabelProps) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
48
memento-note/components/ui/popover.tsx
Normal file
48
memento-note/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
44
memento-note/components/ui/radio-group.tsx
Normal file
44
memento-note/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
||||
import { Circle } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
160
memento-note/components/ui/select.tsx
Normal file
160
memento-note/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
28
memento-note/components/ui/separator.tsx
Normal file
28
memento-note/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
140
memento-note/components/ui/sheet.tsx
Normal file
140
memento-note/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
28
memento-note/components/ui/switch.tsx
Normal file
28
memento-note/components/ui/switch.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
18
memento-note/components/ui/textarea.tsx
Normal file
18
memento-note/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
28
memento-note/components/ui/toast.tsx
Normal file
28
memento-note/components/ui/toast.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner'
|
||||
|
||||
// Re-export toast functions from Sonner
|
||||
export const toast = sonnerToast
|
||||
|
||||
// Toaster component with custom styles
|
||||
export function Toaster() {
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="top-right"
|
||||
expand={false}
|
||||
richColors
|
||||
closeButton
|
||||
duration={3000}
|
||||
className="toaster"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: 'toast pointer-events-auto',
|
||||
description: 'toast-description',
|
||||
actionButton: 'toast-action-button',
|
||||
closeButton: 'toast-close-button',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
61
memento-note/components/ui/tooltip.tsx
Normal file
61
memento-note/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
77
memento-note/components/user-nav.tsx
Normal file
77
memento-note/components/user-nav.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useSession, signOut } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { LogOut, Settings, User, Shield } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function UserNav({ user }: { user?: any }) {
|
||||
const { data: session } = useSession()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const currentUser = user || session?.user
|
||||
|
||||
if (!currentUser) return null
|
||||
|
||||
const userRole = (currentUser as any).role || currentUser.role
|
||||
const userInitials = currentUser.name
|
||||
? currentUser.name.split(' ').map((n: string) => n[0]).join('').toUpperCase().substring(0, 2)
|
||||
: 'U'
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={currentUser.image || ''} alt={currentUser.name || ''} />
|
||||
<AvatarFallback>{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user