feat: Notion-like rich text editor with TipTap, 4 note types, slash commands & bubble menu
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m33s

This commit is contained in:
2026-05-01 01:11:03 +02:00
parent 7053e242d2
commit 1345403a31
31 changed files with 3222 additions and 150 deletions

View File

@@ -40,7 +40,7 @@ services:
- NODE_ENV=production - NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1 - NEXT_TELEMETRY_DISABLED=1
volumes: volumes:
- uploads-data:/app/public/uploads - uploads-data:/app/data/uploads
- backup-data:/app/data/backups - backup-data:/app/data/backups
depends_on: depends_on:
postgres: postgres:

View File

@@ -2,7 +2,7 @@
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { Note, CheckItem } from '@/lib/types' import { Note, CheckItem, NoteType } from '@/lib/types'
import { auth } from '@/auth' import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory' import { getAIProvider } from '@/lib/ai/factory'
import { parseNote as parseNoteUtil, cosineSimilarity, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils' import { parseNote as parseNoteUtil, cosineSimilarity, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
@@ -626,7 +626,7 @@ export async function createNote(data: {
title?: string title?: string
content: string content: string
color?: string color?: string
type?: 'text' | 'checklist' type?: NoteType
checkItems?: CheckItem[] checkItems?: CheckItem[]
labels?: string[] labels?: string[]
images?: string[] images?: string[]
@@ -637,8 +637,8 @@ export async function createNote(data: {
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
autoGenerated?: boolean autoGenerated?: boolean
aiProvider?: string aiProvider?: string
notebookId?: string | undefined // Assign note to a notebook if provided notebookId?: string | undefined
skipRevalidation?: boolean // Option to prevent full page refresh for smooth optimistic UI updates skipRevalidation?: boolean
}) { }) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized'); if (!session?.user?.id) throw new Error('Unauthorized');
@@ -659,7 +659,7 @@ export async function createNote(data: {
title: data.title || null, title: data.title || null,
content: data.content, content: data.content,
color: data.color || 'default', color: data.color || 'default',
type: data.type || 'text', type: data.type || 'richtext',
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null, checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
labels: null, // set by syncNoteLabels below labels: null, // set by syncNoteLabels below
images: data.images ? JSON.stringify(data.images) : null, images: data.images ? JSON.stringify(data.images) : null,
@@ -799,7 +799,7 @@ export async function updateNote(id: string, data: {
color?: string color?: string
isPinned?: boolean isPinned?: boolean
isArchived?: boolean isArchived?: boolean
type?: 'text' | 'checklist' type?: NoteType
checkItems?: CheckItem[] | null checkItems?: CheckItem[] | null
labels?: string[] | null labels?: string[] | null
images?: string[] | null images?: string[] | null

View File

@@ -502,4 +502,473 @@
direction: ltr; direction: ltr;
unicode-bidi: embed; unicode-bidi: embed;
display: inline-block; display: inline-block;
} }
/* ============================================
Notion-like Rich Text Editor (TipTap)
============================================ */
/* --- Editor Wrapper --- */
.notion-editor-wrapper {
position: relative;
}
/* --- ProseMirror Base --- */
.notion-editor-wrapper .ProseMirror {
outline: none;
min-height: 120px;
padding: 4px 0;
font-size: 0.9375rem;
line-height: 1.7;
caret-color: var(--primary);
}
.notion-editor-wrapper .ProseMirror > *:first-child { margin-top: 0; }
/* Placeholder */
.notion-editor-wrapper .ProseMirror p.is-editor-empty:first-child::before,
.notion-editor-wrapper .ProseMirror p.is-empty::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
pointer-events: none;
height: 0;
opacity: 0.4;
font-style: italic;
}
/* --- Paragraphs --- */
.notion-editor-wrapper .ProseMirror p { margin: 0.2em 0; }
/* --- Headings (Notion style — clean, sans-serif) --- */
.notion-editor-wrapper .ProseMirror h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 1.2em 0 0.2em;
line-height: 1.25;
letter-spacing: -0.02em;
}
.notion-editor-wrapper .ProseMirror h2 {
font-size: 1.375rem;
font-weight: 600;
margin: 1em 0 0.15em;
line-height: 1.3;
letter-spacing: -0.01em;
}
.notion-editor-wrapper .ProseMirror h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0.8em 0 0.1em;
line-height: 1.35;
}
/* --- Lists --- */
.notion-editor-wrapper .ProseMirror ul {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0.25em 0;
}
.notion-editor-wrapper .ProseMirror ol {
list-style-type: decimal;
padding-left: 1.5rem;
margin: 0.25em 0;
}
.notion-editor-wrapper .ProseMirror li > p { margin: 0.1em 0; }
.notion-editor-wrapper .ProseMirror li > ul,
.notion-editor-wrapper .ProseMirror li > ol { margin: 0.1em 0; }
/* --- Task / Todo List --- */
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.4rem;
margin: 0.2em 0;
}
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li > label {
display: flex;
align-items: center;
cursor: pointer;
margin-top: 2px;
}
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li > label input[type="checkbox"] {
-webkit-appearance: none; appearance: none;
width: 16px; height: 16px;
border: 2px solid var(--border);
border-radius: 4px;
cursor: pointer;
display: grid; place-content: center;
transition: all 0.15s ease;
background: transparent;
flex-shrink: 0;
}
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li > label input[type="checkbox"]:checked {
background: var(--primary);
border-color: var(--primary);
}
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li > label input[type="checkbox"]::before {
content: ""; width: 10px; height: 10px;
transform: scale(0);
transition: 120ms transform ease-in-out;
box-shadow: inset 1em 1em white;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li > label input[type="checkbox"]:checked::before { transform: scale(1); }
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: var(--muted-foreground);
text-decoration: line-through;
}
/* --- Blockquote --- */
.notion-editor-wrapper .ProseMirror blockquote {
border-left: 3px solid var(--primary);
padding: 0.25em 0 0.25em 1em;
margin: 0.4em 0;
color: var(--muted-foreground);
font-style: italic;
background: oklch(0.5 0 0 / 0.03);
border-radius: 0 4px 4px 0;
}
/* --- Code --- */
.notion-editor-wrapper .ProseMirror code {
background: var(--muted);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.85em;
font-family: var(--font-mono, ui-monospace, monospace);
color: var(--foreground);
}
.notion-editor-wrapper .ProseMirror pre {
background: var(--muted);
border-radius: 8px;
padding: 0.85rem 1rem;
margin: 0.5em 0;
overflow-x: auto;
border: 1px solid var(--border);
}
.notion-editor-wrapper .ProseMirror pre code {
background: none;
padding: 0;
border-radius: 0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* --- Horizontal Rule --- */
.notion-editor-wrapper .ProseMirror hr {
border: none;
border-top: 1px solid var(--border);
margin: 0.75em 0;
}
/* --- Strong / Marks --- */
.notion-editor-wrapper .ProseMirror strong { font-weight: 600; }
/* --- Images --- */
.notion-editor-wrapper .ProseMirror img {
border-radius: 8px;
max-width: 100%;
height: auto;
display: block;
margin: 0.5em 0;
transition: filter 0.15s ease;
}
.notion-editor-wrapper .ProseMirror img:hover { filter: brightness(95%); }
.notion-editor-wrapper .ProseMirror img.ProseMirror-selectednode {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* --- Links --- */
.notion-editor-wrapper .ProseMirror a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.notion-editor-wrapper .ProseMirror a:hover {
opacity: 0.8;
}
/* --- Highlight --- */
.notion-editor-wrapper .ProseMirror mark {
background: oklch(0.85 0.12 90);
border-radius: 2px;
padding: 0 1px;
}
.dark .notion-editor-wrapper .ProseMirror mark {
background: oklch(0.4 0.1 90);
}
/* ============================================
Bubble Menu (floating toolbar on selection)
============================================ */
.notion-bubble-menu {
background: var(--popover);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);
padding: 2px;
display: flex;
align-items: center;
gap: 0;
z-index: 100;
}
.dark .notion-bubble-menu {
box-shadow: 0 4px 16px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06);
}
.notion-bubble-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--foreground);
cursor: pointer;
transition: all 0.12s ease;
}
.notion-bubble-btn:hover {
background: var(--accent);
}
.notion-bubble-btn-active {
background: var(--accent);
color: var(--primary);
}
/* AI submenu inside bubble */
.notion-ai-submenu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--popover);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
padding: 4px;
min-width: 150px;
z-index: 110;
}
.dark .notion-ai-submenu {
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.notion-ai-subitem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 0.8125rem;
color: var(--foreground);
transition: background 0.1s ease;
}
.notion-ai-subitem:hover {
background: var(--accent);
}
/* Inline input inside bubble menu (link editor) */
.notion-inline-input {
background: transparent;
border: none;
outline: none;
font-size: 0.8125rem;
color: var(--foreground);
width: 200px;
min-width: 120px;
}
.notion-inline-input::placeholder {
color: var(--muted-foreground);
opacity: 0.6;
}
/* Image insert overlay */
.notion-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.3);
}
.dark .notion-overlay {
background: rgba(0,0,0,0.6);
}
.notion-image-modal {
background: var(--popover);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
padding: 20px;
width: 400px;
max-width: 90vw;
}
.dark .notion-image-modal {
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
}
.notion-modal-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
color: var(--foreground);
font-size: 0.8125rem;
outline: none;
transition: border-color 0.15s ease;
}
.notion-modal-input:focus {
border-color: var(--primary);
}
.notion-modal-input::placeholder {
color: var(--muted-foreground);
opacity: 0.5;
}
.notion-modal-btn {
padding: 6px 14px;
border-radius: 6px;
font-size: 0.8125rem;
border: 1px solid var(--border);
background: transparent;
color: var(--foreground);
cursor: pointer;
transition: background 0.1s ease;
}
.notion-modal-btn:hover {
background: var(--accent);
}
.notion-modal-btn-primary {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
.notion-modal-btn-primary:hover {
opacity: 0.9;
background: var(--primary);
}
/* ============================================
Slash Command Menu
============================================ */
.notion-slash-menu {
position: fixed;
z-index: 9999;
background: var(--popover);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.1);
padding: 4px;
min-width: 300px;
max-height: 340px;
overflow-y: auto;
scrollbar-width: thin;
}
.dark .notion-slash-menu {
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
.notion-slash-section + .notion-slash-section {
border-top: 1px solid var(--border);
margin-top: 4px;
padding-top: 4px;
}
.notion-slash-label {
padding: 6px 8px 2px;
font-size: 11px;
font-weight: 600;
color: var(--muted-foreground);
user-select: none;
}
.notion-slash-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 8px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
text-align: left;
color: var(--foreground);
transition: background 0.1s ease;
}
.notion-slash-item:hover,
.notion-slash-item-selected {
background: var(--accent);
}
.notion-slash-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--muted-foreground);
flex-shrink: 0;
}
.notion-slash-icon-ai {
background: oklch(0.95 0.05 270);
border-color: oklch(0.8 0.08 270);
color: oklch(0.55 0.15 270);
}
.dark .notion-slash-icon-ai {
background: oklch(0.25 0.05 270);
border-color: oklch(0.4 0.08 270);
color: oklch(0.75 0.15 270);
}
.notion-slash-loading {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
font-size: 13px;
color: var(--muted-foreground);
}
.notion-slash-title {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto;
}
.notion-slash-desc {
font-size: 11px;
color: var(--muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
text-align: right;
}
/* ============================================
Note Card Rich Text Preview
============================================ */
.rt-preview { font-size: 0.875rem; line-height: 1.6; }
.rt-preview h1 { font-size: 1.3rem; font-weight: 700; margin: 0.3em 0; }
.rt-preview h2 { font-size: 1.1rem; font-weight: 600; margin: 0.3em 0; }
.rt-preview h3 { font-size: 1rem; font-weight: 600; margin: 0.3em 0; }
.rt-preview p { margin: 0.2em 0; }
.rt-preview ul, .rt-preview ol { padding-left: 1.25rem; margin: 0.2em 0; }
.rt-preview blockquote { border-left: 3px solid var(--border); padding-left: 0.75rem; color: var(--muted-foreground); margin: 0.3em 0; }

View File

@@ -19,6 +19,10 @@ import {
Trash2, Trash2,
RotateCcw, RotateCcw,
History, History,
AlignLeft,
FileCode2,
PenLine,
ListChecks,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { NOTE_COLORS } from "@/lib/types" import { NOTE_COLORS } from "@/lib/types"
@@ -38,6 +42,7 @@ interface NoteActionsProps {
onDelete: () => void onDelete: () => void
onShareCollaborators?: () => void onShareCollaborators?: () => void
isMarkdown?: boolean isMarkdown?: boolean
noteType?: string
onToggleMarkdown?: () => void onToggleMarkdown?: () => void
isTrashView?: boolean isTrashView?: boolean
onRestore?: () => void onRestore?: () => void
@@ -62,6 +67,7 @@ export function NoteActions({
onDelete, onDelete,
onShareCollaborators, onShareCollaborators,
isMarkdown = false, isMarkdown = false,
noteType = 'text',
onToggleMarkdown, onToggleMarkdown,
isTrashView, isTrashView,
onRestore, onRestore,
@@ -170,19 +176,22 @@ export function NoteActions({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Markdown Toggle */} {onToggleMarkdown && (() => {
{onToggleMarkdown && ( const iconMap: Record<string, React.ElementType> = { text: AlignLeft, markdown: FileCode2, richtext: PenLine, checklist: ListChecks }
<Button const TypeIcon = iconMap[noteType] || FileText
variant="ghost" return (
size="sm" <Button
className={cn("h-8 gap-1 px-2 text-xs", isMarkdown && "text-primary bg-primary/10")} variant="ghost"
title="Markdown" size="sm"
onClick={onToggleMarkdown} className={cn("h-8 gap-1 px-2 text-xs", noteType !== 'text' && "text-primary bg-primary/10")}
> title={noteType === 'markdown' ? 'Markdown' : noteType === 'richtext' ? 'Rich Text' : noteType === 'checklist' ? 'Checklist' : 'Text'}
<FileText className="h-4 w-4" /> onClick={onToggleMarkdown}
<span className="hidden sm:inline">MD</span> >
</Button> <TypeIcon className="h-4 w-4" />
)} <span className="hidden sm:inline">{noteType === 'markdown' ? 'MD' : noteType === 'richtext' ? 'RT' : noteType === 'checklist' ? 'CL' : 'TXT'}</span>
</Button>
)
})()}
{/* More Options */} {/* More Options */}
<DropdownMenu> <DropdownMenu>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types' import { Note, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -20,7 +20,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react' import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2, AlignLeft, FileCode2, PenLine, ListChecks } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react' import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
@@ -83,6 +83,13 @@ function getDateLocale(language: string): Locale {
return localeMap[language] || enUS return localeMap[language] || enUS
} }
const NOTE_TYPE_ICONS: Record<NoteType, LucideIcon> = {
text: AlignLeft,
markdown: FileCode2,
richtext: PenLine,
checklist: ListChecks,
}
// Map icon names to lucide-react components // Map icon names to lucide-react components
const ICON_MAP: Record<string, LucideIcon> = { const ICON_MAP: Record<string, LucideIcon> = {
'folder': Folder, 'folder': Folder,
@@ -536,8 +543,12 @@ export const NoteCard = memo(function NoteCard({
{/* Title */} {/* Title */}
{optimisticNote.title && ( {optimisticNote.title && (
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight"> <h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight flex items-center gap-2">
{optimisticNote.title} {(() => {
const TypeIcon = NOTE_TYPE_ICONS[optimisticNote.type] || AlignLeft
return <TypeIcon className="h-4 w-4 shrink-0 text-muted-foreground/50" />
})()}
<span className="min-w-0 truncate">{optimisticNote.title}</span>
</h3> </h3>
)} )}
@@ -608,18 +619,20 @@ export const NoteCard = memo(function NoteCard({
)} )}
{/* Content */} {/* Content */}
{optimisticNote.type === 'text' ? ( {optimisticNote.type === 'checklist' ? (
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
) : optimisticNote.type === 'richtext' ? (
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: optimisticNote.content || '' }} />
) : (
<div className="text-sm text-foreground line-clamp-10"> <div className="text-sm text-foreground line-clamp-10">
<MarkdownContent <MarkdownContent
content={optimisticNote.content} content={optimisticNote.content}
className="prose-h1:text-xl prose-h1:font-semibold prose-h1:leading-snug prose-h1:mt-1 prose-h1:mb-2 prose-h2:text-lg prose-h2:font-medium prose-h3:text-base prose-p:text-sm prose-p:leading-relaxed" className="prose-h1:text-xl prose-h1:font-semibold prose-h1:leading-snug prose-h1:mt-1 prose-h1:mb-2 prose-h2:text-lg prose-h2:font-medium prose-h3:text-base prose-p:text-sm prose-p:leading-relaxed"
/> />
</div> </div>
) : (
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
)} )}
{/* Labels - using shared LabelBadge component */} {/* Labels - using shared LabelBadge component */}

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata } from '@/lib/types' import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType } from '@/lib/types'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -23,7 +23,9 @@ import {
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut } from 'lucide-react' import { NoteTypeSelector } from '@/components/note-type-selector'
import { RichTextEditor } from '@/components/rich-text-editor'
import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut } from 'lucide-react'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes' import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape' import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -85,8 +87,9 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
const [size, setSize] = useState<NoteSize>(note.size || 'small') const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([]) const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false) const [noteType, setNoteType] = useState<NoteType>(note.type)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false) const isMarkdown = noteType === 'markdown'
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// Update context notebookId when note changes // Update context notebookId when note changes
@@ -96,9 +99,9 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
// Auto-tagging hook - use local state for live suggestions as user types // Auto-tagging hook - use local state for live suggestions as user types
const { suggestions, isAnalyzing } = useAutoTagging({ const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '', content: noteType !== 'checklist' ? content : '',
notebookId: note.notebookId, notebookId: note.notebookId,
enabled: note.type === 'text' && autoLabelingEnabled enabled: noteType !== 'checklist' && autoLabelingEnabled
}) })
// Reminder state // Reminder state
@@ -466,7 +469,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
// Set the transformed markdown content and enable markdown mode // Set the transformed markdown content and enable markdown mode
setContent(data.transformedText) setContent(data.transformedText)
setIsMarkdown(true) setNoteType('markdown')
setShowMarkdownPreview(false) setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess')) toast.success(t('ai.transformSuccess'))
@@ -523,14 +526,15 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
try { try {
await updateNote(note.id, { await updateNote(note.id, {
title: title.trim() || null, title: title.trim() || null,
content: note.type === 'text' ? content : '', content: noteType !== 'checklist' ? content : '',
checkItems: note.type === 'checklist' ? checkItems : null, checkItems: noteType === 'checklist' ? checkItems : null,
labels, labels,
images, images,
links, links,
color, color,
reminder: currentReminder, reminder: currentReminder,
isMarkdown, isMarkdown: noteType === 'markdown',
type: noteType,
size, size,
}) })
@@ -588,12 +592,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
title: `${title || t('notes.untitled')} (${t('notes.copy')})`, title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
content: content, content: content,
color: color, color: color,
type: note.type,
checkItems: checkItems, checkItems: checkItems,
labels: labels, labels: labels,
images: images, images: images,
links: links, links: links,
isMarkdown: isMarkdown, isMarkdown: noteType === 'markdown',
type: noteType,
size: size, size: size,
}) })
toast.success(t('notes.copySuccess')) toast.success(t('notes.copySuccess'))
@@ -697,7 +701,13 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
)} )}
{/* Content or Checklist */} {/* Content or Checklist */}
{note.type === 'text' ? ( {noteType === 'richtext' ? (
<RichTextEditor
content={content}
onChange={setContent}
className="min-h-[200px]"
/>
) : noteType === 'text' || noteType === 'markdown' ? (
<div className="space-y-2"> <div className="space-y-2">
{showMarkdownPreview && isMarkdown ? ( {showMarkdownPreview && isMarkdown ? (
<MarkdownContent <MarkdownContent
@@ -847,18 +857,9 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
<LinkIcon className="h-4 w-4" /> <LinkIcon className="h-4 w-4" />
</Button> </Button>
{/* Markdown toggle */} <NoteTypeSelector value={noteType} onChange={(newType) => { setNoteType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} />
{note.type === 'text' && (
<Button variant="ghost" size="icon"
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false) }}
title="Markdown">
<FileText className="h-4 w-4" />
</Button>
)}
{/* Markdown preview toggle */} {noteType === 'markdown' && (
{isMarkdown && (
<Button variant="ghost" size="icon" className="h-8 w-8" <Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)} onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}> title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
@@ -867,7 +868,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
)} )}
{/* AI Copilot */} {/* AI Copilot */}
{note.type === 'text' && aiAssistantEnabled && ( {noteType !== 'checklist' && aiAssistantEnabled && (
<Button variant="ghost" size="sm" <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')} 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="Assistant IA"> onClick={() => setAiOpen(!aiOpen)} title="Assistant IA">

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useRef, useCallback, useTransition } from 'react' import { useState, useEffect, useRef, useCallback, useTransition } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types' import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
@@ -14,6 +14,8 @@ import { LabelBadge } from '@/components/label-badge'
import { EditorConnectionsSection } from '@/components/editor-connections-section' import { EditorConnectionsSection } from '@/components/editor-connections-section'
import { FusionModal } from '@/components/fusion-modal' import { FusionModal } from '@/components/fusion-modal'
import { ComparisonModal } from '@/components/comparison-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 { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
@@ -35,7 +37,6 @@ import {
X, X,
Plus, Plus,
CheckSquare, CheckSquare,
FileText,
Eye, Eye,
Sparkles, Sparkles,
Loader2, Loader2,
@@ -83,7 +84,7 @@ function getDateLocale(language: string) {
/** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */ /** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */
async function saveInline( async function saveInline(
id: string, id: string,
data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean } data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean; type?: NoteType }
) { ) {
await fetch(`/api/notes/${id}`, { await fetch(`/api/notes/${id}`, {
method: 'PUT', method: 'PUT',
@@ -127,7 +128,8 @@ export function NoteInlineEditor({
const [title, setTitle] = useState(note.title || '') const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content || '') const [content, setContent] = useState(note.content || '')
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || []) const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false) const [noteType, setNoteType] = useState<NoteType>(note.type)
const isMarkdown = noteType === 'markdown'
const [showMarkdownPreview, setShowMarkdownPreview] = useState( const [showMarkdownPreview, setShowMarkdownPreview] = useState(
defaultPreviewMode && (note.isMarkdown || false) defaultPreviewMode && (note.isMarkdown || false)
) )
@@ -157,31 +159,32 @@ export function NoteInlineEditor({
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined) const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingRef = useRef({ title, content, checkItems, isMarkdown }) const pendingRef = useRef({ title, content, checkItems, isMarkdown, noteType })
const noteIdRef = useRef(note.id) const noteIdRef = useRef(note.id)
// Title suggestions // Title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false) const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({ const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
content: note.type === 'text' ? content : '', content: noteType !== 'checklist' ? content : '',
enabled: note.type === 'text' && !title enabled: noteType !== 'checklist' && !title
}) })
// Keep pending ref in sync for unmount save // Keep pending ref in sync for unmount save
useEffect(() => { useEffect(() => {
pendingRef.current = { title, content, checkItems, isMarkdown } pendingRef.current = { title, content, checkItems, isMarkdown, noteType }
}, [title, content, checkItems, isMarkdown]) }, [title, content, checkItems, isMarkdown, noteType])
// ── Sync when selected note switches ───────────────────────────────────── // ── Sync when selected note switches ─────────────────────────────────────
useEffect(() => { useEffect(() => {
// Flush unsaved changes for the PREVIOUS note before switching // Flush unsaved changes for the PREVIOUS note before switching
if (isDirty && noteIdRef.current !== note.id) { if (isDirty && noteIdRef.current !== note.id) {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
saveInline(noteIdRef.current, { saveInline(noteIdRef.current, {
title: t.trim() || null, title: t.trim() || null,
content: c, content: c,
checkItems: note.type === 'checklist' ? ci : undefined, checkItems: nt === 'checklist' ? ci : undefined,
isMarkdown: im, type: nt,
isMarkdown: nt === 'markdown',
}).catch(() => {}) }).catch(() => {})
} }
@@ -189,8 +192,8 @@ export function NoteInlineEditor({
setTitle(note.title || '') setTitle(note.title || '')
setContent(note.content || '') setContent(note.content || '')
setCheckItems(note.checkItems || []) setCheckItems(note.checkItems || [])
setIsMarkdown(note.isMarkdown || false) setNoteType(note.type)
setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false)) setShowMarkdownPreview(defaultPreviewMode && (note.type === 'markdown'))
setIsDirty(false) setIsDirty(false)
setDismissedTitleSuggestions(false) setDismissedTitleSuggestions(false)
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
@@ -202,14 +205,15 @@ export function NoteInlineEditor({
setIsDirty(true) setIsDirty(true)
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => { saveTimerRef.current = setTimeout(async () => {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
setIsSaving(true) setIsSaving(true)
try { try {
await saveInline(noteIdRef.current, { await saveInline(noteIdRef.current, {
title: t.trim() || null, title: t.trim() || null,
content: c, content: c,
checkItems: note.type === 'checklist' ? ci : undefined, checkItems: nt === 'checklist' ? ci : undefined,
isMarkdown: im, type: nt,
isMarkdown: nt === 'markdown',
}) })
setIsDirty(false) setIsDirty(false)
} catch { } catch {
@@ -218,18 +222,19 @@ export function NoteInlineEditor({
setIsSaving(false) setIsSaving(false)
} }
}, 1500) }, 1500)
}, [note.type]) }, [noteType])
// Flush on unmount // Flush on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
saveInline(noteIdRef.current, { saveInline(noteIdRef.current, {
title: t.trim() || null, title: t.trim() || null,
content: c, content: c,
checkItems: note.type === 'checklist' ? ci : undefined, checkItems: nt === 'checklist' ? ci : undefined,
isMarkdown: im, type: nt,
isMarkdown: nt === 'markdown',
}).catch(() => {}) }).catch(() => {})
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -237,9 +242,9 @@ export function NoteInlineEditor({
// ── Auto-tagging ────────────────────────────────────────────────────────── // ── Auto-tagging ──────────────────────────────────────────────────────────
const { suggestions, isAnalyzing } = useAutoTagging({ const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '', content: noteType !== 'checklist' ? content : '',
notebookId: note.notebookId, notebookId: note.notebookId,
enabled: note.type === 'text' && autoLabelingEnabled, enabled: noteType !== 'checklist' && autoLabelingEnabled,
}) })
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase()) const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter( const filteredSuggestions = suggestions.filter(
@@ -288,7 +293,7 @@ export function NoteInlineEditor({
? [...new Set(fusionNotes.flatMap(n => n.labels || []))] ? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [], : fusionNotes[0].labels || [],
color: fusionNotes[0].color, color: fusionNotes[0].color,
type: 'text', type: 'markdown',
isMarkdown: true, isMarkdown: true,
autoGenerated: true, autoGenerated: true,
aiProvider: 'fusion', aiProvider: 'fusion',
@@ -488,20 +493,17 @@ export function NoteInlineEditor({
<LinkIcon className="h-4 w-4" /> <LinkIcon className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" <NoteTypeSelector
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')} value={noteType}
onClick={() => { onChange={(newType) => {
const nextIsMarkdown = !isMarkdown setNoteType(newType)
setIsMarkdown(nextIsMarkdown) if (newType !== 'markdown') setShowMarkdownPreview(false)
onChange?.(note.id, { isMarkdown: nextIsMarkdown }) onChange?.(note.id, { isMarkdown: newType === 'markdown' })
if (!nextIsMarkdown) setShowMarkdownPreview(false)
scheduleSave()
}} }}
title="Markdown"> compact
<FileText className="h-4 w-4" /> />
</Button>
{isMarkdown && ( {noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8" <Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)} onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}> title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}>
@@ -509,7 +511,7 @@ export function NoteInlineEditor({
</Button> </Button>
)} )}
{note.type === 'text' && aiAssistantEnabled && ( {noteType !== 'checklist' && aiAssistantEnabled && (
<Button variant="ghost" size="sm" <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')} className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)} onClick={() => setAiOpen(!aiOpen)}
@@ -743,7 +745,13 @@ export function NoteInlineEditor({
{/* ── Text / Checklist content ───────────────────────────────────── */} {/* ── Text / Checklist content ───────────────────────────────────── */}
<div className="mt-4 flex flex-1 flex-col"> <div className="mt-4 flex flex-1 flex-col">
{note.type === 'text' ? ( {noteType === 'richtext' ? (
<RichTextEditor
content={content}
onChange={setContent}
className="min-h-[200px]"
/>
) : noteType === 'text' || noteType === 'markdown' ? (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
{showMarkdownPreview && isMarkdown ? ( {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"> <div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">

View File

@@ -16,13 +16,12 @@ import {
MoreVertical, MoreVertical,
Undo2, Undo2,
Redo2, Redo2,
FileText,
Eye, Eye,
Link as LinkIcon Link as LinkIcon
} from 'lucide-react' } from 'lucide-react'
import { createNote } from '@/app/actions/notes' import { createNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape' import { fetchLinkMetadata } from '@/app/actions/scrape'
import { CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, Note } from '@/lib/types' import { CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, Note, NoteType } from '@/lib/types'
import { ContextualAIChat } from './contextual-ai-chat' import { ContextualAIChat } from './contextual-ai-chat'
import { Maximize2, Minimize2, Sparkles, Loader2 } from 'lucide-react' import { Maximize2, Minimize2, Sparkles, Loader2 } from 'lucide-react'
import { useNotebooks } from '@/context/notebooks-context' import { useNotebooks } from '@/context/notebooks-context'
@@ -41,6 +40,8 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { NoteTypeSelector } from '@/components/note-type-selector'
import { RichTextEditor } from '@/components/rich-text-editor'
import { MarkdownContent } from './markdown-content' import { MarkdownContent } from './markdown-content'
import { LabelSelector } from './label-selector' import { LabelSelector } from './label-selector'
import { LabelBadge } from './label-badge' import { LabelBadge } from './label-badge'
@@ -107,7 +108,7 @@ export function NoteInput({
useEffect(() => { useEffect(() => {
if (forceExpanded) setIsExpanded(true) if (forceExpanded) setIsExpanded(true)
}, [forceExpanded]) }, [forceExpanded])
const [type, setType] = useState<'text' | 'checklist'>('text') const [type, setType] = useState<NoteType>('richtext')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [color, setColor] = useState<NoteColor>('default') const [color, setColor] = useState<NoteColor>('default')
const [isArchived, setIsArchived] = useState(false) const [isArchived, setIsArchived] = useState(false)
@@ -126,8 +127,9 @@ export function NoteInput({
const [checkItems, setCheckItems] = useState<CheckItem[]>([]) const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([]) const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([]) const [links, setLinks] = useState<LinkMetadata[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false) const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
const isMarkdown = type === 'markdown'
const isRichText = type === 'richtext'
// Auto-resize textarea based on content // Auto-resize textarea based on content
useEffect(() => { useEffect(() => {
@@ -145,14 +147,14 @@ export function NoteInput({
// Auto-tagging hook // Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({ const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? fullContentForAI : '', content: type !== 'checklist' ? fullContentForAI : '',
enabled: type === 'text' && isExpanded && autoLabelingEnabled, enabled: type !== 'checklist' && isExpanded && autoLabelingEnabled,
notebookId: currentNotebookId notebookId: currentNotebookId
}) })
// Title suggestions // Title suggestions
const titleSuggestionsEnabled = type === 'text' && isExpanded && !title const titleSuggestionsEnabled = (type === 'text' || type === 'markdown' || type === 'richtext') && isExpanded && !title
const titleSuggestionsContent = type === 'text' ? fullContentForAI : '' const titleSuggestionsContent = type !== 'checklist' ? fullContentForAI : ''
// Title suggestions hook // Title suggestions hook
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({ const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
@@ -357,9 +359,8 @@ export function NoteInput({
const data = await response.json() const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to transform') if (!response.ok) throw new Error(data.error || 'Failed to transform')
// Set the transformed markdown content and enable markdown mode
setContent(data.transformedText) setContent(data.transformedText)
setIsMarkdown(true) setType('markdown')
setShowMarkdownPreview(false) setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess')) toast.success(t('ai.transformSuccess'))
@@ -556,11 +557,11 @@ export function NoteInput({
const handleSubmit = async () => { const handleSubmit = async () => {
// Validation: Allow submit if content OR images OR links exist // Validation: Allow submit if content OR images OR links exist
const hasContent = content.trim().length > 0; const hasContent = type === 'checklist' ? false : content.trim().length > 0
const hasMedia = images.length > 0 || links.length > 0; const hasCheckItems = type === 'checklist' ? checkItems.some(item => item.text.trim()) : false
const hasCheckItems = checkItems.some(i => i.text.trim().length > 0); const hasMedia = (images && images.length > 0) || (links && links.length > 0)
if (type === 'text' && !hasContent && !hasMedia) { if (type !== 'checklist' && !hasContent && !hasMedia) {
toast.warning(t('notes.contentOrMediaRequired')) toast.warning(t('notes.contentOrMediaRequired'))
return return
} }
@@ -573,7 +574,7 @@ export function NoteInput({
try { try {
const createdNote = await createNote({ const createdNote = await createNote({
title: title.trim() || undefined, title: title.trim() || undefined,
content: type === 'text' ? content : '', content: type === 'checklist' ? '' : content,
type, type,
checkItems: type === 'checklist' ? checkItems : undefined, checkItems: type === 'checklist' ? checkItems : undefined,
color, color,
@@ -597,12 +598,11 @@ export function NoteInput({
setCheckItems([]) setCheckItems([])
setImages([]) setImages([])
setLinks([]) setLinks([])
setIsMarkdown(false)
setShowMarkdownPreview(false) setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }]) setHistory([{ title: '', content: '' }])
setHistoryIndex(0) setHistoryIndex(0)
setIsExpanded(false) setIsExpanded(false)
setType('text') setType('richtext')
setColor('default') setColor('default')
setIsArchived(false) setIsArchived(false)
setCurrentReminder(null) setCurrentReminder(null)
@@ -643,7 +643,6 @@ export function NoteInput({
setCheckItems([]) setCheckItems([])
setImages([]) setImages([])
setLinks([]) setLinks([])
setIsMarkdown(false)
setShowMarkdownPreview(false) setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }]) setHistory([{ title: '', content: '' }])
setHistoryIndex(0) setHistoryIndex(0)
@@ -765,7 +764,13 @@ export function NoteInput({
{/* Content area — scrolls internally when constrained by max-h */} {/* Content area — scrolls internally when constrained by max-h */}
<div className="px-5 pb-3 flex-1 min-h-0 overflow-y-auto"> <div className="px-5 pb-3 flex-1 min-h-0 overflow-y-auto">
{type === 'text' ? ( {type === 'richtext' ? (
<RichTextEditor
content={content}
onChange={setContent}
className="min-h-[120px]"
/>
) : type === 'text' || type === 'markdown' ? (
<> <>
{showMarkdownPreview && isMarkdown ? ( {showMarkdownPreview && isMarkdown ? (
<MarkdownContent <MarkdownContent
@@ -929,16 +934,9 @@ export function NoteInput({
</Button> </Button>
</TooltipTrigger><TooltipContent>{t('notes.remindMe')}</TooltipContent></Tooltip> </TooltipTrigger><TooltipContent>{t('notes.remindMe')}</TooltipContent></Tooltip>
{type === 'text' && ( <NoteTypeSelector value={type} onChange={(newType) => { setType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} />
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false) }}>
<FileText className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.markdown')}</TooltipContent></Tooltip>
)}
{type === 'text' && isMarkdown && ( {type === 'markdown' && (
<Tooltip><TooltipTrigger asChild> <Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" <Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}> onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}>

View File

@@ -0,0 +1,87 @@
'use client'
import { NoteType } from '@/lib/types'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Check, AlignLeft, FileCode2, PenLine, ListChecks, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
const TYPE_ICONS: Record<NoteType, React.ElementType> = {
text: AlignLeft,
markdown: FileCode2,
richtext: PenLine,
checklist: ListChecks,
}
const TYPE_I18N_KEYS: Record<NoteType, string> = {
text: 'notes.typeText',
markdown: 'notes.typeMarkdown',
richtext: 'notes.typeRichText',
checklist: 'notes.typeChecklist',
}
interface NoteTypeSelectorProps {
value: NoteType
onChange: (type: NoteType) => void
compact?: boolean
className?: string
}
export function NoteTypeSelector({ value, onChange, compact = false, className }: NoteTypeSelectorProps) {
const { t } = useLanguage()
const CurrentIcon = TYPE_ICONS[value]
const types: NoteType[] = ['text', 'markdown', 'richtext', 'checklist']
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size={compact ? 'icon' : 'sm'}
className={cn(
'gap-1.5 shrink-0',
compact ? 'h-8 w-8' : 'h-8 px-2 text-xs font-medium',
value !== 'text' && 'text-primary bg-primary/10',
className
)}
title={t('notes.noteType')}
>
<CurrentIcon className="h-4 w-4" />
{!compact && (
<>
<span>{t(TYPE_I18N_KEYS[value])}</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{types.map((type) => {
const Icon = TYPE_ICONS[type]
const isActive = type === value
return (
<DropdownMenuItem
key={type}
onClick={() => onChange(type)}
className={cn('gap-2 cursor-pointer', isActive && 'bg-accent')}
>
<div className="flex items-center gap-2 flex-1">
<Icon className="h-4 w-4" />
<span className="text-sm">{t(TYPE_I18N_KEYS[type])}</span>
</div>
{isActive && <Check className="h-3.5 w-3.5 text-primary" />}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export { TYPE_ICONS, TYPE_I18N_KEYS }

View File

@@ -0,0 +1,451 @@
'use client'
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Placeholder from '@tiptap/extension-placeholder'
import TiptapLink from '@tiptap/extension-link'
import Highlight from '@tiptap/extension-highlight'
import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import Superscript from '@tiptap/extension-superscript'
import Subscript from '@tiptap/extension-subscript'
import Typography from '@tiptap/extension-typography'
import type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon,
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
} from 'lucide-react'
import { cn } from '@/lib/utils'
export interface RichTextEditorHandle {
getEditor: () => Editor | null
}
interface RichTextEditorProps {
content?: string
onChange?: (content: string) => void
className?: string
placeholder?: string
}
type SlashItem = {
title: string
description: string
icon: typeof Bold
category?: string
isImage?: boolean
isAi?: boolean
aiOption?: 'clarify' | 'shorten' | 'improve'
command: (editor: Editor) => void
}
const slashCommands: SlashItem[] = [
{ title: 'Text', description: 'Just start writing with plain text', icon: Pilcrow, category: 'Basic blocks', command: (e) => e.chain().focus().setParagraph().run() },
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
{ title: 'Bullet List', description: 'Create a simple bullet list', icon: List, category: 'Basic blocks', command: (e) => e.chain().focus().toggleBulletList().run() },
{ title: 'Numbered List', description: 'Create a list with numbering', icon: ListOrdered, category: 'Basic blocks', command: (e) => e.chain().focus().toggleOrderedList().run() },
{ title: 'To-do List', description: 'Track tasks with checkboxes', icon: CheckSquare, category: 'Basic blocks', command: (e) => e.chain().focus().toggleTaskList().run() },
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', command: (e) => e.chain().focus().toggleBlockquote().run() },
{ title: 'Code Block', description: 'Capture a code snippet', icon: CodeXml, category: 'Basic blocks', command: (e) => e.chain().focus().toggleCodeBlock().run() },
{ title: 'Divider', description: 'Visually divide blocks', icon: Minus, category: 'Basic blocks', command: (e) => e.chain().focus().setHorizontalRule().run() },
{ title: 'Image', description: 'Embed an image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => {} },
{ title: 'Clarify', description: 'Make text clearer and easier to understand', icon: Lightbulb, category: 'AI', isAi: true, aiOption: 'clarify', command: () => {} },
{ title: 'Shorten', description: 'Make text more concise', icon: Scissors, category: 'AI', isAi: true, aiOption: 'shorten', command: () => {} },
{ title: 'Improve', description: 'Improve writing style and flow', icon: Wand2, category: 'AI', isAi: true, aiOption: 'improve', command: () => {} },
]
async function aiReformulate(text: string, option: 'clarify' | 'shorten' | 'improve'): Promise<string> {
const res = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, option }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'AI failed')
return data.reformulatedText || data.text || text
}
function useImageInsert() {
const [open, setOpen] = useState(false)
const editorRef = useRef<Editor | null>(null)
const requestInsert = useCallback((editor: Editor) => {
editorRef.current = editor
setOpen(true)
}, [])
const confirm = useCallback((url: string) => {
if (url.trim() && editorRef.current) {
editorRef.current.chain().focus().setImage({ src: url.trim() }).run()
}
setOpen(false)
editorRef.current = null
}, [])
const cancel = useCallback(() => {
setOpen(false)
editorRef.current = null
}, [])
return { open, requestInsert, confirm, cancel }
}
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, className, placeholder }, ref) {
const imageInsert = useImageInsert()
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: false, underline: false }),
Underline,
TiptapLink.configure({ openOnClick: false, autolink: true }),
Highlight.configure({ multicolor: false }),
Image.configure({ inline: false, allowBase64: true }),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TaskList,
TaskItem.configure({ nested: true }),
Superscript,
Subscript,
Typography,
Placeholder.configure({ placeholder: placeholder || "Type '/' for commands..." }),
],
content: content || '',
immediatelyRender: false,
editorProps: {
attributes: { class: 'notion-editor' },
},
onUpdate: ({ editor: e }) => {
onChange?.(e.getHTML())
},
})
useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor])
return (
<div className={cn('notion-editor-wrapper', className)}>
{editor && (
<BubbleMenu
editor={editor}
className="notion-bubble-menu"
shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => {
const { from, to } = state.selection
return from !== to && !e.isActive('codeBlock')
}}
>
<BubbleToolbar editor={editor} />
</BubbleMenu>
)}
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} />}
<EditorContent editor={editor} />
{imageInsert.open && (
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
)}
</div>
)
}
)
function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void; onCancel: () => void }) {
const [url, setUrl] = useState('')
const [preview, setPreview] = useState<string | null>(null)
const [error, setError] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { inputRef.current?.focus() }, [])
const handleConfirm = () => {
if (!url.trim()) return
if (!/^https?:\/\/.+/i.test(url.trim())) { setError('Please enter a valid URL'); return }
onConfirm(url.trim())
}
return (
<div className="notion-overlay" onClick={onCancel}>
<div className="notion-image-modal" onClick={(e) => e.stopPropagation()}>
<div className="text-sm font-medium mb-3">Insert image</div>
<input
ref={inputRef}
type="url"
value={url}
onChange={(e) => { setUrl(e.target.value); setError(''); setPreview(null) }}
onKeyDown={(e) => { if (e.key === 'Enter') handleConfirm(); if (e.key === 'Escape') onCancel() }}
placeholder="https://example.com/image.png"
className="notion-modal-input"
/>
{url.trim() && !preview && (
<button onClick={() => setPreview(url.trim())} className="text-xs text-muted-foreground hover:text-foreground transition-colors underline mt-2">
Preview
</button>
)}
{preview && (
<div className="mt-2 rounded-lg overflow-hidden border border-border max-h-40">
<img src={preview} alt="" className="max-h-40 object-contain w-full" onError={() => { setPreview(null); setError('Failed to load image') }} />
</div>
)}
{error && <div className="mt-1.5 text-xs text-destructive">{error}</div>}
<div className="flex justify-end gap-2 mt-4">
<button onClick={onCancel} className="notion-modal-btn">Cancel</button>
<button onClick={handleConfirm} className="notion-modal-btn notion-modal-btn-primary">Insert</button>
</div>
</div>
</div>
)
}
function BubbleToolbar({ editor }: { editor: Editor | null }) {
const [, setTick] = useState(0)
const [aiOpen, setAiOpen] = useState(false)
const [aiLoading, setAiLoading] = useState(false)
const [linkOpen, setLinkOpen] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const linkInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!editor) return
const h = () => setTick(t => t + 1)
editor.on('transaction', h)
editor.on('selectionUpdate', h)
return () => { editor.off('transaction', h); editor.off('selectionUpdate', h) }
}, [editor])
useEffect(() => {
if (linkOpen && linkInputRef.current) linkInputRef.current.focus()
}, [linkOpen])
if (!editor) return null
const marks = [
{ icon: Bold, active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
{ icon: Italic, active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
{ icon: UnderlineIcon, active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
{ icon: Strikethrough, active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() },
{ icon: Code, active: editor.isActive('code'), action: () => editor.chain().focus().toggleCode().run() },
{ icon: Highlighter, active: editor.isActive('highlight'), action: () => editor.chain().focus().toggleHighlight().run() },
]
const handleAI = async (option: 'clarify' | 'shorten' | 'improve') => {
const { from, to } = editor.state.selection
const text = editor.state.doc.textBetween(from, to, ' ')
if (!text || text.split(/\s+/).length < 5) return
setAiLoading(true)
setAiOpen(false)
try {
const result = await aiReformulate(text, option)
editor.chain().focus().insertContentAt({ from, to }, result).run()
} catch (err) {
console.error('AI error:', err)
} finally {
setAiLoading(false)
}
}
const openLinkEditor = () => {
const existing = editor.getAttributes('link').href || ''
setLinkUrl(existing)
setLinkOpen(true)
setAiOpen(false)
}
const applyLink = () => {
if (linkUrl.trim()) {
editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl.trim() }).run()
} else {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
}
setLinkOpen(false)
}
if (linkOpen) {
return (
<div className="flex items-center gap-1.5 px-2 py-1.5">
<LinkIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<input
ref={linkInputRef}
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyLink() }; if (e.key === 'Escape') { setLinkOpen(false); editor.chain().focus().run() } }}
placeholder="Paste or type a link..."
className="notion-inline-input"
/>
<button onClick={applyLink} className="notion-bubble-btn notion-bubble-btn-active"><Check className="w-3.5 h-3.5" /></button>
{editor.isActive('link') && (
<button onClick={() => { editor.chain().focus().extendMarkRange('link').unsetLink().run(); setLinkOpen(false) }} className="notion-bubble-btn">
<ExternalLink className="w-3.5 h-3.5" />
</button>
)}
<button onClick={() => { setLinkOpen(false); editor.chain().focus().run() }} className="notion-bubble-btn"><X className="w-3.5 h-3.5" /></button>
</div>
)
}
return (
<div className="flex items-center gap-0.5 px-1 py-0.5 relative">
{marks.map((m, i) => (
<button key={i} onClick={m.action} className={cn('notion-bubble-btn', m.active && 'notion-bubble-btn-active')}>
<m.icon className="w-3.5 h-3.5" />
</button>
))}
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={openLinkEditor} className={cn('notion-bubble-btn', editor.isActive('link') && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
<button onClick={() => setAiOpen(!aiOpen)} className={cn('notion-bubble-btn', aiLoading && 'animate-pulse')}><Sparkles className="w-3.5 h-3.5" /></button>
{aiOpen && (
<div className="notion-ai-submenu">
<button className="notion-ai-subitem" onClick={() => handleAI('clarify')}><Lightbulb className="w-3.5 h-3.5 text-amber-500" /><span>Clarify</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-blue-500" /><span>Shorten</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('improve')}><Wand2 className="w-3.5 h-3.5 text-purple-500" /><span>Improve</span></button>
</div>
)}
</div>
)
}
function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertImage: (editor: Editor) => void }) {
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [coords, setCoords] = useState({ top: 0, left: 0 })
const [aiLoading, setAiLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const closeMenu = useCallback(() => { setIsOpen(false); setQuery(''); setSelectedIndex(0) }, [])
const deleteSlashText = useCallback(() => {
const { from, to } = editor.state.selection
const textBefore = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n')
const slashIdx = textBefore.lastIndexOf('/')
if (slashIdx >= 0) {
const deleteFrom = from - (textBefore.length - slashIdx)
editor.chain().focus().deleteRange({ from: deleteFrom, to }).run()
}
}, [editor])
const handleSelect = useCallback(async (item: SlashItem) => {
if (item.isImage) {
deleteSlashText()
closeMenu()
onInsertImage(editor)
} else if (item.isAi && item.aiOption) {
deleteSlashText()
closeMenu()
setAiLoading(true)
try {
const allText = editor.state.doc.textContent
if (!allText || allText.split(/\s+/).length < 5) return
const result = await aiReformulate(allText, item.aiOption)
editor.chain().focus().setContent(result).run()
} catch (err) {
console.error('AI slash error:', err)
} finally {
setAiLoading(false)
}
} else {
deleteSlashText()
item.command(editor)
closeMenu()
}
}, [editor, closeMenu, deleteSlashText, onInsertImage])
const filtered = slashCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()))
const categories = filtered.reduce((acc, item) => {
const cat = item.category || 'Basic blocks'
if (!acc[cat]) acc[cat] = []
acc[cat].push(item)
return acc
}, {} as Record<string, SlashItem[]>)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length); return }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return }
if (e.key === 'Enter') { e.preventDefault(); if (filtered[selectedIndex]) handleSelect(filtered[selectedIndex]); return }
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return }
}
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu])
useEffect(() => {
if (!isOpen) return
const updatePosition = () => {
const { from } = editor.state.selection
const c = editor.view.coordsAtPos(from)
setCoords({ top: c.bottom + 8, left: c.left })
}
updatePosition()
}, [isOpen, editor, query])
useEffect(() => {
const handleClick = () => { if (isOpen) closeMenu() }
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}, [isOpen, closeMenu])
useEffect(() => {
const handler = () => {
const { from, to, empty } = editor.state.selection
if (!empty) { if (isOpen) closeMenu(); return }
const text = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n')
const m = text.match(/\/([^\s/]*)$/)
if (m) { setQuery(m[1]); setSelectedIndex(0); if (!isOpen) setIsOpen(true) }
else if (isOpen) closeMenu()
}
editor.on('update', handler)
editor.on('selectionUpdate', handler)
return () => { editor.off('update', handler); editor.off('selectionUpdate', handler) }
}, [editor, isOpen, closeMenu])
if (!isOpen || filtered.length === 0) return null
let flatIndex = -1
return (
<div ref={menuRef} className="notion-slash-menu" style={{ top: coords.top, left: coords.left }} onClick={(e) => e.stopPropagation()}>
{aiLoading && (
<div className="notion-slash-loading">
<Sparkles className="w-3.5 h-3.5 animate-spin" />
<span>AI is thinking...</span>
</div>
)}
{!aiLoading && Object.entries(categories).map(([cat, items]) => (
<div key={cat} className="notion-slash-section">
<div className="notion-slash-label">{cat}</div>
{items.map((item) => {
flatIndex++
const idx = flatIndex
return (
<button
key={item.title}
className={cn('notion-slash-item', idx === selectedIndex && 'notion-slash-item-selected')}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
<item.icon className="w-3 h-3" />
</div>
<span className="notion-slash-title">{item.title}</span>
<span className="notion-slash-desc">{item.description}</span>
</button>
)
})}
</div>
))}
</div>
)
}

View File

@@ -135,6 +135,16 @@ export interface Translations {
viewTabsTooltip: string viewTabsTooltip: string
viewModeGroup: string viewModeGroup: string
reorderTabs: string reorderTabs: string
noteType: string
typeText: string
typeMarkdown: string
typeRichText: string
typeChecklist: string
richTextPlaceholder: string
switchTypeTitle: string
switchTypeWarning: string
switchTypeContentPreserved: string
switchType: string
} }
pagination: { pagination: {
previous: string previous: string

View File

@@ -44,6 +44,15 @@ export interface LinkMetadata {
siteName?: string; siteName?: string;
} }
export type NoteType = 'text' | 'markdown' | 'richtext' | 'checklist'
export const NOTE_TYPE_CONFIG: Record<NoteType, { icon: string; label: string }> = {
text: { icon: 'AlignLeft', label: 'Text' },
markdown: { icon: 'FileCode2', label: 'Markdown' },
richtext: { icon: 'PenLine', label: 'Rich Text' },
checklist: { icon: 'ListChecks', label: 'Checklist' },
}
export interface Note { export interface Note {
id: string; id: string;
title: string | null; title: string | null;
@@ -52,9 +61,9 @@ export interface Note {
isPinned: boolean; isPinned: boolean;
isArchived: boolean; isArchived: boolean;
trashedAt?: Date | null; trashedAt?: Date | null;
type: 'text' | 'checklist'; type: NoteType;
checkItems: CheckItem[] | null; checkItems: CheckItem[] | null;
labels: string[] | null; // DEPRECATED: Array of label names stored as JSON string labels: string[] | null;
images: string[] | null; images: string[] | null;
links: LinkMetadata[] | null; links: LinkMetadata[] | null;
reminder: Date | null; reminder: Date | null;
@@ -70,13 +79,11 @@ export interface Note {
contentUpdatedAt: Date; contentUpdatedAt: Date;
sharedWith?: string[]; sharedWith?: string[];
userId?: string | null; userId?: string | null;
// Notebook relation (optional - null = "General Notes" / Inbox)
notebookId?: string | null; notebookId?: string | null;
notebook?: Notebook | null; notebook?: Notebook | null;
autoGenerated?: boolean | null; autoGenerated?: boolean | null;
aiProvider?: string | null; aiProvider?: string | null;
historyEnabled?: boolean; historyEnabled?: boolean;
// Search result metadata (optional)
matchType?: 'exact' | 'related' | null; matchType?: 'exact' | 'related' | null;
searchScore?: number | null; searchScore?: number | null;
} }
@@ -92,8 +99,7 @@ export interface NoteHistoryEntry {
color: string; color: string;
isPinned: boolean; isPinned: boolean;
isArchived: boolean; isArchived: boolean;
type: 'text' | 'checklist'; type: NoteType; checkItems: CheckItem[] | null;
checkItems: CheckItem[] | null;
labels: string[] | null; labels: string[] | null;
images: string[] | null; images: string[] | null;
links: LinkMetadata[] | null; links: LinkMetadata[] | null;

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { LABEL_COLORS, LabelColorName, QueryType, Note } from "./types" import { LABEL_COLORS, LabelColorName, QueryType, Note, NoteType } from "./types"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -50,8 +50,14 @@ export function asArray<T = unknown>(val: unknown, fallback: T[] = []): T[] {
* Guarantees array fields are always real arrays or null. * Guarantees array fields are always real arrays or null.
*/ */
export function parseNote(dbNote: any): Note { export function parseNote(dbNote: any): Note {
let noteType: NoteType = dbNote.type || 'text'
if (noteType === 'text' && dbNote.isMarkdown) {
noteType = 'markdown'
}
return { return {
...dbNote, ...dbNote,
type: noteType,
isMarkdown: noteType === 'markdown',
checkItems: asArray(dbNote.checkItems, null as any) ?? null, checkItems: asArray(dbNote.checkItems, null as any) ?? null,
labels: asArray(dbNote.labels) || null, labels: asArray(dbNote.labels) || null,
images: asArray(dbNote.images) || null, images: asArray(dbNote.images) || null,

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "نوع الملاحظة",
"typeText": "نص",
"typeMarkdown": "ماركداون",
"typeRichText": "نص منسق",
"typeChecklist": "قائمة مراجعة",
"richTextPlaceholder": "اكتب ملاحظة...",
"switchTypeTitle": "تغيير نوع الملاحظة؟",
"switchTypeWarning": "قد يفقد بعض التنسيق عند التحويل إلى {type}.",
"switchTypeContentPreserved": "سيتم الحفاظ على المحتوى كنص عادي.",
"switchType": "تحويل إلى {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "Notiztyp",
"typeText": "Text",
"typeMarkdown": "Markdown",
"typeRichText": "Rich Text",
"typeChecklist": "Checkliste",
"richTextPlaceholder": "Notiz erstellen...",
"switchTypeTitle": "Notiztyp ändern?",
"switchTypeWarning": "Formatierung kann beim Wechsel zu {type} verloren gehen.",
"switchTypeContentPreserved": "Dein Inhalt wird als Klartext erhalten.",
"switchType": "Wechseln zu {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "Note type",
"typeText": "Text",
"typeMarkdown": "Markdown",
"typeRichText": "Rich Text",
"typeChecklist": "Checklist",
"richTextPlaceholder": "Take a note...",
"switchTypeTitle": "Switch note type?",
"switchTypeWarning": "Some formatting may be lost when switching to {type}.",
"switchTypeContentPreserved": "Your content will be preserved as plain text.",
"switchType": "Switch to {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "Tipo de nota",
"typeText": "Texto",
"typeMarkdown": "Markdown",
"typeRichText": "Texto enriquecido",
"typeChecklist": "Lista de tareas",
"richTextPlaceholder": "Escribe una nota...",
"switchTypeTitle": "¿Cambiar tipo de nota?",
"switchTypeWarning": "Se puede perder formato al cambiar a {type}.",
"switchTypeContentPreserved": "Tu contenido se preservará como texto plano.",
"switchType": "Cambiar a {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "نوع یادداشت",
"typeText": "متن",
"typeMarkdown": "مارکداون",
"typeRichText": "متن غنی",
"typeChecklist": "چک‌لیست",
"richTextPlaceholder": "یادداشتی بنویسید...",
"switchTypeTitle": "نوع یادداشت تغییر کند؟",
"switchTypeWarning": "هنگام تغییر به {type} ممکن است برخی قالب‌بندی‌ها از بین بروند.",
"switchTypeContentPreserved": "محتوای شما به عنوان متن ساده حفظ می‌شود.",
"switchType": "تغییر به {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Supprimer la note", "confirmDeleteTitle": "Supprimer la note",
"leftShare": "Partage retiré", "leftShare": "Partage retiré",
"dismissed": "Note retirée des récentes", "dismissed": "Note retirée des récentes",
"generalNotes": "Notes générales" "generalNotes": "Notes générales",
"noteType": "Type de note",
"typeText": "Texte",
"typeMarkdown": "Markdown",
"typeRichText": "Texte riche",
"typeChecklist": "Liste de tâches",
"richTextPlaceholder": "Prenez une note...",
"switchTypeTitle": "Changer le type de note ?",
"switchTypeWarning": "Certaines mises en forme peuvent être perdues lors du passage en {type}.",
"switchTypeContentPreserved": "Votre contenu sera préservé en texte brut.",
"switchType": "Passer en {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "नोट प्रकार",
"typeText": "पाठ",
"typeMarkdown": "मार्कडाउन",
"typeRichText": "रिच टेक्स्ट",
"typeChecklist": "चेकलिस्ट",
"richTextPlaceholder": "एक नोट लिखें...",
"switchTypeTitle": "नोट प्रकार बदलें?",
"switchTypeWarning": "{type} में बदलने पर कुछ फ़ॉर्मेटिंग खो सकती है।",
"switchTypeContentPreserved": "आपकी सामग्री सादे पाठ के रूप में संरक्षित रहेगी।",
"switchType": "{type} में बदलें"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "Tipo di nota",
"typeText": "Testo",
"typeMarkdown": "Markdown",
"typeRichText": "Testo formattato",
"typeChecklist": "Lista di controllo",
"richTextPlaceholder": "Prendi una nota...",
"switchTypeTitle": "Cambiare tipo di nota?",
"switchTypeWarning": "Alcune formattazioni potrebbero andare perse con {type}.",
"switchTypeContentPreserved": "Il contenuto sarà preservato come testo semplice.",
"switchType": "Passa a {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "ノートタイプ",
"typeText": "テキスト",
"typeMarkdown": "Markdown",
"typeRichText": "リッチテキスト",
"typeChecklist": "チェックリスト",
"richTextPlaceholder": "メモを取る...",
"switchTypeTitle": "ノートタイプを切り替えますか?",
"switchTypeWarning": "{type} に切り替えると書式が失われる場合があります。",
"switchTypeContentPreserved": "内容はプレーンテキストとして保持されます。",
"switchType": "{type} に切り替え"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "노트 유형",
"typeText": "텍스트",
"typeMarkdown": "Markdown",
"typeRichText": "리치 텍스트",
"typeChecklist": "체크리스트",
"richTextPlaceholder": "노트를 작성하세요...",
"switchTypeTitle": "노트 유형을 변경하시겠습니까?",
"switchTypeWarning": "{type}(으)로 전환하면 일부 서식이 손실될 수 있습니다.",
"switchTypeContentPreserved": "콘텐츠는 일반 텍스트로 보존됩니다.",
"switchType": "{type}(으)로 전환"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "Notitietype",
"typeText": "Tekst",
"typeMarkdown": "Markdown",
"typeRichText": "Rich Text",
"typeChecklist": "Checklist",
"richTextPlaceholder": "Maak een notitie...",
"switchTypeTitle": "Notitietype wijzigen?",
"switchTypeWarning": "Opmaak kan verloren gaan bij wijziging naar {type}.",
"switchTypeContentPreserved": "Je inhoud wordt bewaard als platte tekst.",
"switchType": "Wijzigen naar {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "Typ notatki",
"typeText": "Tekst",
"typeMarkdown": "Markdown",
"typeRichText": "Tekst sformatowany",
"typeChecklist": "Lista kontrolna",
"richTextPlaceholder": "Zrób notatkę...",
"switchTypeTitle": "Zmienić typ notatki?",
"switchTypeWarning": "Niektóre formatowanie może zostać utracone przy zmianie na {type}.",
"switchTypeContentPreserved": "Twoja treść zostanie zachowana jako zwykły tekst.",
"switchType": "Zmień na {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "Tipo de nota",
"typeText": "Texto",
"typeMarkdown": "Markdown",
"typeRichText": "Texto rico",
"typeChecklist": "Lista de verificação",
"richTextPlaceholder": "Tome uma nota...",
"switchTypeTitle": "Alterar tipo de nota?",
"switchTypeWarning": "Alguma formatação pode ser perdida ao mudar para {type}.",
"switchTypeContentPreserved": "Seu conteúdo será preservado como texto simples.",
"switchType": "Mudar para {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "Тип заметки",
"typeText": "Текст",
"typeMarkdown": "Markdown",
"typeRichText": "Форматированный текст",
"typeChecklist": "Чек-лист",
"richTextPlaceholder": "Сделайте заметку...",
"switchTypeTitle": "Сменить тип заметки?",
"switchTypeWarning": "Некоторое форматирование может быть потеряно при смене на {type}.",
"switchTypeContentPreserved": "Ваш контент будет сохранён как простой текст.",
"switchType": "Переключить на {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note", "confirmDeleteTitle": "Delete note",
"leftShare": "Share removed", "leftShare": "Share removed",
"dismissed": "Note dismissed from recent", "dismissed": "Note dismissed from recent",
"generalNotes": "General Notes" "generalNotes": "General Notes",
"noteType": "笔记类型",
"typeText": "文本",
"typeMarkdown": "Markdown",
"typeRichText": "富文本",
"typeChecklist": "待办清单",
"richTextPlaceholder": "记笔记...",
"switchTypeTitle": "切换笔记类型?",
"switchTypeWarning": "切换到 {type} 可能会丢失部分格式。",
"switchTypeContentPreserved": "您的内容将保留为纯文本。",
"switchType": "切换到 {type}"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@excalidraw/excalidraw": "^0.18.0", "@excalidraw/excalidraw": "^0.18.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -44,6 +45,21 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tiptap/extension-color": "^3.22.5",
"@tiptap/extension-highlight": "^3.22.5",
"@tiptap/extension-image": "^3.22.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-subscript": "^3.22.5",
"@tiptap/extension-superscript": "^3.22.5",
"@tiptap/extension-task-item": "^3.22.5",
"@tiptap/extension-task-list": "^3.22.5",
"@tiptap/extension-text-align": "^3.22.5",
"@tiptap/extension-text-style": "^3.22.5",
"@tiptap/extension-typography": "^3.22.5",
"@tiptap/pm": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@tiptap/suggestion": "^3.22.5",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"ai": "^6.0.23", "ai": "^6.0.23",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
@@ -61,6 +77,7 @@
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"nodemailer": "^8.0.4", "nodemailer": "^8.0.4",
"novel": "^1.0.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.3", "react": "19.2.3",
@@ -71,13 +88,13 @@
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"resend": "^6.12.0", "resend": "^6.12.0",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"sonner": "^2.0.7",
"sharp": "^0.34.0", "sharp": "^0.34.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tinyld": "^1.3.4", "tinyld": "^1.3.4",
"tippy.js": "^6.3.7",
"vazirmatn": "^33.0.3", "vazirmatn": "^33.0.3",
"zod": "^4.3.5", "zod": "^4.3.5"
"@prisma/client": "^5.22.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",

View File

@@ -119,7 +119,7 @@ model Note {
color String @default("default") color String @default("default")
isPinned Boolean @default(false) isPinned Boolean @default(false)
isArchived Boolean @default(false) isArchived Boolean @default(false)
type String @default("text") type String @default("richtext")
dismissedFromRecent Boolean @default(false) dismissedFromRecent Boolean @default(false)
checkItems String? checkItems String?
labels String? labels String?
@@ -129,7 +129,7 @@ model Note {
isReminderDone Boolean @default(false) isReminderDone Boolean @default(false)
reminderRecurrence String? reminderRecurrence String?
reminderLocation String? reminderLocation String?
isMarkdown Boolean @default(false) isMarkdown Boolean @default(false) // DEPRECATED: use type = 'markdown' instead
size String @default("small") size String @default("small")
sharedWith String? sharedWith String?
userId String? userId String?