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
- NEXT_TELEMETRY_DISABLED=1
volumes:
- uploads-data:/app/public/uploads
- uploads-data:/app/data/uploads
- backup-data:/app/data/backups
depends_on:
postgres:

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
'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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -20,7 +20,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} 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 { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
@@ -83,6 +83,13 @@ function getDateLocale(language: string): Locale {
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
const ICON_MAP: Record<string, LucideIcon> = {
'folder': Folder,
@@ -536,8 +543,12 @@ export const NoteCard = memo(function NoteCard({
{/* Title */}
{optimisticNote.title && (
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight">
{optimisticNote.title}
<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">
{(() => {
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>
)}
@@ -608,18 +619,20 @@ export const NoteCard = memo(function NoteCard({
)}
{/* 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">
<MarkdownContent
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"
/>
</div>
) : (
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
)}
{/* Labels - using shared LabelBadge component */}

View File

@@ -1,7 +1,7 @@
'use client'
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 {
Dialog,
DialogContent,
@@ -23,7 +23,9 @@ import {
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} 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 { fetchLinkMetadata } from '@/app/actions/scrape'
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 [isSaving, setIsSaving] = useState(false)
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false)
const [noteType, setNoteType] = useState<NoteType>(note.type)
const isMarkdown = noteType === 'markdown'
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
const fileInputRef = useRef<HTMLInputElement>(null)
// 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
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '',
content: noteType !== 'checklist' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text' && autoLabelingEnabled
enabled: noteType !== 'checklist' && autoLabelingEnabled
})
// Reminder state
@@ -466,7 +469,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
// Set the transformed markdown content and enable markdown mode
setContent(data.transformedText)
setIsMarkdown(true)
setNoteType('markdown')
setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess'))
@@ -523,14 +526,15 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
try {
await updateNote(note.id, {
title: title.trim() || null,
content: note.type === 'text' ? content : '',
checkItems: note.type === 'checklist' ? checkItems : null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown,
isMarkdown: noteType === 'markdown',
type: noteType,
size,
})
@@ -588,12 +592,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
content: content,
color: color,
type: note.type,
checkItems: checkItems,
labels: labels,
images: images,
links: links,
isMarkdown: isMarkdown,
isMarkdown: noteType === 'markdown',
type: noteType,
size: size,
})
toast.success(t('notes.copySuccess'))
@@ -697,7 +701,13 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
)}
{/* 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">
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
@@ -847,18 +857,9 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
<LinkIcon className="h-4 w-4" />
</Button>
{/* Markdown toggle */}
{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>
)}
<NoteTypeSelector value={noteType} onChange={(newType) => { setNoteType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} />
{/* Markdown preview toggle */}
{isMarkdown && (
{noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
@@ -867,7 +868,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
)}
{/* AI Copilot */}
{note.type === 'text' && aiAssistantEnabled && (
{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="Assistant IA">

View File

@@ -1,7 +1,7 @@
'use client'
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 {
DropdownMenu,
@@ -14,6 +14,8 @@ 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 } from '@/lib/utils'
import {
@@ -35,7 +37,6 @@ import {
X,
Plus,
CheckSquare,
FileText,
Eye,
Sparkles,
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 */
async function saveInline(
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}`, {
method: 'PUT',
@@ -127,7 +128,8 @@ export function NoteInlineEditor({
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content || '')
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(
defaultPreviewMode && (note.isMarkdown || false)
)
@@ -157,31 +159,32 @@ export function NoteInlineEditor({
const fileInputRef = useRef<HTMLInputElement>(null)
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)
// Title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
content: note.type === 'text' ? content : '',
enabled: note.type === 'text' && !title
content: noteType !== 'checklist' ? content : '',
enabled: noteType !== 'checklist' && !title
})
// Keep pending ref in sync for unmount save
useEffect(() => {
pendingRef.current = { title, content, checkItems, isMarkdown }
}, [title, content, checkItems, isMarkdown])
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 } = pendingRef.current
const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
checkItems: nt === 'checklist' ? ci : undefined,
type: nt,
isMarkdown: nt === 'markdown',
}).catch(() => {})
}
@@ -189,8 +192,8 @@ export function NoteInlineEditor({
setTitle(note.title || '')
setContent(note.content || '')
setCheckItems(note.checkItems || [])
setIsMarkdown(note.isMarkdown || false)
setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false))
setNoteType(note.type)
setShowMarkdownPreview(defaultPreviewMode && (note.type === 'markdown'))
setIsDirty(false)
setDismissedTitleSuggestions(false)
clearTimeout(saveTimerRef.current)
@@ -202,14 +205,15 @@ export function NoteInlineEditor({
setIsDirty(true)
clearTimeout(saveTimerRef.current)
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)
try {
await saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
checkItems: nt === 'checklist' ? ci : undefined,
type: nt,
isMarkdown: nt === 'markdown',
})
setIsDirty(false)
} catch {
@@ -218,18 +222,19 @@ export function NoteInlineEditor({
setIsSaving(false)
}
}, 1500)
}, [note.type])
}, [noteType])
// Flush on unmount
useEffect(() => {
return () => {
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, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
checkItems: nt === 'checklist' ? ci : undefined,
type: nt,
isMarkdown: nt === 'markdown',
}).catch(() => {})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -237,9 +242,9 @@ export function NoteInlineEditor({
// ── Auto-tagging ──────────────────────────────────────────────────────────
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '',
content: noteType !== 'checklist' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text' && autoLabelingEnabled,
enabled: noteType !== 'checklist' && autoLabelingEnabled,
})
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(
@@ -288,7 +293,7 @@ export function NoteInlineEditor({
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [],
color: fusionNotes[0].color,
type: 'text',
type: 'markdown',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
@@ -488,20 +493,17 @@ export function NoteInlineEditor({
<LinkIcon className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => {
const nextIsMarkdown = !isMarkdown
setIsMarkdown(nextIsMarkdown)
onChange?.(note.id, { isMarkdown: nextIsMarkdown })
if (!nextIsMarkdown) setShowMarkdownPreview(false)
scheduleSave()
<NoteTypeSelector
value={noteType}
onChange={(newType) => {
setNoteType(newType)
if (newType !== 'markdown') setShowMarkdownPreview(false)
onChange?.(note.id, { isMarkdown: newType === 'markdown' })
}}
title="Markdown">
<FileText className="h-4 w-4" />
</Button>
compact
/>
{isMarkdown && (
{noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}>
@@ -509,7 +511,7 @@ export function NoteInlineEditor({
</Button>
)}
{note.type === 'text' && aiAssistantEnabled && (
{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)}
@@ -743,7 +745,13 @@ export function NoteInlineEditor({
{/* ── Text / Checklist content ───────────────────────────────────── */}
<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">
{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">

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
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[]) {
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.
*/
export function parseNote(dbNote: any): Note {
let noteType: NoteType = dbNote.type || 'text'
if (noteType === 'text' && dbNote.isMarkdown) {
noteType = 'markdown'
}
return {
...dbNote,
type: noteType,
isMarkdown: noteType === 'markdown',
checkItems: asArray(dbNote.checkItems, null as any) ?? null,
labels: asArray(dbNote.labels) || null,
images: asArray(dbNote.images) || null,

View File

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

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

View File

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

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Supprimer la note",
"leftShare": "Partage retiré",
"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": {
"previous": "←",

View File

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

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

View File

@@ -201,7 +201,17 @@
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"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": {
"previous": "←",

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@excalidraw/excalidraw": "^0.18.0",
"@mozilla/readability": "^0.6.0",
"@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -44,6 +45,21 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@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",
"ai": "^6.0.23",
"autoprefixer": "^10.4.23",
@@ -61,6 +77,7 @@
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^8.0.4",
"novel": "^1.0.2",
"postcss": "^8.5.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
@@ -71,13 +88,13 @@
"remark-math": "^6.0.0",
"resend": "^6.12.0",
"rss-parser": "^3.13.0",
"sonner": "^2.0.7",
"sharp": "^0.34.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tinyld": "^1.3.4",
"tippy.js": "^6.3.7",
"vazirmatn": "^33.0.3",
"zod": "^4.3.5",
"@prisma/client": "^5.22.0"
"zod": "^4.3.5"
},
"devDependencies": {
"@playwright/test": "^1.57.0",

View File

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