feat: hierarchical notebooks (tree), remove all list view code, delete 22 unused files
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m3s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m3s
- Add parentId to Notebook model (tree structure) - Update sidebar to render parent/child notebooks with expand/collapse - Add sub-notebook creation from parent notebook - Remove 'list' from NotesViewMode type everywhere - Delete 22 unused components, hooks, and UI files - Wrap revalidatePath in try-catch to prevent save 500 - Update notebook API to support parentId in creation
This commit is contained in:
@@ -13,9 +13,7 @@ export default async function HomePage() {
|
||||
? ('masonry' as const)
|
||||
: settings?.notesViewMode === 'tabs'
|
||||
? ('tabs' as const)
|
||||
: settings?.notesViewMode === 'list'
|
||||
? ('list' as const)
|
||||
: ('masonry' as const)
|
||||
: ('masonry' as const)
|
||||
|
||||
return (
|
||||
<HomeClient
|
||||
|
||||
@@ -14,7 +14,7 @@ export type UserAISettingsData = {
|
||||
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
|
||||
demoMode?: boolean
|
||||
showRecentNotes?: boolean
|
||||
notesViewMode?: 'masonry' | 'tabs' | 'list'
|
||||
notesViewMode?: 'masonry' | 'tabs'
|
||||
emailNotifications?: boolean
|
||||
desktopNotifications?: boolean
|
||||
anonymousAnalytics?: boolean
|
||||
@@ -64,8 +64,7 @@ function pickUserAISettingsForDb(input: UserAISettingsData): Partial<Record<User
|
||||
if (
|
||||
out.notesViewMode != null &&
|
||||
out.notesViewMode !== 'masonry' &&
|
||||
out.notesViewMode !== 'tabs' &&
|
||||
out.notesViewMode !== 'list'
|
||||
out.notesViewMode !== 'tabs'
|
||||
) {
|
||||
delete out.notesViewMode
|
||||
}
|
||||
@@ -170,9 +169,7 @@ const getCachedAISettings = unstable_cache(
|
||||
? ('masonry' as const)
|
||||
: raw === 'tabs'
|
||||
? ('tabs' as const)
|
||||
: raw === 'list'
|
||||
? ('list' as const)
|
||||
: ('masonry' as const)
|
||||
: ('masonry' as const)
|
||||
|
||||
return {
|
||||
titleSuggestions: settings.titleSuggestions,
|
||||
|
||||
@@ -6,7 +6,23 @@ import { revalidatePath } from 'next/cache'
|
||||
const DEFAULT_COLORS = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#06B6D4']
|
||||
const DEFAULT_ICONS = ['📁', '📚', '💼', '🎯', '📊', '🎨', '💡', '🔧']
|
||||
|
||||
// GET /api/notebooks - Get all notebooks for current user
|
||||
function buildTree(notebooks: any[]): any[] {
|
||||
const map = new Map<string, any>()
|
||||
const roots: any[] = []
|
||||
for (const nb of notebooks) {
|
||||
map.set(nb.id, { ...nb, children: [] })
|
||||
}
|
||||
for (const nb of notebooks) {
|
||||
const node = map.get(nb.id)!
|
||||
if (nb.parentId && map.has(nb.parentId)) {
|
||||
map.get(nb.parentId)!.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
@@ -17,9 +33,7 @@ export async function GET(request: NextRequest) {
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
labels: {
|
||||
orderBy: { name: 'asc' }
|
||||
},
|
||||
labels: { orderBy: { name: 'asc' } },
|
||||
_count: {
|
||||
select: { notes: { where: { isArchived: false, trashedAt: null } } }
|
||||
}
|
||||
@@ -27,23 +41,20 @@ export async function GET(request: NextRequest) {
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
notebooks: notebooks.map(nb => ({
|
||||
...nb,
|
||||
notesCount: nb._count.notes
|
||||
}))
|
||||
})
|
||||
const flat = notebooks.map(nb => ({
|
||||
...nb,
|
||||
notesCount: nb._count.notes
|
||||
}))
|
||||
|
||||
const tree = buildTree(flat)
|
||||
|
||||
return NextResponse.json({ success: true, notebooks: flat, tree })
|
||||
} catch (error) {
|
||||
console.error('Error fetching notebooks:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch notebooks' },
|
||||
{ status: 500 }
|
||||
)
|
||||
return NextResponse.json({ success: false, error: 'Failed to fetch notebooks' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/notebooks - Create a new notebook
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
@@ -52,42 +63,49 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, icon, color } = body
|
||||
const { name, icon, color, parentId } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook name is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ success: false, error: 'Notebook name is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the highest order value for this user
|
||||
if (parentId) {
|
||||
const parent = await prisma.notebook.findFirst({
|
||||
where: { id: parentId, userId: session.user.id }
|
||||
})
|
||||
if (!parent) {
|
||||
return NextResponse.json({ success: false, error: 'Parent notebook not found' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause: any = { userId: session.user.id }
|
||||
if (parentId) whereClause.parentId = parentId
|
||||
else whereClause.parentId = null
|
||||
|
||||
const highestOrder = await prisma.notebook.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
where: whereClause,
|
||||
orderBy: { order: 'desc' },
|
||||
select: { order: true }
|
||||
})
|
||||
|
||||
const nextOrder = (highestOrder?.order ?? -1) + 1
|
||||
|
||||
// Create notebook
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
icon: icon || DEFAULT_ICONS[Math.floor(Math.random() * DEFAULT_ICONS.length)],
|
||||
color: color || DEFAULT_COLORS[Math.floor(Math.random() * DEFAULT_COLORS.length)],
|
||||
order: nextOrder,
|
||||
parentId: parentId || null,
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
labels: true,
|
||||
_count: {
|
||||
select: { notes: { where: { isArchived: false, trashedAt: null } } }
|
||||
}
|
||||
_count: { select: { notes: { where: { isArchived: false, trashedAt: null } } } }
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
try { revalidatePath('/') } catch {}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -96,9 +114,6 @@ export async function POST(request: NextRequest) {
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating notebook:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create notebook' },
|
||||
{ status: 500 }
|
||||
)
|
||||
return NextResponse.json({ success: false, error: 'Failed to create notebook' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
'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')
|
||||
}
|
||||
@@ -1,782 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Form Component
|
||||
* Simplified form for creating and editing agents.
|
||||
* Novice-friendly: hides system prompt and tools behind "Advanced mode".
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon, Presentation, Pencil, Check } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
|
||||
|
||||
/** Small "?" tooltip shown next to form labels */
|
||||
function FieldHelp({ tooltip }: { tooltip: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="inline-flex items-center ml-1 text-muted-foreground/40 hover:text-muted-foreground transition-colors">
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs text-balance">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentFormProps {
|
||||
agent?: {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
type?: string | null
|
||||
role: string
|
||||
sourceUrls?: string | null
|
||||
sourceNotebookId?: string | null
|
||||
sourceNoteIds?: string | null
|
||||
targetNotebookId?: string | null
|
||||
frequency: string
|
||||
tools?: string | null
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
scheduledTime?: string | null
|
||||
scheduledDay?: number | null
|
||||
timezone?: string | null
|
||||
slideTheme?: string | null
|
||||
slideStyle?: string | null
|
||||
} | null
|
||||
notebooks: { id: string; name: string; icon?: string | null }[]
|
||||
onSave: (data: FormData) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
// --- Tool presets per type ---
|
||||
const TOOL_PRESETS: Record<string, string[]> = {
|
||||
scraper: ['web_scrape', 'note_create', 'memory_search'],
|
||||
researcher: ['web_search', 'web_scrape', 'note_search', 'note_create', 'memory_search'],
|
||||
monitor: ['note_search', 'note_read', 'note_create', 'memory_search'],
|
||||
custom: ['memory_search'],
|
||||
'slide-generator': ['generate_pptx'],
|
||||
'excalidraw-generator': ['generate_excalidraw'],
|
||||
}
|
||||
|
||||
// --- Shared class strings ---
|
||||
const labelCls = 'block text-sm font-medium text-foreground mb-1.5'
|
||||
const labelCls2 = 'block text-sm font-medium text-foreground mb-2'
|
||||
const inputCls = 'w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-card text-foreground'
|
||||
const selectCls = 'w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-card text-foreground'
|
||||
const toggleOffBorder = 'border-border hover:border-primary/30'
|
||||
const toggleOffIcon = 'text-muted-foreground'
|
||||
const toggleOffLabel = 'text-sm font-medium text-foreground'
|
||||
const toggleOffHint = 'text-xs text-muted-foreground'
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps) {
|
||||
const { t } = useLanguage()
|
||||
const [name, setName] = useState(agent?.name || '')
|
||||
const [description, setDescription] = useState(agent?.description || '')
|
||||
const [type, setType] = useState<AgentType>((agent?.type as AgentType) || 'scraper')
|
||||
const [role, setRole] = useState(agent?.role || '')
|
||||
const [urls, setUrls] = useState<string[]>(() => {
|
||||
if (agent?.sourceUrls) {
|
||||
try { return JSON.parse(agent.sourceUrls) } catch { return [''] }
|
||||
}
|
||||
return ['']
|
||||
})
|
||||
const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '')
|
||||
const [sourceNoteIds, setSourceNoteIds] = useState<string[]>(() => {
|
||||
if (agent?.sourceNoteIds) {
|
||||
try { return JSON.parse(agent.sourceNoteIds) } catch { return [] }
|
||||
}
|
||||
return []
|
||||
})
|
||||
const [noteOptions, setNoteOptions] = useState<{ id: string; title: string }[]>([])
|
||||
const [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '')
|
||||
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
|
||||
const [scheduledTime, setScheduledTime] = useState(agent?.scheduledTime || '08:00')
|
||||
const [scheduledDay, setScheduledDay] = useState<number>(agent?.scheduledDay ?? 1)
|
||||
const [timezone] = useState(() => {
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
|
||||
})
|
||||
const [selectedTools, setSelectedTools] = useState<string[]>(() => {
|
||||
if (agent?.tools) {
|
||||
try {
|
||||
const parsed = JSON.parse(agent.tools)
|
||||
if (parsed.length > 0) return parsed
|
||||
} catch { /* fall through to presets */ }
|
||||
}
|
||||
// New agent or old agent with empty tools: use preset defaults
|
||||
const defaultType = (agent?.type as AgentType) || 'scraper'
|
||||
return TOOL_PRESETS[defaultType] || []
|
||||
})
|
||||
const [maxSteps, setMaxSteps] = useState(agent?.maxSteps || 10)
|
||||
const [notifyEmail, setNotifyEmail] = useState(agent?.notifyEmail || false)
|
||||
const [includeImages, setIncludeImages] = useState(agent?.includeImages || false)
|
||||
const [slideTheme, setSlideTheme] = useState(agent?.slideTheme || '')
|
||||
const [slideStyle, setSlideStyle] = useState<'soft' | 'sharp' | 'rounded' | 'pill'>(
|
||||
(agent?.slideStyle as 'soft' | 'sharp' | 'rounded' | 'pill') || 'soft'
|
||||
)
|
||||
const [excalidrawStyle, setExcalidrawStyle] = useState<'default' | 'austere' | 'sketch-plus'>(() => {
|
||||
if (agent?.slideStyle === 'austere') return 'austere'
|
||||
if (agent?.slideStyle === 'sketch-plus') return 'sketch-plus'
|
||||
return 'default'
|
||||
})
|
||||
const [excalidrawType, setExcalidrawType] = useState<'auto' | 'architecture-cloud' | 'flowchart' | 'mindmap' | 'org-chart' | 'timeline' | 'process-map'>(() => {
|
||||
const value = (agent?.slideTheme || '').trim()
|
||||
if (value === 'auto' || value === 'architecture-cloud' || value === 'mindmap' || value === 'org-chart' || value === 'timeline' || value === 'process-map') return value
|
||||
return 'flowchart'
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceNotebookId || (type !== 'slide-generator' && type !== 'excalidraw-generator' && type !== 'monitor')) {
|
||||
setNoteOptions([])
|
||||
return
|
||||
}
|
||||
fetch(`/api/notes?notebookId=${sourceNotebookId}&limit=50`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const notes = Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : []
|
||||
setNoteOptions(notes.map((n: any) => ({ id: n.id, title: n.title || 'Sans titre' })))
|
||||
})
|
||||
.catch(() => setNoteOptions([]))
|
||||
}, [sourceNotebookId, type])
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(() => {
|
||||
// Auto-open advanced if editing an agent with custom tools or custom prompt
|
||||
if (agent?.tools) {
|
||||
try {
|
||||
const tools = JSON.parse(agent.tools)
|
||||
if (tools.length > 0) return true
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// Also open if agent has a custom role (instructions)
|
||||
if (agent?.role && agent.role.trim().length > 0) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// Tool definitions
|
||||
const availableTools = useMemo(() => [
|
||||
{ id: 'web_search', icon: Globe, labelKey: 'agents.tools.webSearch', external: true },
|
||||
{ id: 'web_scrape', icon: ExternalLink, labelKey: 'agents.tools.webScrape', external: true },
|
||||
{ id: 'note_search', icon: FileSearch, labelKey: 'agents.tools.noteSearch', external: false },
|
||||
{ id: 'note_read', icon: FileText, labelKey: 'agents.tools.noteRead', external: false },
|
||||
{ id: 'note_create', icon: FilePlus, labelKey: 'agents.tools.noteCreate', external: false },
|
||||
{ id: 'url_fetch', icon: ExternalLink, labelKey: 'agents.tools.urlFetch', external: false },
|
||||
{ id: 'memory_search', icon: Brain, labelKey: 'agents.tools.memorySearch', external: false },
|
||||
{ id: 'generate_pptx', icon: Presentation, labelKey: 'agents.tools.generatePptx', external: false },
|
||||
{ id: 'generate_slides', icon: Presentation, labelKey: 'agents.tools.generateSlides', external: false },
|
||||
{ id: 'generate_excalidraw', icon: Pencil, labelKey: 'agents.tools.generateExcalidraw', external: false },
|
||||
], [])
|
||||
|
||||
// Track previous type to detect user-initiated type changes
|
||||
const prevTypeRef = useRef(type)
|
||||
|
||||
// When user explicitly changes type (not on mount), reset tools to presets
|
||||
if (prevTypeRef.current !== type) {
|
||||
prevTypeRef.current = type
|
||||
// This is a user-initiated type change, not a mount
|
||||
// We queue the state update to happen after render
|
||||
setSelectedTools(TOOL_PRESETS[type] || [])
|
||||
setRole('')
|
||||
}
|
||||
|
||||
const addUrl = () => setUrls([...urls, ''])
|
||||
const removeUrl = (index: number) => setUrls(urls.filter((_, i) => i !== index))
|
||||
const updateUrl = (index: number, value: string) => {
|
||||
const newUrls = [...urls]
|
||||
newUrls[index] = value
|
||||
setUrls(newUrls)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
toast.error(t('agents.form.nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set('name', name.trim())
|
||||
formData.set('description', description.trim())
|
||||
formData.set('type', type)
|
||||
formData.set('role', role || t(`agents.defaultRoles.${type}`))
|
||||
formData.set('frequency', frequency)
|
||||
formData.set('targetNotebookId', targetNotebookId)
|
||||
|
||||
if (type === 'monitor' || type === 'slide-generator' || type === 'excalidraw-generator') {
|
||||
formData.set('sourceNotebookId', sourceNotebookId)
|
||||
}
|
||||
|
||||
if (sourceNoteIds.length > 0) {
|
||||
formData.set('sourceNoteIds', JSON.stringify(sourceNoteIds))
|
||||
}
|
||||
|
||||
const validUrls = urls.filter(u => u.trim())
|
||||
if (validUrls.length > 0) {
|
||||
formData.set('sourceUrls', JSON.stringify(validUrls))
|
||||
}
|
||||
|
||||
formData.set('tools', JSON.stringify(selectedTools))
|
||||
formData.set('maxSteps', String(maxSteps))
|
||||
formData.set('notifyEmail', String(notifyEmail))
|
||||
formData.set('includeImages', String(includeImages))
|
||||
formData.set('scheduledTime', scheduledTime)
|
||||
formData.set('scheduledDay', String(scheduledDay))
|
||||
formData.set('timezone', timezone)
|
||||
|
||||
if (type === 'slide-generator') {
|
||||
if (slideTheme) formData.set('slideTheme', slideTheme)
|
||||
formData.set('slideStyle', slideStyle)
|
||||
}
|
||||
if (type === 'excalidraw-generator') {
|
||||
formData.set('slideTheme', excalidrawType)
|
||||
formData.set('slideStyle', excalidrawStyle)
|
||||
}
|
||||
|
||||
await onSave(formData)
|
||||
} catch {
|
||||
toast.error(t('agents.toasts.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showSourceNotebook = type === 'monitor' || type === 'slide-generator' || type === 'excalidraw-generator'
|
||||
|
||||
const agentTypes: { value: AgentType; labelKey: string; descKey: string }[] = [
|
||||
{ value: 'researcher', labelKey: 'agents.types.researcher', descKey: 'agents.typeDescriptions.researcher' },
|
||||
{ value: 'scraper', labelKey: 'agents.types.scraper', descKey: 'agents.typeDescriptions.scraper' },
|
||||
{ value: 'monitor', labelKey: 'agents.types.monitor', descKey: 'agents.typeDescriptions.monitor' },
|
||||
{ value: 'slide-generator', labelKey: 'agents.types.slideGenerator', descKey: 'agents.typeDescriptions.slideGenerator' },
|
||||
{ value: 'excalidraw-generator', labelKey: 'agents.types.excalidrawGenerator', descKey: 'agents.typeDescriptions.excalidrawGenerator' },
|
||||
{ value: 'custom', labelKey: 'agents.types.custom', descKey: 'agents.typeDescriptions.custom' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/20 flex justify-end z-50" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-card shadow-2xl w-full max-w-md h-full overflow-y-auto animate-in slide-in-from-right duration-300 flex flex-col border-l border-border/40"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header — editable agent name */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="text-lg font-semibold text-card-foreground bg-transparent border-none outline-none focus:ring-0 p-0 flex-1 placeholder:text-muted-foreground/40"
|
||||
placeholder={t('agents.form.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
<button onClick={onCancel} className="p-1 rounded-md hover:bg-accent ml-3">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
{/* Agent Type */}
|
||||
<div>
|
||||
<label className={labelCls2}>{t('agents.form.agentType')}<FieldHelp tooltip={t('agents.help.tooltips.agentType')} /></label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{agentTypes.map(at => (
|
||||
<button
|
||||
key={at.value}
|
||||
type="button"
|
||||
onClick={() => setType(at.value)}
|
||||
className={`
|
||||
text-left px-3 py-2.5 rounded-lg border-2 transition-all text-sm
|
||||
${type === at.value
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: `${toggleOffBorder} text-muted-foreground`}
|
||||
`}
|
||||
>
|
||||
<div className="font-medium">{t(at.labelKey)}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{t(at.descKey)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Research Topic (researcher only) — replaces Description for this type */}
|
||||
{type === 'researcher' && (
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.researchTopic')}<FieldHelp tooltip={t('agents.help.tooltips.researchTopic')} /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder={t('agents.form.researchTopicPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (for non-researcher types) */}
|
||||
{type !== 'researcher' && (
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.description')}<FieldHelp tooltip={t('agents.help.tooltips.description')} /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder={t('agents.form.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URLs (scraper and custom only — researcher uses search, not URLs) */}
|
||||
{(type === 'scraper' || type === 'custom') && (
|
||||
<div>
|
||||
<label className={labelCls}>
|
||||
{t('agents.form.urlsLabel')}<FieldHelp tooltip={t('agents.help.tooltips.urls')} />
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{urls.map((url, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => updateUrl(i, e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
{urls.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeUrl(i)}
|
||||
className="p-2 text-destructive/80 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addUrl}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('agents.form.addUrl')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Notebook (monitor, slide, excalidraw) */}
|
||||
{showSourceNotebook && (
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
|
||||
<select
|
||||
value={sourceNotebookId}
|
||||
onChange={e => { setSourceNotebookId(e.target.value); setSourceNoteIds([]) }}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="">{t('agents.form.selectNotebook')}</option>
|
||||
{notebooks.map(nb => (
|
||||
<option key={nb.id} value={nb.id}>
|
||||
{nb.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note multi-select (slide-generator, excalidraw-generator only) */}
|
||||
{(type === 'slide-generator' || type === 'excalidraw-generator') && sourceNotebookId && noteOptions.length > 0 && (
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.selectNotes')}<FieldHelp tooltip={t('agents.help.tooltips.selectNotes')} /></label>
|
||||
<div className="border border-input rounded-lg max-h-48 overflow-y-auto bg-card">
|
||||
{noteOptions.map(note => {
|
||||
const isSelected = sourceNoteIds.includes(note.id)
|
||||
return (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSourceNoteIds(prev =>
|
||||
isSelected ? prev.filter(id => id !== note.id) : [...prev, note.id]
|
||||
)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-accent/50 transition-colors ${isSelected ? 'bg-primary/5' : ''}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${isSelected ? 'border-primary bg-primary' : 'border-input'}`}>
|
||||
{isSelected && <Check className="w-3 h-3 text-primary-foreground" />}
|
||||
</div>
|
||||
<span className={isSelected ? 'text-primary font-medium' : 'text-foreground'}>{note.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{sourceNoteIds.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('agents.form.notesSelected', { count: sourceNoteIds.length })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme selector — slide-generator only */}
|
||||
{type === 'slide-generator' && (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.slideTheme')}<FieldHelp tooltip={t('agents.help.tooltips.slideTheme')} /></label>
|
||||
<select
|
||||
value={slideTheme}
|
||||
onChange={e => setSlideTheme(e.target.value)}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="">{t('agents.form.slideThemeDefault')}</option>
|
||||
<option value="modern_wellness">Modern & Wellness</option>
|
||||
<option value="business_authority">Business & Authority</option>
|
||||
<option value="nature_outdoors">Nature & Outdoors</option>
|
||||
<option value="vintage_academic">Vintage & Academic</option>
|
||||
<option value="soft_creative">Soft & Creative</option>
|
||||
<option value="bohemian">Bohemian</option>
|
||||
<option value="vibrant_tech">Vibrant & Tech</option>
|
||||
<option value="craft_artisan">Craft & Artisan</option>
|
||||
<option value="tech_night">Tech & Night (dark)</option>
|
||||
<option value="education_charts">Education & Charts</option>
|
||||
<option value="forest_eco">Forest & Eco</option>
|
||||
<option value="elegant_fashion">Elegant & Fashion</option>
|
||||
<option value="art_food">Art & Food</option>
|
||||
<option value="luxury_mystery">Luxury & Mystery</option>
|
||||
<option value="pure_tech_blue">Pure Tech Blue</option>
|
||||
<option value="coastal_coral">Coastal Coral</option>
|
||||
<option value="vibrant_orange_mint">Vibrant Orange Mint</option>
|
||||
<option value="platinum_white_gold">Platinum White Gold</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.slideStyle')}<FieldHelp tooltip={t('agents.help.tooltips.slideStyle')} /></label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['soft', 'sharp', 'rounded', 'pill'] as const).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSlideStyle(s)}
|
||||
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
|
||||
slideStyle === s
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: `${toggleOffBorder} text-muted-foreground`
|
||||
}`}
|
||||
>
|
||||
{t(`agents.form.slideStyle${s.charAt(0).toUpperCase() + s.slice(1)}` as any)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Visual style selector — excalidraw-generator only */}
|
||||
{type === 'excalidraw-generator' && (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.excalidrawDiagramType')}</label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{([
|
||||
{ id: 'auto', labelKey: 'agents.form.excalidrawDiagramTypeAuto' },
|
||||
{ id: 'flowchart', labelKey: 'agents.form.excalidrawDiagramTypeFlowchart' },
|
||||
{ id: 'mindmap', labelKey: 'agents.form.excalidrawDiagramTypeMindmap' },
|
||||
{ id: 'org-chart', labelKey: 'agents.form.excalidrawDiagramTypeOrgChart' },
|
||||
{ id: 'timeline', labelKey: 'agents.form.excalidrawDiagramTypeTimeline' },
|
||||
{ id: 'process-map', labelKey: 'agents.form.excalidrawDiagramTypeProcessMap' },
|
||||
{ id: 'architecture-cloud', labelKey: 'agents.form.excalidrawDiagramTypeArchitectureCloud' },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setExcalidrawType(opt.id)}
|
||||
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
|
||||
excalidrawType === opt.id
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: `${toggleOffBorder} text-muted-foreground`
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.excalidrawDiagramStyle')}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{ id: 'default', labelKey: 'agents.form.excalidrawDiagramStyleDefault' },
|
||||
{ id: 'sketch-plus', labelKey: 'agents.form.excalidrawDiagramStyleSketchPlus' },
|
||||
{ id: 'austere', labelKey: 'agents.form.excalidrawDiagramStyleAustere' },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setExcalidrawStyle(opt.id)}
|
||||
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
|
||||
excalidrawStyle === opt.id
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: `${toggleOffBorder} text-muted-foreground`
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Target Notebook — hidden for file generators (they never create notes) */}
|
||||
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
|
||||
<select
|
||||
value={targetNotebookId}
|
||||
onChange={e => setTargetNotebookId(e.target.value)}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="">{t('agents.form.inbox')}</option>
|
||||
{notebooks.map(nb => (
|
||||
<option key={nb.id} value={nb.id}>
|
||||
{nb.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.frequency')}<FieldHelp tooltip={t('agents.help.tooltips.frequency')} /></label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={e => setFrequency(e.target.value)}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="manual">{t('agents.frequencies.manual')}</option>
|
||||
<option value="hourly">{t('agents.frequencies.hourly')}</option>
|
||||
<option value="daily">{t('agents.frequencies.daily')}</option>
|
||||
<option value="weekly">{t('agents.frequencies.weekly')}</option>
|
||||
<option value="monthly">{t('agents.frequencies.monthly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Schedule config: time + day pickers (hidden for manual/hourly) */}
|
||||
{frequency !== 'manual' && frequency !== 'hourly' && (
|
||||
<div className="flex gap-3">
|
||||
{/* Day selector (weekly/monthly only) */}
|
||||
{frequency === 'weekly' && (
|
||||
<div className="flex-1">
|
||||
<label className={labelCls}>{t('agents.schedule.dayOfWeek')}</label>
|
||||
<select
|
||||
value={scheduledDay}
|
||||
onChange={e => setScheduledDay(Number(e.target.value))}
|
||||
className={selectCls}
|
||||
>
|
||||
{[
|
||||
{ value: 0, label: t('agents.schedule.days.mon') },
|
||||
{ value: 1, label: t('agents.schedule.days.tue') },
|
||||
{ value: 2, label: t('agents.schedule.days.wed') },
|
||||
{ value: 3, label: t('agents.schedule.days.thu') },
|
||||
{ value: 4, label: t('agents.schedule.days.fri') },
|
||||
{ value: 5, label: t('agents.schedule.days.sat') },
|
||||
{ value: 6, label: t('agents.schedule.days.sun') },
|
||||
].map(d => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{frequency === 'monthly' && (
|
||||
<div className="flex-1">
|
||||
<label className={labelCls}>{t('agents.schedule.dayOfMonth')}</label>
|
||||
<select
|
||||
value={scheduledDay}
|
||||
onChange={e => setScheduledDay(Number(e.target.value))}
|
||||
className={selectCls}
|
||||
>
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{/* Time picker */}
|
||||
<div className="flex-1">
|
||||
<label className={labelCls}>{t('agents.schedule.time')}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduledTime}
|
||||
onChange={e => setScheduledTime(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Notification — hidden for file generators */}
|
||||
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
|
||||
<div
|
||||
onClick={() => setNotifyEmail(!notifyEmail)}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
notifyEmail
|
||||
? 'border-primary bg-primary/5'
|
||||
: toggleOffBorder
|
||||
}`}
|
||||
>
|
||||
<Mail className={`w-4 h-4 flex-shrink-0 ${notifyEmail ? 'text-primary' : toggleOffIcon}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={toggleOffLabel}>{t('agents.form.notifyEmail')}</div>
|
||||
<div className={toggleOffHint}>{t('agents.form.notifyEmailHint')}</div>
|
||||
</div>
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${notifyEmail ? 'bg-primary' : 'bg-muted'}`}>
|
||||
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${notifyEmail ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Include Images — hidden for file generators */}
|
||||
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
|
||||
<div
|
||||
onClick={() => setIncludeImages(!includeImages)}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
includeImages
|
||||
? 'border-primary bg-primary/5'
|
||||
: toggleOffBorder
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className={`w-4 h-4 flex-shrink-0 ${includeImages ? 'text-primary' : toggleOffIcon}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={toggleOffLabel}>{t('agents.form.includeImages')}</div>
|
||||
<div className={toggleOffHint}>{t('agents.form.includeImagesHint')}</div>
|
||||
</div>
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${includeImages ? 'bg-primary' : 'bg-muted'}`}>
|
||||
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${includeImages ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced mode toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground font-medium w-full pt-2 border-t border-border"
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{t('agents.form.advancedMode')}
|
||||
</button>
|
||||
|
||||
{/* Advanced: System Prompt */}
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{t('agents.form.instructions')}
|
||||
<FieldHelp tooltip={t('agents.help.tooltips.instructions')} />
|
||||
<span className="text-xs text-muted-foreground font-normal ml-1">({t('agents.form.instructionsHint')})</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={role}
|
||||
onChange={e => setRole(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-y min-h-[80px] bg-card text-foreground"
|
||||
placeholder={t('agents.form.instructionsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced: Tools */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">{t('agents.tools.title')}<FieldHelp tooltip={t('agents.help.tooltips.tools')} /></label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableTools.map(at => {
|
||||
const Icon = at.icon
|
||||
const isSelected = selectedTools.includes(at.id)
|
||||
return (
|
||||
<button
|
||||
key={at.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedTools(prev =>
|
||||
isSelected ? prev.filter(t => t !== at.id) : [...prev, at.id]
|
||||
)
|
||||
}}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all text-left
|
||||
${isSelected
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: `${toggleOffBorder} text-muted-foreground`}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t(at.labelKey)}</span>
|
||||
{at.external && !isSelected && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground bg-muted border border-border/60 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedTools.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t('agents.tools.selected', { count: selectedTools.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced: Max Steps */}
|
||||
{selectedTools.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{t('agents.tools.maxSteps')}<FieldHelp tooltip={t('agents.help.tooltips.maxSteps')} />
|
||||
<span className="text-muted-foreground font-normal ml-1">({maxSteps})</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={3}
|
||||
max={25}
|
||||
value={maxSteps}
|
||||
onChange={e => setMaxSteps(Number(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>3</span>
|
||||
<span>25</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-6 pb-4 mt-auto border-t border-border/40 bg-card sticky bottom-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-muted-foreground bg-muted rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
{t('agents.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{isSaving ? t('agents.form.saving') : agent ? t('agents.form.save') : t('agents.form.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -8,20 +8,29 @@ import { useNotebooks } from '@/context/notebooks-context'
|
||||
interface CreateNotebookDialogProps {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
parentNotebookId?: string | null
|
||||
}
|
||||
|
||||
export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialogProps) {
|
||||
export function CreateNotebookDialog({ open, onOpenChange, parentNotebookId }: CreateNotebookDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const { createNotebookOptimistic } = useNotebooks()
|
||||
const { createNotebookOptimistic, notebooks } = useNotebooks()
|
||||
const [name, setName] = useState('')
|
||||
const [selectedParentId, setSelectedParentId] = useState<string | null>(parentNotebookId ?? null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const rootNotebooks = notebooks.filter(nb => !nb.parentId)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createNotebookOptimistic({ name: name.trim(), icon: 'folder', color: '#64748B' })
|
||||
await createNotebookOptimistic({
|
||||
name: name.trim(),
|
||||
icon: 'folder',
|
||||
color: '#64748B',
|
||||
parentId: selectedParentId,
|
||||
})
|
||||
setName('')
|
||||
onOpenChange?.(false)
|
||||
} catch (err) {
|
||||
@@ -33,6 +42,7 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
|
||||
|
||||
const handleClose = () => {
|
||||
setName('')
|
||||
setSelectedParentId(parentNotebookId ?? null)
|
||||
onOpenChange?.(false)
|
||||
}
|
||||
|
||||
@@ -40,7 +50,6 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -49,7 +58,6 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
{/* Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.92, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
@@ -79,6 +87,24 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!parentNotebookId && rootNotebooks.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-foreground mb-2">
|
||||
{t('notebook.parentNotebook') || 'Carnet parent'}
|
||||
</label>
|
||||
<select
|
||||
value={selectedParentId || ''}
|
||||
onChange={(e) => setSelectedParentId(e.target.value || null)}
|
||||
className="w-full bg-white dark:bg-zinc-800 border border-black/12 dark:border-white/15 rounded-lg px-4 py-3 outline-none focus:border-foreground/40 transition-colors text-sm text-foreground"
|
||||
>
|
||||
<option value="">{t('notebook.noParent') || 'Aucun (racine)'}</option>
|
||||
{rootNotebooks.map(nb => (
|
||||
<option key={nb.id} value={nb.id}>{nb.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
'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)
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
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'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Notebook } from '@/lib/types'
|
||||
|
||||
interface EditNotebookDialogProps {
|
||||
notebook: Notebook
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EditNotebookDialog({ notebook, open, onOpenChange }: EditNotebookDialogProps) {
|
||||
const { updateNotebook } = useNotebooks()
|
||||
const { t } = useLanguage()
|
||||
const [name, setName] = useState(notebook?.name || '')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(notebook?.name || '')
|
||||
}
|
||||
}, [open, notebook?.name])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await updateNotebook(notebook.id, { name: name.trim() })
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// Error 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">
|
||||
{t('notebook.name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('notebook.myNotebook')}
|
||||
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 ? t('notebook.saving') : t('general.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
'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'
|
||||
import { useCardSizeMode } from '@/hooks/use-card-size-mode'
|
||||
|
||||
interface FavoritesSectionProps {
|
||||
pinnedNotes: Note[]
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading }: FavoritesSectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const cardSizeMode = useCardSizeMode()
|
||||
|
||||
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" id="memento-pinned" className="mb-8 scroll-mt-28">
|
||||
<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="font-memento-serif text-xl font-normal tracking-tight text-foreground md:text-2xl">
|
||||
{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={`favorites-grid ${cardSizeMode === 'uniform' ? 'favorites-columns' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'}`}
|
||||
data-card-size-mode={cardSizeMode}
|
||||
>
|
||||
{pinnedNotes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={onEdit}
|
||||
onSizeChange={(size) => onSizeChange?.(note.id, size)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { Header } from './header'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
|
||||
interface HeaderWrapperProps {
|
||||
onColorFilterChange?: (color: string | null) => void
|
||||
user?: any
|
||||
}
|
||||
|
||||
function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { labels } = useNotebooks()
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, Settings, LogOut, User, Shield, Coffee, MessageSquare, FlaskConical, Bot } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { LabelFilter } from './label-filter'
|
||||
import { NotificationPanel } from './notification-panel'
|
||||
import { updateTheme } from '@/app/actions/profile'
|
||||
import { applyDocumentTheme, normalizeThemeId } from '@/lib/apply-document-theme'
|
||||
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<string>('light')
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const [isSemanticSearching, setIsSemanticSearching] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { labels, setNotebookId } = useNotebooks()
|
||||
const { t } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
|
||||
const noSidebarMode = ['/agents', '/chat', '/lab'].some(r => pathname.startsWith(r))
|
||||
|
||||
|
||||
// Track last pushed search to avoid infinite loops
|
||||
const lastPushedSearch = useRef<string | null>(null)
|
||||
|
||||
const currentLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const currentSearch = searchParams.get('search') || ''
|
||||
const currentColor = searchParams.get('color') || ''
|
||||
|
||||
const currentUser = user || session?.user
|
||||
|
||||
// Initialize search query from URL ONLY on mount
|
||||
useEffect(() => {
|
||||
setSearchQuery(currentSearch)
|
||||
lastPushedSearch.current = currentSearch
|
||||
}, []) // Run only once on mount
|
||||
|
||||
// Sync LabelContext notebookId with URL notebook parameter
|
||||
const currentNotebook = searchParams.get('notebook')
|
||||
useEffect(() => {
|
||||
setNotebookId(currentNotebook || null)
|
||||
}, [currentNotebook, setNotebookId])
|
||||
|
||||
// Prevent body scroll when mobile menu is open
|
||||
useEffect(() => {
|
||||
if (isSidebarOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.body.style.position = 'fixed'
|
||||
document.body.style.width = '100%'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
document.body.style.position = ''
|
||||
document.body.style.width = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
document.body.style.position = ''
|
||||
document.body.style.width = ''
|
||||
}
|
||||
}, [isSidebarOpen])
|
||||
|
||||
// Close mobile menu on Esc key press
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isSidebarOpen) {
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSidebarOpen) {
|
||||
document.addEventListener('keydown', handleEscapeKey)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscapeKey)
|
||||
}
|
||||
}, [isSidebarOpen])
|
||||
|
||||
// Simple debounced search with URL update (150ms for more responsiveness)
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 150)
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if search hasn't changed or if we already pushed this value
|
||||
if (debouncedSearchQuery === lastPushedSearch.current) return
|
||||
|
||||
// Only trigger search navigation from the home page
|
||||
if (pathname !== '/') {
|
||||
lastPushedSearch.current = debouncedSearchQuery
|
||||
return
|
||||
}
|
||||
|
||||
// Build new params preserving other filters (notebook, labels, etc.)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
params.set('search', debouncedSearchQuery)
|
||||
} else {
|
||||
params.delete('search')
|
||||
}
|
||||
|
||||
const newUrl = `/?${params.toString()}`
|
||||
|
||||
// Mark as pushed before calling router.push to prevent loops
|
||||
lastPushedSearch.current = debouncedSearchQuery
|
||||
router.push(newUrl)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchQuery])
|
||||
|
||||
// Handle semantic search button click
|
||||
const handleSemanticSearch = () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
// Add semantic flag to URL
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('search', searchQuery)
|
||||
params.set('semantic', 'true')
|
||||
router.push(`/?${params.toString()}`)
|
||||
|
||||
// Show loading state briefly
|
||||
setIsSemanticSearching(true)
|
||||
setTimeout(() => setIsSemanticSearching(false), 1500)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Use 'theme-preference' to match the unified theme system
|
||||
const savedTheme = localStorage.getItem('theme-preference') || currentUser?.theme || 'light'
|
||||
// Don't persist on initial load to avoid unnecessary DB calls
|
||||
applyTheme(savedTheme, false)
|
||||
}, [currentUser])
|
||||
|
||||
const applyTheme = async (newTheme: string, persist = true) => {
|
||||
const normalized = normalizeThemeId(newTheme)
|
||||
setTheme(normalized)
|
||||
localStorage.setItem('theme-preference', normalized)
|
||||
applyDocumentTheme(normalized)
|
||||
|
||||
if (persist && currentUser) {
|
||||
await updateTheme(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
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-lg text-sm font-medium transition-all duration-200 mr-2 text-left",
|
||||
active
|
||||
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
|
||||
: "hover:bg-gray-100 dark:hover:bg-white/5 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-lg text-sm font-medium transition-all duration-200 mr-2",
|
||||
active
|
||||
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
|
||||
: "hover:bg-gray-100 dark:hover:bg-white/5 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-border/50 bg-card/85 backdrop-blur-xl px-5 py-3 z-20 shadow-sm md:px-8">
|
||||
<div className="flex items-center gap-6 md:gap-10">
|
||||
|
||||
|
||||
{/* Wordmark */}
|
||||
<div className="flex cursor-pointer items-center gap-3 text-foreground group" onClick={() => router.push('/')}>
|
||||
<div className="flex size-9 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-md shadow-primary/20 transition-all duration-200 group-hover:shadow-lg group-hover:shadow-primary/25">
|
||||
<StickyNote className="w-[1.15rem] h-[1.15rem]" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 className="font-memento-serif text-[1.35rem] font-normal tracking-tight leading-none md:text-2xl">Memento</h2>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<label className="hidden md:flex flex-col min-w-40 w-96 !h-10">
|
||||
<div className="flex w-full flex-1 items-stretch rounded-xl h-full bg-muted/50 border border-transparent focus-within:bg-card focus-within:border-primary/35 focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_18%,transparent)] transition-all duration-200">
|
||||
<div className="text-muted-foreground flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
|
||||
<Search className="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
id="memento-global-search"
|
||||
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-foreground placeholder:text-muted-foreground px-3 text-sm focus:ring-0 focus:outline-none"
|
||||
placeholder={t('search.placeholder') }
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 justify-end gap-2 items-center">
|
||||
|
||||
{/* Quick nav: Notes (hidden-sidebar only), Chat, Agents, Lab */}
|
||||
<div className="hidden md:flex items-center gap-1 rounded-xl bg-muted/60 px-1.5 py-1 ring-1 ring-border/50">
|
||||
{noSidebarMode && (
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
|
||||
pathname === '/'
|
||||
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
<StickyNote className="h-3.5 w-3.5" />
|
||||
<span>{t('sidebar.notes') || 'Notes'}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/agents"
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
|
||||
pathname === '/agents'
|
||||
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span>{t('nav.agents')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/lab"
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
|
||||
pathname === '/lab'
|
||||
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
<span>{t('nav.lab')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<NotificationPanel />
|
||||
|
||||
{/* Settings Button */}
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex items-center justify-center size-10 rounded-xl text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
{/* User Avatar Menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center justify-center bg-center bg-no-repeat bg-cover rounded-xl size-10 ring-2 ring-border/60 cursor-pointer shadow-sm hover:shadow-md transition-shadow duration-200 bg-primary/10 text-primary"
|
||||
style={currentUser?.image ? { backgroundImage: `url(${currentUser?.image})` } : undefined}>
|
||||
{!currentUser?.image && (
|
||||
<span className="text-sm font-semibold">
|
||||
{currentUser?.name ? currentUser.name.charAt(0).toUpperCase() : <User className="w-5 h-5" />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center justify-start gap-2 p-2">
|
||||
<div className="flex flex-col space-y-1 leading-none">
|
||||
{currentUser?.name && <p className="font-medium">{currentUser.name}</p>}
|
||||
{currentUser?.email && <p className="w-[200px] truncate text-sm text-muted-foreground">{currentUser.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href="/settings/profile">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>{t('settings.profile') || 'Profile'}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{(currentUser as any)?.role === 'ADMIN' && (
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
{/* Force hard reload: client-side navigation between (main) and (admin)
|
||||
route groups triggers React #310 in Next.js 16.x (framework bug). */}
|
||||
<a href="/admin">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
<span>{t('nav.adminDashboard')}</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{t('auth.signOut') || 'Sign out'}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* User Avatar - Removed from here */}
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ const NotebookSummaryDialog = dynamic(
|
||||
|
||||
type InitialSettings = {
|
||||
showRecentNotes: boolean
|
||||
notesViewMode: 'masonry' | 'tabs' | 'list'
|
||||
notesViewMode: 'masonry' | 'tabs'
|
||||
noteHistory: boolean
|
||||
noteHistoryMode: 'manual' | 'auto'
|
||||
aiAssistantEnabled: boolean
|
||||
@@ -65,7 +65,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
|
||||
initialNotes.filter(n => n.isPinned)
|
||||
)
|
||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode === 'list' ? 'masonry' : initialSettings.notesViewMode)
|
||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
||||
const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode)
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
'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 { useNotebooks } from '@/context/notebooks-context'
|
||||
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, isLoading: loading } = useNotebooks()
|
||||
const { t, language } = 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 dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'} className={cn("flex items-center gap-2", className ? "" : "")}>
|
||||
<DropdownMenu dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
'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 { useNotebooks } from '@/context/notebooks-context'
|
||||
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, isLoading: loading, addLabel } = useNotebooks()
|
||||
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={variant === 'compact' ? 'icon' : 'sm'} className={cn("h-8 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100", variant === 'compact' ? "w-8 relative" : "px-2")} title={t('labels.title')}>
|
||||
<Tag className={cn("h-4 w-4", variant !== 'compact' && (triggerLabel || t('labels.title')) ? "mr-2" : "")} />
|
||||
{variant !== 'compact' && (triggerLabel || t('labels.title'))}
|
||||
{selectedLabels.length > 0 && (
|
||||
<Badge variant="secondary" className={cn("h-5 min-w-5 px-1.5 bg-gray-200 text-gray-800 dark:bg-zinc-700 dark:text-zinc-300", variant === 'compact' ? "absolute -top-2 -right-2 scale-75" : "ml-2")}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,54 +0,0 @@
|
||||
'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'
|
||||
import { Notebook } from '@/lib/types'
|
||||
|
||||
interface NotebookActionsProps {
|
||||
notebook: Notebook
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onSummary?: () => void
|
||||
}
|
||||
|
||||
export function NotebookActions({ notebook, onEdit, onDelete, onSummary }: NotebookActionsProps) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight, GripVertical } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useEditorUI } from '@/context/editor-ui-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 { LabelManagementDialog } from '@/components/label-management-dialog'
|
||||
import { Notebook } from '@/lib/types'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
|
||||
function NotebookName({ children }: { name: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="relative truncate min-w-0 group-hover:overflow-visible group-hover:text-nowrap">
|
||||
<span className="group-hover:font-bold group-hover:relative group-hover:z-20 group-hover:inline-block group-hover:bg-white dark:group-hover:bg-[#1e2128] group-hover:pr-4 group-hover:shadow-[4px_0_12px_8px] group-hover:shadow-white dark:group-hover:shadow-[#1e2128]">
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotebooksList() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading, labels } = useNotebooks()
|
||||
const { draggedNoteId, dragOverNotebookId, dragOver } = useEditorUI()
|
||||
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [editingNotebook, setEditingNotebook] = useState<Notebook | null>(null)
|
||||
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
|
||||
const [summaryNotebook, setSummaryNotebook] = useState<Notebook | null>(null)
|
||||
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
|
||||
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false)
|
||||
|
||||
// ── Notebook reorder drag state ──
|
||||
const draggingNbRef = useRef<string | null>(null)
|
||||
const [draggingNbId, setDraggingNbId] = useState<string | null>(null)
|
||||
const [overNbId, setOverNbId] = useState<string | null>(null)
|
||||
|
||||
const currentNotebookId = searchParams.get('notebook')
|
||||
|
||||
// Handle drop on a notebook (note-to-notebook OR notebook reorder)
|
||||
const handleDrop = useCallback(async (e: React.DragEvent, notebookId: string | null) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const sourceNbId = e.dataTransfer.getData('application/x-notebook')
|
||||
const noteId = e.dataTransfer.getData('text/plain')
|
||||
|
||||
if (sourceNbId && notebookId && sourceNbId !== notebookId) {
|
||||
// ── Reorder notebooks ──
|
||||
const currentIds = notebooks.map((nb: Notebook) => nb.id)
|
||||
const fromIdx = currentIds.indexOf(sourceNbId)
|
||||
const toIdx = currentIds.indexOf(notebookId)
|
||||
if (fromIdx !== -1 && toIdx !== -1) {
|
||||
const newIds = [...currentIds]
|
||||
newIds.splice(fromIdx, 1)
|
||||
newIds.splice(toIdx, 0, sourceNbId)
|
||||
await updateNotebookOrderOptimistic(newIds)
|
||||
}
|
||||
} else if (noteId) {
|
||||
// ── Move note to notebook ──
|
||||
await moveNoteToNotebookOptimistic(noteId, notebookId)
|
||||
}
|
||||
|
||||
dragOver(null)
|
||||
draggingNbRef.current = null
|
||||
setDraggingNbId(null)
|
||||
setOverNbId(null)
|
||||
}, [notebooks, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, dragOver])
|
||||
|
||||
// Handle drag over a notebook
|
||||
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
|
||||
e.preventDefault()
|
||||
if (draggingNbRef.current) {
|
||||
// Notebook reorder mode — just track the insertion target
|
||||
setOverNbId(notebookId)
|
||||
} else {
|
||||
// Note-to-notebook mode
|
||||
dragOver(notebookId)
|
||||
}
|
||||
}, [dragOver])
|
||||
|
||||
// Handle drag leave
|
||||
const handleDragLeave = useCallback(() => {
|
||||
if (draggingNbRef.current) {
|
||||
setOverNbId(null)
|
||||
} else {
|
||||
dragOver(null)
|
||||
}
|
||||
}, [dragOver])
|
||||
|
||||
// ── Notebook reorder handlers ──
|
||||
const handleNotebookDragStart = useCallback((e: React.DragEvent, notebookId: string) => {
|
||||
e.dataTransfer.setData('application/x-notebook', notebookId)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
draggingNbRef.current = notebookId
|
||||
// Slight delay so the drag ghost renders before opacity change
|
||||
setTimeout(() => setDraggingNbId(notebookId), 0)
|
||||
}, [])
|
||||
|
||||
const handleNotebookDragEnd = useCallback(() => {
|
||||
draggingNbRef.current = null
|
||||
setDraggingNbId(null)
|
||||
setOverNbId(null)
|
||||
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((prev) => (prev === 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 (
|
||||
<>
|
||||
<LabelManagementDialog open={labelsDialogOpen} onOpenChange={setLabelsDialogOpen} />
|
||||
<div className="flex flex-col pt-1">
|
||||
{/* Header with Add Button */}
|
||||
<div className="group mt-1 flex cursor-pointer items-center justify-between px-3 py-2 text-muted-foreground hover:text-foreground">
|
||||
<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: Notebook) => {
|
||||
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={cn(
|
||||
"group flex flex-col transition-opacity duration-150",
|
||||
draggingNbId === notebook.id && "opacity-40"
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) => handleNotebookDragStart(e, notebook.id)}
|
||||
onDragEnd={handleNotebookDragEnd}
|
||||
>
|
||||
{/* Insertion indicator above this notebook when reordering */}
|
||||
{overNbId === notebook.id && draggingNbId !== notebook.id && (
|
||||
<div className="h-0.5 bg-primary/70 mx-4 rounded-full mb-0.5 transition-all" />
|
||||
)}
|
||||
{isActive ? (
|
||||
// Active notebook with expanded labels
|
||||
<div
|
||||
onDrop={(e) => handleDrop(e, notebook.id)}
|
||||
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"flex flex-col me-2 rounded-e-full transition-all relative",
|
||||
!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 */}
|
||||
<div className="pointer-events-auto flex items-center justify-between px-3 py-3">
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
<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}
|
||||
/>
|
||||
<NotebookName name={notebook.name}>
|
||||
<span
|
||||
className={cn("text-[15px] font-medium tracking-wide", !notebook.color && "text-primary dark:text-primary-foreground")}
|
||||
style={notebook.color ? { color: notebook.color } : undefined}
|
||||
>
|
||||
{notebook.name}
|
||||
</span>
|
||||
</NotebookName>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Actions menu for active notebook */}
|
||||
<NotebookActions
|
||||
notebook={notebook}
|
||||
onEdit={() => setEditingNotebook(notebook)}
|
||||
onDelete={() => setDeletingNotebook(notebook)}
|
||||
onSummary={() => setSummaryNotebook(notebook)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleExpand(notebook.id)
|
||||
}}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full p-1 transition-colors",
|
||||
!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}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contextual Labels Tree */}
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col pb-2">
|
||||
{labels.length === 0 ? (
|
||||
<p className="pointer-events-none ps-12 pe-4 py-2 text-xs text-muted-foreground">
|
||||
{t('sidebar.noLabelsInNotebook')}
|
||||
</p>
|
||||
) : (
|
||||
labels.map((label: any) => (
|
||||
<button
|
||||
key={label.id}
|
||||
type="button"
|
||||
onClick={() => handleLabelFilter(label.name, notebook.id)}
|
||||
className={cn(
|
||||
'pointer-events-auto flex items-center gap-4 ps-12 pe-4 py-2 rounded-e-full me-2 transition-colors',
|
||||
'hover:bg-accent/60 text-muted-foreground hover:text-foreground',
|
||||
searchParams.get('labels')?.includes(label.name) &&
|
||||
'font-semibold text-foreground'
|
||||
)}
|
||||
>
|
||||
<Tag className="h-4 w-4 shrink-0" />
|
||||
<span className="text-xs font-medium truncate">{label.name}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLabelsDialogOpen(true)}
|
||||
className="pointer-events-auto flex items-center gap-2 ps-12 pe-4 py-2 mt-1 rounded-e-full me-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label"
|
||||
>
|
||||
<Plus className="h-3 w-3 shrink-0 group-hover/label:scale-110 transition-transform" />
|
||||
<span className="text-xs font-medium">{t('sidebar.editLabels')}</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 relative",
|
||||
isDragOver && "ring-2 ring-primary ring-dashed rounded-e-full me-2"
|
||||
)}
|
||||
>
|
||||
<TooltipProvider delayDuration={600}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => handleSelectNotebook(notebook.id)}
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-14",
|
||||
isDragOver && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5 flex-shrink-0 text-gray-300 dark:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing" />
|
||||
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-[15px] font-medium tracking-wide text-start truncate min-w-0 flex-1">
|
||||
{notebook.name}
|
||||
</span>
|
||||
{(notebook as any).notesCount > 0 && (
|
||||
<span className="text-xs text-gray-400 ms-auto flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm font-medium">
|
||||
{notebook.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Actions + expand on the right — always rendered, visible on hover */}
|
||||
<div className="absolute end-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||||
<NotebookActions
|
||||
notebook={notebook}
|
||||
onEdit={() => setEditingNotebook(notebook)}
|
||||
onDelete={() => setDeletingNotebook(notebook)}
|
||||
onSummary={() => setSummaryNotebook(notebook)}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleExpand(notebook.id); }}
|
||||
className={cn(
|
||||
"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",
|
||||
expandedNotebook === notebook.id && "rotate-180"
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</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 ?? null}
|
||||
notebookName={summaryNotebook?.name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Note } from '@/lib/types'
|
||||
import { getNoteFeedImage, getNotePlainExcerpt } from '@/lib/note-preview'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type NotesNotebookFeedProps = {
|
||||
notes: Note[]
|
||||
onOpen: (note: Note, readOnly?: boolean) => void
|
||||
}
|
||||
|
||||
export function NotesNotebookFeed({ notes, onOpen }: NotesNotebookFeedProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{notes.map((note) => {
|
||||
const title = note.title?.trim() || t('notes.untitled')
|
||||
const img = getNoteFeedImage(note)
|
||||
const excerpt = getNotePlainExcerpt(note)
|
||||
|
||||
return (
|
||||
<article
|
||||
key={note.id}
|
||||
className="mb-16 border-b border-foreground/10 pb-16 last:mb-0 last:border-0 last:pb-0"
|
||||
>
|
||||
<h2 className="font-memento-serif text-2xl font-normal tracking-tight text-foreground md:text-[1.65rem]">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-7 w-full text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm"
|
||||
onClick={() => onOpen(note)}
|
||||
>
|
||||
<div className="flex flex-col gap-8 md:flex-row md:items-start">
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full shrink-0 overflow-hidden rounded-lg bg-muted/40 md:w-[clamp(11rem,32%,15rem)]',
|
||||
'aspect-[4/3]'
|
||||
)}
|
||||
>
|
||||
{img ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- note content may be any uploaded or external URL
|
||||
<img src={img} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-muted/30" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-4 pt-0 md:pt-1">
|
||||
{excerpt ? (
|
||||
<p className="text-sm leading-relaxed text-foreground/85 line-clamp-5">
|
||||
{excerpt}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">{t('notes.noContent')}</p>
|
||||
)}
|
||||
<span className="inline-block text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t('notes.readMore')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { Bell, BellOff, CheckCircle2, Circle, Clock, AlertCircle, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { Note } from '@/lib/types'
|
||||
import { toggleReminderDone, clearCompletedReminders } from '@/app/actions/notes'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface RemindersPageProps {
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
function formatReminderDate(date: Date | string, t: (key: string, params?: Record<string, string | number>) => string, locale = 'fr-FR'): string {
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const noteDay = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
|
||||
const timeStr = d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
if (noteDay.getTime() === today.getTime()) return t('reminders.todayAt', { time: timeStr })
|
||||
if (noteDay.getTime() === tomorrow.getTime()) return t('reminders.tomorrowAt', { time: timeStr })
|
||||
|
||||
return d.toLocaleDateString(locale, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function ReminderCard({ note, onToggleDone, t }: { note: Note; onToggleDone: (id: string, done: boolean) => void; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
const now = new Date()
|
||||
const reminderDate = note.reminder ? new Date(note.reminder) : null
|
||||
const isOverdue = reminderDate && reminderDate < now && !note.isReminderDone
|
||||
const isDone = note.isReminderDone
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-start gap-4 rounded-2xl border p-4 transition-all duration-200',
|
||||
isDone
|
||||
? 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/30 opacity-60'
|
||||
: isOverdue
|
||||
? 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20'
|
||||
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-[#1e2128] hover:shadow-md'
|
||||
)}
|
||||
>
|
||||
{/* Done toggle */}
|
||||
<button
|
||||
onClick={() => onToggleDone(note.id, !isDone)}
|
||||
className={cn(
|
||||
'mt-0.5 flex-none transition-colors',
|
||||
isDone
|
||||
? 'text-green-500 hover:text-slate-400'
|
||||
: 'text-slate-300 hover:text-green-500 dark:text-slate-600'
|
||||
)}
|
||||
title={isDone ? t('reminders.markUndone') : t('reminders.markDone')}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{note.title && (
|
||||
<p className={cn('font-semibold text-slate-900 dark:text-white truncate', isDone && 'line-through opacity-60')}>
|
||||
{note.title}
|
||||
</p>
|
||||
)}
|
||||
<p className={cn('text-sm text-slate-600 dark:text-slate-300 line-clamp-2 mt-0.5', isDone && 'line-through opacity-60')}>
|
||||
{note.content}
|
||||
</p>
|
||||
|
||||
{/* Reminder date badge */}
|
||||
{reminderDate && (
|
||||
<div className={cn(
|
||||
'inline-flex items-center gap-1.5 mt-2 px-2.5 py-1 rounded-full text-xs font-medium',
|
||||
isDone
|
||||
? 'bg-slate-100 dark:bg-slate-700 text-slate-500'
|
||||
: isOverdue
|
||||
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-400'
|
||||
: 'bg-primary/10 text-primary'
|
||||
)}>
|
||||
{isOverdue && !isDone ? (
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
) : (
|
||||
<Clock className="w-3 h-3" />
|
||||
)}
|
||||
{formatReminderDate(reminderDate, t)}
|
||||
{note.reminderRecurrence && (
|
||||
<span className="ml-1 opacity-70">· {note.reminderRecurrence}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ icon: Icon, label, count, color }: { icon: any; label: string; count: number; color: string }) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 mb-3', color)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider">{label}</h2>
|
||||
<span className="ml-auto text-xs font-medium bg-current/10 px-2 py-0.5 rounded-full opacity-70">{count}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RemindersPage({ notes: initialNotes }: RemindersPageProps) {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [notes, setNotes] = useState(initialNotes)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const upcoming = notes.filter(n => !n.isReminderDone && n.reminder && new Date(n.reminder) >= now)
|
||||
const overdue = notes.filter(n => !n.isReminderDone && n.reminder && new Date(n.reminder) < now)
|
||||
const done = notes.filter(n => n.isReminderDone)
|
||||
|
||||
const handleToggleDone = (noteId: string, newDone: boolean) => {
|
||||
// Optimistic update
|
||||
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, isReminderDone: newDone } : n))
|
||||
|
||||
startTransition(async () => {
|
||||
await toggleReminderDone(noteId, newDone)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-12 max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
||||
<Bell className="w-8 h-8 text-primary" />
|
||||
{t('reminders.title') || 'Rappels'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center text-slate-500 dark:text-slate-400">
|
||||
<div className="bg-slate-100 dark:bg-slate-800 p-6 rounded-full mb-4">
|
||||
<BellOff className="w-12 h-12 text-slate-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2 text-slate-700 dark:text-slate-300">
|
||||
{t('reminders.empty') || 'Aucun rappel'}
|
||||
</h2>
|
||||
<p className="max-w-sm text-sm opacity-80">
|
||||
{t('reminders.emptyDescription') || 'Ajoutez un rappel à une note pour le retrouver ici.'}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
||||
<Bell className="w-8 h-8 text-primary" />
|
||||
{t('reminders.title') || 'Rappels'}
|
||||
</h1>
|
||||
{isPending && (
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* En retard */}
|
||||
{overdue.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle
|
||||
icon={AlertCircle}
|
||||
label={t('reminders.overdue') || 'En retard'}
|
||||
count={overdue.length}
|
||||
color="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{overdue.map(note => (
|
||||
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* À venir */}
|
||||
{upcoming.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle
|
||||
icon={Clock}
|
||||
label={t('reminders.upcoming') || 'À venir'}
|
||||
count={upcoming.length}
|
||||
color="text-primary"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{upcoming.map(note => (
|
||||
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Terminés */}
|
||||
{done.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<SectionTitle
|
||||
icon={CheckCircle2}
|
||||
label={t('reminders.done') || 'Terminés'}
|
||||
count={done.length}
|
||||
color="text-green-600 dark:text-green-400"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
await clearCompletedReminders()
|
||||
setNotes(prev => prev.filter(n => !n.isReminderDone))
|
||||
router.refresh()
|
||||
})
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t('reminders.clearCompleted') || 'Effacer'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{done.map(note => (
|
||||
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -82,6 +82,7 @@ function SidebarCarnetItem({
|
||||
activeNoteId,
|
||||
onCarnetClick,
|
||||
onNoteClick,
|
||||
children,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
@@ -91,6 +92,7 @@ function SidebarCarnetItem({
|
||||
activeNoteId: string | null
|
||||
onCarnetClick: () => void
|
||||
onNoteClick: (noteId: string, carnetId: string) => void
|
||||
children?: React.ReactNode
|
||||
isDragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}) {
|
||||
@@ -98,7 +100,6 @@ function SidebarCarnetItem({
|
||||
return (
|
||||
<div className={cn('space-y-1 transition-opacity', isDragging && 'opacity-40')}>
|
||||
<div className="relative group/carnet">
|
||||
{/* Drag handle — visible on hover */}
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover/carnet:opacity-100 transition-opacity z-10"
|
||||
@@ -152,6 +153,7 @@ function SidebarCarnetItem({
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden space-y-0.5"
|
||||
>
|
||||
{children}
|
||||
{notes.map(note => (
|
||||
<NoteLink
|
||||
key={note.id}
|
||||
@@ -160,7 +162,7 @@ function SidebarCarnetItem({
|
||||
onClick={() => onNoteClick(note.id, carnet.id)}
|
||||
/>
|
||||
))}
|
||||
{notes.length === 0 && (
|
||||
{notes.length === 0 && !children && (
|
||||
<p className="pl-12 text-[11px] text-muted-foreground/50 py-2 italic font-light">{t('common.noResults')}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
@@ -178,18 +180,30 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const { notebooks, updateNotebookOrderOptimistic } = useNotebooks()
|
||||
const { refreshKey } = useNoteRefresh()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [createParentId, setCreateParentId] = useState<string | null>(null)
|
||||
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
|
||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||
|
||||
// ── Drag state ──
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||
const [orderedNotebooks, setOrderedNotebooks] = useState<Notebook[]>([])
|
||||
const dragOverId = useRef<string | null>(null)
|
||||
// Prevents the sync effect from overwriting a just-saved drag order
|
||||
const isSavingRef = useRef(false)
|
||||
|
||||
const rootNotebooks = useMemo(() => orderedNotebooks.filter(nb => !nb.parentId), [orderedNotebooks])
|
||||
const childNotebooks = useMemo(() => {
|
||||
const map = new Map<string, Notebook[]>()
|
||||
for (const nb of orderedNotebooks) {
|
||||
if (nb.parentId) {
|
||||
const children = map.get(nb.parentId) || []
|
||||
children.push(nb)
|
||||
map.set(nb.parentId, children)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [orderedNotebooks])
|
||||
|
||||
const currentNotebookId = searchParams.get('notebook')
|
||||
const currentNoteId = searchParams.get('openNote')
|
||||
|
||||
@@ -548,10 +562,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{orderedNotebooks.map((notebook: Notebook) => {
|
||||
{rootNotebooks.map((notebook: Notebook) => {
|
||||
const isActive = currentNotebookId === notebook.id
|
||||
const notes = notebookNotes[notebook.id] || []
|
||||
const isDragging = draggedId === notebook.id
|
||||
const children = childNotebooks.get(notebook.id) || []
|
||||
const isChildActive = children.some(c => currentNotebookId === c.id)
|
||||
const isExpanded = isActive || isChildActive
|
||||
return (
|
||||
<motion.div
|
||||
key={notebook.id}
|
||||
@@ -575,20 +592,58 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
name: notebook.name,
|
||||
initial: notebook.name.charAt(0).toUpperCase(),
|
||||
}}
|
||||
isActive={isActive}
|
||||
isActive={isExpanded}
|
||||
notes={notes}
|
||||
activeNoteId={currentNoteId}
|
||||
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
||||
onNoteClick={handleNoteClick}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
>
|
||||
{children.length > 0 && (
|
||||
<div className="pl-4 space-y-1 mt-1">
|
||||
{children.map(child => {
|
||||
const childActive = currentNotebookId === child.id
|
||||
const childNotes = notebookNotes[child.id] || []
|
||||
return (
|
||||
<div key={child.id}>
|
||||
<SidebarCarnetItem
|
||||
carnet={{
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
initial: child.name.charAt(0).toUpperCase(),
|
||||
}}
|
||||
isActive={childActive}
|
||||
notes={childNotes}
|
||||
activeNoteId={currentNoteId}
|
||||
onCarnetClick={() => handleCarnetClick(child.id)}
|
||||
onNoteClick={handleNoteClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateParentId(notebook.id)
|
||||
setIsCreateDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>{t('notebook.createSubNotebook') || 'Sous-carnet'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SidebarCarnetItem>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
onClick={() => {
|
||||
setCreateParentId(null)
|
||||
setIsCreateDialogOpen(true)
|
||||
}}
|
||||
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/40"
|
||||
>
|
||||
<Plus size={16} />
|
||||
@@ -669,6 +724,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<CreateNotebookDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
parentNotebookId={createParentId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -1,155 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { DialogProps } from '@radix-ui/react-dialog'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,77 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface CreateNotebookInput {
|
||||
name: string
|
||||
icon?: string
|
||||
color?: string
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateNotebookInput {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export type DragState = 'idle' | 'dragging' | 'drag-over'
|
||||
|
||||
export function useNoteDrag() {
|
||||
const [draggedNoteId, setDraggedNoteId] = useState<string | null>(null)
|
||||
const [dragOverNotebookId, setDragOverNotebookId] = useState<string | null>(null)
|
||||
|
||||
const startDrag = useCallback((noteId: string) => {
|
||||
setDraggedNoteId(noteId)
|
||||
}, [])
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDraggedNoteId(null)
|
||||
setDragOverNotebookId(null)
|
||||
}, [])
|
||||
|
||||
const dragOver = useCallback((notebookId: string | null) => {
|
||||
setDragOverNotebookId(notebookId)
|
||||
}, [])
|
||||
|
||||
const isDragging = draggedNoteId !== null
|
||||
const isDragOver = dragOverNotebookId !== null
|
||||
|
||||
return {
|
||||
draggedNoteId,
|
||||
dragOverNotebookId,
|
||||
startDrag,
|
||||
endDrag,
|
||||
dragOver,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function useResizeObserver(callback: (entry: ResizeObserverEntry) => void) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const frameId = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
// Cancel previous frame to avoid stacking updates
|
||||
if (frameId.current) cancelAnimationFrame(frameId.current);
|
||||
|
||||
frameId.current = requestAnimationFrame(() => {
|
||||
for (const entry of entries) {
|
||||
callback(entry);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frameId.current) cancelAnimationFrame(frameId.current);
|
||||
};
|
||||
}, [callback]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -13,12 +13,13 @@ export interface Notebook {
|
||||
icon: string | null;
|
||||
color: string | null;
|
||||
order: number;
|
||||
parentId: string | null;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
// Relations
|
||||
notes?: Note[];
|
||||
labels?: Label[];
|
||||
children?: Notebook[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,6 +82,7 @@ model Notebook {
|
||||
icon String?
|
||||
color String?
|
||||
order Int
|
||||
parentId String?
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -91,9 +92,12 @@ model Notebook {
|
||||
notes Note[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workflows Workflow[]
|
||||
parent Notebook? @relation("NotebookTree", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
children Notebook[] @relation("NotebookTree")
|
||||
|
||||
@@index([userId, order])
|
||||
@@index([userId])
|
||||
@@index([parentId])
|
||||
}
|
||||
|
||||
model Label {
|
||||
|
||||
Reference in New Issue
Block a user