chore: remove dead code — 8 components, 5 libs, 4 API routes, 4 npm packages, 30+ scripts, dead CSS, dead exports
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
Removed unused components: - brainstorm-canvas, brainstorm-create-dialog, invite-dialog, manual-idea-dialog - note-inline-editor, profile-page-header, quota-paywall, label-management-dialog Removed dead lib files: - api-auth.ts, color-harmony-recommendation.ts, label-storage.ts, modern-color-options.ts - hooks/use-card-size-mode.ts Removed dead API routes: - ai/test-chat, ai/test-embeddings, ai/test-tags, admin/randomize-labels Removed unused npm packages: - cmdk, novel, tippy.js, react-force-graph-2d Cleaned dead CSS from globals.css: - acrylic-*, win11-shadow-*, muuri-grid/item, ai-glass, ai-tab-indicator, ai-send-btn, sidebar-view-toggle, memento-sidebar-depth Removed 29 orphan scripts and 3 root orphan files Cleaned dead exports from 8 lib files: - NOTE_TYPE_CONFIG, getPublishableKey, PROVIDER_DEFAULTS, useNotes/useNote/invalidateNote, etc.
This commit is contained in:
@@ -1,386 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { BrainstormSession, BrainstormIdea } from '@/types/brainstorm'
|
||||
import {
|
||||
useExpandIdea,
|
||||
useDismissIdea,
|
||||
useConvertIdea,
|
||||
useExportBrainstorm,
|
||||
brainstormQuotaMessageKey,
|
||||
} from '@/hooks/use-brainstorm'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sparkles, X, FileText, Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
interface GraphNode {
|
||||
id: string
|
||||
name: string
|
||||
val: number
|
||||
color: string
|
||||
borderColor: string
|
||||
wave: number
|
||||
status: string
|
||||
idea: BrainstormIdea
|
||||
x?: number
|
||||
y?: number
|
||||
__bckgDimensions?: [number, number]
|
||||
}
|
||||
|
||||
interface GraphLink {
|
||||
source: string | GraphNode
|
||||
target: string | GraphNode
|
||||
color: string
|
||||
}
|
||||
|
||||
const WAVE_COLORS: Record<number, string> = {
|
||||
0: '#ffffff',
|
||||
1: '#f97316',
|
||||
2: '#3b82f6',
|
||||
3: '#a855f7',
|
||||
}
|
||||
|
||||
const WAVE_BORDER: Record<number, string> = {
|
||||
0: '#e5e5e5',
|
||||
1: '#ea580c',
|
||||
2: '#2563eb',
|
||||
3: '#9333ea',
|
||||
}
|
||||
|
||||
const STATUS_ALPHA: Record<string, number> = {
|
||||
active: 1,
|
||||
dismissed: 0.25,
|
||||
converted: 0.9,
|
||||
}
|
||||
|
||||
interface BrainstormCanvasProps {
|
||||
session: BrainstormSession
|
||||
}
|
||||
|
||||
export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
||||
const { t } = useLanguage()
|
||||
const fgRef = useRef<any>(null)
|
||||
const [selectedIdea, setSelectedIdea] = useState<BrainstormIdea | null>(null)
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
||||
const expandIdea = useExpandIdea(session.id)
|
||||
const dismissIdea = useDismissIdea(session.id)
|
||||
const convertIdea = useConvertIdea(session.id)
|
||||
const exportBrainstorm = useExportBrainstorm(session.id)
|
||||
|
||||
const { graphData } = useMemo(() => {
|
||||
const nodes: GraphNode[] = []
|
||||
const links: GraphLink[] = []
|
||||
|
||||
nodes.push({
|
||||
id: 'seed',
|
||||
name: session.seedIdea.length > 30 ? session.seedIdea.slice(0, 30) + '...' : session.seedIdea,
|
||||
val: 25,
|
||||
color: WAVE_COLORS[0],
|
||||
borderColor: WAVE_BORDER[0],
|
||||
wave: 0,
|
||||
status: 'active',
|
||||
idea: {
|
||||
id: 'seed',
|
||||
sessionId: session.id,
|
||||
waveNumber: 0,
|
||||
title: session.seedIdea,
|
||||
description: t('brainstorm.originalSeedDescription'),
|
||||
connectionToSeed: null,
|
||||
noveltyScore: null,
|
||||
parentIdeaId: null,
|
||||
convertedToNoteId: null,
|
||||
relatedNoteIds: null,
|
||||
status: 'active',
|
||||
positionX: null,
|
||||
positionY: null,
|
||||
createdAt: session.createdAt,
|
||||
} as BrainstormIdea,
|
||||
})
|
||||
|
||||
for (const idea of session.ideas) {
|
||||
const parentNode = idea.parentIdeaId
|
||||
? idea.parentIdeaId
|
||||
: 'seed'
|
||||
|
||||
nodes.push({
|
||||
id: idea.id,
|
||||
name: idea.title,
|
||||
val: idea.status === 'dismissed' ? 8 : 15,
|
||||
color: WAVE_COLORS[idea.waveNumber] || WAVE_COLORS[3],
|
||||
borderColor: idea.status === 'converted' ? '#22c55e' : (WAVE_BORDER[idea.waveNumber] || WAVE_BORDER[3]),
|
||||
wave: idea.waveNumber,
|
||||
status: idea.status,
|
||||
idea,
|
||||
})
|
||||
|
||||
const linkColor = WAVE_COLORS[idea.waveNumber] || WAVE_COLORS[3]
|
||||
links.push({
|
||||
source: parentNode,
|
||||
target: idea.id,
|
||||
color: idea.status === 'dismissed' ? '#444444' : linkColor,
|
||||
})
|
||||
}
|
||||
|
||||
return { graphData: { nodes, links } }
|
||||
}, [session, t])
|
||||
|
||||
const handleNodeClick = useCallback((node: any) => {
|
||||
setSelectedIdea(node.idea)
|
||||
setIsSheetOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleExpand = useCallback(async () => {
|
||||
if (!selectedIdea || selectedIdea.id === 'seed') return
|
||||
try {
|
||||
await expandIdea.mutateAsync({ ideaId: selectedIdea.id })
|
||||
toast.success(t('brainstorm.toastExpandSuccess'))
|
||||
} catch (err: unknown) {
|
||||
const quotaKey = brainstormQuotaMessageKey(err)
|
||||
toast.error(quotaKey ? t(quotaKey) : (err instanceof Error ? err.message : t('brainstorm.toastExpandFailed')))
|
||||
}
|
||||
}, [selectedIdea, expandIdea, t])
|
||||
|
||||
const handleDismiss = useCallback(async () => {
|
||||
if (!selectedIdea || selectedIdea.id === 'seed') return
|
||||
try {
|
||||
await dismissIdea.mutateAsync(selectedIdea.id)
|
||||
setIsSheetOpen(false)
|
||||
setSelectedIdea(null)
|
||||
toast.success(t('brainstorm.toastDismissSuccess'))
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('brainstorm.toastDismissFailed'))
|
||||
}
|
||||
}, [selectedIdea, dismissIdea, t])
|
||||
|
||||
const handleConvert = useCallback(async () => {
|
||||
if (!selectedIdea || selectedIdea.id === 'seed') return
|
||||
try {
|
||||
await convertIdea.mutateAsync(selectedIdea.id)
|
||||
toast.success(t('brainstorm.toastConvertSuccess'))
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('brainstorm.toastConvertFailed'))
|
||||
}
|
||||
}, [selectedIdea, convertIdea, t])
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
try {
|
||||
const note = await exportBrainstorm.mutateAsync()
|
||||
toast.success(t('brainstorm.toastExportNoteSuccess'))
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('brainstorm.toastExportFailed'))
|
||||
}
|
||||
}, [exportBrainstorm, t])
|
||||
|
||||
const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const label = node.name
|
||||
const fontSize = Math.max(12 / globalScale, 4)
|
||||
ctx.font = `${fontSize}px Sans-Serif`
|
||||
const textWidth = ctx.measureText(label).width
|
||||
const nodeSize = node.val
|
||||
|
||||
const bgDimensions: [number, number] = [textWidth + nodeSize * 0.8, nodeSize * 1.2]
|
||||
node.__bckgDimensions = bgDimensions
|
||||
|
||||
const alpha = STATUS_ALPHA[node.status] || 1
|
||||
ctx.globalAlpha = alpha
|
||||
|
||||
ctx.fillStyle = node.color
|
||||
ctx.strokeStyle = node.borderColor
|
||||
ctx.lineWidth = node.status === 'converted' ? 3 / globalScale : 1.5 / globalScale
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(
|
||||
node.x! - bgDimensions[0] / 2,
|
||||
node.y! - bgDimensions[1] / 2,
|
||||
bgDimensions[0],
|
||||
bgDimensions[1],
|
||||
nodeSize * 0.3
|
||||
)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = node.wave === 0 ? '#000000' : '#ffffff'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(label, node.x!, node.y!)
|
||||
|
||||
if (node.status === 'converted') {
|
||||
ctx.fillStyle = '#22c55e'
|
||||
ctx.font = `${fontSize * 1.2}px Sans-Serif`
|
||||
ctx.fillText('✓', node.x! + bgDimensions[0] / 2 - fontSize * 0.5, node.y! - bgDimensions[1] / 2 + fontSize * 0.5)
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
}, [])
|
||||
|
||||
const waveLegend = useMemo(
|
||||
() => [
|
||||
{ label: t('brainstorm.legendSeed'), color: WAVE_COLORS[0] },
|
||||
{ label: t('brainstorm.legendVariations'), color: WAVE_COLORS[1] },
|
||||
{ label: t('brainstorm.legendAnalogies'), color: WAVE_COLORS[2] },
|
||||
{ label: t('brainstorm.legendDisruptions'), color: WAVE_COLORS[3] },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-zinc-950 rounded-xl overflow-hidden">
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
nodeCanvasObject={paintNode}
|
||||
nodePointerAreaPaint={(node: any, color: string, ctx: CanvasRenderingContext2D) => {
|
||||
const dims = node.__bckgDimensions
|
||||
if (!dims) return
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(node.x! - dims[0] / 2, node.y! - dims[1] / 2, dims[0], dims[1], (node.val || 10) * 0.3)
|
||||
ctx.fill()
|
||||
}}
|
||||
onNodeClick={handleNodeClick}
|
||||
linkColor={(link: any) => link.color}
|
||||
linkWidth={1}
|
||||
linkDirectionalArrowLength={3}
|
||||
linkDirectionalArrowRelPos={1}
|
||||
backgroundColor="#09090b"
|
||||
nodeVal={(node: any) => node.val}
|
||||
cooldownTicks={100}
|
||||
enableNodeDrag={true}
|
||||
enableZoomInteraction={true}
|
||||
enablePanInteraction={true}
|
||||
warmupTicks={50}
|
||||
/>
|
||||
|
||||
<div className="absolute top-4 left-4 flex flex-col gap-1.5">
|
||||
{waveLegend.map(w => (
|
||||
<div key={w.label} className="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<div className="w-3 h-3 rounded-sm border border-zinc-600" style={{ backgroundColor: w.color }} />
|
||||
<span>{w.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 right-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-300 hover:bg-zinc-700 hover:text-white"
|
||||
onClick={handleExport}
|
||||
disabled={exportBrainstorm.isPending}
|
||||
>
|
||||
<Download size={14} className="mr-1" />
|
||||
{exportBrainstorm.isPending ? t('brainstorm.exporting') : t('brainstorm.export')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||
<SheetContent className="bg-zinc-900 border-zinc-800 text-white w-96">
|
||||
{selectedIdea && (
|
||||
<>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-white text-left">
|
||||
{selectedIdea.id === 'seed' ? session.seedIdea : selectedIdea.title}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 space-y-4">
|
||||
{selectedIdea.description && selectedIdea.id !== 'seed' && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailDescription')}</p>
|
||||
<p className="text-sm text-zinc-300">{selectedIdea.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedIdea.connectionToSeed && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailConnection')}</p>
|
||||
<p className="text-sm text-zinc-300">{selectedIdea.connectionToSeed}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedIdea.noveltyScore != null && selectedIdea.noveltyScore > 0) && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailNovelty')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-orange-500 via-brand-accent to-purple-500 rounded-full"
|
||||
style={{ width: `${Math.min(100, Math.max(0, selectedIdea.noveltyScore * 10))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-zinc-400">{selectedIdea.noveltyScore}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedIdea.waveNumber > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailWave')}</p>
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: WAVE_COLORS[selectedIdea.waveNumber] + '30',
|
||||
color: WAVE_COLORS[selectedIdea.waveNumber],
|
||||
}}
|
||||
>
|
||||
{selectedIdea.waveNumber === 1
|
||||
? t('brainstorm.waveFlavorVariation')
|
||||
: selectedIdea.waveNumber === 2
|
||||
? t('brainstorm.waveFlavorAnalogy')
|
||||
: t('brainstorm.waveFlavorDisruption')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedIdea.status === 'converted' && (
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<p className="text-xs text-green-400 flex items-center gap-1">
|
||||
<FileText size={12} />
|
||||
{t('brainstorm.convertedToNoteStatus')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedIdea.id !== 'seed' && selectedIdea.status === 'active' && (
|
||||
<div className="flex flex-col gap-2 pt-4 border-t border-zinc-800">
|
||||
<Button
|
||||
onClick={handleExpand}
|
||||
disabled={expandIdea.isPending}
|
||||
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
<Sparkles size={14} className="mr-1" />
|
||||
{expandIdea.isPending ? t('brainstorm.deepening') : t('brainstorm.deepen')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConvert}
|
||||
disabled={convertIdea.isPending}
|
||||
className="w-full bg-brand-accent hover:bg-blue-700 text-white"
|
||||
>
|
||||
<FileText size={14} className="mr-1" />
|
||||
{convertIdea.isPending ? t('brainstorm.converting') : t('brainstorm.extract')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDismiss}
|
||||
disabled={dismissIdea.isPending}
|
||||
variant="outline"
|
||||
className="w-full border-zinc-700 text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||
>
|
||||
<X size={14} className="mr-1" />
|
||||
{t('brainstorm.dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface BrainstormCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (seedIdea: string) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function BrainstormCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
}: BrainstormCreateDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [seedIdea, setSeedIdea] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!seedIdea.trim()) return
|
||||
onSubmit(seedIdea.trim())
|
||||
setSeedIdea('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-white dark:bg-zinc-900 border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-orange-500" />
|
||||
{t('brainstorm.newBrainstorm')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wider mb-1 block">
|
||||
{t('brainstorm.seedLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={seedIdea}
|
||||
onChange={(e) => setSeedIdea(e.target.value)}
|
||||
placeholder={t('brainstorm.ideaPromptDetailed')}
|
||||
className="w-full h-28 px-3 py-2 text-sm border border-border rounded-lg bg-transparent focus:outline-none focus:ring-2 focus:ring-orange-500/30 resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('brainstorm.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!seedIdea.trim() || isLoading}
|
||||
className="bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Sparkles size={14} className="mr-1 animate-spin" />
|
||||
{t('brainstorm.generating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} className="mr-1" />
|
||||
{t('brainstorm.startBrainstorm')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { UserPlus, Link, Mail, Check, Copy } from 'lucide-react'
|
||||
|
||||
interface InviteDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onInviteByEmail: (email: string, role: 'editor' | 'viewer') => Promise<any>
|
||||
onInviteByLink: () => Promise<any>
|
||||
seedIdea: string
|
||||
isLoading: boolean
|
||||
t: (key: string) => string | undefined
|
||||
}
|
||||
|
||||
export function InviteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onInviteByEmail,
|
||||
onInviteByLink,
|
||||
seedIdea,
|
||||
isLoading,
|
||||
t,
|
||||
}: InviteDialogProps) {
|
||||
const [tab, setTab] = useState<'email' | 'link'>('email')
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<'editor' | 'viewer'>('editor')
|
||||
const [linkCopied, setLinkCopied] = useState(false)
|
||||
const [emailSent, setEmailSent] = useState(false)
|
||||
const [localLoading, setLocalLoading] = useState(false)
|
||||
|
||||
const handleEmailInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email.trim()) return
|
||||
setLocalLoading(true)
|
||||
try {
|
||||
const res = await onInviteByEmail(email.trim(), role)
|
||||
if (res?.invitedUser) {
|
||||
setEmailSent(true)
|
||||
setTimeout(() => {
|
||||
setEmailSent(false)
|
||||
setEmail('')
|
||||
}, 2000)
|
||||
}
|
||||
} finally {
|
||||
setLocalLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLinkCopy = async () => {
|
||||
setLocalLoading(true)
|
||||
try {
|
||||
const res = await onInviteByLink()
|
||||
if (res?.inviteUrl) {
|
||||
const url = window.location.origin + res.inviteUrl
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = url
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
setLinkCopied(true)
|
||||
setTimeout(() => setLinkCopied(false), 3000)
|
||||
}
|
||||
} finally {
|
||||
setLocalLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const busy = isLoading || localLoading
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<div className="w-7 h-7 rounded-lg bg-emerald-500/10 flex items-center justify-center">
|
||||
<UserPlus size={14} className="text-emerald-500" />
|
||||
</div>
|
||||
<span className="font-serif">{t('brainstorm.inviteTitle') || 'Invite to brainstorm'}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground text-xs italic font-serif">
|
||||
{seedIdea.length > 60 ? seedIdea.substring(0, 60) + '…' : seedIdea}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-1 p-1 bg-foreground/5 rounded-xl mt-2">
|
||||
<button
|
||||
onClick={() => setTab('email')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-[10px] font-bold uppercase tracking-[0.1em] transition-all ${
|
||||
tab === 'email'
|
||||
? 'bg-white dark:bg-[#2A2A2A] text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Mail size={12} />
|
||||
{t('brainstorm.inviteByEmail') || 'Email'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('link')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-[10px] font-bold uppercase tracking-[0.1em] transition-all ${
|
||||
tab === 'link'
|
||||
? 'bg-white dark:bg-[#2A2A2A] text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Link size={12} />
|
||||
{t('brainstorm.inviteByLink') || 'Link'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'email' ? (
|
||||
<form onSubmit={handleEmailInvite} className="space-y-4 mt-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
|
||||
{t('brainstorm.inviteEmailLabel') || 'Email address'}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('brainstorm.inviteEmailPlaceholder') || 'colleague@email.com'}
|
||||
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/40 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
|
||||
{t('brainstorm.inviteRoleLabel') || 'Role'}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole('editor')}
|
||||
className={`flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] border transition-all ${
|
||||
role === 'editor'
|
||||
? 'border-emerald-500/40 bg-emerald-500/5 text-emerald-600'
|
||||
: 'border-border text-muted-foreground hover:border-emerald-500/20'
|
||||
}`}
|
||||
>
|
||||
{t('brainstorm.roleEditor') || 'Editor'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole('viewer')}
|
||||
className={`flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] border transition-all ${
|
||||
role === 'viewer'
|
||||
? 'border-brand-accent/40 bg-brand-accent/5 text-brand-accent'
|
||||
: 'border-border text-muted-foreground hover:border-brand-accent/20'
|
||||
}`}
|
||||
>
|
||||
{t('brainstorm.roleViewer') || 'Viewer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!email.trim() || busy}
|
||||
className="w-full py-3 bg-emerald-500 hover:bg-emerald-600 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{emailSent ? (
|
||||
<>
|
||||
<Check size={12} />
|
||||
{t('brainstorm.inviteSent') || 'Invitation sent!'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail size={12} />
|
||||
{busy ? '...' : (t('brainstorm.sendInvite') || 'Send invitation')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4 mt-2">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t('brainstorm.linkDescription') || 'Anyone with this link can join the brainstorm session.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleLinkCopy}
|
||||
disabled={busy}
|
||||
className="w-full py-3 bg-foreground/5 hover:bg-foreground/10 text-foreground text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl transition-all flex items-center justify-center gap-1.5 border border-border"
|
||||
>
|
||||
{linkCopied ? (
|
||||
<>
|
||||
<Check size={12} className="text-emerald-500" />
|
||||
<span className="text-emerald-500">{t('brainstorm.linkCopied') || 'Link copied!'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={12} />
|
||||
{busy ? '...' : (t('brainstorm.copyLink') || 'Copy invite link')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Lightbulb } from 'lucide-react'
|
||||
|
||||
interface ManualIdeaDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (title: string, description?: string) => void
|
||||
isLoading?: boolean
|
||||
parentIdeaTitle?: string | null
|
||||
t: (key: string) => string | undefined
|
||||
}
|
||||
|
||||
export function ManualIdeaDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
parentIdeaTitle,
|
||||
t,
|
||||
}: ManualIdeaDialogProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
onSubmit(title.trim(), description.trim() || undefined)
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center">
|
||||
<Lightbulb size={14} className="text-brand-accent" />
|
||||
</div>
|
||||
<span className="font-serif">{t('brainstorm.addIdea') || 'Add an idea'}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground text-xs">
|
||||
{parentIdeaTitle
|
||||
? `${t('brainstorm.respondsTo') || 'Responds to'} « ${parentIdeaTitle.length > 40 ? parentIdeaTitle.substring(0, 40) + '…' : parentIdeaTitle} »`
|
||||
: (t('brainstorm.manualIdeaDesc') || 'Share your idea with the brainstorm canvas')
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
|
||||
{t('brainstorm.manualIdeaTitle') || 'Title'}
|
||||
</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t('brainstorm.manualIdeaTitlePlaceholder') || 'Your idea in a few words...'}
|
||||
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/40 transition-all font-serif"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
|
||||
{t('brainstorm.manualIdeaDescLabel') || 'Description (optional)'}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('brainstorm.manualIdeaDescPlaceholder') || 'Elaborate on your idea...'}
|
||||
className="w-full h-24 px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/40 resize-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="px-4 py-2 text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground hover:text-foreground rounded-xl hover:bg-foreground/5 transition-all"
|
||||
>
|
||||
{t('brainstorm.cancel') || 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || isLoading}
|
||||
className="px-5 py-2.5 bg-brand-accent hover:bg-brand-accent text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<Lightbulb size={12} />
|
||||
{isLoading ? (t('brainstorm.adding') || 'Adding...') : (t('brainstorm.addIdea') || 'Add idea')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './ui/dialog'
|
||||
import { Settings, Plus, Palette, Trash2, Sparkles } from 'lucide-react'
|
||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
|
||||
export interface LabelManagementDialogProps {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
||||
const { open, onOpenChange } = props
|
||||
const { labels, isLoading: loading, addLabel, updateLabel, deleteLabel } = useNotebooks()
|
||||
const { t, language } = useLanguage()
|
||||
const { refreshLabels } = useRefresh()
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const [editingColorId, setEditingColorId] = useState<string | null>(null)
|
||||
|
||||
const controlled = open !== undefined && onOpenChange !== undefined
|
||||
|
||||
const handleAddLabel = async () => {
|
||||
const trimmed = newLabel.trim()
|
||||
if (trimmed) {
|
||||
try {
|
||||
await addLabel(trimmed, 'gray')
|
||||
refreshLabels()
|
||||
setNewLabel('')
|
||||
} catch (error) {
|
||||
console.error('Failed to add label:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteLabel = async (id: string) => {
|
||||
try {
|
||||
const labelToDelete = labels.find(l => l.id === id)
|
||||
await deleteLabel(id)
|
||||
refreshLabels()
|
||||
if (labelToDelete) {
|
||||
window.dispatchEvent(new CustomEvent('label-deleted', { detail: { name: labelToDelete.name } }))
|
||||
}
|
||||
setConfirmDeleteId(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete label:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeColor = async (id: string, color: LabelColorName) => {
|
||||
try {
|
||||
await updateLabel(id, { color })
|
||||
refreshLabels()
|
||||
setEditingColorId(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to update label color:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const dialogContent = (
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const isSonnerElement =
|
||||
target.closest('[data-sonner-toast]') ||
|
||||
target.closest('[data-sonner-toaster]') ||
|
||||
target.closest('[data-icon]') ||
|
||||
target.closest('[data-content]') ||
|
||||
target.closest('[data-description]') ||
|
||||
target.closest('[data-title]') ||
|
||||
target.closest('[data-button]');
|
||||
if (isSonnerElement) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (target.getAttribute('data-sonner-toaster') !== null) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('labels.editLabels')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('labels.editLabelsDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('labels.newLabelPlaceholder')}
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddLabel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleAddLabel} size="icon">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto space-y-2">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">{t('labels.loading')}</p>
|
||||
) : labels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('labels.noLabelsFound')}</p>
|
||||
) : (
|
||||
labels.map((label) => {
|
||||
const colorClasses = LABEL_COLORS[label.color]
|
||||
const isEditing = editingColorId === label.id
|
||||
const isAI = label.type === 'ai'
|
||||
|
||||
return (
|
||||
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group">
|
||||
<div className="flex items-center gap-3 flex-1 relative">
|
||||
{isAI ? (
|
||||
<Sparkles className={cn("h-4 w-4", "text-brand-accent")} />
|
||||
) : (
|
||||
<div className={cn("h-3 w-3 rounded-full", colorClasses.bg)} />
|
||||
)}
|
||||
<span className="font-medium text-sm">{label.name}</span>
|
||||
{isAI && (
|
||||
<span className="text-[8px] px-1.5 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent font-bold uppercase">IA</span>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="absolute z-20 top-8 left-0 bg-popover text-popover-foreground border border-border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
||||
const classes = LABEL_COLORS[color]
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full border-2 transition-all hover:scale-110',
|
||||
classes.bg,
|
||||
label.color === color ? 'border-foreground dark:border-foreground ring-2 ring-offset-1' : 'border-transparent'
|
||||
)}
|
||||
onClick={() => handleChangeColor(label.id, color)}
|
||||
title={color}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmDeleteId === label.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-red-500 font-medium">{t('labels.confirmDeleteShort') || 'Confirmer ?'}</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setConfirmDeleteId(null)}>
|
||||
{t('common.cancel') || 'Annuler'}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={() => handleDeleteLabel(label.id)}>
|
||||
{t('common.delete') || 'Supprimer'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setEditingColorId(isEditing ? null : label.id)}
|
||||
title={t('labels.changeColor')}
|
||||
>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={() => setConfirmDeleteId(label.id)}
|
||||
title={t('labels.deleteTooltip')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
|
||||
if (controlled) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t('labels.manage')}>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,992 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useTransition, useMemo } from 'react'
|
||||
import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
import { EditorConnectionsSection } from '@/components/editor-connections-section'
|
||||
import { FusionModal } from '@/components/fusion-modal'
|
||||
import { ComparisonModal } from '@/components/comparison-modal'
|
||||
import { NoteTypeSelector } from '@/components/note-type-selector'
|
||||
import { RichTextEditor } from '@/components/rich-text-editor'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn, extractImagesFromHTML } from '@/lib/utils'
|
||||
import {
|
||||
updateNote,
|
||||
toggleArchive,
|
||||
deleteNote,
|
||||
createNote,
|
||||
commitNoteHistory,
|
||||
} from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import {
|
||||
Pin,
|
||||
Palette,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Trash2,
|
||||
ImageIcon,
|
||||
Link as LinkIcon,
|
||||
X,
|
||||
Plus,
|
||||
CheckSquare,
|
||||
Eye,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Check,
|
||||
RotateCcw,
|
||||
History,
|
||||
GitCommitHorizontal,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
import { EditorImages } from '@/components/editor-images'
|
||||
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||
import { GhostTags } from '@/components/ghost-tags'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
|
||||
interface NoteInlineEditorProps {
|
||||
note: Note
|
||||
onDelete?: (noteId: string) => void
|
||||
onArchive?: (noteId: string) => void
|
||||
onChange?: (noteId: string, fields: Partial<Note>) => void
|
||||
onOpenHistory?: (note: Note) => void
|
||||
onEnableHistory?: (noteId: string) => Promise<void>
|
||||
noteHistoryMode?: 'manual' | 'auto'
|
||||
colorKey: NoteColor
|
||||
/** If true and the note is a Markdown note, open directly in preview mode */
|
||||
defaultPreviewMode?: boolean
|
||||
}
|
||||
|
||||
function getDateLocale(language: string) {
|
||||
if (language === 'fr') return fr;
|
||||
if (language === 'fa') return require('date-fns/locale').faIR;
|
||||
return enUS;
|
||||
}
|
||||
|
||||
/** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */
|
||||
async function saveInline(
|
||||
id: string,
|
||||
data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean; type?: NoteType }
|
||||
) {
|
||||
await fetch(`/api/notes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export function NoteInlineEditor({
|
||||
note,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onChange,
|
||||
onOpenHistory,
|
||||
onEnableHistory,
|
||||
noteHistoryMode = 'manual',
|
||||
colorKey,
|
||||
defaultPreviewMode = false,
|
||||
}: NoteInlineEditorProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) {
|
||||
getAISettings(session.user.id).then((settings) => {
|
||||
setAiAssistantEnabled(settings.paragraphRefactor !== false)
|
||||
setAutoLabelingEnabled(settings.autoLabeling !== false)
|
||||
setAutoSaveEnabled(settings.autoSave !== false)
|
||||
}).catch(err => console.error("Failed to fetch AI settings", err))
|
||||
}
|
||||
}, [session?.user?.id])
|
||||
const { labels: globalLabels, addLabel } = useNotebooks()
|
||||
const [, startTransition] = useTransition()
|
||||
const { refreshNotes } = useRefresh()
|
||||
|
||||
// ── Local edit state ──────────────────────────────────────────────────────
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
const [content, setContent] = useState(note.content || '')
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||
const [noteType, setNoteType] = useState<NoteType>(note.type)
|
||||
const isMarkdown = noteType === 'markdown'
|
||||
|
||||
const allImages = useMemo(() => {
|
||||
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
|
||||
return Array.from(new Set([...(note.images || []), ...extracted]));
|
||||
}, [note.images, content, noteType]);
|
||||
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(
|
||||
defaultPreviewMode && (note.isMarkdown || false)
|
||||
)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
||||
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
||||
|
||||
const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) }
|
||||
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
|
||||
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
|
||||
|
||||
// Link dialog
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [showLinkInput, setShowLinkInput] = useState(false)
|
||||
const [isAddingLink, setIsAddingLink] = useState(false)
|
||||
|
||||
// AI side panel
|
||||
const [aiOpen, setAiOpen] = useState(false)
|
||||
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
||||
// Undo after AI copilot applies content
|
||||
const [previousContent, setPreviousContent] = useState<string | null>(null)
|
||||
|
||||
// Notebooks list (for copilot chat scope)
|
||||
const { notebooks } = useNotebooks()
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const pendingRef = useRef({ title, content, checkItems, isMarkdown, noteType })
|
||||
const noteIdRef = useRef(note.id)
|
||||
|
||||
// Title suggestions
|
||||
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
|
||||
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
|
||||
content: noteType !== 'checklist' ? content : '',
|
||||
enabled: noteType !== 'checklist' && !title
|
||||
})
|
||||
|
||||
// Keep pending ref in sync for unmount save
|
||||
useEffect(() => {
|
||||
pendingRef.current = { title, content, checkItems, isMarkdown, noteType }
|
||||
}, [title, content, checkItems, isMarkdown, noteType])
|
||||
|
||||
// ── Sync when selected note switches ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
// Flush unsaved changes for the PREVIOUS note before switching
|
||||
if (isDirty && noteIdRef.current !== note.id) {
|
||||
const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
|
||||
saveInline(noteIdRef.current, {
|
||||
title: t.trim() || null,
|
||||
content: c,
|
||||
checkItems: nt === 'checklist' ? ci : undefined,
|
||||
type: nt,
|
||||
isMarkdown: nt === 'markdown',
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
noteIdRef.current = note.id
|
||||
setTitle(note.title || '')
|
||||
setContent(note.content || '')
|
||||
setCheckItems(note.checkItems || [])
|
||||
setNoteType(note.type)
|
||||
setShowMarkdownPreview(defaultPreviewMode && (note.type === 'markdown'))
|
||||
setIsDirty(false)
|
||||
setDismissedTitleSuggestions(false)
|
||||
clearTimeout(saveTimerRef.current)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [note.id])
|
||||
|
||||
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
|
||||
const scheduleSave = useCallback(() => {
|
||||
if (!autoSaveEnabled) {
|
||||
setIsDirty(true)
|
||||
return
|
||||
}
|
||||
setIsDirty(true)
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await saveInline(noteIdRef.current, {
|
||||
title: t.trim() || null,
|
||||
content: c,
|
||||
checkItems: nt === 'checklist' ? ci : undefined,
|
||||
type: nt,
|
||||
isMarkdown: nt === 'markdown',
|
||||
})
|
||||
setIsDirty(false)
|
||||
} catch {
|
||||
// silent — retry on next keystroke
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, 1500)
|
||||
}, [noteType])
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
|
||||
saveInline(noteIdRef.current, {
|
||||
title: t.trim() || null,
|
||||
content: c,
|
||||
checkItems: nt === 'checklist' ? ci : undefined,
|
||||
type: nt,
|
||||
isMarkdown: nt === 'markdown',
|
||||
}).catch(() => {})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// ── Auto-tagging ──────────────────────────────────────────────────────────
|
||||
const { suggestions, isAnalyzing } = useAutoTagging({
|
||||
content: noteType !== 'checklist' ? content : '',
|
||||
notebookId: note.notebookId,
|
||||
enabled: noteType !== 'checklist' && autoLabelingEnabled,
|
||||
})
|
||||
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
|
||||
const filteredSuggestions = suggestions.filter(
|
||||
(s) => s?.tag && !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
|
||||
)
|
||||
const handleSelectGhostTag = async (tag: string) => {
|
||||
const exists = (note.labels || []).some((l) => l.toLowerCase() === tag.toLowerCase())
|
||||
if (!exists) {
|
||||
const newLabels = [...(note.labels || []), tag]
|
||||
// Optimistic UI — update sidebar immediately, no page refresh needed
|
||||
onChange?.(note.id, { labels: newLabels })
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
|
||||
if (!globalExists) {
|
||||
try { await addLabel(tag) } catch {}
|
||||
}
|
||||
toast.success(t('ai.tagAdded', { tag }))
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNotesByIds = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
return fetched.filter((n: any) => n !== null) as Array<Partial<Note>>
|
||||
}
|
||||
|
||||
const handleMergeNotes = async (noteIds: string[]) => {
|
||||
setFusionNotes(await fetchNotesByIds(noteIds))
|
||||
}
|
||||
|
||||
const handleCompareNotes = async (noteIds: string[]) => {
|
||||
setComparisonNotes(await fetchNotesByIds(noteIds))
|
||||
}
|
||||
|
||||
const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => {
|
||||
await createNote({
|
||||
title,
|
||||
content,
|
||||
labels: options.keepAllTags
|
||||
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
|
||||
: fusionNotes[0].labels || [],
|
||||
color: fusionNotes[0].color,
|
||||
type: 'markdown',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
aiProvider: 'fusion',
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
setFusionNotes([])
|
||||
refreshNotes(note?.notebookId)
|
||||
}
|
||||
|
||||
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
|
||||
const handleTogglePin = () => {
|
||||
const prev = note.isPinned
|
||||
startTransition(async () => {
|
||||
onChange?.(note.id, { isPinned: !prev })
|
||||
try {
|
||||
await updateNote(note.id, { isPinned: !prev }, { skipRevalidation: true })
|
||||
toast.success(prev ? t('notes.unpinned') : t('notes.pinned') )
|
||||
} catch {
|
||||
onChange?.(note.id, { isPinned: prev })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleArchive = () => {
|
||||
startTransition(async () => {
|
||||
onArchive?.(note.id)
|
||||
try {
|
||||
await toggleArchive(note.id, !note.isArchived)
|
||||
refreshNotes(note?.notebookId)
|
||||
} catch {
|
||||
// Cannot easily revert since onArchive removes from list
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
const prev = color
|
||||
startTransition(async () => {
|
||||
onChange?.(note.id, { color })
|
||||
try {
|
||||
await updateNote(note.id, { color }, { skipRevalidation: true })
|
||||
} catch {
|
||||
onChange?.(note.id, { color: prev })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm(t('notes.confirmDelete'))) return
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
refreshNotes(note?.notebookId)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Image upload ──────────────────────────────────────────────────────────
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
const newImages = [...(note.images || []), url]
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
||||
}
|
||||
}
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const uploadImageFile = async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
if (!res.ok) throw new Error('Upload failed')
|
||||
const data = await res.json()
|
||||
return data.url
|
||||
}
|
||||
|
||||
// Paste handler: upload clipboard images
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
const newImages = [...(note.images || []), url]
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('paste', handlePaste, { capture: true })
|
||||
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||
}, [note.id, note.images, onChange, t])
|
||||
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
const newImages = (note.images || []).filter((_, i) => i !== index)
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
}
|
||||
|
||||
// ── Link ──────────────────────────────────────────────────────────────────
|
||||
const handleAddLink = async () => {
|
||||
if (!linkUrl) return
|
||||
setIsAddingLink(true)
|
||||
try {
|
||||
const metadata = await fetchLinkMetadata(linkUrl)
|
||||
const newLink = metadata || { url: linkUrl, title: linkUrl }
|
||||
const newLinks = [...(note.links || []), newLink]
|
||||
onChange?.(note.id, { links: newLinks })
|
||||
await updateNote(note.id, { links: newLinks })
|
||||
toast.success(t('notes.linkAdded'))
|
||||
} catch {
|
||||
toast.error(t('notes.linkAddFailed'))
|
||||
} finally {
|
||||
setLinkUrl('')
|
||||
setShowLinkInput(false)
|
||||
setIsAddingLink(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLink = async (index: number) => {
|
||||
const newLinks = (note.links || []).filter((_, i) => i !== index)
|
||||
onChange?.(note.id, { links: newLinks })
|
||||
await updateNote(note.id, { links: newLinks })
|
||||
}
|
||||
|
||||
// ── Checklist helpers ─────────────────────────────────────────────────────
|
||||
const handleToggleCheckItem = (id: string) => {
|
||||
const updated = checkItems.map((ci) =>
|
||||
ci.id === id ? { ...ci, checked: !ci.checked } : ci
|
||||
)
|
||||
setCheckItems(updated)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
const handleUpdateCheckText = (id: string, text: string) => {
|
||||
const updated = checkItems.map((ci) => (ci.id === id ? { ...ci, text } : ci))
|
||||
setCheckItems(updated)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
const handleAddCheckItem = () => {
|
||||
const updated = [...checkItems, { id: Date.now().toString(), text: '', checked: false }]
|
||||
setCheckItems(updated)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
const handleRemoveCheckItem = (id: string) => {
|
||||
const updated = checkItems.filter((ci) => ci.id !== id)
|
||||
setCheckItems(updated)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
const dateLocale = getDateLocale(language)
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
<div className="flex flex-1 min-w-0 flex-col overflow-hidden transition-all duration-300">
|
||||
|
||||
{/* ── Toolbar ───────────────────────────────────────────────── */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-1.5 gap-2">
|
||||
|
||||
{/* Left group: content tools */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||
title={t('notes.addImage') }
|
||||
onClick={() => fileInputRef.current?.click()}>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
|
||||
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||
title={t('notes.addLink') }
|
||||
onClick={() => setShowLinkInput(!showLinkInput)}>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<NoteTypeSelector
|
||||
value={noteType}
|
||||
onChange={async (newType) => {
|
||||
const oldType = noteType
|
||||
|
||||
// markdown → richtext: convert content to HTML first
|
||||
if (oldType === 'markdown' && newType === 'richtext') {
|
||||
try {
|
||||
const res = await fetch('/api/ai/convert-markdown', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const { html } = await res.json()
|
||||
setNoteType('richtext')
|
||||
setShowMarkdownPreview(false)
|
||||
setContent(html)
|
||||
saveInline(note.id, {
|
||||
type: 'richtext',
|
||||
isMarkdown: false,
|
||||
content: html,
|
||||
}).catch(() => {})
|
||||
onChange?.(note.id, { type: 'richtext', isMarkdown: false, content: html })
|
||||
toast.success(t('notes.convertedToRichText') || 'Converted to rich text')
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
// Conversion failed — abort the type change
|
||||
toast.error(t('notes.conversionFailed') || 'Conversion failed, staying in Markdown')
|
||||
return
|
||||
}
|
||||
|
||||
setNoteType(newType)
|
||||
if (newType === 'markdown') setShowMarkdownPreview(true)
|
||||
else setShowMarkdownPreview(false)
|
||||
|
||||
// Persist both type and isMarkdown immediately
|
||||
saveInline(note.id, {
|
||||
type: newType,
|
||||
isMarkdown: newType === 'markdown',
|
||||
}).catch(() => {})
|
||||
onChange?.(note.id, { type: newType, isMarkdown: newType === 'markdown' })
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
|
||||
{noteType === 'markdown' && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
||||
title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{noteType !== 'checklist' && aiAssistantEnabled && (
|
||||
<Button variant="ghost" size="sm"
|
||||
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
|
||||
onClick={() => setAiOpen(!aiOpen)}
|
||||
title={t('ai.aiNoteTitle')}>
|
||||
{isProcessingAI
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <Sparkles className="h-3.5 w-3.5" />}
|
||||
<span className="hidden sm:inline">{t('ai.aiNoteTitle')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{previousContent !== null && (
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30 font-medium"
|
||||
title={t('ai.undoAI') }
|
||||
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span className="text-[11px]">{t('general.undo') || 'Annuler'}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right group: meta actions + save indicator */}
|
||||
<div className="flex items-center gap-1">
|
||||
{note.historyEnabled && noteHistoryMode === 'manual' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs text-primary/70 hover:text-primary"
|
||||
title={t('notes.commitVersion')}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await commitNoteHistory(note.id)
|
||||
toast.success(t('notes.versionSaved'))
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="me-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
|
||||
{isSaving ? (
|
||||
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
|
||||
) : isDirty ? (
|
||||
!autoSaveEnabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[11px] text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
||||
onClick={() => {
|
||||
setIsSaving(true)
|
||||
saveInline(note.id, { title, content, checkItems, type: noteType, isMarkdown: showMarkdownPreview && noteType === 'markdown' })
|
||||
.then(() => {
|
||||
setIsSaving(false)
|
||||
setIsDirty(false)
|
||||
toast.success(t('notes.savedStatus'))
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSaving(false)
|
||||
toast.error(t('general.error'))
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 me-1.5" />
|
||||
{t('notes.saveNow') || 'Enregistrer'}
|
||||
</Button>
|
||||
) : (
|
||||
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
|
||||
)
|
||||
) : (
|
||||
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeColor')}>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="grid grid-cols-5 gap-2 p-2">
|
||||
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
|
||||
<button type="button" key={name}
|
||||
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', cls.bg,
|
||||
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
|
||||
onClick={() => handleColorChange(name)} title={name} />
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.moreOptions')}>
|
||||
<span className="text-base leading-none text-muted-foreground">⋯</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleToggleArchive}>
|
||||
{note.isArchived
|
||||
? <><ArchiveRestore className="h-4 w-4 me-2" />{t('notes.unarchive')}</>
|
||||
: <><Archive className="h-4 w-4 me-2" />{t('notes.archive')}</>}
|
||||
</DropdownMenuItem>
|
||||
{onOpenHistory && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (note.historyEnabled) {
|
||||
onOpenHistory(note)
|
||||
} else if (onEnableHistory) {
|
||||
onEnableHistory(note.id).then(() => onOpenHistory({ ...note, historyEnabled: true }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<History className="h-4 w-4 me-2" />
|
||||
{note.historyEnabled
|
||||
? (t('notes.history') || 'Historique')
|
||||
: (t('notes.enableHistory') || "Activer l'historique")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 me-2" />{t('notes.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
|
||||
{showLinkInput && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/30 bg-muted/30 px-4 py-2">
|
||||
<input
|
||||
type="url"
|
||||
className="flex-1 rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
|
||||
placeholder="https://..."
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" disabled={!linkUrl || isAddingLink} onClick={handleAddLink}>
|
||||
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : t('notes.add')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */}
|
||||
{((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
|
||||
{/* Existing labels */}
|
||||
{(note.labels ?? []).map((label) => (
|
||||
<LabelBadge key={label} label={label} />
|
||||
))}
|
||||
{/* AI-suggested tags inline with labels */}
|
||||
<GhostTags
|
||||
suggestions={filteredSuggestions}
|
||||
addedTags={note.labels || []}
|
||||
isAnalyzing={isAnalyzing}
|
||||
onSelectTag={handleSelectGhostTag}
|
||||
onDismissTag={(tag) => setDismissedTags((p) => [...p, tag])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Scrollable editing area ── */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
|
||||
{/* Title */}
|
||||
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40 resize-none overflow-hidden min-h-[1.5em]"
|
||||
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
changeTitle(e.target.value);
|
||||
scheduleSave();
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
}}
|
||||
/>
|
||||
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
||||
<button type="button"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/title-suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const suggested = data.title || data.suggestedTitle || ''
|
||||
if (suggested) { changeTitle(suggested); scheduleSave() }
|
||||
}
|
||||
} catch { } finally { setIsProcessingAI(false) }
|
||||
}}
|
||||
disabled={isProcessingAI}
|
||||
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
|
||||
title={t('ai.suggestTitle')}
|
||||
>
|
||||
{isProcessingAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title Suggestions Dropdown / Inline list */}
|
||||
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
|
||||
<div className="mt-2 text-sm shrink-0">
|
||||
<TitleSuggestions
|
||||
suggestions={titleSuggestions}
|
||||
onSelect={(selectedTitle) => { changeTitle(selectedTitle); scheduleSave() }}
|
||||
onDismiss={() => setDismissedTitleSuggestions(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images */}
|
||||
{Array.isArray(note.images) && note.images.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<EditorImages images={note.images} onRemove={handleRemoveImage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link previews */}
|
||||
{Array.isArray(note.links) && note.links.length > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{note.links.map((link, idx) => (
|
||||
<div key={idx} className="group relative flex overflow-hidden rounded-xl border border-border/60 bg-background/60">
|
||||
{link.imageUrl && (
|
||||
<div className="h-auto w-24 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col justify-center gap-0.5 p-3">
|
||||
<p className="truncate text-sm font-medium">{link.title || link.url}</p>
|
||||
{link.description && <p className="line-clamp-1 text-xs text-muted-foreground">{link.description}</p>}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-[11px] text-primary hover:underline">
|
||||
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button"
|
||||
className="absolute end-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
|
||||
onClick={() => handleRemoveLink(idx)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Text / Checklist content ───────────────────────────────────── */}
|
||||
<div className="mt-4 flex flex-1 flex-col">
|
||||
{noteType === 'richtext' ? (
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
) : noteType === 'text' || noteType === 'markdown' ? (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{showMarkdownPreview && isMarkdown ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">
|
||||
<MarkdownContent content={content || ''} />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
dir="auto"
|
||||
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={isMarkdown
|
||||
? t('notes.takeNoteMarkdown')
|
||||
: t('notes.takeNote')
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ghost tag suggestions are now shown in the top labels strip */}
|
||||
</div>
|
||||
) : (
|
||||
/* Checklist */
|
||||
<div className="space-y-1">
|
||||
{checkItems.filter((ci) => !ci.checked).map((ci, index) => (
|
||||
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-muted/30">
|
||||
<button type="button"
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/60 transition-colors hover:border-primary"
|
||||
onClick={() => handleToggleCheckItem(ci.id)}
|
||||
/>
|
||||
<input
|
||||
dir="auto"
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
|
||||
value={ci.text}
|
||||
placeholder={t('notes.listItem') }
|
||||
onChange={(e) => handleUpdateCheckText(ci.id, e.target.value)}
|
||||
/>
|
||||
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button"
|
||||
className="flex items-center gap-2 px-2 py-1 text-sm text-muted-foreground/60 hover:text-foreground"
|
||||
onClick={handleAddCheckItem}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('notes.addItem') }
|
||||
</button>
|
||||
|
||||
{checkItems.filter((ci) => ci.checked).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 px-2 text-xs text-muted-foreground/40 uppercase tracking-wider">
|
||||
{t('notes.completedLabel')} ({checkItems.filter((ci) => ci.checked).length})
|
||||
</p>
|
||||
{checkItems.filter((ci) => ci.checked).map((ci) => (
|
||||
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 text-muted-foreground transition-colors hover:bg-muted/20">
|
||||
<button type="button"
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40"
|
||||
onClick={() => handleToggleCheckItem(ci.id)}
|
||||
>
|
||||
<CheckSquare className="h-3 w-3 opacity-60" />
|
||||
</button>
|
||||
<span dir="auto" className="flex-1 text-sm line-through">{ci.text}</span>
|
||||
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Memory Echo Connections Section ── */}
|
||||
<EditorConnectionsSection
|
||||
noteId={note.id}
|
||||
onOpenNote={(connNoteId) => {
|
||||
window.open(`/?note=${connNoteId}`, '_blank')
|
||||
}}
|
||||
onCompareNotes={handleCompareNotes}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
/>
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────────────────── */}
|
||||
<div className="shrink-0 border-t border-border/20 px-8 py-2">
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
|
||||
<span suppressHydrationWarning>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
|
||||
<span>·</span>
|
||||
<span suppressHydrationWarning>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fusion Modal */}
|
||||
{fusionNotes.length > 0 && (
|
||||
<FusionModal
|
||||
isOpen={fusionNotes.length > 0}
|
||||
onClose={() => setFusionNotes([])}
|
||||
notes={fusionNotes}
|
||||
onConfirmFusion={handleConfirmFusion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comparison Modal */}
|
||||
{comparisonNotes.length > 0 && (
|
||||
<ComparisonModal
|
||||
isOpen={comparisonNotes.length > 0}
|
||||
onClose={() => setComparisonNotes([])}
|
||||
notes={comparisonNotes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── AI Copilot Side Panel ── */}
|
||||
{aiOpen && (
|
||||
<ContextualAIChat
|
||||
onClose={() => setAiOpen(false)}
|
||||
noteTitle={title}
|
||||
noteContent={content}
|
||||
noteImages={allImages}
|
||||
noteId={note.id}
|
||||
onApplyToNote={(newContent) => {
|
||||
const current = content
|
||||
setPreviousContent(current)
|
||||
changeContent(newContent)
|
||||
scheduleSave()
|
||||
toast.success(t('ai.appliedToNote') || 'Applied to note', {
|
||||
action: {
|
||||
label: t('general.undo') || 'Undo',
|
||||
onClick: () => {
|
||||
changeContent(current)
|
||||
setPreviousContent(null)
|
||||
scheduleSave()
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
onUndoLastAction={previousContent !== null ? () => {
|
||||
changeContent(previousContent)
|
||||
setPreviousContent(null)
|
||||
scheduleSave()
|
||||
} : undefined}
|
||||
lastActionApplied={previousContent !== null}
|
||||
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId, trashedAt: nb.trashedAt }))}
|
||||
diagramInsertFormat={noteType === 'richtext' ? 'html' : 'markdown'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function ProfilePageHeader() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<h1 className="text-3xl font-bold mb-8">{t('nav.accountSettings')}</h1>
|
||||
)
|
||||
}
|
||||
|
||||
export function AISettingsCard() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">✨</span>
|
||||
{t('nav.aiSettings')}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('nav.configureAI')}
|
||||
</p>
|
||||
<span>{t('nav.manageAISettings')}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Zap } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
interface QuotaPaywallProps {
|
||||
feature?: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function QuotaPaywall({ feature: _feature, onDismiss }: QuotaPaywallProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#D4A373]/40 bg-[#D4A373]/5 p-5 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#D4A373]/20">
|
||||
<Zap className="h-4 w-4 text-[#D4A373]" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t('billing.upgradeToPro')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('billing.proDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="px-4 py-1.5 text-xs font-medium rounded-lg bg-[#D4A373] text-white hover:bg-[#C49363] transition-colors"
|
||||
>
|
||||
{t('billing.startCheckout')}
|
||||
</Link>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('common.later') ?? 'Later'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user