All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
448 lines
20 KiB
TypeScript
448 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback } from 'react'
|
|
import { motion, AnimatePresence } from 'motion/react'
|
|
import { Sparkles, X, CheckCircle2, FolderPlus, Folder, ChevronDown, ChevronUp, Loader2, AlertCircle, Check } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
analyzeNotebookForOrganization,
|
|
executeNotebookOrganization,
|
|
type OrganizationGroup,
|
|
type OrganizationPlan,
|
|
} from '@/app/actions/organize-notebook'
|
|
import { toast } from 'sonner'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
|
|
interface OrganizeNotebookDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
notebookId: string
|
|
notebookName: string
|
|
onDone?: () => void
|
|
}
|
|
|
|
type Step = 'idle' | 'analyzing' | 'preview' | 'executing' | 'done'
|
|
|
|
export function OrganizeNotebookDialog({
|
|
open,
|
|
onOpenChange,
|
|
notebookId,
|
|
notebookName,
|
|
onDone,
|
|
}: OrganizeNotebookDialogProps) {
|
|
const { t, language } = useLanguage()
|
|
const organizePanelSlide = language === 'fa' || language === 'ar' ? -60 : 60
|
|
const [step, setStep] = useState<Step>('idle')
|
|
const [plan, setPlan] = useState<OrganizationPlan | null>(null)
|
|
const [editableGroups, setEditableGroups] = useState<OrganizationGroup[]>([])
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set())
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [result, setResult] = useState<{ created: number; moved: number } | null>(null)
|
|
|
|
const handleAnalyze = useCallback(async () => {
|
|
setStep('analyzing')
|
|
setError(null)
|
|
setPlan(null)
|
|
|
|
const res = await analyzeNotebookForOrganization(notebookId)
|
|
|
|
if (!res.success || !res.plan) {
|
|
setError(res.error ?? t('organizeNotebook.unknownError'))
|
|
setStep('idle')
|
|
return
|
|
}
|
|
|
|
setPlan(res.plan)
|
|
setEditableGroups(res.plan.groups.map(g => ({ ...g, notes: [...g.notes] })))
|
|
setExpandedGroups(new Set(res.plan.groups.map((_, i) => i)))
|
|
setStep('preview')
|
|
}, [notebookId])
|
|
|
|
const handleRenameGroup = (idx: number, name: string) => {
|
|
setEditableGroups(prev => prev.map((g, i) => i === idx ? { ...g, name } : g))
|
|
}
|
|
|
|
const handleToggleNote = (groupIdx: number, noteId: string) => {
|
|
setEditableGroups(prev => prev.map((g, i) => {
|
|
if (i !== groupIdx) return g
|
|
const has = g.notes.some(n => n.id === noteId)
|
|
return {
|
|
...g,
|
|
notes: has ? g.notes.filter(n => n.id !== noteId) : g.notes,
|
|
}
|
|
}))
|
|
}
|
|
|
|
const handleRemoveGroup = (idx: number) => {
|
|
setEditableGroups(prev => prev.filter((_, i) => i !== idx))
|
|
}
|
|
|
|
const toggleExpand = (idx: number) => {
|
|
setExpandedGroups(prev => {
|
|
const next = new Set(prev)
|
|
next.has(idx) ? next.delete(idx) : next.add(idx)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleExecute = useCallback(async () => {
|
|
if (!plan) return
|
|
setStep('executing')
|
|
|
|
const finalPlan: OrganizationPlan = {
|
|
notebookId: plan.notebookId,
|
|
groups: editableGroups.filter(g => g.notes.length > 0 && g.name.trim()),
|
|
}
|
|
|
|
const res = await executeNotebookOrganization(finalPlan)
|
|
|
|
if (!res.success) {
|
|
setError(res.error ?? t('organizeNotebook.unknownError'))
|
|
setStep('preview')
|
|
return
|
|
}
|
|
|
|
setResult({ created: res.created, moved: res.moved })
|
|
setStep('done')
|
|
toast.success(t('organizeNotebook.toastSuccess', { created: res.created, moved: res.moved }))
|
|
onDone?.()
|
|
}, [plan, editableGroups, onDone])
|
|
|
|
const handleClose = () => {
|
|
if (step === 'analyzing' || step === 'executing') return
|
|
onOpenChange(false)
|
|
// Reset after close animation
|
|
setTimeout(() => {
|
|
setStep('idle')
|
|
setPlan(null)
|
|
setEditableGroups([])
|
|
setError(null)
|
|
setResult(null)
|
|
}, 300)
|
|
}
|
|
|
|
if (!open) return null
|
|
|
|
const totalNotes = editableGroups.reduce((acc, g) => acc + g.notes.length, 0)
|
|
const newSubNbs = editableGroups.filter(g => g.isNew).length
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 bg-ink/30 backdrop-blur-sm"
|
|
onClick={handleClose}
|
|
/>
|
|
|
|
{/* Panel */}
|
|
<motion.div
|
|
initial={{ opacity: 0, x: organizePanelSlide }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: organizePanelSlide }}
|
|
transition={{ type: 'spring', stiffness: 280, damping: 30 }}
|
|
className="fixed end-0 top-0 bottom-0 z-50 w-[460px] bg-card border-s border-border shadow-2xl flex flex-col"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="px-6 py-5 border-b border-border/60 flex items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-brand-accent/10 flex items-center justify-center">
|
|
<Sparkles size={16} className="text-brand-accent" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-[13px] font-semibold text-ink">{t('organizeNotebook.title')}</h2>
|
|
<p className="text-[11px] text-muted-ink truncate max-w-[240px]">{notebookName}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleClose}
|
|
disabled={step === 'analyzing' || step === 'executing'}
|
|
className="w-7 h-7 rounded-md flex items-center justify-center text-muted-ink hover:text-ink hover:bg-foreground/5 transition-colors disabled:opacity-30"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
<AnimatePresence mode="wait">
|
|
|
|
{/* IDLE */}
|
|
{step === 'idle' && (
|
|
<motion.div
|
|
key="idle"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -12 }}
|
|
className="p-6 space-y-5"
|
|
>
|
|
{error && (
|
|
<div className="flex items-start gap-3 p-3 rounded-xl bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800/50">
|
|
<AlertCircle size={14} className="text-rose-500 mt-0.5 shrink-0" />
|
|
<p className="text-[12px] text-rose-600 dark:text-rose-400">{error}</p>
|
|
</div>
|
|
)}
|
|
<div className="space-y-3">
|
|
<p className="text-[13px] text-ink leading-relaxed">
|
|
{t('organizeNotebook.intro')}
|
|
</p>
|
|
<ul className="space-y-2">
|
|
{[t('organizeNotebook.bulletThemes'), t('organizeNotebook.bulletSubfolders'), t('organizeNotebook.bulletPreview')].map(item => (
|
|
<li key={item} className="flex items-center gap-2 text-[12px] text-muted-ink">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-brand-accent shrink-0" />
|
|
{item}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* ANALYZING */}
|
|
{step === 'analyzing' && (
|
|
<motion.div
|
|
key="analyzing"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="flex flex-col items-center justify-center p-12 gap-6 min-h-[300px]"
|
|
>
|
|
<div className="relative">
|
|
<div className="w-16 h-16 rounded-2xl bg-brand-accent/10 flex items-center justify-center">
|
|
<Sparkles size={28} className="text-brand-accent" />
|
|
</div>
|
|
<motion.div
|
|
animate={{ rotate: 360 }}
|
|
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
|
className="absolute -inset-2 rounded-2xl border-2 border-transparent border-t-brand-accent/40"
|
|
/>
|
|
</div>
|
|
<div className="text-center space-y-1.5">
|
|
<p className="text-[14px] font-medium text-ink">{t('organizeNotebook.analyzingTitle')}</p>
|
|
<p className="text-[12px] text-muted-ink">{t('organizeNotebook.analyzingSubtitle')}</p>
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
{[0, 1, 2].map(i => (
|
|
<motion.div
|
|
key={i}
|
|
animate={{ opacity: [0.3, 1, 0.3] }}
|
|
transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.2 }}
|
|
className="w-1.5 h-1.5 rounded-full bg-brand-accent"
|
|
/>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* PREVIEW */}
|
|
{step === 'preview' && (
|
|
<motion.div
|
|
key="preview"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
className="p-5 space-y-4"
|
|
>
|
|
{/* Summary bar */}
|
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-brand-accent/5 border border-brand-accent/20">
|
|
<Sparkles size={12} className="text-brand-accent shrink-0" />
|
|
<p className="text-[11px] text-brand-accent font-medium">
|
|
{t('organizeNotebook.previewSummary', {
|
|
groups: editableGroups.length,
|
|
notes: totalNotes,
|
|
newSubs: newSubNbs,
|
|
})}
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex items-start gap-3 p-3 rounded-xl bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800/50">
|
|
<AlertCircle size={14} className="text-rose-500 mt-0.5 shrink-0" />
|
|
<p className="text-[12px] text-rose-600 dark:text-rose-400">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Groups */}
|
|
<div className="space-y-3">
|
|
{editableGroups.map((group, idx) => (
|
|
<motion.div
|
|
key={idx}
|
|
layout
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: idx * 0.05 }}
|
|
className="rounded-xl border border-border bg-background overflow-hidden"
|
|
>
|
|
{/* Group header */}
|
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
|
<div className="w-6 h-6 rounded-md flex items-center justify-center shrink-0">
|
|
{group.isNew
|
|
? <FolderPlus size={13} className="text-brand-accent" />
|
|
: <Folder size={13} className="text-muted-ink" />
|
|
}
|
|
</div>
|
|
<input
|
|
value={group.name}
|
|
onChange={e => handleRenameGroup(idx, e.target.value)}
|
|
className="flex-1 bg-transparent text-[12px] font-semibold text-ink outline-none focus:text-brand-accent transition-colors min-w-0"
|
|
/>
|
|
{group.isNew && (
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-brand-accent/10 text-brand-accent shrink-0">
|
|
{t('organizeNotebook.badgeNew')}
|
|
</span>
|
|
)}
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<button
|
|
onClick={() => toggleExpand(idx)}
|
|
className="w-6 h-6 rounded flex items-center justify-center text-muted-ink hover:text-ink transition-colors"
|
|
>
|
|
{expandedGroups.has(idx) ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
</button>
|
|
<button
|
|
onClick={() => handleRemoveGroup(idx)}
|
|
className="w-6 h-6 rounded flex items-center justify-center text-muted-ink hover:text-rose-500 transition-colors"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes list */}
|
|
<AnimatePresence>
|
|
{expandedGroups.has(idx) && (
|
|
<motion.div
|
|
initial={{ height: 0 }}
|
|
animate={{ height: 'auto' }}
|
|
exit={{ height: 0 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="border-t border-border/40 px-3 pb-2 pt-1 space-y-0.5">
|
|
{group.notes.map(note => (
|
|
<div
|
|
key={note.id}
|
|
className="flex items-center gap-2 py-1.5 rounded-lg px-2 hover:bg-foreground/3 transition-colors group cursor-pointer"
|
|
onClick={() => handleToggleNote(idx, note.id)}
|
|
>
|
|
<div className="w-4 h-4 rounded border border-border flex items-center justify-center shrink-0 group-hover:border-brand-accent/50 transition-colors">
|
|
<Check size={10} className="text-brand-accent" />
|
|
</div>
|
|
<span className="text-[11px] text-muted-ink truncate group-hover:text-ink transition-colors">
|
|
{note.title || t('organizeNotebook.untitledNote')}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Collapsed count */}
|
|
{!expandedGroups.has(idx) && (
|
|
<div className="px-4 pb-2.5 text-[11px] text-muted-ink/60">
|
|
{t('organizeNotebook.notesInGroup', { count: group.notes.length })}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* EXECUTING */}
|
|
{step === 'executing' && (
|
|
<motion.div
|
|
key="executing"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="flex flex-col items-center justify-center p-12 gap-5 min-h-[300px]"
|
|
>
|
|
<Loader2 size={32} className="text-brand-accent animate-spin" />
|
|
<div className="text-center space-y-1">
|
|
<p className="text-[14px] font-medium text-ink">{t('organizeNotebook.executingTitle')}</p>
|
|
<p className="text-[12px] text-muted-ink">{t('organizeNotebook.executingSubtitle')}</p>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* DONE */}
|
|
{step === 'done' && (
|
|
<motion.div
|
|
key="done"
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="flex flex-col items-center justify-center p-12 gap-5 min-h-[300px]"
|
|
>
|
|
<div className="w-16 h-16 rounded-2xl bg-emerald-50 dark:bg-emerald-950/30 flex items-center justify-center">
|
|
<CheckCircle2 size={32} className="text-emerald-500" />
|
|
</div>
|
|
<div className="text-center space-y-1.5">
|
|
<p className="text-[15px] font-semibold text-ink">{t('organizeNotebook.doneTitle')}</p>
|
|
{result && (
|
|
<p className="text-[12px] text-muted-ink">
|
|
{t('organizeNotebook.doneStats', { created: result.created, moved: result.moved })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Footer actions */}
|
|
<div className="px-5 py-4 border-t border-border/60 shrink-0">
|
|
{step === 'idle' && (
|
|
<button
|
|
onClick={handleAnalyze}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-ink text-paper text-[13px] font-semibold hover:opacity-85 transition-opacity"
|
|
>
|
|
<Sparkles size={14} />
|
|
{t('organizeNotebook.analyzeButton')}
|
|
</button>
|
|
)}
|
|
|
|
{step === 'preview' && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => { setStep('idle'); setError(null) }}
|
|
className="flex-1 px-4 py-2.5 rounded-xl border border-border text-[13px] font-medium text-muted-ink hover:text-ink transition-colors"
|
|
>
|
|
{t('organizeNotebook.restart')}
|
|
</button>
|
|
<button
|
|
onClick={handleExecute}
|
|
disabled={editableGroups.filter(g => g.notes.length > 0).length === 0}
|
|
className={cn(
|
|
'flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-all',
|
|
editableGroups.filter(g => g.notes.length > 0).length > 0
|
|
? 'bg-ink text-paper hover:opacity-85'
|
|
: 'bg-foreground/10 text-muted-ink cursor-not-allowed'
|
|
)}
|
|
>
|
|
<Check size={14} />
|
|
{t('organizeNotebook.confirm')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'done' && (
|
|
<button
|
|
onClick={handleClose}
|
|
className="w-full px-4 py-2.5 rounded-xl border border-border text-[13px] font-medium text-muted-ink hover:text-ink transition-colors"
|
|
>
|
|
{t('organizeNotebook.closeButton')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|