rspace-online/modules/rnotes/components/notes-demo.ts

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, "&lt;").replace(/>/g, "&gt;");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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();