From 6414ab2b119e09be582dabd738606afd9d805757 Mon Sep 17 00:00:00 2001 From: widlestudiollp Date: Mon, 27 Apr 2026 15:36:05 +0530 Subject: [PATCH] Component Added --- components/note-editor/README.md | 423 ++++++ components/note-editor/cover.png | Bin 0 -> 56772 bytes components/note-editor/metadata.json | 18 + components/note-editor/package.json | 48 + .../note-editor/src/component/index.tsx | 1176 +++++++++++++++++ components/note-editor/src/index.tsx | 1 + 6 files changed, 1666 insertions(+) create mode 100644 components/note-editor/README.md create mode 100644 components/note-editor/cover.png create mode 100644 components/note-editor/metadata.json create mode 100644 components/note-editor/package.json create mode 100644 components/note-editor/src/component/index.tsx create mode 100644 components/note-editor/src/index.tsx diff --git a/components/note-editor/README.md b/components/note-editor/README.md new file mode 100644 index 0000000..1021d94 --- /dev/null +++ b/components/note-editor/README.md @@ -0,0 +1,423 @@ +# File Editor Notes Component + +A Retool Custom Component for creating, editing, grouping, filtering, saving, updating, and deleting notes using dynamic database field mapping. + +This component is designed so every user can connect their own database table, even if their column names are different. + +--- + +## Features + +- Display notes from a database +- Group notes by group/category +- Create new groups +- Add notes inside groups +- Edit existing notes +- Delete selected notes +- Grid and list view +- Filter/search notes +- Dynamic database field mapping +- Dynamic validation limits from Retool Inspector +- Save and update events for database queries +- Exposes selected/draft note data through `notesMeta` + +--- + +## Inspector Inputs + +These inputs are configurable from the Retool Inspector. + +### Database Field Mapping + +Use these to match your database column names. + +| Inspector input | Default value | Purpose | +|---|---:|---| +| `idField` | `id` | Primary key column | +| `titleField` | `title` | Note title column | +| `contentField` | `content` | Note content/body column | +| `dateField` | `date` | Date column | +| `groupField` | `groupName` | Group/category column | + +Example: + +If your database columns are: + +```sql +note_id +note_title +note_body +created_at +category +``` + +Set the inspector values like this: + +```txt +idField = note_id +titleField = note_title +contentField = note_body +dateField = created_at +groupField = category +``` + +--- + +## Dynamic Validation Inputs + +These values are also configurable from the Retool Inspector. + +| Inspector input | Default value | Purpose | +|---|---:|---| +| `maxTitleLength` | `200` | Maximum title characters | +| `maxGroupLength` | `100` | Maximum group name characters | +| `maxContentLength` | `20000` | Maximum note content characters | +| `fontSize` | `14` | Editor font size | + +--- + +## Component State + +### `notesList` + +Pass your database query result into this state. + +Example: + +```js +{{ getNotesQuery.data }} +``` + +The data should be an array of objects. + +Example database result: + +```json +[ + { + "id": 1, + "title": "First note", + "content": "This is my first note", + "date": "27 Apr 2026", + "groupName": "Work" + }, + { + "id": 2, + "title": "Second note", + "content": "This is another note", + "date": "27 Apr 2026", + "groupName": "Personal" + } +] +``` + +--- + +## Output States + +### `selectedId` + +Stores the currently selected note ID. + +### `editorTitle` + +Stores the current editor title. + +### `editorText` + +Stores the current editor content. + +### `notesMeta` + +Main output object used for save, update, and delete queries. + +It includes: + +```js +notesMeta.draft +notesMeta.savedNote +notesMeta.pendingSave +notesMeta.removeCandidate +notesMeta.validation +``` + +--- + +## Events + +The component exposes these Retool events: + +| Event | Purpose | +|---|---| +| `saveClick` | Triggered when saving a new note | +| `updateClick` | Triggered when updating an existing note | +| `selectedNoteRemoveConfirmClick` | Triggered when deleting a note | + +--- + +# Database Setup + +## Example Table + +You can create a notes table like this: + +```sql +CREATE TABLE notes ( + id SERIAL PRIMARY KEY, + title TEXT, + content TEXT, + date TEXT, + groupName TEXT +); +``` + +You can also use different column names. Just update the inspector field mapping. + +--- + +## Get Notes Query + +Create a Retool query named: + +```txt +getNotesQuery +``` + +Example SQL: + +```sql +SELECT * +FROM notes +ORDER BY id DESC; +``` + +Then pass this query data into the component: + +```js +{{ getNotesQuery.data }} +``` + +--- + +## Save New Note Query + +Create a query named: + +```txt +saveNoteQuery +``` + +Example SQL: + +```sql +INSERT INTO notes ( + title, + content, + date, + groupName +) +VALUES ( + {{ notesComponent.notesMeta.savedNote.title }}, + {{ notesComponent.notesMeta.savedNote.content }}, + {{ notesComponent.notesMeta.savedNote.date }}, + {{ notesComponent.notesMeta.savedNote.groupName }} +); +``` + +After success, run: + +```txt +getNotesQuery.trigger() +``` + +Connect this query to the component's `saveClick` event. + +--- + +## Update Existing Note Query + +Create a query named: + +```txt +updateNoteQuery +``` + +Example SQL: + +```sql +UPDATE notes +SET + title = {{ notesComponent.notesMeta.savedNote.title }}, + content = {{ notesComponent.notesMeta.savedNote.content }}, + date = {{ notesComponent.notesMeta.savedNote.date }}, + groupName = {{ notesComponent.notesMeta.savedNote.groupName }} +WHERE id = {{ notesComponent.notesMeta.savedNote.id }}; +``` + +After success, run: + +```txt +getNotesQuery.trigger() +``` + +Connect this query to the component's `updateClick` event. + +--- + +## Delete Note Query + +Create a query named: + +```txt +deleteNoteQuery +``` + +Example SQL: + +```sql +DELETE FROM notes +WHERE id = {{ notesComponent.notesMeta.removeCandidate.id }}; +``` + +After success, run: + +```txt +getNotesQuery.trigger() +``` + +Connect this query to the component's `selectedNoteRemoveConfirmClick` event. + +--- + +# Using Custom Database Columns + +If your table uses custom column names, for example: + +```sql +CREATE TABLE user_notes ( + note_id SERIAL PRIMARY KEY, + note_title TEXT, + note_text TEXT, + created_date TEXT, + folder_name TEXT +); +``` + +Set the inspector values: + +```txt +idField = note_id +titleField = note_title +contentField = note_text +dateField = created_date +groupField = folder_name +``` + +Then your insert query should use those same fields: + +```sql +INSERT INTO user_notes ( + note_title, + note_text, + created_date, + folder_name +) +VALUES ( + {{ notesComponent.notesMeta.savedNote.note_title }}, + {{ notesComponent.notesMeta.savedNote.note_text }}, + {{ notesComponent.notesMeta.savedNote.created_date }}, + {{ notesComponent.notesMeta.savedNote.folder_name }} +); +``` + +Update query: + +```sql +UPDATE user_notes +SET + note_title = {{ notesComponent.notesMeta.savedNote.note_title }}, + note_text = {{ notesComponent.notesMeta.savedNote.note_text }}, + created_date = {{ notesComponent.notesMeta.savedNote.created_date }}, + folder_name = {{ notesComponent.notesMeta.savedNote.folder_name }} +WHERE note_id = {{ notesComponent.notesMeta.savedNote.note_id }}; +``` + +Delete query: + +```sql +DELETE FROM user_notes +WHERE note_id = {{ notesComponent.notesMeta.removeCandidate.note_id }}; +``` + +--- + +# How to Use + +1. Add the custom component to Retool. +2. Paste the component code. +3. Create a database table for notes. +4. Create a query to fetch notes. +5. Pass the query result into `notesList`. +6. Configure field names in the inspector. +7. Create insert, update, and delete queries. +8. Connect those queries to the component events. +9. Refresh the notes query after each save, update, or delete. + +--- + +# User Interaction + +## Create Group + +Enter a group name and click: + +```txt ++ New Group +``` + +This opens the editor for a new note inside that group. + +## Add Note + +Click the `+` button inside any group. + +## Select Note + +Single-click a note card. + +## Edit Note + +Double-click a note card. + +## Delete Note + +Select a note, then click the `×` button. + +## Save Note + +Click the `Save` button inside the editor. + +--- + +# Validation + +The component validates: + +- Group name is required +- Title or content is required +- Title cannot exceed `maxTitleLength` +- Group name cannot exceed `maxGroupLength` +- Content cannot exceed `maxContentLength` + +All limits can be changed from the Retool Inspector. + +--- + +# Notes + +- New notes use `id: null` before saving. +- Existing notes use their database ID. +- The component does not directly write to the database. +- Retool queries handle insert, update, and delete actions. +- `notesMeta.savedNote` is the main object used for save and update. +- `notesMeta.removeCandidate` is used for delete. diff --git a/components/note-editor/cover.png b/components/note-editor/cover.png new file mode 100644 index 0000000000000000000000000000000000000000..62bcb00a3fc650432c539d6b93bd00adb505428f GIT binary patch literal 56772 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∨eU#>p<$i5b{wo`=7y!Y^ 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 b=6M@L&0I??!s=Ime=20.0.0" + }, + "scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy", + "test": "vitest" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "postcss-modules": "^6.0.0", + "prettier": "^3.0.3", + "vitest": "^4.0.17" + }, + "retoolCustomComponentLibraryConfig": { + "name": "Notes", + "label": "Notes", + "description": "File editor", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} \ No newline at end of file diff --git a/components/note-editor/src/component/index.tsx b/components/note-editor/src/component/index.tsx new file mode 100644 index 0000000..8bd88ef --- /dev/null +++ b/components/note-editor/src/component/index.tsx @@ -0,0 +1,1176 @@ +import React, { FC, useEffect, useMemo, useState } from "react"; +import { Retool } from "@tryretool/custom-component-support"; + +type Note = { + id: string; + title: string; + content: string; + date: string; + groupName: string; +}; + +type ViewMode = "grid" | "list"; + +const TEMP_NOTE_ID = "__new__"; + +function formatDate(date = new Date()) { + const day = String(date.getDate()).padStart(2, "0"); + const month = date.toLocaleString("en-US", { month: "short" }); + const year = date.getFullYear(); + return `${day} ${month} ${year}`; +} + +function safeText(value: any, fallback = "") { + const text = String(value ?? "").trim(); + return text || fallback; +} + +function getSafeLimit(value: number, fallback: number) { + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +export const FileEditorNotes: FC = () => { + Retool.useComponentSettings({ + defaultHeight: 10, + defaultWidth: 14, + }); + + const [notesList] = Retool.useStateArray({ + name: "notesList", + initialValue: [], + }); + + const [idField] = Retool.useStateString({ + name: "idField", + initialValue: "id", + inspector: "text", + label: "ID field", + }); + + const [titleField] = Retool.useStateString({ + name: "titleField", + initialValue: "title", + inspector: "text", + label: "Title field", + }); + + const [contentField] = Retool.useStateString({ + name: "contentField", + initialValue: "content", + inspector: "text", + label: "Content field", + }); + + const [dateField] = Retool.useStateString({ + name: "dateField", + initialValue: "date", + inspector: "text", + label: "Date field", + }); + + const [groupField] = Retool.useStateString({ + name: "groupField", + initialValue: "groupName", + inspector: "text", + label: "Group field", + }); + + const [fontSize] = Retool.useStateNumber({ + name: "fontSize", + initialValue: 14, + inspector: "text", + label: "Font size", + }); + + const [maxTitleLengthInput] = Retool.useStateNumber({ + name: "maxTitleLength", + initialValue: 200, + inspector: "text", + label: "Maximum title characters", + }); + + const [maxGroupLengthInput] = Retool.useStateNumber({ + name: "maxGroupLength", + initialValue: 100, + inspector: "text", + label: "Maximum group characters", + }); + + const [maxContentLengthInput] = Retool.useStateNumber({ + name: "maxContentLength", + initialValue: 20000, + inspector: "text", + label: "Maximum content characters", + }); + + const maxTitleLength = getSafeLimit(maxTitleLengthInput, 200); + const maxGroupLength = getSafeLimit(maxGroupLengthInput, 100); + const maxContentLength = getSafeLimit(maxContentLengthInput, 20000); + + const [selectedId, setSelectedId] = Retool.useStateString({ + name: "selectedId", + initialValue: "", + inspector: "hidden", + }); + + const [, setEditorTitle] = Retool.useStateString({ + name: "editorTitle", + initialValue: "", + inspector: "hidden", + }); + + const [, setEditorText] = Retool.useStateString({ + name: "editorText", + initialValue: "", + inspector: "hidden", + }); + + const [, setNotesMeta] = Retool.useStateObject({ + name: "notesMeta", + initialValue: {}, + inspector: "hidden", + }); + + const saveClick = Retool.useEventCallback({ name: "saveClick" }); + const updateClick = Retool.useEventCallback({ name: "updateClick" }); + + const selectedNoteRemoveConfirmClick = Retool.useEventCallback({ + name: "selectedNoteRemoveConfirmClick", + }); + + const [selectedGroupName, setSelectedGroupName] = useState(""); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [newGroupName, setNewGroupName] = useState(""); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [viewMode, setViewMode] = useState("grid"); + const [filterText, setFilterText] = useState(""); + + const [newGroupError, setNewGroupError] = useState(""); + const [editorGroupError, setEditorGroupError] = useState(""); + const [noteError, setNoteError] = useState(""); + + const dbNotes = useMemo(() => { + const rows = Array.isArray(notesList) ? notesList : []; + + return rows + .map((row: any, index: number) => ({ + id: String( + row?.[idField || "id"] ?? row?.id ?? row?.ID ?? `note-${index}` + ), + title: String( + row?.[titleField || "title"] ?? row?.title ?? row?.TITLE ?? "" + ), + content: String( + row?.[contentField || "content"] ?? + row?.content ?? + row?.CONTENT ?? + "" + ), + date: String( + row?.[dateField || "date"] ?? row?.date ?? row?.DATE ?? formatDate() + ), + groupName: safeText( + row?.[groupField || "groupName"] ?? + row?.groupName ?? + row?.GROUPNAME ?? + row?.groupname, + "" + ), + })) + .filter((note) => note.groupName); + }, [notesList, idField, titleField, contentField, dateField, groupField]); + + const filteredDbNotes = useMemo(() => { + const q = filterText.trim().toLowerCase(); + if (!q) return dbNotes; + + return dbNotes.filter((note) => { + return ( + note.title.toLowerCase().includes(q) || + note.content.toLowerCase().includes(q) || + note.groupName.toLowerCase().includes(q) || + note.date.toLowerCase().includes(q) + ); + }); + }, [dbNotes, filterText]); + + const groupNames = useMemo(() => { + const set = new Set(); + + filteredDbNotes.forEach((note) => { + if (note.groupName) set.add(note.groupName); + }); + + return Array.from(set); + }, [filteredDbNotes]); + + const selectedNote = useMemo(() => { + if (selectedId === TEMP_NOTE_ID) { + return { + id: TEMP_NOTE_ID, + title, + content, + date: formatDate(), + groupName: selectedGroupName, + }; + } + + return dbNotes.find((note) => note.id === selectedId) || null; + }, [selectedId, dbNotes, title, content, selectedGroupName]); + + const groupedNotes = useMemo(() => { + const map = new Map(); + + groupNames.forEach((group) => { + if (group) map.set(group, []); + }); + + filteredDbNotes.forEach((note) => { + if (!note.groupName) return; + + if (!map.has(note.groupName)) { + map.set(note.groupName, []); + } + + map.get(note.groupName)!.push(note); + }); + + if (selectedId === TEMP_NOTE_ID && selectedGroupName.trim()) { + const group = selectedGroupName.trim(); + + if (!map.has(group)) { + map.set(group, []); + } + + map.get(group)!.unshift({ + id: TEMP_NOTE_ID, + title, + content, + date: formatDate(), + groupName: group, + }); + } + + return Array.from(map.entries()).map(([groupName, notes]) => ({ + groupName, + notes, + })); + }, [ + filteredDbNotes, + groupNames, + selectedId, + selectedGroupName, + title, + content, + ]); + + useEffect(() => { + setEditorTitle(title); + }, [title, setEditorTitle]); + + useEffect(() => { + setEditorText(content); + }, [content, setEditorText]); + + useEffect(() => { + setNotesMeta({ + selectedId, + selectedGroupName: selectedGroupName.trim(), + viewMode, + filterText, + draft: { + [idField || "id"]: selectedId, + [titleField || "title"]: title, + [contentField || "content"]: content, + [dateField || "date"]: selectedNote?.date || formatDate(), + [groupField || "groupName"]: selectedGroupName.trim(), + + id: selectedId, + title, + content, + date: selectedNote?.date || formatDate(), + groupName: selectedGroupName.trim(), + isNew: selectedId === TEMP_NOTE_ID, + }, + validation: { + maxTitleLength, + maxGroupLength, + maxContentLength, + titleLength: title.length, + groupLength: selectedGroupName.length, + contentLength: content.length, + }, + totalNotes: dbNotes.length, + filteredNotes: filteredDbNotes.length, + updatedAt: new Date().toISOString(), + }); + }, [ + selectedId, + selectedGroupName, + title, + content, + selectedNote, + dbNotes.length, + filteredDbNotes.length, + viewMode, + filterText, + idField, + titleField, + contentField, + dateField, + groupField, + setNotesMeta, + maxTitleLength, + maxGroupLength, + maxContentLength, + ]); + + const validateNote = () => { + const cleanTitle = title.trim(); + const cleanGroup = selectedGroupName.trim(); + const cleanContent = content.trim(); + + let hasError = false; + + if (!cleanGroup) { + setEditorGroupError("Group name is required"); + hasError = true; + } else if (cleanGroup.length > maxGroupLength) { + setEditorGroupError( + `Group name cannot exceed ${maxGroupLength} characters` + ); + hasError = true; + } else { + setEditorGroupError(""); + } + + if (cleanTitle.length > maxTitleLength) { + setNoteError(`Title cannot exceed ${maxTitleLength} characters`); + hasError = true; + } else if (content.length > maxContentLength) { + setNoteError( + `Note is too large. Maximum ${maxContentLength.toLocaleString()} characters allowed.` + ); + hasError = true; + } else if (!cleanTitle && !cleanContent) { + setNoteError("Add a title or note content"); + hasError = true; + } else { + setNoteError(""); + } + + return !hasError; + }; + + const handleCreateGroup = () => { + const group = newGroupName.trim(); + + if (!group) { + setNewGroupError("Group name is required"); + return; + } + + if (group.length > maxGroupLength) { + setNewGroupError( + `Group name cannot exceed ${maxGroupLength} characters` + ); + return; + } + + setNewGroupError(""); + setEditorGroupError(""); + setNoteError(""); + + setSelectedId(TEMP_NOTE_ID); + setSelectedGroupName(group); + setTitle(""); + setContent(""); + setNewGroupName(""); + setIsEditorOpen(true); + }; + + const handleAddNewNote = (groupName: string) => { + const group = groupName.trim(); + + if (!group) return; + + setEditorGroupError(""); + setNoteError(""); + + setSelectedId(TEMP_NOTE_ID); + setSelectedGroupName(group); + setTitle(""); + setContent(""); + setIsEditorOpen(true); + }; + + const handleSelectOnly = (note: Note) => { + setSelectedId(note.id); + setSelectedGroupName(note.groupName); + }; + + const handleOpenNote = (note: Note) => { + setEditorGroupError(""); + setNoteError(""); + + setSelectedId(note.id); + setSelectedGroupName(note.groupName); + setTitle(note.title); + setContent(note.content); + setIsEditorOpen(true); + }; + + const handleSave = () => { + if (!validateNote()) return; + + const cleanTitle = title.trim(); + const groupName = selectedGroupName.trim(); + + const isNew = selectedId === TEMP_NOTE_ID || !selectedId; + const noteId = isNew ? null : selectedId; + const noteDate = selectedNote?.date || formatDate(); + + const savedNote = { + [idField || "id"]: noteId, + [titleField || "title"]: cleanTitle || "Untitled", + [contentField || "content"]: content, + [dateField || "date"]: noteDate, + [groupField || "groupName"]: groupName, + + id: noteId, + title: cleanTitle || "Untitled", + content, + date: noteDate, + groupName, + }; + + setEditorTitle(cleanTitle || "Untitled"); + setEditorText(content); + + setNotesMeta({ + savedNote, + pendingSave: { + ...savedNote, + isNew, + isUpdate: !isNew, + }, + validation: { + isValid: true, + maxTitleLength, + maxGroupLength, + maxContentLength, + contentLength: content.length, + titleLength: cleanTitle.length, + groupLength: groupName.length, + }, + updatedAt: new Date().toISOString(), + }); + + window.setTimeout(() => { + if (isNew) { + saveClick(); + + setSelectedId(""); + setTitle(""); + setContent(""); + setIsEditorOpen(false); + } else { + updateClick(); + + setIsEditorOpen(false); + } + }, 150); + }; + + const handleRemoveCard = (note: Note) => { + if (note.id === TEMP_NOTE_ID) { + setSelectedId(""); + setTitle(""); + setContent(""); + setSelectedGroupName(""); + setIsEditorOpen(false); + return; + } + + setNotesMeta({ + removeCandidate: { + [idField || "id"]: note.id, + [titleField || "title"]: note.title, + [contentField || "content"]: note.content, + [dateField || "date"]: note.date, + [groupField || "groupName"]: note.groupName, + + ...note, + }, + updatedAt: new Date().toISOString(), + }); + + selectedNoteRemoveConfirmClick(); + + if (selectedId === note.id) { + setSelectedId(""); + setTitle(""); + setContent(""); + setSelectedGroupName(""); + setIsEditorOpen(false); + } + }; + + const handleCloseModal = () => { + setEditorGroupError(""); + setNoteError(""); + setIsEditorOpen(false); + }; + + const renderNoteCard = (note: Note) => { + const isSelected = note.id === selectedId; + + return ( +

+ {isSelected && ( + + )} + + +
+ ); + }; + + const renderNoteListItem = (note: Note) => { + const isSelected = note.id === selectedId; + + return ( +
+ {isSelected && ( + + )} + + +
+ ); + }; + + return ( +
+
+
+
+
+ { + setNewGroupName(e.target.value); + + if (e.target.value.trim()) { + setNewGroupError(""); + } + }} + placeholder="New group name" + style={{ + width: 220, + background: "#111826", + border: `1px solid ${newGroupError ? "#ef4444" : "#233045" + }`, + borderRadius: 10, + color: "#f3f4f6", + padding: "9px 10px", + outline: "none", + fontSize: 14, + }} + /> + + +
+ + {newGroupError && ( +
+ {newGroupError} +
+ )} +
+ +
+ setFilterText(e.target.value)} + placeholder="Filter notes..." + style={{ + width: 220, + background: "#111826", + border: "1px solid #233045", + borderRadius: 10, + color: "#f3f4f6", + padding: "9px 10px", + outline: "none", + fontSize: 14, + }} + /> + +
+ + + +
+
+
+ + {groupedNotes.length === 0 && ( +
+ {filterText.trim() + ? "No notes match your filter." + : "Create a group first, then add notes inside it."} +
+ )} + + {groupedNotes.map((group) => ( +
+
+
+ {group.groupName} +
+ + {viewMode === "list" && !filterText.trim() && ( + + )} +
+ + {viewMode === "grid" ? ( +
+ {group.notes.map(renderNoteCard)} + + {!filterText.trim() && ( + + )} +
+ ) : ( +
+ {group.notes.map(renderNoteListItem)} +
+ )} +
+ ))} +
+ + {isEditorOpen && ( +
+
+ {selectedNote?.date || formatDate()} + + +
+ +
+ { + setSelectedGroupName(e.target.value); + + if (e.target.value.trim()) { + setEditorGroupError(""); + } + }} + placeholder="Group name" + style={{ + width: "100%", + marginBottom: editorGroupError ? 6 : 14, + background: "#111826", + border: `1px solid ${editorGroupError ? "#ef4444" : "#233045" + }`, + borderRadius: 10, + color: "#f3f4f6", + padding: "9px 10px", + outline: "none", + fontSize: 14, + boxSizing: "border-box", + }} + /> + + {editorGroupError && ( +
+ {editorGroupError} +
+ )} + + { + setTitle(e.target.value); + + if (e.target.value.trim() || content.trim()) { + setNoteError(""); + } + }} + placeholder="Title" + style={{ + width: "100%", + background: "transparent", + border: "none", + outline: "none", + color: "#f3f4f6", + fontSize: 28, + fontWeight: 800, + }} + /> +
+ +