354 lines
13 KiB
TypeScript
354 lines
13 KiB
TypeScript
/**
|
|
* rNotes demo — client-side WebSocket controller.
|
|
*
|
|
* Connects to rSpace via DemoSync, populates note cards,
|
|
* packing list checkboxes, sidebar, and notebook header.
|
|
*/
|
|
|
|
import { DemoSync, type DemoShape } from "../../../lib/demo-sync-vanilla";
|
|
|
|
// ── Helpers ──
|
|
|
|
function shapesByType(shapes: Record<string, DemoShape>, type: string): DemoShape[] {
|
|
return Object.values(shapes).filter((s) => s.type === type);
|
|
}
|
|
|
|
function shapeByType(shapes: Record<string, DemoShape>, type: string): DemoShape | undefined {
|
|
return Object.values(shapes).find((s) => s.type === type);
|
|
}
|
|
|
|
function $(id: string): HTMLElement | null {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
// ── Simple markdown renderer ──
|
|
|
|
function renderMarkdown(text: string): string {
|
|
if (!text) return "";
|
|
const lines = text.split("\n");
|
|
const out: string[] = [];
|
|
let inCodeBlock = false;
|
|
let codeLang = "";
|
|
let codeLines: string[] = [];
|
|
let inList: "ul" | "ol" | null = null;
|
|
|
|
function flushList() {
|
|
if (inList) { out.push(inList === "ul" ? "</ul>" : "</ol>"); inList = null; }
|
|
}
|
|
|
|
function flushCode() {
|
|
if (inCodeBlock) {
|
|
const escaped = codeLines.join("\n").replace(/</g, "<").replace(/>/g, ">");
|
|
out.push(`<div class="rd-md-codeblock">${codeLang ? `<div class="rd-md-codeblock-lang"><span>${codeLang}</span></div>` : ""}<pre>${escaped}</pre></div>`);
|
|
inCodeBlock = false;
|
|
codeLines = [];
|
|
codeLang = "";
|
|
}
|
|
}
|
|
|
|
for (const raw of lines) {
|
|
const line = raw;
|
|
|
|
// Code fence
|
|
if (line.startsWith("```")) {
|
|
if (inCodeBlock) { flushCode(); } else { flushList(); inCodeBlock = true; codeLang = line.slice(3).trim(); }
|
|
continue;
|
|
}
|
|
if (inCodeBlock) { codeLines.push(line); continue; }
|
|
|
|
// Blank line
|
|
if (!line.trim()) { flushList(); continue; }
|
|
|
|
// Headings
|
|
if (line.startsWith("### ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(4))}</h3>`); continue; }
|
|
if (line.startsWith("## ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(3))}</h3>`); continue; }
|
|
if (line.startsWith("# ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(2))}</h3>`); continue; }
|
|
if (line.startsWith("#### ")) { flushList(); out.push(`<h4>${inlineFormat(line.slice(5))}</h4>`); continue; }
|
|
if (line.startsWith("##### ")) { flushList(); out.push(`<h5>${inlineFormat(line.slice(6))}</h5>`); continue; }
|
|
|
|
// Blockquote
|
|
if (line.startsWith("> ")) { flushList(); out.push(`<div class="rd-md-quote"><p>${inlineFormat(line.slice(2))}</p></div>`); continue; }
|
|
|
|
// Unordered list
|
|
const ulMatch = line.match(/^[-*]\s+(.+)/);
|
|
if (ulMatch) {
|
|
if (inList !== "ul") { flushList(); out.push("<ul>"); inList = "ul"; }
|
|
out.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
|
|
continue;
|
|
}
|
|
|
|
// Ordered list
|
|
const olMatch = line.match(/^(\d+)\.\s+(.+)/);
|
|
if (olMatch) {
|
|
if (inList !== "ol") { flushList(); out.push("<ol>"); inList = "ol"; }
|
|
out.push(`<li><span class="rd-md-num">${olMatch[1]}.</span>${inlineFormat(olMatch[2])}</li>`);
|
|
continue;
|
|
}
|
|
|
|
// Paragraph
|
|
flushList();
|
|
out.push(`<p>${inlineFormat(line)}</p>`);
|
|
}
|
|
|
|
flushCode();
|
|
flushList();
|
|
return out.join("\n");
|
|
}
|
|
|
|
function inlineFormat(text: string): string {
|
|
return text
|
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
}
|
|
|
|
// ── Note card rendering ──
|
|
|
|
const TAG_COLORS: Record<string, string> = {
|
|
planning: "rgba(245,158,11,0.15)",
|
|
travel: "rgba(20,184,166,0.15)",
|
|
food: "rgba(251,146,60,0.15)",
|
|
gear: "rgba(168,85,247,0.15)",
|
|
safety: "rgba(239,68,68,0.15)",
|
|
accommodation: "rgba(59,130,246,0.15)",
|
|
};
|
|
|
|
function renderNoteCard(note: DemoShape, expanded: boolean): string {
|
|
const title = (note.title as string) || "Untitled";
|
|
const content = (note.content as string) || "";
|
|
const tags = (note.tags as string[]) || [];
|
|
const lastEdited = note.lastEdited as string;
|
|
const synced = note.synced !== false;
|
|
|
|
const preview = content.split("\n").slice(0, 3).join(" ").slice(0, 120);
|
|
const previewText = preview.replace(/[#*>`\-]/g, "").trim();
|
|
|
|
return `
|
|
<div class="rd-card rd-note-card ${expanded ? "rd-note-card--expanded" : ""}" data-note-id="${note.id}" style="cursor:pointer;">
|
|
<div style="padding:1rem 1.25rem;">
|
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:0.5rem;">
|
|
<h3 style="font-size:0.9375rem;font-weight:600;color:white;margin:0;">${escHtml(title)}</h3>
|
|
${synced ? `<span class="rd-synced-badge"><span style="width:6px;height:6px;border-radius:50%;background:#2dd4bf;"></span>synced</span>` : ""}
|
|
</div>
|
|
${expanded
|
|
? `<div class="rd-md" style="margin-top:0.75rem;">${renderMarkdown(content)}</div>`
|
|
: `<p style="font-size:0.8125rem;color:#94a3b8;margin:0 0 0.75rem;line-height:1.5;">${escHtml(previewText)}${content.length > 120 ? "..." : ""}</p>`
|
|
}
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:0.75rem;">
|
|
<div style="display:flex;flex-wrap:wrap;gap:0.375rem;">
|
|
${tags.map((t) => `<span class="rd-note-tag" style="background:${TAG_COLORS[t] || "rgba(51,65,85,0.5)"}">${escHtml(t)}</span>`).join("")}
|
|
</div>
|
|
${lastEdited ? `<span style="font-size:0.6875rem;color:#64748b;">${formatRelative(lastEdited)}</span>` : ""}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function escHtml(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
function formatRelative(iso: string): string {
|
|
try {
|
|
const d = new Date(iso);
|
|
const now = Date.now();
|
|
const diff = now - d.getTime();
|
|
if (diff < 60_000) return "just now";
|
|
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
|
|
return d.toLocaleDateString("en", { month: "short", day: "numeric" });
|
|
} catch { return ""; }
|
|
}
|
|
|
|
// ── Packing list rendering ──
|
|
|
|
function renderPackingList(packingList: DemoShape): string {
|
|
const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || [];
|
|
if (items.length === 0) return "";
|
|
|
|
// Group by category
|
|
const groups: Record<string, typeof items> = {};
|
|
for (const item of items) {
|
|
const cat = item.category || "General";
|
|
if (!groups[cat]) groups[cat] = [];
|
|
groups[cat].push(item);
|
|
}
|
|
|
|
const checked = items.filter((i) => i.packed).length;
|
|
const pct = Math.round((checked / items.length) * 100);
|
|
|
|
let html = `
|
|
<div class="rd-card" style="overflow:hidden;">
|
|
<div style="padding:0.75rem 1rem;border-bottom:1px solid rgba(51,65,85,0.5);display:flex;align-items:center;justify-content:space-between;">
|
|
<span style="font-size:0.8125rem;font-weight:600;color:white;">Packing Checklist</span>
|
|
<span style="font-size:0.75rem;color:#94a3b8;">${checked}/${items.length} packed (${pct}%)</span>
|
|
</div>
|
|
<div style="padding:0.75rem 1rem 0.25rem;">
|
|
<div style="height:0.375rem;background:rgba(51,65,85,0.5);border-radius:9999px;overflow:hidden;margin-bottom:0.75rem;">
|
|
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#f59e0b,#fb923c);border-radius:9999px;transition:width 0.3s;"></div>
|
|
</div>
|
|
</div>`;
|
|
|
|
for (const [cat, catItems] of Object.entries(groups)) {
|
|
html += `<div style="padding:0 0.75rem 0.75rem;">
|
|
<h4 style="font-size:0.6875rem;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.25rem;">${escHtml(cat)}</h4>`;
|
|
for (let i = 0; i < catItems.length; i++) {
|
|
const item = catItems[i];
|
|
const globalIdx = items.indexOf(item);
|
|
html += `
|
|
<div class="rd-pack-item" data-pack-idx="${globalIdx}">
|
|
<div class="rd-pack-check ${item.packed ? "rd-pack-check--checked" : ""}">
|
|
${item.packed ? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>` : ""}
|
|
</div>
|
|
<span style="font-size:0.8125rem;${item.packed ? "color:#64748b;text-decoration:line-through;" : "color:#e2e8f0;"}">${escHtml(item.name)}</span>
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
return html;
|
|
}
|
|
|
|
// ── Avatars ──
|
|
|
|
const AVATAR_COLORS = ["#14b8a6", "#06b6d4", "#8b5cf6", "#f59e0b", "#f43f5e"];
|
|
|
|
function renderAvatars(members: string[]): string {
|
|
if (!members.length) return "";
|
|
return members.map((name, i) =>
|
|
`<div class="rd-avatar" style="background:${AVATAR_COLORS[i % AVATAR_COLORS.length]}" title="${escHtml(name)}">${name[0]}</div>`
|
|
).join("") + `<span style="font-size:0.75rem;color:#94a3b8;margin-left:0.25rem;">${members.length} collaborators</span>`;
|
|
}
|
|
|
|
// ── Main ──
|
|
|
|
const expandedNotes = new Set<string>();
|
|
|
|
const sync = new DemoSync({ filter: ["folk-notebook", "folk-note", "folk-packing-list"] });
|
|
|
|
function render(shapes: Record<string, DemoShape>) {
|
|
const notebook = shapeByType(shapes, "folk-notebook");
|
|
const notes = shapesByType(shapes, "folk-note").sort((a, b) => {
|
|
const aTime = a.lastEdited ? new Date(a.lastEdited as string).getTime() : 0;
|
|
const bTime = b.lastEdited ? new Date(b.lastEdited as string).getTime() : 0;
|
|
return bTime - aTime;
|
|
});
|
|
const packingList = shapeByType(shapes, "folk-packing-list");
|
|
|
|
// Hide loading skeleton
|
|
const loading = $("rd-loading");
|
|
if (loading) loading.style.display = "none";
|
|
|
|
// Notebook header
|
|
if (notebook) {
|
|
const nbTitle = $("rd-nb-title");
|
|
const nbCount = $("rd-nb-count");
|
|
const nbDesc = $("rd-nb-desc");
|
|
const sbTitle = $("rd-sb-nb-title");
|
|
const sbCount = $("rd-sb-note-count");
|
|
const sbNum = $("rd-sb-notes-num");
|
|
|
|
if (nbTitle) nbTitle.textContent = (notebook.name as string) || "Trip Notebook";
|
|
if (nbCount) nbCount.textContent = `${notes.length} notes`;
|
|
if (nbDesc) nbDesc.textContent = (notebook.description as string) || "";
|
|
if (sbTitle) sbTitle.textContent = (notebook.name as string) || "Trip Notebook";
|
|
if (sbCount) sbCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`;
|
|
if (sbNum) sbNum.textContent = String(notes.length);
|
|
}
|
|
|
|
// Notes count
|
|
const notesCount = $("rd-notes-count");
|
|
if (notesCount) notesCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`;
|
|
|
|
// Notes container
|
|
const container = $("rd-notes-container");
|
|
const empty = $("rd-notes-empty");
|
|
if (container) {
|
|
if (notes.length === 0) {
|
|
container.innerHTML = "";
|
|
if (empty) empty.style.display = "block";
|
|
} else {
|
|
if (empty) empty.style.display = "none";
|
|
container.innerHTML = notes.map((n) => renderNoteCard(n, expandedNotes.has(n.id))).join("");
|
|
}
|
|
}
|
|
|
|
// Packing list
|
|
const packSection = $("rd-packing-section");
|
|
const packContainer = $("rd-packing-container");
|
|
if (packingList && packSection && packContainer) {
|
|
packSection.style.display = "block";
|
|
packContainer.innerHTML = renderPackingList(packingList);
|
|
}
|
|
|
|
// Avatars — extract from notebook members or note authors
|
|
const members = (notebook?.members as string[]) || [];
|
|
const avatarsEl = $("rd-avatars");
|
|
if (avatarsEl && members.length > 0) {
|
|
avatarsEl.innerHTML = renderAvatars(members);
|
|
}
|
|
}
|
|
|
|
// ── Event listeners ──
|
|
|
|
sync.addEventListener("snapshot", ((e: CustomEvent) => {
|
|
render(e.detail.shapes);
|
|
}) as EventListener);
|
|
|
|
sync.addEventListener("connected", () => {
|
|
const dot = $("rd-hero-dot");
|
|
const label = $("rd-hero-label");
|
|
if (dot) dot.style.background = "#10b981";
|
|
if (label) label.textContent = "Live — Connected to rSpace";
|
|
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
|
|
if (resetBtn) resetBtn.disabled = false;
|
|
});
|
|
|
|
sync.addEventListener("disconnected", () => {
|
|
const dot = $("rd-hero-dot");
|
|
const label = $("rd-hero-label");
|
|
if (dot) dot.style.background = "#64748b";
|
|
if (label) label.textContent = "Reconnecting...";
|
|
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
|
|
if (resetBtn) resetBtn.disabled = true;
|
|
});
|
|
|
|
// ── Event delegation ──
|
|
|
|
document.addEventListener("click", (e) => {
|
|
const target = e.target as HTMLElement;
|
|
|
|
// Note card expand/collapse
|
|
const noteCard = target.closest<HTMLElement>("[data-note-id]");
|
|
if (noteCard) {
|
|
const id = noteCard.dataset.noteId!;
|
|
if (expandedNotes.has(id)) expandedNotes.delete(id);
|
|
else expandedNotes.add(id);
|
|
render(sync.shapes);
|
|
return;
|
|
}
|
|
|
|
// Packing checkbox toggle
|
|
const packItem = target.closest<HTMLElement>("[data-pack-idx]");
|
|
if (packItem) {
|
|
const idx = parseInt(packItem.dataset.packIdx!, 10);
|
|
const packingList = shapeByType(sync.shapes, "folk-packing-list");
|
|
if (packingList) {
|
|
const items = [...(packingList.items as Array<{ name: string; packed: boolean; category: string }>)];
|
|
items[idx] = { ...items[idx], packed: !items[idx].packed };
|
|
sync.updateShape(packingList.id, { items });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Reset button
|
|
if (target.closest("#rd-reset-btn")) {
|
|
sync.resetDemo().catch((err) => console.error("[Notes] Reset failed:", err));
|
|
}
|
|
});
|
|
|
|
// ── Start ──
|
|
|
|
sync.connect();
|