feat: 8 AI providers, rich text editor, agent notifications, UI contrast & font settings
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s

- Add DeepSeek, OpenRouter, Mistral, Z.AI, LM Studio as AI providers
  with editable model names via Combobox in admin settings
- Fix OpenRouter broken by normalizeProvider bug in config.ts
- Convert agent-created notes from Markdown to HTML (TipTap rich text)
- Add Notification model + in-app notifications for agent results
- Agent notification click opens the created note directly
- Add note count display on notebook and inbox headers
- Fix checklist toggle in card view (persist state via localCheckItems)
- Add checklist creation option in tabs/list view (dropdown on + button)
- Fix image description ENOENT error with HTTP fallback
- Improve UI contrast across all themes (input, border, checkbox visibility)
- Add font family setting (Inter vs System Default) in Appearance settings
- Fix CSS font-sans variable conflict (removed dead Geist references)
- Update README with new features and 8 providers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-05-01 16:14:07 +02:00
parent 1345403a31
commit dbd49d6fcb
64 changed files with 4124 additions and 1392 deletions

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { getAllNotes, searchNotes, enableNoteHistory } from '@/app/actions/notes'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
import { NotesViewToggle } from '@/components/notes-view-toggle'
@@ -165,6 +165,33 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
useReminderCheck(notes)
// Handle ?openNote=ID — open a note from a notification click
useEffect(() => {
const openNoteId = searchParams.get('openNote')
if (!openNoteId) return
const openNote = async () => {
// Try to find the note in current state first
const existing = notes.find(n => n.id === openNoteId)
if (existing) {
setEditingNote({ note: existing, readOnly: false })
} else {
// Fetch from server
const fetched = await getNoteById(openNoteId)
if (fetched) {
setEditingNote({ note: fetched, readOnly: false })
}
}
// Clean URL — remove openNote param
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false })
}
openNote()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
// Listen for global label deletion and immediately update local state
useEffect(() => {
const handler = (e: Event) => {
@@ -321,6 +348,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
})()}
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
<span className="text-sm font-medium text-muted-foreground mt-2">({notes.length})</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
@@ -351,6 +379,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
<FileText className="w-8 h-8 text-primary" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
<span className="text-sm font-medium text-muted-foreground mt-2">{notes.length} {notes.length === 1 ? (t('notes.note') || 'note') : (t('notes.notes') || 'notes')}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />

View File

@@ -218,6 +218,13 @@ export const NoteCard = memo(function NoteCard({
// Local color state so color persists after transition ends
const [localColor, setLocalColor] = useState(note.color)
// Local checkItems state so checklist toggles persist after transition ends
const [localCheckItems, setLocalCheckItems] = useState(note.checkItems)
// Sync local state when parent data refreshes
useEffect(() => {
setLocalCheckItems(note.checkItems)
}, [note.checkItems])
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
@@ -373,14 +380,15 @@ export const NoteCard = memo(function NoteCard({
}
const handleCheckItem = async (checkItemId: string) => {
if (note.type === 'checklist' && Array.isArray(note.checkItems)) {
const updatedItems = note.checkItems.map(item =>
if (note.type === 'checklist') {
const currentItems = localCheckItems || note.checkItems || []
const updatedItems = currentItems.map(item =>
item.id === checkItemId ? { ...item, checked: !item.checked } : item
)
setLocalCheckItems(updatedItems) // instant visual update, survives transition
startTransition(async () => {
addOptimisticNote({ checkItems: updatedItems })
await updateNote(note.id, { checkItems: updatedItems })
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
})
}
}
@@ -621,7 +629,7 @@ export const NoteCard = memo(function NoteCard({
{/* Content */}
{optimisticNote.type === 'checklist' ? (
<NoteChecklist
items={optimisticNote.checkItems || []}
items={localCheckItems || optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
) : optimisticNote.type === 'richtext' ? (

View File

@@ -497,8 +497,14 @@ export function NoteInlineEditor({
value={noteType}
onChange={(newType) => {
setNoteType(newType)
if (newType !== 'markdown') setShowMarkdownPreview(false)
onChange?.(note.id, { isMarkdown: newType === 'markdown' })
if (newType === 'markdown') setShowMarkdownPreview(true)
else setShowMarkdownPreview(false)
// Persist both type and isMarkdown immediately
saveInline(note.id, {
type: newType,
isMarkdown: newType === 'markdown',
}).catch(() => {})
onChange?.(note.id, { type: newType, isMarkdown: newType === 'markdown' })
}}
compact
/>

View File

@@ -19,7 +19,7 @@ import {
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Note, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types'
import { cn } from '@/lib/utils'
import { NoteInlineEditor } from '@/components/note-inline-editor'
import { useLanguage } from '@/lib/i18n'
@@ -65,6 +65,7 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
@@ -737,11 +738,13 @@ export function NotesTabsView({
const selected = items.find((n) => n.id === selectedId) ?? null
const colorKey = selected ? getColorKey(selected) : 'default'
const handleCreateNote = () => {
const handleCreateNote = (noteType: NoteType = 'richtext') => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
content: noteType === 'checklist' ? '' : '',
type: noteType,
checkItems: noteType === 'checklist' ? [{ id: Date.now().toString(), text: '', checked: false }] : undefined,
title: undefined,
notebookId: currentNotebookId || undefined,
skipRevalidation: true
@@ -843,19 +846,32 @@ export function NotesTabsView({
</DropdownMenuContent>
</DropdownMenu>
{/* New note button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
onClick={handleCreateNote}
disabled={isCreating}
title={t('notes.newNote')}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-4 w-4" />}
</Button>
{/* New note button — dropdown to choose type */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
disabled={isCreating}
title={t('notes.newNote')}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem onClick={() => handleCreateNote('richtext')}>
<FileText className="h-4 w-4 mr-2" />
{t('notes.newNote') || 'Note'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCreateNote('checklist')}>
<ListChecks className="h-4 w-4 mr-2" />
{t('notes.newChecklist') || 'Checklist'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>

View File

@@ -1,15 +1,17 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2 } from 'lucide-react'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2 } from 'lucide-react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications'
import { toast } from 'sonner'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
import { cn } from '@/lib/utils'
@@ -47,19 +49,23 @@ interface ReminderNote {
export function NotificationPanel() {
const { triggerRefresh } = useNoteRefreshOptional()
const { t } = useLanguage()
const router = useRouter()
const [requests, setRequests] = useState<ShareRequest[]>([])
const [reminders, setReminders] = useState<ReminderNote[]>([])
const [appNotifications, setAppNotifications] = useState<AppNotification[]>([])
const [isLoading, setIsLoading] = useState(false)
const [open, setOpen] = useState(false)
const loadData = useCallback(async () => {
try {
const [shareData, reminderData] = await Promise.all([
const [shareData, reminderData, notifData] = await Promise.all([
getPendingShareRequests(),
getNotesWithReminders(),
getUnreadNotifications(),
])
setRequests(shareData as any)
setReminders((reminderData as any) || [])
setAppNotifications(notifData || [])
} catch (error: any) {
console.error('Failed to load notifications:', error)
}
@@ -81,7 +87,7 @@ export function NotificationPanel() {
const overdueReminders = activeReminders.filter(r => new Date(r.reminder!) < now)
const upcomingReminders = activeReminders.filter(r => new Date(r.reminder!) >= now)
const pendingCount = requests.length + overdueReminders.length
const pendingCount = requests.length + overdueReminders.length + appNotifications.length
const handleAccept = async (shareId: string) => {
try {
@@ -121,7 +127,17 @@ export function NotificationPanel() {
}
}
const hasContent = requests.length > 0 || activeReminders.length > 0
const handleMarkNotifRead = async (notifId: string) => {
await markNotificationRead(notifId)
setAppNotifications(prev => prev.filter(n => n.id !== notifId))
}
const handleMarkAllRead = async () => {
await markAllNotificationsRead()
setAppNotifications([])
}
const hasContent = requests.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -149,11 +165,22 @@ export function NotificationPanel() {
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
</div>
{pendingCount > 0 && (
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
{pendingCount}
</Badge>
)}
<div className="flex items-center gap-2">
{appNotifications.length > 0 && (
<button
onClick={handleMarkAllRead}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
title={t('notification.markAllRead') || 'Mark all read'}
>
<Check className="h-3.5 w-3.5" />
</button>
)}
{pendingCount > 0 && (
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
{pendingCount}
</Badge>
)}
</div>
</div>
</div>
@@ -168,6 +195,65 @@ export function NotificationPanel() {
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{/* App notifications (agents, system) */}
{appNotifications.map((notif) => (
<div
key={notif.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
>
<div className="flex items-start gap-3">
<div className={cn(
"mt-0.5 flex-none rounded-full p-1",
notif.type === 'agent_success' && 'bg-green-100 dark:bg-green-900/30 text-green-600',
notif.type === 'agent_failure' && 'bg-red-100 dark:bg-red-900/30 text-red-600',
notif.type === 'system' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
)}>
{notif.type.startsWith('agent') ? (
<Bot className="w-3.5 h-3.5" />
) : (
<AlertCircle className="w-3.5 h-3.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className={cn(
"text-[10px] font-semibold uppercase tracking-wider",
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
notif.type === 'agent_failure' && 'text-red-600 dark:text-red-400',
notif.type === 'system' && 'text-blue-600 dark:text-blue-400',
)}>
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')}
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
{notif.type === 'system' && 'System'}
</span>
</div>
<p className="text-sm font-medium truncate">{notif.title}</p>
{notif.message && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
)}
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
className="mt-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
title={t('notification.dismiss') || 'Dismiss'}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
{/* Overdue reminders */}
{overdueReminders.map((note) => (
<div

View File

@@ -5,9 +5,10 @@ import { useEffect } from 'react'
interface ThemeInitializerProps {
theme?: string
fontSize?: string
fontFamily?: string
}
export function ThemeInitializer({ theme, fontSize }: ThemeInitializerProps) {
export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializerProps) {
useEffect(() => {
// Helper to apply theme
@@ -77,7 +78,20 @@ export function ThemeInitializer({ theme, fontSize }: ThemeInitializerProps) {
}
applyFontSize(fontSize)
}, [theme, fontSize])
// Apply font family
const localFontFamily = localStorage.getItem('font-family')
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
const root = document.documentElement
if (effectiveFontFamily === 'system') {
root.classList.add('font-system')
} else {
root.classList.remove('font-system')
}
if (!localFontFamily && fontFamily) {
localStorage.setItem('font-family', fontFamily)
}
}, [theme, fontSize, fontFamily])
return null
}

View File

@@ -14,7 +14,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-input dark:bg-input/50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View File

@@ -23,6 +23,8 @@ interface ComboboxProps {
emptyMessage?: string
disabled?: boolean
className?: string
/** When true, the search text can be submitted as a custom value not in the options list */
allowCustomValue?: boolean
}
export function Combobox({
@@ -34,6 +36,7 @@ export function Combobox({
emptyMessage = 'No results found.',
disabled = false,
className,
allowCustomValue = false,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState('')
@@ -56,6 +59,14 @@ export function Combobox({
setSearch('')
}
const handleConfirmCustom = () => {
if (allowCustomValue && search.trim()) {
onChange(search.trim())
setOpen(false)
setSearch('')
}
}
return (
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setSearch('') }}>
<PopoverTrigger asChild>
@@ -85,14 +96,26 @@ export function Combobox({
placeholder={searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleConfirmCustom() } }}
autoFocus
/>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filtered.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
allowCustomValue && search.trim() ? (
<button
type="button"
onClick={handleConfirmCustom}
className="flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
>
<span className="text-muted-foreground mr-2">+</span>
<span>Use "<strong>{search.trim()}</strong>"</span>
</button>
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
)
) : (
filtered.map((option) => {
const isSelected = option.value === value

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/50 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className