Merge branch 'dev'
This commit is contained in:
commit
d294d5e164
|
|
@ -87,6 +87,7 @@ interface Note {
|
||||||
mimeType?: string | null;
|
mimeType?: string | null;
|
||||||
duration?: number | null;
|
duration?: number | null;
|
||||||
source_ref?: { source: string; syncStatus?: string; lastSyncedAt?: number };
|
source_ref?: { source: string; syncStatus?: string; lastSyncedAt?: number };
|
||||||
|
sort_order?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
@ -526,7 +527,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
const newNote: Note = {
|
const newNote: Note = {
|
||||||
id: noteId, title: "Untitled Note", content: "", content_plain: "",
|
id: noteId, title: "Untitled Note", content: "", content_plain: "",
|
||||||
content_format: 'tiptap-json',
|
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(),
|
created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
|
||||||
};
|
};
|
||||||
const nb = {
|
const nb = {
|
||||||
|
|
@ -555,7 +556,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
const newNote: Note = {
|
const newNote: Note = {
|
||||||
id: noteId, title, content: opts.content || "", content_plain: "",
|
id: noteId, title, content: opts.content || "", content_plain: "",
|
||||||
content_format: type === 'CODE' ? 'html' : 'tiptap-json',
|
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,
|
url: opts.url || null, language: opts.language || null,
|
||||||
fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
|
fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
|
||||||
duration: opts.duration ?? null,
|
duration: opts.duration ?? null,
|
||||||
|
|
@ -686,16 +687,14 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
fileUrl: item.fileUrl || null,
|
fileUrl: item.fileUrl || null,
|
||||||
mimeType: item.mimeType || null,
|
mimeType: item.mimeType || null,
|
||||||
duration: item.duration ?? null,
|
duration: item.duration ?? null,
|
||||||
|
sort_order: item.sortOrder || 0,
|
||||||
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
|
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
|
||||||
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
|
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.sort((a, b) => {
|
this.sortNotes(notes);
|
||||||
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.selectedNotebook = {
|
this.selectedNotebook = {
|
||||||
id: nb.id,
|
id: nb.id,
|
||||||
|
|
@ -730,6 +729,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
fileUrl: noteItem.fileUrl || null,
|
fileUrl: noteItem.fileUrl || null,
|
||||||
mimeType: noteItem.mimeType || null,
|
mimeType: noteItem.mimeType || null,
|
||||||
duration: noteItem.duration ?? null,
|
duration: noteItem.duration ?? null,
|
||||||
|
sort_order: noteItem.sortOrder || 0,
|
||||||
created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
|
created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
|
||||||
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
|
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
@ -792,6 +792,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
fileUrl: noteItem.fileUrl || null,
|
fileUrl: noteItem.fileUrl || null,
|
||||||
mimeType: noteItem.mimeType || null,
|
mimeType: noteItem.mimeType || null,
|
||||||
duration: noteItem.duration ?? null,
|
duration: noteItem.duration ?? null,
|
||||||
|
sort_order: noteItem.sortOrder || 0,
|
||||||
created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
|
created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
|
||||||
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
|
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
@ -856,7 +857,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
this.selectedNote = {
|
this.selectedNote = {
|
||||||
id: noteId, title, content: opts.content || "", content_plain: "",
|
id: noteId, title, content: opts.content || "", content_plain: "",
|
||||||
content_format: contentFormat,
|
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,
|
url: opts.url || null, language: opts.language || null,
|
||||||
fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
|
fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
|
||||||
duration: opts.duration ?? null,
|
duration: opts.duration ?? null,
|
||||||
|
|
@ -885,6 +886,64 @@ Gear: EUR 400 (10%)</code></pre><p><em>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. */
|
/** Move a note from one notebook to another via Automerge docs. */
|
||||||
private async moveNoteToNotebook(noteId: string, sourceNotebookId: string, targetNotebookId: string) {
|
private async moveNoteToNotebook(noteId: string, sourceNotebookId: string, targetNotebookId: string) {
|
||||||
if (sourceNotebookId === targetNotebookId) return;
|
if (sourceNotebookId === targetNotebookId) return;
|
||||||
|
|
@ -950,6 +1009,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
is_pinned: item.isPinned || false, url: item.url || null,
|
is_pinned: item.isPinned || false, url: item.url || null,
|
||||||
language: item.language || null, fileUrl: item.fileUrl || null,
|
language: item.language || null, fileUrl: item.fileUrl || null,
|
||||||
mimeType: item.mimeType || null, duration: item.duration ?? 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(),
|
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
|
||||||
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
|
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -1114,6 +1174,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.selectedNotebook = data;
|
this.selectedNotebook = data;
|
||||||
if (data?.notes) {
|
if (data?.notes) {
|
||||||
|
this.sortNotes(data.notes);
|
||||||
this.notebookNotes.set(id, data.notes);
|
this.notebookNotes.set(id, data.notes);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -1130,6 +1191,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
const res = await fetch(`${base}/api/notebooks/${id}`, { headers: this.authHeaders() });
|
const res = await fetch(`${base}/api/notebooks/${id}`, { headers: this.authHeaders() });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data?.notes) {
|
if (data?.notes) {
|
||||||
|
this.sortNotes(data.notes);
|
||||||
this.notebookNotes.set(id, data.notes);
|
this.notebookNotes.set(id, data.notes);
|
||||||
const nbIdx = this.notebooks.findIndex(n => n.id === id);
|
const nbIdx = this.notebooks.findIndex(n => n.id === id);
|
||||||
if (nbIdx >= 0) this.notebooks[nbIdx].note_count = String(data.notes.length);
|
if (nbIdx >= 0) this.notebooks[nbIdx].note_count = String(data.notes.length);
|
||||||
|
|
@ -1240,6 +1302,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
fileUrl: item.fileUrl || null,
|
fileUrl: item.fileUrl || null,
|
||||||
mimeType: item.mimeType || null,
|
mimeType: item.mimeType || null,
|
||||||
duration: item.duration ?? null,
|
duration: item.duration ?? null,
|
||||||
|
sort_order: item.sortOrder || 0,
|
||||||
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
|
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
|
||||||
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
|
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
@ -3171,16 +3234,22 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
return title ? { title, module: "rnotes", entityId: id, label: "Note", color: "#f59e0b" } : null;
|
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 => {
|
this.shadow.querySelectorAll(".sbt-note[data-note]").forEach(el => {
|
||||||
(el as HTMLElement).addEventListener("dragstart", (e) => {
|
(el as HTMLElement).addEventListener("dragstart", (e) => {
|
||||||
const noteId = (el as HTMLElement).dataset.note!;
|
const noteId = (el as HTMLElement).dataset.note!;
|
||||||
const nbId = (el as HTMLElement).dataset.notebook!;
|
const nbId = (el as HTMLElement).dataset.notebook!;
|
||||||
e.dataTransfer?.setData("application/x-rnotes-move", JSON.stringify({ noteId, sourceNotebookId: nbId }));
|
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 => {
|
this.shadow.querySelectorAll(".sbt-notebook-header[data-toggle-notebook]").forEach(el => {
|
||||||
(el as HTMLElement).addEventListener("dragover", (e) => {
|
(el as HTMLElement).addEventListener("dragover", (e) => {
|
||||||
if (e.dataTransfer?.types.includes("application/x-rnotes-move")) {
|
if (e.dataTransfer?.types.includes("application/x-rnotes-move")) {
|
||||||
|
|
@ -3205,6 +3274,60 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
} catch {}
|
} 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) {
|
private demoUpdateNoteField(noteId: string, field: string, value: string) {
|
||||||
|
|
@ -3417,6 +3540,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
.sbt-note-icon { font-size: 14px; flex-shrink: 0; }
|
.sbt-note-icon { font-size: 14px; flex-shrink: 0; }
|
||||||
.sbt-note-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.sbt-note-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.sbt-note-pin { font-size: 10px; flex-shrink: 0; }
|
.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 {
|
.sidebar-footer {
|
||||||
padding: 8px 12px; border-top: 1px solid var(--rs-border-subtle);
|
padding: 8px 12px; border-top: 1px solid var(--rs-border-subtle);
|
||||||
|
|
|
||||||
|
|
@ -1622,7 +1622,7 @@ routes.get("/", (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
|
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
|
||||||
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=12"></script>`,
|
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=13"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
|
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ import { vnbModule } from "../modules/rvnb/mod";
|
||||||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||||
import type { SpaceRoleString } from "./spaces";
|
import type { SpaceRoleString } from "./spaces";
|
||||||
import { renderShell, renderSubPageInfo, renderOnboarding } from "./shell";
|
import { renderShell, renderSubPageInfo, renderOnboarding, setFragmentMode } from "./shell";
|
||||||
import { renderOutputListPage } from "./output-list";
|
import { renderOutputListPage } from "./output-list";
|
||||||
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
||||||
import { syncServer } from "./sync-instance";
|
import { syncServer } from "./sync-instance";
|
||||||
|
|
@ -2595,6 +2595,22 @@ for (const mod of getAllModules()) {
|
||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Fragment mode: signal renderShell to return lightweight JSON instead of full shell HTML.
|
||||||
|
// Used by TabCache for fast tab switching — skips the entire 2000-line shell template.
|
||||||
|
app.use(`/:space/${mod.id}`, async (c, next) => {
|
||||||
|
if (c.req.method !== "GET" || !c.req.query("fragment")) return next();
|
||||||
|
// Set the global flag so renderShell returns JSON fragment
|
||||||
|
setFragmentMode(true);
|
||||||
|
await next();
|
||||||
|
// renderShell already returned JSON — fix the content-type header
|
||||||
|
if (c.res.headers.get("content-type")?.includes("text/html")) {
|
||||||
|
const body = await c.res.text();
|
||||||
|
c.res = new Response(body, {
|
||||||
|
status: c.res.status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
app.route(`/:space/${mod.id}`, mod.routes);
|
app.route(`/:space/${mod.id}`, mod.routes);
|
||||||
// Auto-mount browsable output list pages
|
// Auto-mount browsable output list pages
|
||||||
if (mod.outputPaths) {
|
if (mod.outputPaths) {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,54 @@ export function getSpaceShellMeta(spaceSlug: string): { enabledModules: string[]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a lightweight JSON fragment for tab-cache.
|
||||||
|
* Returns only the body HTML, title, scripts, and styles — no shell chrome.
|
||||||
|
* Used by TabCache's ?fragment=1 requests to avoid re-rendering the full 2000-line shell.
|
||||||
|
*/
|
||||||
|
export function renderFragment(opts: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
scripts?: string;
|
||||||
|
styles?: string;
|
||||||
|
}): string {
|
||||||
|
// Parse script srcs from the scripts string
|
||||||
|
const scriptSrcs: string[] = [];
|
||||||
|
const scriptRe = /src="([^"]+)"/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
if (opts.scripts) {
|
||||||
|
while ((m = scriptRe.exec(opts.scripts)) !== null) {
|
||||||
|
scriptSrcs.push(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse stylesheet hrefs from the styles string
|
||||||
|
const styleSrcs: string[] = [];
|
||||||
|
const styleRe = /href="([^"]+)"/g;
|
||||||
|
if (opts.styles) {
|
||||||
|
while ((m = styleRe.exec(opts.styles)) !== null) {
|
||||||
|
styleSrcs.push(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract inline <style> content
|
||||||
|
const inlineStyles: string[] = [];
|
||||||
|
const inlineRe = /<style[^>]*>([\s\S]*?)<\/style>/g;
|
||||||
|
if (opts.styles) {
|
||||||
|
while ((m = inlineRe.exec(opts.styles)) !== null) {
|
||||||
|
if (m[1].trim()) inlineStyles.push(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body,
|
||||||
|
scripts: scriptSrcs,
|
||||||
|
styles: styleSrcs,
|
||||||
|
inlineStyles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShellOptions {
|
export interface ShellOptions {
|
||||||
/** Page <title> */
|
/** Page <title> */
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -112,6 +160,8 @@ export interface ShellOptions {
|
||||||
enabledModules?: string[] | null;
|
enabledModules?: string[] | null;
|
||||||
/** Whether this space has client-side encryption enabled */
|
/** Whether this space has client-side encryption enabled */
|
||||||
spaceEncrypted?: boolean;
|
spaceEncrypted?: boolean;
|
||||||
|
/** If true, return lightweight JSON fragment instead of full shell HTML */
|
||||||
|
fragment?: boolean;
|
||||||
/** Optional tab bar rendered below the subnav. */
|
/** Optional tab bar rendered below the subnav. */
|
||||||
tabs?: Array<{ id: string; label: string; icon?: string }>;
|
tabs?: Array<{ id: string; label: string; icon?: string }>;
|
||||||
/** Active tab ID (matched from URL path by server). First tab if omitted. */
|
/** Active tab ID (matched from URL path by server). First tab if omitted. */
|
||||||
|
|
@ -138,7 +188,23 @@ export function buildSpaceUrl(space: string, path: string, host?: string): strin
|
||||||
return `http://${h}/${space}${path}`;
|
return `http://${h}/${space}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Global flag set by middleware to signal fragment mode to renderShell. */
|
||||||
|
let _fragmentMode = false;
|
||||||
|
export function setFragmentMode(on: boolean) { _fragmentMode = on; }
|
||||||
|
|
||||||
export function renderShell(opts: ShellOptions): string {
|
export function renderShell(opts: ShellOptions): string {
|
||||||
|
// Fragment mode: return lightweight JSON instead of full shell HTML.
|
||||||
|
// Triggered by middleware setting the flag, or by opts.fragment.
|
||||||
|
if (opts.fragment || _fragmentMode) {
|
||||||
|
_fragmentMode = false; // Reset after use
|
||||||
|
return renderFragment({
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body,
|
||||||
|
scripts: opts.scripts,
|
||||||
|
styles: opts.styles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
moduleId,
|
moduleId,
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,20 @@ export class RStackAppSwitcher extends HTMLElement {
|
||||||
};
|
};
|
||||||
document.addEventListener("click", this.#outsideClickHandler);
|
document.addEventListener("click", this.#outsideClickHandler);
|
||||||
|
|
||||||
|
// Prefetch module fragments on hover for faster tab switching.
|
||||||
|
// Uses low-priority fetch so it doesn't compete with user-initiated requests.
|
||||||
|
if ((window as any).__rspaceTabBar) {
|
||||||
|
this.#shadow.querySelectorAll("a.item").forEach((el) => {
|
||||||
|
el.addEventListener("mouseenter", () => {
|
||||||
|
const moduleId = (el as HTMLElement).dataset.id;
|
||||||
|
if (!moduleId || moduleId === current) return;
|
||||||
|
const space = this.#getSpaceSlug();
|
||||||
|
const href = `/${space}/${moduleId}?fragment=1`;
|
||||||
|
fetch(href, { priority: "low" } as RequestInit).catch(() => {});
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Intercept same-origin module links → dispatch event for tab system
|
// Intercept same-origin module links → dispatch event for tab system
|
||||||
// Only intercept when the shell tab system is active (window.__rspaceTabBar).
|
// Only intercept when the shell tab system is active (window.__rspaceTabBar).
|
||||||
// On landing pages (rspace.online/, rspace.online/{moduleId}), let links
|
// On landing pages (rspace.online/, rspace.online/{moduleId}), let links
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
#unsubAwareness: (() => void) | null = null;
|
#unsubAwareness: (() => void) | null = null;
|
||||||
#unsubPresence: (() => void) | null = null;
|
#unsubPresence: (() => void) | null = null;
|
||||||
#unsubLeave: (() => void) | null = null;
|
#unsubLeave: (() => void) | null = null;
|
||||||
|
#unsubConnect: (() => void) | null = null;
|
||||||
|
#unsubDisconnect: (() => void) | null = null;
|
||||||
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
|
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
#lastCursor = { x: 0, y: 0 };
|
#lastCursor = { x: 0, y: 0 };
|
||||||
#gcInterval: ReturnType<typeof setInterval> | null = null;
|
#gcInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
@ -112,11 +114,15 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
this.#unsubAwareness?.();
|
this.#unsubAwareness?.();
|
||||||
this.#unsubPresence?.();
|
this.#unsubPresence?.();
|
||||||
this.#unsubLeave?.();
|
this.#unsubLeave?.();
|
||||||
|
this.#unsubConnect?.();
|
||||||
|
this.#unsubDisconnect?.();
|
||||||
this.#stopMouseTracking();
|
this.#stopMouseTracking();
|
||||||
this.#stopFocusTracking();
|
this.#stopFocusTracking();
|
||||||
}
|
}
|
||||||
if (this.#gcInterval) clearInterval(this.#gcInterval);
|
if (this.#gcInterval) clearInterval(this.#gcInterval);
|
||||||
this.#gcInterval = null;
|
this.#gcInterval = null;
|
||||||
|
if (this.#runtimePollInterval) clearInterval(this.#runtimePollInterval);
|
||||||
|
this.#runtimePollInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public bridge API (for canvas CommunitySync) ──
|
// ── Public bridge API (for canvas CommunitySync) ──
|
||||||
|
|
@ -167,21 +173,36 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
|
|
||||||
// ── Runtime connection ──
|
// ── Runtime connection ──
|
||||||
|
|
||||||
|
#runtimePollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
#tryConnect() {
|
#tryConnect() {
|
||||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
if (runtime?.isInitialized) {
|
if (runtime?.isInitialized) {
|
||||||
this.#onRuntimeReady(runtime);
|
this.#onRuntimeReady(runtime);
|
||||||
} else {
|
} else {
|
||||||
// Retry until runtime is ready
|
// Poll until runtime is ready (no timeout — WS connect can take 30s+)
|
||||||
const check = setInterval(() => {
|
let polls = 0;
|
||||||
|
this.#runtimePollInterval = setInterval(() => {
|
||||||
const rt = (window as any).__rspaceOfflineRuntime;
|
const rt = (window as any).__rspaceOfflineRuntime;
|
||||||
if (rt?.isInitialized) {
|
if (rt?.isInitialized) {
|
||||||
clearInterval(check);
|
clearInterval(this.#runtimePollInterval!);
|
||||||
|
this.#runtimePollInterval = null;
|
||||||
this.#onRuntimeReady(rt);
|
this.#onRuntimeReady(rt);
|
||||||
}
|
}
|
||||||
|
// Slow down after 15s (30 polls × 500ms) to reduce overhead
|
||||||
|
polls++;
|
||||||
|
if (polls === 30 && this.#runtimePollInterval) {
|
||||||
|
clearInterval(this.#runtimePollInterval);
|
||||||
|
this.#runtimePollInterval = setInterval(() => {
|
||||||
|
const rt2 = (window as any).__rspaceOfflineRuntime;
|
||||||
|
if (rt2?.isInitialized) {
|
||||||
|
clearInterval(this.#runtimePollInterval!);
|
||||||
|
this.#runtimePollInterval = null;
|
||||||
|
this.#onRuntimeReady(rt2);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
// Give up after 15s
|
|
||||||
setTimeout(() => clearInterval(check), 15000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,7 +210,14 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
this.#localPeerId = runtime.peerId;
|
this.#localPeerId = runtime.peerId;
|
||||||
// Assign a deterministic color from peer ID
|
// Assign a deterministic color from peer ID
|
||||||
this.#localColor = this.#colorForPeer(this.#localPeerId!);
|
this.#localColor = this.#colorForPeer(this.#localPeerId!);
|
||||||
this.#connState = 'connected';
|
// Set initial state from actual runtime connection status
|
||||||
|
this.#connState = runtime.isOnline ? 'connected' : 'offline';
|
||||||
|
|
||||||
|
// Track ongoing connection state changes
|
||||||
|
this.#unsubConnect = runtime.onConnect(() => this.setConnState('connected'));
|
||||||
|
this.#unsubDisconnect = runtime.onDisconnect(() => {
|
||||||
|
this.setConnState(navigator.onLine ? 'reconnecting' : 'offline');
|
||||||
|
});
|
||||||
|
|
||||||
// Load space members for offline display
|
// Load space members for offline display
|
||||||
this.#loadSpaceMembers();
|
this.#loadSpaceMembers();
|
||||||
|
|
@ -517,7 +545,7 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
if (this.#connState === 'offline' || this.#connState === 'reconnecting' || this.#connState === 'connecting') {
|
if (this.#connState === 'offline' || this.#connState === 'reconnecting' || this.#connState === 'connecting') {
|
||||||
const msg = this.#connState === 'offline'
|
const msg = this.#connState === 'offline'
|
||||||
? '\u26a0 You\u2019re offline. Changes are saved locally and will resync when you reconnect.'
|
? '\u26a0 You\u2019re offline. Changes are saved locally and will resync when you reconnect.'
|
||||||
: 'Reconnecting to server\u2026';
|
: this.#connState === 'connecting' ? 'Connecting to server\u2026' : 'Reconnecting to server\u2026';
|
||||||
fragments.push(`<div class="conn-notice">${this.#escHtml(msg)}</div>`);
|
fragments.push(`<div class="conn-notice">${this.#escHtml(msg)}</div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -669,12 +697,13 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.#connState === 'reconnecting' || this.#connState === 'connecting') {
|
if (this.#connState === 'reconnecting' || this.#connState === 'connecting') {
|
||||||
|
const label = this.#connState === 'connecting' ? 'Connecting\u2026' : 'Reconnecting\u2026';
|
||||||
badge.innerHTML = `
|
badge.innerHTML = `
|
||||||
<span class="dot" style="background:#3b82f6"></span>
|
<span class="dot" style="background:#3b82f6"></span>
|
||||||
<span class="count">Reconnecting\u2026</span>
|
<span class="count">${label}</span>
|
||||||
`;
|
`;
|
||||||
badge.classList.add('visible');
|
badge.classList.add('visible');
|
||||||
badge.title = 'Reconnecting to server\u2026';
|
badge.title = label;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,11 @@ export class RSpaceOfflineRuntime {
|
||||||
// 1. Open IndexedDB
|
// 1. Open IndexedDB
|
||||||
await this.#store.open();
|
await this.#store.open();
|
||||||
|
|
||||||
// 2. Connect WebSocket (non-blocking — works offline)
|
// 2. Mark initialized early so UI components can start tracking state
|
||||||
|
// (WS connect can take 30s+ if the server is slow to respond)
|
||||||
|
this.#initialized = true;
|
||||||
|
|
||||||
|
// 3. Connect WebSocket (non-blocking — works offline)
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${proto}//${location.host}/ws/${this.#activeSpace}`;
|
const wsUrl = `${proto}//${location.host}/ws/${this.#activeSpace}`;
|
||||||
|
|
||||||
|
|
@ -103,9 +107,7 @@ export class RSpaceOfflineRuntime {
|
||||||
this.#setStatus('offline');
|
this.#setStatus('offline');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#initialized = true;
|
// 4. Storage housekeeping (non-blocking)
|
||||||
|
|
||||||
// 3. Storage housekeeping (non-blocking)
|
|
||||||
this.#runStorageHousekeeping();
|
this.#runStorageHousekeeping();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.#setStatus('error');
|
this.#setStatus('error');
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,6 @@ export class TabCache {
|
||||||
try {
|
try {
|
||||||
const resolved = new URL(url, window.location.href);
|
const resolved = new URL(url, window.location.href);
|
||||||
if (resolved.origin !== window.location.origin) {
|
if (resolved.origin !== window.location.origin) {
|
||||||
// Rewrite subdomain URL to path format: /{space}/{moduleId}
|
|
||||||
fetchUrl = `/${space}/${moduleId}`;
|
fetchUrl = `/${space}/${moduleId}`;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -196,7 +195,9 @@ export class TabCache {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
if (!app) { console.warn("[TabCache] fetchAndInject: no #app"); return false; }
|
if (!app) { console.warn("[TabCache] fetchAndInject: no #app"); return false; }
|
||||||
|
|
||||||
console.log("[TabCache] fetchAndInject:", fetchUrl, "for", moduleId);
|
// Append ?fragment=1 for lightweight JSON response (no full shell re-render)
|
||||||
|
const fragmentUrl = fetchUrl + (fetchUrl.includes("?") ? "&" : "?") + "fragment=1";
|
||||||
|
console.log("[TabCache] fetchAndInject:", fragmentUrl, "for", moduleId);
|
||||||
|
|
||||||
// Show loading spinner
|
// Show loading spinner
|
||||||
const loadingPane = document.createElement("div");
|
const loadingPane = document.createElement("div");
|
||||||
|
|
@ -212,23 +213,33 @@ export class TabCache {
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(fetchUrl, {
|
const resp = await fetch(fragmentUrl, {
|
||||||
headers: { "Accept": "text/html" },
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fetchUrl);
|
console.warn("[TabCache] fetchAndInject: HTTP", resp.status, "for", fragmentUrl);
|
||||||
loadingPane.remove();
|
loadingPane.remove();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await resp.text();
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
console.log("[TabCache] fetchAndInject: got", html.length, "bytes for", moduleId);
|
const ct = resp.headers.get("content-type") || "";
|
||||||
const content = this.extractContent(html);
|
let content: { body: string; title: string; scripts: string[]; styles: string[]; inlineStyles: string[] } | null;
|
||||||
|
|
||||||
|
if (ct.includes("application/json")) {
|
||||||
|
// Fast path: server returned pre-extracted JSON fragment
|
||||||
|
content = await resp.json();
|
||||||
|
console.log("[TabCache] fetchAndInject: got JSON fragment for", moduleId);
|
||||||
|
} else {
|
||||||
|
// Fallback: server returned full HTML (fragment middleware didn't match)
|
||||||
|
const html = await resp.text();
|
||||||
|
console.log("[TabCache] fetchAndInject: got", html.length, "bytes HTML (fallback) for", moduleId);
|
||||||
|
content = this.extractContent(html);
|
||||||
|
}
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
console.warn("[TabCache] fetchAndInject: extractContent returned null for", moduleId, "— HTML has #app:", html.includes('id="app"'));
|
console.warn("[TabCache] fetchAndInject: no content extracted for", moduleId);
|
||||||
loadingPane.remove();
|
loadingPane.remove();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue