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: `