Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions components/features/notes/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import * as React from "react"
import { Loader2, Tag } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import RichTextEditor from "@/components/RichTextEditor"

interface NoteEditorProps {
title: string
description: string
tags: string
isSaving: boolean
isNew: boolean
onTitleChange: (value: string) => void
onDescriptionChange: (value: string) => void
onTagsChange: (value: string) => void
Expand All @@ -24,7 +24,6 @@ export const NoteEditor = React.memo(function NoteEditor({
description,
tags,
isSaving,
isNew,
onTitleChange,
onDescriptionChange,
onTagsChange,
Expand All @@ -33,7 +32,7 @@ export const NoteEditor = React.memo(function NoteEditor({
}: NoteEditorProps) {
// Обработчики событий для предотвращения пересоздания на каждом рендере
const handleTitleChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onTitleChange(e.target.value)
},
[onTitleChange]
Expand All @@ -46,12 +45,22 @@ export const NoteEditor = React.memo(function NoteEditor({
[onTagsChange]
)

// Auto-resize textarea
const titleRef = React.useRef<HTMLTextAreaElement>(null)

React.useEffect(() => {
if (titleRef.current) {
titleRef.current.style.height = 'auto'
titleRef.current.style.height = titleRef.current.scrollHeight + 'px'
}
}, [title])

return (
<div className="flex-1 flex flex-col">
{/* Editor Header */}
<div className="p-4 border-b bg-card flex items-center justify-between">
<h2 className="text-lg font-semibold">
{isNew ? 'New Note' : 'Edit Note'}
<h2 className="text-lg font-semibold text-muted-foreground">
Editing
</h2>
<div className="flex gap-2">
<Button
Expand Down Expand Up @@ -80,12 +89,13 @@ export const NoteEditor = React.memo(function NoteEditor({
<div className="flex-1 overflow-y-auto p-6 bg-card">
<div className="max-w-4xl mx-auto space-y-4">
<div>
<Input
type="text"
<Textarea
ref={titleRef}
placeholder="Note title"
value={title}
onChange={handleTitleChange}
className="text-2xl font-bold border-none focus-visible:ring-0 px-0 bg-transparent"
className="text-2xl font-bold border-none focus-visible:ring-0 px-0 bg-transparent min-h-[40px] resize-none overflow-hidden"
rows={1}
/>
</div>
<div>
Expand Down
21 changes: 18 additions & 3 deletions components/features/notes/NoteView.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client"

import * as React from "react"
import { Edit2, Trash2 } from "lucide-react"
import { Edit2, Trash2, ChevronLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import InteractiveTag from "@/components/InteractiveTag"

import { SanitizationService } from "@/lib/services/sanitizer"
import type { Note } from "@/types/domain"

Expand All @@ -20,14 +21,16 @@ interface NoteViewProps {
onDelete: () => void
onTagClick: (tag: string) => void
onRemoveTag: (tag: string) => void
onBack?: () => void
}

export const NoteView = React.memo(function NoteView({
note,
onEdit,
onDelete,
onTagClick,
onRemoveTag
onRemoveTag,
onBack
}: NoteViewProps) {
// Мемоизация санитизированного контента для предотвращения повторной обработки
const sanitizedContent = React.useMemo(
Expand All @@ -45,7 +48,19 @@ export const NoteView = React.memo(function NoteView({
<div className="flex-1 flex flex-col">
{/* Note View Header */}
<div className="p-4 border-b bg-card flex items-center justify-between">
<h2 className="text-lg font-semibold">{note.title}</h2>
<div className="flex items-center gap-2">
{onBack && (
<Button
variant="ghost"
size="icon"
className="md:hidden -ml-2"
onClick={onBack}
>
<ChevronLeft className="w-5 h-5" />
</Button>
)}
<h2 className="text-lg font-semibold text-muted-foreground">Reading</h2>
</div>
<div className="flex gap-2">
<Button
onClick={onEdit}
Expand Down
25 changes: 22 additions & 3 deletions components/features/notes/NotesShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"

import { cn } from "@/lib/utils"
import { Sidebar } from "@/components/features/notes/Sidebar"
import { NoteList } from "@/components/features/notes/NoteList"
import { NoteEditor } from "@/components/features/notes/NoteEditor"
Expand Down Expand Up @@ -40,8 +41,13 @@ export function NotesShell({ controller }: NotesShellProps) {
handleCreateNote,
handleSignOut,
invalidateNotes,
selectedNote,
isEditing,
handleSelectNote,
} = controller

const showEditor = !!(selectedNote || isEditing)

return (
<div className="flex h-screen bg-muted/20">
<Sidebar
Expand All @@ -53,11 +59,24 @@ export function NotesShell({ controller }: NotesShellProps) {
onCreateNote={handleCreateNote}
onSignOut={handleSignOut}
onImportComplete={invalidateNotes}
className={cn(showEditor ? "hidden md:flex" : "w-full md:w-80")}
data-testid="sidebar-container"
>
<ListPane controller={controller} />
</Sidebar>

<EditorPane controller={controller} />
<div
className={cn(
"flex-1 flex flex-col h-full",
!showEditor ? "hidden md:flex" : "w-full"
)}
data-testid="editor-container"
>
<EditorPane
controller={controller}
onBack={() => handleSelectNote(null)}
/>
</div>

<DeleteNoteDialog controller={controller} />
</div>
Expand Down Expand Up @@ -105,7 +124,7 @@ function ListPane({ controller }: { controller: NoteAppController }) {
)
}

function EditorPane({ controller }: { controller: NoteAppController }) {
function EditorPane({ controller, onBack }: { controller: NoteAppController, onBack: () => void }) {
const {
selectedNote,
isEditing,
Expand All @@ -130,7 +149,6 @@ function EditorPane({ controller }: { controller: NoteAppController }) {
description={editForm.description}
tags={editForm.tags}
isSaving={saving}
isNew={!selectedNote}
onTitleChange={(val) => setEditForm((prev) => ({ ...prev, title: val }))}
onDescriptionChange={(val) => setEditForm((prev) => ({ ...prev, description: val }))}
onTagsChange={(val) => setEditForm((prev) => ({ ...prev, tags: val }))}
Expand All @@ -154,6 +172,7 @@ function EditorPane({ controller }: { controller: NoteAppController }) {
onDelete={() => handleDeleteNote(selectedNote)}
onTagClick={controller.handleTagClick}
onRemoveTag={(tag) => handleRemoveTagFromNote(selectedNote.id, tag)}
onBack={onBack}
/>
)
}
Expand Down
43 changes: 38 additions & 5 deletions components/features/notes/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
"use client"

import { BookOpen, LogOut, Plus, Search, Tag } from "lucide-react"
import { BookOpen, LogOut, Plus, Search, Tag, X } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { ThemeToggle } from "@/components/theme-toggle"
import { ImportButton } from "@/components/ImportButton"
import { User } from "@supabase/supabase-js"
import { cn } from "@/lib/utils"

interface SidebarProps {
user: User
Expand All @@ -18,6 +25,8 @@ interface SidebarProps {
onSignOut: () => void
onImportComplete: () => void
children: React.ReactNode // For the NoteList
className?: string
"data-testid"?: string
}

export function Sidebar({
Expand All @@ -29,10 +38,12 @@ export function Sidebar({
onCreateNote,
onSignOut,
onImportComplete,
children
children,
className,
"data-testid": dataTestId
}: SidebarProps) {
return (
<div className="w-80 bg-card border-r flex flex-col h-full">
<div className={cn("w-80 bg-card border-r flex flex-col h-full", className)} data-testid={dataTestId}>
{/* Header */}
<div className="p-4 border-b">
<div className="flex items-center justify-between mb-4">
Expand All @@ -56,7 +67,7 @@ export function Sidebar({
size="sm"
className="h-6 text-xs"
>
Clear filter
Clear Tags
</Button>
</div>
)}
Expand All @@ -69,8 +80,30 @@ export function Sidebar({
placeholder={filterByTag ? `Search in "${filterByTag}" notes...` : "Search notes..."}
value={searchQuery}
onChange={(e) => onSearch(e.target.value)}
className="pl-10"
className="pl-10 pr-8"
/>
{searchQuery && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-transparent"
onClick={() => onSearch('')}
>
<X className="w-4 h-4 text-gray-400 hover:text-foreground" />
<span className="sr-only">Clear Search</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Clear Search</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
</div>

Expand Down
49 changes: 29 additions & 20 deletions core/services/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import {
export class SearchService {
constructor(private supabase: SupabaseClient) {}

// Strip commas to avoid breaking PostgREST OR syntax
// Sanitize for PostgREST OR syntax
private sanitizeOrValue(value: string) {
return value.replace(/,/g, ' ')
// Remove double quotes to avoid breaking PostgREST quoted values
// We will wrap the value in double quotes in the query
return value.replace(/"/g, '')
}

async searchNotes(
Expand All @@ -34,26 +36,33 @@ export class SearchService {
// 1. Try Full Text Search (FTS)
try {
const tsQuery = buildTsQuery(query)
const ftsLang = ftsLanguage(language as LanguageCode)

if (tsQuery) {
const ftsLang = ftsLanguage(language as LanguageCode)

const { data, error } = await this.supabase.rpc('search_notes_fts', {
search_query: tsQuery,
search_language: ftsLang,
min_rank: minRank,
result_limit: limit,
result_offset: offset,
search_user_id: userId,
})
const { data, error } = await this.supabase.rpc('search_notes_fts', {
search_query: tsQuery,
search_language: ftsLang,
min_rank: minRank,
result_limit: limit,
result_offset: offset,
search_user_id: userId,
})

if (!error && data) {
const filtered = tag
? (data as FtsSearchResult[]).filter((note) => (note.tags ?? []).includes(tag))
: (data as FtsSearchResult[])
if (!error && data) {
const filtered = tag
? (data as FtsSearchResult[]).filter((note) => (note.tags ?? []).includes(tag))
: (data as FtsSearchResult[])

return {
results: filtered,
total: filtered.length,
method: 'fts',
// If FTS found results, return them.
// If FTS found nothing (0 results), fall through to ILIKE fallback to support substring search (e.g. "альные" in "специальные")
if (filtered.length > 0) {
return {
results: filtered,
total: filtered.length,
method: 'fts',
}
}
}
}
} catch (e) {
Expand All @@ -68,7 +77,7 @@ export class SearchService {
.from('notes')
.select('id, title, description, tags, created_at, updated_at')
.eq('user_id', userId)
.or(`title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%`)
.or(`title.ilike."%${safeSearch}%",description.ilike."%${safeSearch}%"`)

if (tag) {
supabaseQuery = supabaseQuery.contains('tags', [tag])
Expand Down
10 changes: 5 additions & 5 deletions core/utils/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@ export type LanguageCode = keyof typeof FTS_LANGUAGES
const MAX_QUERY_LENGTH = 1000
const MIN_QUERY_LENGTH = 3

export function buildTsQuery(query: string): string {
export function buildTsQuery(query: string): string | null {
if (!query || typeof query !== 'string') {
throw new Error('Query must be a non-empty string')
return null
}

if (query.length > MAX_QUERY_LENGTH) {
throw new Error(`Query exceeds maximum length: ${MAX_QUERY_LENGTH}`)
return null
}

const trimmed = query.trim()

if (trimmed.length < MIN_QUERY_LENGTH) {
throw new Error(`Query must be at least ${MIN_QUERY_LENGTH} characters`)
return null
Comment on lines +14 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update buildTsQuery contract or dependent tests

buildTsQuery now returns null for empty/short/oversized input instead of throwing (core/utils/search.ts lines 14-26), but the existing cypress/component/core/utils/search.cy.ts still asserts that these inputs throw (it('throws error …')). Running the Cypress component suite now fails because the function no longer matches those expectations. Please either restore the exceptions or adjust the tests/callers to the new null-returning contract.

Useful? React with 👍 / 👎.

}

const sanitized = trimmed
Expand All @@ -32,7 +32,7 @@ export function buildTsQuery(query: string): string {
.trim()

if (!sanitized) {
throw new Error('Query is empty after sanitization')
return null
}

const words = sanitized.split(' ').filter(Boolean)
Expand Down
Loading