diff --git a/CLAUDE.md b/CLAUDE.md index fb290c329..50aa0dfd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ For per-source permission tests, mock `getUserPermissionSetAsync(userId, sourceI ### Migration Registry Migrations use a centralized registry in `src/db/migrations.ts`. Each migration has functions for all three backends. -**Current migration count:** 110 (latest: `110_add_meshcore_position_history`). +**Current migration count:** 112 (latest: `112_add_notes_to_nodes`). For the full "adding a migration" recipe see [Migration recipe](#migration-recipe) below. diff --git a/src/components/MessagesTab.tsx b/src/components/MessagesTab.tsx index bff3dfba5..af8fea251 100644 --- a/src/components/MessagesTab.tsx +++ b/src/components/MessagesTab.tsx @@ -2158,7 +2158,19 @@ const MessagesTab: React.FC = ({ )} - {selectedNode && } + {selectedNode && ( + { + if (!selectedNode.user?.id) throw new Error('Node has no ID'); + await apiService.setNodeNotes(selectedNode.user.id, notes, sourceId); + showToast(t('node_details.notes_saved', 'Notes saved'), 'success'); + }} + /> + )} {/* Security Details Section */} {selectedNode && diff --git a/src/components/NodeDetailsBlock.css b/src/components/NodeDetailsBlock.css index 7cedd1db4..924315cad 100644 --- a/src/components/NodeDetailsBlock.css +++ b/src/components/NodeDetailsBlock.css @@ -131,6 +131,68 @@ grid-column: span 2; } +/* Notes (#3921) — editable free-text per-node annotation */ +.node-detail-notes-input { + width: 100%; + box-sizing: border-box; + margin-top: 4px; + padding: 8px 10px; + background-color: var(--ctp-base); + color: var(--ctp-text); + border: 1px solid var(--ctp-surface1); + border-radius: var(--radius-sm); + font-family: inherit; + font-size: 0.95rem; + line-height: 1.4; + resize: vertical; +} + +.node-detail-notes-input:focus { + outline: none; + border-color: var(--ctp-blue); +} + +.node-detail-notes-input:disabled { + opacity: 0.6; +} + +.node-detail-notes-readonly { + font-size: 0.95rem; + font-weight: 400; + white-space: pre-wrap; + word-break: break-word; +} + +.node-detail-notes-error { + display: block; + color: var(--ctp-red); + font-size: 0.85rem; + margin-top: 4px; +} + +.node-detail-notes-actions { + display: flex; + justify-content: flex-end; + margin-top: 8px; +} + +.node-detail-notes-save { + background-color: var(--ctp-blue); + color: var(--ctp-base); + border: none; + border-radius: var(--radius-sm); + padding: 6px 14px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.node-detail-notes-save:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* Public key styling */ .node-detail-public-key { font-family: var(--font-mono); diff --git a/src/components/NodeDetailsBlock.tsx b/src/components/NodeDetailsBlock.tsx index f1172972e..bd0b3134b 100644 --- a/src/components/NodeDetailsBlock.tsx +++ b/src/components/NodeDetailsBlock.tsx @@ -15,9 +15,19 @@ interface NodeDetailsBlockProps { node: DeviceInfo | null; timeFormat?: TimeFormat; dateFormat?: DateFormat; + /** + * When true (and `onSaveNotes` is provided), the free-text notes field (#3921) + * is rendered as an editable textarea. Otherwise any existing note is shown + * read-only. + */ + canEditNotes?: boolean; + /** Persist the edited note. Resolves on success, rejects on failure. */ + onSaveNotes?: (notes: string) => Promise; } -const NodeDetailsBlock: React.FC = ({ node, timeFormat = '24', dateFormat = 'MM/DD/YYYY' }) => { +const MAX_NODE_NOTES_LENGTH = 2000; + +const NodeDetailsBlock: React.FC = ({ node, timeFormat = '24', dateFormat = 'MM/DD/YYYY', canEditNotes = false, onSaveNotes }) => { const { t } = useTranslation(); const { channels } = useChannels(); const { currentNodeId } = useDeviceConfig(); @@ -29,14 +39,50 @@ const NodeDetailsBlock: React.FC = ({ node, timeFormat = return stored === 'true'; }); + // #3921: editable free-text notes. Draft is kept in local state and only + // persisted on Save; it re-syncs whenever the selected node (or its stored + // note) changes. + const nodeNum = node?.nodeNum; + const storedNotes = node?.notes ?? ''; + const [notesDraft, setNotesDraft] = useState(storedNotes); + // Baseline the draft is compared against for the "dirty" check. It tracks the + // last-persisted value locally so the Save button settles immediately after a + // successful save, even before the polled `nodes` prop catches up. + const [notesBaseline, setNotesBaseline] = useState(storedNotes); + const [notesSaving, setNotesSaving] = useState(false); + const [notesError, setNotesError] = useState(null); + useEffect(() => { localStorage.setItem('nodeDetailsCollapsed', isCollapsed.toString()); }, [isCollapsed]); + // Re-sync draft/baseline whenever the selected node or its stored note changes. + useEffect(() => { + setNotesDraft(storedNotes); + setNotesBaseline(storedNotes); + setNotesError(null); + }, [nodeNum, storedNotes]); + if (!node) { return null; } + const notesDirty = notesDraft !== notesBaseline; + + const handleSaveNotes = async () => { + if (!onSaveNotes || notesSaving || !notesDirty) return; + setNotesSaving(true); + setNotesError(null); + try { + await onSaveNotes(notesDraft); + setNotesBaseline(notesDraft); + } catch (err) { + setNotesError(err instanceof Error ? err.message : t('node_details.notes_error', 'Failed to save notes')); + } finally { + setNotesSaving(false); + } + }; + /** * Get battery level indicator class based on percentage */ @@ -439,6 +485,38 @@ const NodeDetailsBlock: React.FC = ({ node, timeFormat = )} + + {/* Notes (#3921) — editable when permitted, otherwise read-only */} + {(canEditNotes && onSaveNotes) ? ( +
+
{t('node_details.notes', 'Notes')}
+