diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index e3c78fe..89f4a7b 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -87,6 +87,7 @@ interface Note { mimeType?: string | null; duration?: number | null; source_ref?: { source: string; syncStatus?: string; lastSyncedAt?: number }; + sort_order?: number; created_at: string; updated_at: string; } @@ -526,7 +527,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const newNote: Note = { id: noteId, title: "Untitled Note", content: "", content_plain: "", content_format: 'tiptap-json', - type: "NOTE", tags: null, is_pinned: false, + type: "NOTE", tags: null, is_pinned: false, sort_order: 0, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }; const nb = { @@ -555,7 +556,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const newNote: Note = { id: noteId, title, content: opts.content || "", content_plain: "", content_format: type === 'CODE' ? 'html' : 'tiptap-json', - type, tags: opts.tags || null, is_pinned: false, + type, tags: opts.tags || null, is_pinned: false, sort_order: 0, url: opts.url || null, language: opts.language || null, fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null, duration: opts.duration ?? null, @@ -686,16 +687,14 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF fileUrl: item.fileUrl || null, mimeType: item.mimeType || null, duration: item.duration ?? null, + sort_order: item.sortOrder || 0, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), }); } } - notes.sort((a, b) => { - if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; - return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); - }); + this.sortNotes(notes); this.selectedNotebook = { id: nb.id, @@ -730,6 +729,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF fileUrl: noteItem.fileUrl || null, mimeType: noteItem.mimeType || null, duration: noteItem.duration ?? null, + sort_order: noteItem.sortOrder || 0, created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), }; @@ -792,6 +792,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF fileUrl: noteItem.fileUrl || null, mimeType: noteItem.mimeType || null, duration: noteItem.duration ?? null, + sort_order: noteItem.sortOrder || 0, created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), }; @@ -856,7 +857,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.selectedNote = { id: noteId, title, content: opts.content || "", content_plain: "", content_format: contentFormat, - type, tags: opts.tags || null, is_pinned: false, + type, tags: opts.tags || null, is_pinned: false, sort_order: 0, url: opts.url || null, language: opts.language || null, fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null, duration: opts.duration ?? null, @@ -885,6 +886,64 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } } + /** Sort notes: pinned first, then by sort_order (if any are set), then by updated_at desc. */ + private sortNotes(notes: Note[]) { + const hasSortOrder = notes.some(n => (n.sort_order || 0) > 0); + notes.sort((a, b) => { + if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; + if (hasSortOrder) { + const sa = a.sort_order || 0; + const sb = b.sort_order || 0; + if (sa !== sb) return sa - sb; + } + return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); + }); + } + + /** Reorder a note within a notebook's sidebar list. */ + private reorderNote(noteId: string, notebookId: string, targetIndex: number) { + const notes = this.notebookNotes.get(notebookId); + if (!notes) return; + + const srcIdx = notes.findIndex(n => n.id === noteId); + if (srcIdx < 0 || srcIdx === targetIndex) return; + + // Move in the local array + const [note] = notes.splice(srcIdx, 1); + notes.splice(targetIndex, 0, note); + + // Assign sort_order based on new positions + notes.forEach((n, i) => { n.sort_order = i + 1; }); + this.notebookNotes.set(notebookId, notes); + + // Persist to Automerge + const runtime = (window as any).__rspaceOfflineRuntime; + const dataSpace = runtime?.isInitialized ? (runtime.resolveDocSpace?.('rnotes') || this.space) : this.space; + const docId = `${dataSpace}:notes:notebooks:${notebookId}` as DocumentId; + + if (runtime?.isInitialized) { + runtime.change(docId, `Reorder notes`, (d: NotebookDoc) => { + for (const n of notes) { + if (d.items[n.id]) d.items[n.id].sortOrder = n.sort_order!; + } + }); + this.doc = runtime.get(this.subscribedDocId as DocumentId); + } else if (this.doc && this.subscribedDocId === docId) { + this.doc = Automerge.change(this.doc, `Reorder notes`, (d: NotebookDoc) => { + for (const n of notes) { + if (d.items[n.id]) d.items[n.id].sortOrder = n.sort_order!; + } + }); + } + + // Also update selectedNotebook if it matches + if (this.selectedNotebook?.id === notebookId) { + this.selectedNotebook.notes = [...notes]; + } + + this.renderNav(); + } + /** Move a note from one notebook to another via Automerge docs. */ private async moveNoteToNotebook(noteId: string, sourceNotebookId: string, targetNotebookId: string) { if (sourceNotebookId === targetNotebookId) return; @@ -950,6 +1009,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF is_pinned: item.isPinned || false, url: item.url || null, language: item.language || null, fileUrl: item.fileUrl || null, mimeType: item.mimeType || null, duration: item.duration ?? null, + sort_order: item.sortOrder || 0, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), })); @@ -1114,6 +1174,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const data = await res.json(); this.selectedNotebook = data; if (data?.notes) { + this.sortNotes(data.notes); this.notebookNotes.set(id, data.notes); } } catch { @@ -1130,6 +1191,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const res = await fetch(`${base}/api/notebooks/${id}`, { headers: this.authHeaders() }); const data = await res.json(); if (data?.notes) { + this.sortNotes(data.notes); this.notebookNotes.set(id, data.notes); const nbIdx = this.notebooks.findIndex(n => n.id === id); if (nbIdx >= 0) this.notebooks[nbIdx].note_count = String(data.notes.length); @@ -1240,6 +1302,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF fileUrl: item.fileUrl || null, mimeType: item.mimeType || null, duration: item.duration ?? null, + sort_order: item.sortOrder || 0, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), }; @@ -3171,16 +3234,22 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF return title ? { title, module: "rnotes", entityId: id, label: "Note", color: "#f59e0b" } : null; }); - // Also set native drag data for intra-sidebar notebook moves + // Also set native drag data for intra-sidebar notebook moves + cleanup on dragend this.shadow.querySelectorAll(".sbt-note[data-note]").forEach(el => { (el as HTMLElement).addEventListener("dragstart", (e) => { const noteId = (el as HTMLElement).dataset.note!; const nbId = (el as HTMLElement).dataset.notebook!; e.dataTransfer?.setData("application/x-rnotes-move", JSON.stringify({ noteId, sourceNotebookId: nbId })); + (el as HTMLElement).style.opacity = "0.4"; + }); + (el as HTMLElement).addEventListener("dragend", () => { + (el as HTMLElement).style.opacity = ""; + this.shadow.querySelectorAll('.sbt-note').forEach(n => + (n as HTMLElement).classList.remove('drag-above', 'drag-below')); }); }); - // Notebook headers accept dropped notes + // Notebook headers accept dropped notes (cross-notebook move) this.shadow.querySelectorAll(".sbt-notebook-header[data-toggle-notebook]").forEach(el => { (el as HTMLElement).addEventListener("dragover", (e) => { if (e.dataTransfer?.types.includes("application/x-rnotes-move")) { @@ -3205,6 +3274,60 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } catch {} }); }); + + // Note items accept drops for intra-notebook reordering + this.shadow.querySelectorAll(".sbt-note[data-note]").forEach(el => { + const noteEl = el as HTMLElement; + noteEl.addEventListener("dragover", (e) => { + if (!e.dataTransfer?.types.includes("application/x-rnotes-move")) return; + e.preventDefault(); + e.stopPropagation(); + // Determine above/below based on cursor position + const rect = noteEl.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + // Clear all indicators in this container + noteEl.closest('.sbt-notes')?.querySelectorAll('.sbt-note').forEach(n => + (n as HTMLElement).classList.remove('drag-above', 'drag-below')); + noteEl.classList.add(e.clientY < midY ? 'drag-above' : 'drag-below'); + }); + noteEl.addEventListener("dragleave", (e) => { + // Only clear if leaving the element entirely (not entering a child) + if (noteEl.contains(e.relatedTarget as Node)) return; + noteEl.classList.remove('drag-above', 'drag-below'); + }); + noteEl.addEventListener("drop", (e) => { + e.preventDefault(); + e.stopPropagation(); + // Clear all indicators + this.shadow.querySelectorAll('.sbt-note').forEach(n => + (n as HTMLElement).classList.remove('drag-above', 'drag-below')); + const raw = e.dataTransfer?.getData("application/x-rnotes-move"); + if (!raw) return; + try { + const { noteId, sourceNotebookId } = JSON.parse(raw); + const targetNbId = noteEl.dataset.notebook!; + // Cross-notebook move — delegate to moveNoteToNotebook + if (sourceNotebookId !== targetNbId) { + this.moveNoteToNotebook(noteId, sourceNotebookId, targetNbId); + return; + } + // Same notebook — reorder + const notes = this.notebookNotes.get(targetNbId); + if (!notes) return; + const targetNoteId = noteEl.dataset.note!; + const targetIdx = notes.findIndex(n => n.id === targetNoteId); + if (targetIdx < 0) return; + // Determine final index based on above/below + const rect = noteEl.getBoundingClientRect(); + const insertAfter = e.clientY >= rect.top + rect.height / 2; + const srcIdx = notes.findIndex(n => n.id === noteId); + let finalIdx = insertAfter ? targetIdx + 1 : targetIdx; + // Adjust if dragging from before the target + if (srcIdx < finalIdx) finalIdx--; + this.reorderNote(noteId, targetNbId, finalIdx); + } catch {} + }); + }); } private demoUpdateNoteField(noteId: string, field: string, value: string) { @@ -3417,6 +3540,8 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .sbt-note-icon { font-size: 14px; flex-shrink: 0; } .sbt-note-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sbt-note-pin { font-size: 10px; flex-shrink: 0; } + .sbt-note.drag-above { box-shadow: 0 -2px 0 0 var(--rs-primary, #6366f1); } + .sbt-note.drag-below { box-shadow: 0 2px 0 0 var(--rs-primary, #6366f1); } .sidebar-footer { padding: 8px 12px; border-top: 1px solid var(--rs-border-subtle); diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index a5ed9fe..0ea08b1 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -1622,7 +1622,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); });