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
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m33s
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
87
memento-note/components/note-type-selector.tsx
Normal file
87
memento-note/components/note-type-selector.tsx
Normal 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 }
|
||||
451
memento-note/components/rich-text-editor.tsx
Normal file
451
memento-note/components/rich-text-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
@@ -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": "←",
|
||||
|
||||
1849
memento-note/package-lock.json
generated
1849
memento-note/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user