feat: add ViewHistory for in-app back navigation, rename rWork to rTasks
Add shared ViewHistory<V> utility class that provides a proper navigation stack for rApps with hierarchical views. Replaces hardcoded data-back targets with stack-based back navigation across 10 rApps: rtrips, rmaps, rtasks, rforum, rphotos, rvote, rnotes, rinbox, rschedule, rcart. Rename rWork module to rTasks — directory, component (folk-tasks-board), CSS, exports, domains, and all cross-module references updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9de37d7405
commit
31b088543e
|
|
@ -23,6 +23,7 @@ Thumbs.db
|
|||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
open-notebook.env
|
||||
|
||||
# Bun
|
||||
bun.lockb
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ redirects to the unified server with subdomain-based space routing.
|
|||
| **rCal** | rcal.online | Spatio-temporal calendar with map + lunar overlay |
|
||||
| **rMaps** | rmaps.online | Geographic mapping & location hierarchy |
|
||||
| **rTrips** | rtrips.online | Trip planning with itineraries |
|
||||
| **rWork** | rwork.online | Task boards & project management |
|
||||
| **rTasks** | rtasks.online | Task boards & project management |
|
||||
| **rSchedule** | rschedule.online | Persistent cron-based job scheduling with email, webhooks & briefings |
|
||||
|
||||
### Communication
|
||||
|
|
@ -332,7 +332,7 @@ The canvas is the primary interface. Modules render as shapes that can
|
|||
be positioned, connected, resized, and composed spatially. This enables
|
||||
non-linear knowledge work: a funding proposal (rFunds) sits next to
|
||||
the vote (rVote), the budget spreadsheet (rData), and the project
|
||||
plan (rWork) — all on one canvas, all synced in real-time.
|
||||
plan (rTasks) — all on one canvas, all synced in real-time.
|
||||
|
||||
### Offline-First
|
||||
Every interaction works offline. Changes queue locally and merge
|
||||
|
|
|
|||
|
|
@ -197,16 +197,16 @@ services:
|
|||
traefik.http.routers.rmaps-sa.entrypoints: web
|
||||
traefik.http.services.rmaps-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rWork ──
|
||||
rwork-standalone:
|
||||
# ── rTasks ──
|
||||
rtasks-standalone:
|
||||
<<: *standalone-base
|
||||
container_name: rwork-standalone
|
||||
command: ["bun", "run", "modules/rwork/standalone.ts"]
|
||||
container_name: rtasks-standalone
|
||||
command: ["bun", "run", "modules/rtasks/standalone.ts"]
|
||||
labels:
|
||||
<<: *traefik-enabled
|
||||
traefik.http.routers.rwork-sa.rule: Host(`rwork.online`)
|
||||
traefik.http.routers.rwork-sa.entrypoints: web
|
||||
traefik.http.services.rwork-sa.loadbalancer.server.port: "3000"
|
||||
traefik.http.routers.rtasks-sa.rule: Host(`rtasks.online`)
|
||||
traefik.http.routers.rtasks-sa.entrypoints: web
|
||||
traefik.http.services.rtasks-sa.loadbalancer.server.port: "3000"
|
||||
|
||||
# ── rTrips ──
|
||||
rtrips-standalone:
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ services:
|
|||
- INFISICAL_AI_PROJECT_SLUG=claude-ops
|
||||
- INFISICAL_AI_SECRET_PATH=/ai
|
||||
- LISTMONK_URL=https://newsletter.cosmolocal.world
|
||||
- NOTEBOOK_API_URL=http://open-notebook:5055
|
||||
depends_on:
|
||||
rspace-db:
|
||||
condition: service_healthy
|
||||
|
|
@ -88,10 +89,10 @@ services:
|
|||
- "traefik.http.routers.rspace-rvote.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rvote.priority=120"
|
||||
- "traefik.http.routers.rspace-rvote.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rwork.rule=Host(`rwork.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rwork.online`)"
|
||||
- "traefik.http.routers.rspace-rwork.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rwork.priority=120"
|
||||
- "traefik.http.routers.rspace-rwork.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rtasks.rule=Host(`rtasks.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rtasks.online`)"
|
||||
- "traefik.http.routers.rspace-rtasks.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rtasks.priority=120"
|
||||
- "traefik.http.routers.rspace-rtasks.service=rspace-online"
|
||||
- "traefik.http.routers.rspace-rcal.rule=Host(`rcal.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rcal.online`)"
|
||||
- "traefik.http.routers.rspace-rcal.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-rcal.priority=120"
|
||||
|
|
@ -252,6 +253,32 @@ services:
|
|||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# ── Open Notebook (NotebookLM-like RAG service) ──
|
||||
open-notebook:
|
||||
image: ghcr.io/lfnovo/open-notebook:v1-latest-single
|
||||
container_name: open-notebook
|
||||
restart: always
|
||||
env_file: ./open-notebook.env
|
||||
volumes:
|
||||
- open-notebook-data:/app/data
|
||||
- open-notebook-db:/mydata
|
||||
networks:
|
||||
- traefik-public
|
||||
- ai-internal
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
# Frontend UI
|
||||
- "traefik.http.routers.rspace-notebook.rule=Host(`notebook.rspace.online`)"
|
||||
- "traefik.http.routers.rspace-notebook.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-notebook.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.rspace-notebook.loadbalancer.server.port=8502"
|
||||
# API endpoint (used by rNotes integration)
|
||||
- "traefik.http.routers.rspace-notebook-api.rule=Host(`notebook-api.rspace.online`)"
|
||||
- "traefik.http.routers.rspace-notebook-api.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-notebook-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.rspace-notebook-api.loadbalancer.server.port=5055"
|
||||
|
||||
volumes:
|
||||
rspace-data:
|
||||
rspace-books:
|
||||
|
|
@ -262,6 +289,8 @@ volumes:
|
|||
rspace-backups:
|
||||
rspace-pgdata:
|
||||
encryptid-pgdata:
|
||||
open-notebook-data:
|
||||
open-notebook-db:
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const MODULE_META: Record<string, { badge: string; color: string; name: string;
|
|||
rbooks: { badge: "rB", color: "#fda4af", name: "rBooks", icon: "📚" },
|
||||
rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" },
|
||||
rfiles: { badge: "rFi", color: "#67e8f9", name: "rFiles", icon: "📁" },
|
||||
rwork: { badge: "rWo", color: "#cbd5e1", name: "rWork", icon: "📋" },
|
||||
rtasks: { badge: "rTa", color: "#cbd5e1", name: "rTasks", icon: "📋" },
|
||||
rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" },
|
||||
rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" },
|
||||
rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", icon: "🎬" },
|
||||
|
|
@ -454,7 +454,7 @@ const WIDGET_API: Record<string, { path: string; transform: (data: any) => Widge
|
|||
};
|
||||
},
|
||||
},
|
||||
rwork: {
|
||||
rtasks: {
|
||||
path: "/api/spaces",
|
||||
transform: (data) => {
|
||||
const spaces = Array.isArray(data) ? data : [];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
|
||||
interface BookData {
|
||||
id: string;
|
||||
|
|
@ -31,6 +32,13 @@ export class FolkBookShelf extends HTMLElement {
|
|||
private _searchTerm = "";
|
||||
private _selectedTag: string | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.search-input', title: "Search", message: "Search books by title, author, or description.", advanceOnClick: false },
|
||||
{ target: '.tag', title: "Filter by Tag", message: "Click a tag to filter the library by category.", advanceOnClick: true },
|
||||
{ target: '.upload-btn', title: "Add Book", message: "Upload a new book as a PDF to share with the community.", advanceOnClick: false },
|
||||
{ target: '.book-card', title: "Read a Book", message: "Click any book to open it in the flipbook reader.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["space-slug"];
|
||||
|
|
@ -52,6 +60,12 @@ export class FolkBookShelf extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadowRoot!,
|
||||
FolkBookShelf.TOUR_STEPS,
|
||||
"rbooks_tour_done",
|
||||
() => this.shadowRoot!.host as HTMLElement,
|
||||
);
|
||||
this._spaceSlug = this.getAttribute("space-slug") || this._spaceSlug;
|
||||
this.render();
|
||||
if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") {
|
||||
|
|
@ -59,6 +73,9 @@ export class FolkBookShelf extends HTMLElement {
|
|||
} else {
|
||||
this.subscribeOffline();
|
||||
}
|
||||
if (!localStorage.getItem("rbooks_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -426,6 +443,7 @@ export class FolkBookShelf extends HTMLElement {
|
|||
<span class="rapp-nav__title">Library</span>
|
||||
<div class="rapp-nav__actions">
|
||||
<button class="rapp-nav__btn upload-btn">+ Add Book</button>
|
||||
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px">Tour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -446,7 +464,7 @@ export class FolkBookShelf extends HTMLElement {
|
|||
</div>`
|
||||
: `<div class="grid">
|
||||
${books.map((b) => `
|
||||
<a class="book-card" href="/${this._spaceSlug}/rbooks/read/${b.slug}">
|
||||
<a class="book-card" data-collab-id="book:${b.id}" href="/${this._spaceSlug}/rbooks/read/${b.slug}">
|
||||
<div class="book-cover" style="background:${b.cover_color}">
|
||||
<span class="book-cover-title">${this.escapeHtml(b.title)}</span>
|
||||
${b.featured ? '<span class="featured-badge">Featured</span>' : ""}
|
||||
|
|
@ -498,11 +516,18 @@ export class FolkBookShelf extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.bindEvents();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
if (!this.shadowRoot) return;
|
||||
|
||||
this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Search
|
||||
const searchInput = this.shadowRoot.querySelector(".search-input") as HTMLInputElement;
|
||||
searchInput?.addEventListener("input", () => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rbooks" class="rl-cta-primary" id="ml-primary">Browse Library</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-book-shelf')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ function leafletZoomToSpatial(zoom: number): number {
|
|||
// ── Offline-first imports ──
|
||||
import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
|
||||
// ── Component ──
|
||||
|
||||
|
|
@ -155,19 +156,35 @@ class FolkCalendarView extends HTMLElement {
|
|||
private mapMarkerLayer: any = null;
|
||||
private transitLineLayer: any = null;
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '#prev', title: "Navigate", message: "Use the arrow buttons to move between time periods. Keyboard shortcuts: left/right arrows.", advanceOnClick: false },
|
||||
{ target: '#calendar-pane', title: "Calendar View", message: "Click any day cell to add an event. Drag events to reschedule. Use keyboard 1-5 to switch views.", advanceOnClick: false },
|
||||
{ target: '#toggle-lunar', title: "Lunar Phases", message: "Toggle lunar phase display to see moon cycles alongside your events. Press 'L' as a shortcut.", advanceOnClick: true },
|
||||
{ target: '#map-fab, .map-panel', title: "Map View", message: "Events with locations appear on the map. Expand to see spatial context alongside your calendar.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkCalendarView.TOUR_STEPS,
|
||||
"rcal_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
|
||||
document.addEventListener("keydown", this.boundKeyHandler);
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.subscribeOffline();
|
||||
this.loadMonth();
|
||||
this.render();
|
||||
if (this.space === "demo") { this.loadDemoData(); }
|
||||
else { this.subscribeOffline(); this.loadMonth(); this.render(); }
|
||||
if (!localStorage.getItem("rcal_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -784,6 +801,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
<button class="nav-btn" id="today">Today</button>
|
||||
<span class="nav-title">${this.getViewLabel()}</span>
|
||||
<button class="nav-btn" id="next">\u2192</button>
|
||||
<button class="nav-btn" id="btn-tour" style="margin-left:auto;font-size:0.78rem">Tour</button>
|
||||
</div>
|
||||
|
||||
${this.renderSources()}
|
||||
|
|
@ -806,6 +824,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.attachListeners();
|
||||
this._tour.renderOverlay();
|
||||
|
||||
// Play transition if pending
|
||||
if (this._pendingTransition && this._ghostHtml) {
|
||||
|
|
@ -1499,7 +1518,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
</div>
|
||||
${allDay.length > 0 ? `<div class="day-allday">
|
||||
<div class="day-allday-label">All Day</div>
|
||||
${allDay.map(e => `<div class="dd-event" data-event-id="${e.id}">
|
||||
${allDay.map(e => `<div class="dd-event" data-event-id="${e.id}" data-collab-id="event:${e.id}">
|
||||
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
|
||||
<div class="dd-info"><div class="dd-title">${this.esc(e.title)}</div></div>
|
||||
</div>`).join("")}
|
||||
|
|
@ -1607,7 +1626,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
const srcTag = e.source_name ? `<span class="dd-source" style="border-color:${e.source_color || '#666'};color:${e.source_color || '#aaa'}">${this.esc(e.source_name)}</span>` : "";
|
||||
const es = this.getEventStyles(e);
|
||||
const likelihoodBadge = es.isTentative ? `<span class="dd-likelihood">${es.likelihoodLabel}</span>` : "";
|
||||
return `<div class="dd-event" data-event-id="${e.id}" style="opacity:${es.opacity}">
|
||||
return `<div class="dd-event" data-event-id="${e.id}" data-collab-id="event:${e.id}" style="opacity:${es.opacity}">
|
||||
<div class="dd-color" style="background:${e.source_color || "#6366f1"};${es.isTentative ? "border:1px dashed " + (e.source_color || "#6366f1") + ";background:transparent" : ""}"></div>
|
||||
<div class="dd-info">
|
||||
<div class="dd-title">${this.esc(e.title)}${likelihoodBadge}${srcTag}</div>
|
||||
|
|
@ -1705,12 +1724,16 @@ class FolkCalendarView extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
// ── Attach Listeners ──
|
||||
|
||||
private attachListeners() {
|
||||
const $ = (id: string) => this.shadow.getElementById(id);
|
||||
const $$ = (sel: string) => this.shadow.querySelectorAll(sel);
|
||||
|
||||
$("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Nav
|
||||
$("prev")?.addEventListener("click", () => this.navigate(-1));
|
||||
$("next")?.addEventListener("click", () => this.navigate(1));
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ export function renderLanding(): string {
|
|||
</a>
|
||||
<a href="#features" class="rl-cta-secondary">Learn More</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-calendar-view')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Principles (4-card grid) -->
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
shoppingCartSchema, shoppingCartDocId, type ShoppingCartDoc,
|
||||
} from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { ViewHistory } from "../../../shared/view-history.js";
|
||||
|
||||
class FolkCartShop extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
|
|
@ -26,10 +28,26 @@ class FolkCartShop extends HTMLElement {
|
|||
private extensionInstalled = false;
|
||||
private bannerDismissed = false;
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "orders">("carts");
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-view="carts"]', title: "Carts", message: "View and manage group shopping carts. Each cart collects items from multiple contributors.", advanceOnClick: true },
|
||||
{ target: "[data-action='new-cart']", title: "Create a Cart", message: "Start a new group cart — add a name and invite contributors to add items.", advanceOnClick: true },
|
||||
{ target: '[data-view="catalog"]', title: "Catalog", message: "Browse the cosmolocal catalog of available products and add them to your carts.", advanceOnClick: true },
|
||||
{ target: '[data-view="orders"]', title: "Orders", message: "Track submitted orders and their status. Click to finish the tour!", advanceOnClick: true },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkCartShop.TOUR_STEPS,
|
||||
"rcart_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
|
@ -46,13 +64,16 @@ class FolkCartShop extends HTMLElement {
|
|||
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
this.render();
|
||||
this.subscribeOffline();
|
||||
this.loadData();
|
||||
}
|
||||
// Auto-start tour on first visit
|
||||
if (!localStorage.getItem("rcart_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
|
|
@ -342,11 +363,23 @@ class FolkCartShop extends HTMLElement {
|
|||
<button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">📦 Catalog (${this.catalog.length})</button>
|
||||
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">📋 Orders (${this.orders.length})</button>
|
||||
</div>
|
||||
<button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.8rem">Tour</button>
|
||||
</div>
|
||||
${content}
|
||||
`;
|
||||
|
||||
this.bindEvents();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
this.view = prev.view;
|
||||
if (prev.view !== "cart-detail") this.selectedCartId = null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
|
|
@ -354,12 +387,19 @@ class FolkCartShop extends HTMLElement {
|
|||
this.shadow.querySelectorAll(".tab").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const v = (el as HTMLElement).dataset.view as any;
|
||||
if (v && v !== this.view) {
|
||||
this._history.push(this.view);
|
||||
this._history.push(v);
|
||||
}
|
||||
this.view = v;
|
||||
if (v === 'carts') this.selectedCartId = null;
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Tour button
|
||||
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Extension banner dismiss
|
||||
this.shadow.querySelector("[data-action='dismiss-banner']")?.addEventListener("click", () => {
|
||||
this.bannerDismissed = true;
|
||||
|
|
@ -370,15 +410,15 @@ class FolkCartShop extends HTMLElement {
|
|||
// Cart card clicks
|
||||
this.shadow.querySelectorAll("[data-cart-id]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
this._history.push(this.view);
|
||||
this._history.push("cart-detail", { cartId: (el as HTMLElement).dataset.cartId });
|
||||
this.loadCartDetail((el as HTMLElement).dataset.cartId!);
|
||||
});
|
||||
});
|
||||
|
||||
// Back button
|
||||
this.shadow.querySelector("[data-action='back']")?.addEventListener("click", () => {
|
||||
this.view = "carts";
|
||||
this.selectedCartId = null;
|
||||
this.render();
|
||||
this.goBack();
|
||||
});
|
||||
|
||||
// New cart form
|
||||
|
|
@ -527,9 +567,7 @@ class FolkCartShop extends HTMLElement {
|
|||
`).join("") : `<div class="card-meta">No contributions yet.</div>`;
|
||||
|
||||
return `
|
||||
<div style="margin-bottom:1rem">
|
||||
<button data-action="back" class="btn btn-sm">← Back to Carts</button>
|
||||
</div>
|
||||
${this._history.canGoBack ? '<div style="margin-bottom:1rem"><button data-action="back" class="btn btn-sm">\u2190 Back</button></div>' : ''}
|
||||
|
||||
<div class="detail-layout">
|
||||
<div class="detail-left">
|
||||
|
|
@ -582,7 +620,7 @@ class FolkCartShop extends HTMLElement {
|
|||
|
||||
return `<div class="grid">
|
||||
${this.catalog.map((entry) => `
|
||||
<div class="card">
|
||||
<div class="card" data-collab-id="product:${entry.id || entry.title}">
|
||||
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
|
||||
<div class="card-meta">
|
||||
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
|
||||
|
|
@ -607,7 +645,7 @@ class FolkCartShop extends HTMLElement {
|
|||
|
||||
return `<div class="grid">
|
||||
${this.orders.map((order) => `
|
||||
<div class="card">
|
||||
<div class="card" data-collab-id="order:${order.id}">
|
||||
<div class="order-card">
|
||||
<div class="order-info">
|
||||
<h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rcart" class="rl-cta-primary" id="ml-primary">Start Shopping</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-cart-shop')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
* from the current space and links to the canvas to create/interact with them.
|
||||
*/
|
||||
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
|
||||
class FolkChoicesDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private choices: any[] = [];
|
||||
|
|
@ -19,20 +21,37 @@ class FolkChoicesDashboard extends HTMLElement {
|
|||
private votedId: string | null = null;
|
||||
private simTimer: number | null = null;
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-tab="spider"]', title: "Spider Charts", message: "Compare multiple criteria on a radar chart. Each participant's scores overlay in real time.", advanceOnClick: true },
|
||||
{ target: '[data-tab="ranking"]', title: "Rankings", message: "Drag items to rank them. Rankings aggregate across all participants for a collective order.", advanceOnClick: true },
|
||||
{ target: '[data-tab="voting"]', title: "Voting", message: "Cast your vote and watch results update live with animated bars and totals.", advanceOnClick: true },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkChoicesDashboard.TOUR_STEPS,
|
||||
"rchoices_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.render();
|
||||
this.loadChoices();
|
||||
}
|
||||
if (!localStorage.getItem("rchoices_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.simTimer !== null) {
|
||||
|
|
@ -124,7 +143,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
|||
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
|
||||
return `<div class="grid">
|
||||
${this.choices.map((ch) => `
|
||||
<a class="card" href="/${this.space}/rspace">
|
||||
<a class="card" data-collab-id="choice:${ch.id}" href="/${this.space}/rspace">
|
||||
<div class="card-icon">${icons[ch.type] || "☑"}</div>
|
||||
<div class="card-type">${labels[ch.type] || ch.type}</div>
|
||||
<h3 class="card-title">${this.esc(ch.title)}</h3>
|
||||
|
|
@ -247,6 +266,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
|||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Choices</span>
|
||||
<span class="demo-badge">DEMO</span>
|
||||
<button class="demo-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem">Tour</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-tabs">
|
||||
|
|
@ -259,8 +279,11 @@ class FolkChoicesDashboard extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.bindDemoEvents();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
/* -- Spider Chart -- */
|
||||
|
||||
private polarToXY(cx: number, cy: number, radius: number, angleDeg: number): { x: number; y: number } {
|
||||
|
|
@ -383,6 +406,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
|||
/* -- Demo event binding -- */
|
||||
|
||||
private bindDemoEvents() {
|
||||
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
// Tab switching
|
||||
this.shadow.querySelectorAll<HTMLButtonElement>(".demo-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rchoices" class="rl-cta-primary" id="ml-primary">Create a Choice Room</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-choices-dashboard')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Three tools -->
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class FolkAnalyticsView extends HTMLElement {
|
|||
cookiesSet: 0,
|
||||
scriptSize: "~2KB",
|
||||
selfHosted: true,
|
||||
apps: ["rSpace", "rBooks", "rCart", "rFlows", "rVote", "rWork", "rCal", "rTrips", "rPubs", "rForum", "rInbox", "rMaps", "rTube", "rChoices", "rWallet", "rData", "rNotes"],
|
||||
apps: ["rSpace", "rBooks", "rCart", "rFlows", "rVote", "rTasks", "rCal", "rTrips", "rPubs", "rForum", "rInbox", "rMaps", "rTube", "rChoices", "rWallet", "rData", "rNotes"],
|
||||
dashboardUrl: "https://analytics.rspace.online",
|
||||
};
|
||||
this.render();
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6
|
|||
const TRACKED_APPS = [
|
||||
"rSpace", "rNotes", "rVote", "rFlows", "rCart", "rWallet",
|
||||
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
|
||||
"rTrips", "rTube", "rWork", "rNetwork", "rData",
|
||||
"rTrips", "rTube", "rTasks", "rNetwork", "rData",
|
||||
];
|
||||
|
||||
// ── API routes ──
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { filesSchema, type FilesDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
|
||||
class FolkFileBrowser extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
|
|
@ -16,10 +17,22 @@ class FolkFileBrowser extends HTMLElement {
|
|||
private tab: "files" | "cards" = "files";
|
||||
private loading = false;
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.tab-btn[data-tab="files"]', title: "Files Tab", message: "Browse uploaded files — download, share, or delete them.", advanceOnClick: true },
|
||||
{ target: '.tab-btn[data-tab="cards"]', title: "Memory Cards", message: "Create and manage memory cards to organise your knowledge.", advanceOnClick: true },
|
||||
{ target: '#upload-form', title: "Upload Files", message: "Upload files to your space. They're stored locally and synced when online.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkFileBrowser.TOUR_STEPS,
|
||||
"rfiles_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
|
@ -27,14 +40,16 @@ class FolkFileBrowser extends HTMLElement {
|
|||
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
this.render();
|
||||
this.subscribeOffline();
|
||||
this.loadFiles();
|
||||
this.loadCards();
|
||||
}
|
||||
if (!localStorage.getItem("rfiles_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
|
|
@ -429,11 +444,14 @@ class FolkFileBrowser extends HTMLElement {
|
|||
<div class="tabs">
|
||||
<div class="tab-btn" data-tab="files">📁 Files</div>
|
||||
<div class="tab-btn" data-tab="cards">🎴 Memory Cards</div>
|
||||
<button class="tab-btn" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
||||
</div>
|
||||
|
||||
${filesActive ? this.renderFilesTab() : this.renderCardsTab()}
|
||||
`;
|
||||
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
this.shadow.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
this.tab = (btn as HTMLElement).dataset.tab as "files" | "cards";
|
||||
|
|
@ -464,6 +482,12 @@ class FolkFileBrowser extends HTMLElement {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private renderFilesTab(): string {
|
||||
|
|
@ -487,7 +511,7 @@ class FolkFileBrowser extends HTMLElement {
|
|||
${this.files
|
||||
.map(
|
||||
(f) => `
|
||||
<div class="file-card">
|
||||
<div class="file-card" data-collab-id="file:${f.id}">
|
||||
<div class="file-icon">${this.mimeIcon(f.mime_type)}</div>
|
||||
<div class="file-name" title="${this.esc(f.original_filename)}">${this.esc(f.title || f.original_filename)}</div>
|
||||
<div class="file-meta">${this.formatSize(f.file_size)} · ${this.formatDate(f.created_at)}</div>
|
||||
|
|
@ -533,7 +557,7 @@ class FolkFileBrowser extends HTMLElement {
|
|||
${this.cards
|
||||
.map(
|
||||
(c) => `
|
||||
<div class="memory-card">
|
||||
<div class="memory-card" data-collab-id="card:${c.id}">
|
||||
<div class="card-header">
|
||||
<span class="card-title">${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)}</span>
|
||||
<span class="card-type">${c.card_type}</span>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rfiles" class="rl-cta-primary" id="ml-primary">Start Sharing</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-file-browser')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
|
||||
import { PORT_DEFS, deriveThresholds } from "../lib/types";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
||||
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
|
||||
import { mapFlowToNodes } from "../lib/map-flow";
|
||||
|
|
@ -154,9 +155,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
private flowManagerOpen = false;
|
||||
private _lfcUnsub: (() => void) | null = null;
|
||||
|
||||
// Tour state
|
||||
private tourActive = false;
|
||||
private tourStep = 0;
|
||||
// Tour engine
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true },
|
||||
{ target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true },
|
||||
|
|
@ -168,6 +168,12 @@ class FolkFlowsApp extends HTMLElement {
|
|||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkFlowsApp.TOUR_STEPS,
|
||||
"rflows_tour_done",
|
||||
() => this.shadow.getElementById("canvas-container"),
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
|
@ -1777,7 +1783,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
}).join("");
|
||||
}
|
||||
|
||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
|
||||
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="transparent" stroke="none"/>
|
||||
<rect class="faucet-pipe" x="0" y="${pipeY}" width="${w}" height="${pipeH}" rx="${pipeRx}" fill="url(#faucet-pipe-grad)" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-text-secondary)"}" stroke-width="${selected ? 2 : 1}"/>
|
||||
<text x="${w / 2}" y="${pipeY + pipeH / 2 + 1}" text-anchor="middle" dominant-baseline="central" fill="white" font-size="11" font-weight="600" pointer-events="none">${this.esc(d.label)}</text>
|
||||
|
|
@ -1908,7 +1914,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)";
|
||||
const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b";
|
||||
|
||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
|
||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
|
||||
<defs>
|
||||
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
|
||||
</defs>
|
||||
|
|
@ -1982,7 +1988,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`;
|
||||
|
||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
|
||||
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}"/>
|
||||
<foreignObject x="0" y="0" width="${w}" height="${h}">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card outcome-card ${selected ? "selected" : ""}">
|
||||
|
|
@ -4973,105 +4979,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
// ── Guided Tour ──
|
||||
|
||||
startTour() {
|
||||
this.tourActive = true;
|
||||
this.tourStep = 0;
|
||||
this.renderTourOverlay();
|
||||
}
|
||||
|
||||
private advanceTour() {
|
||||
this.tourStep++;
|
||||
if (this.tourStep >= FolkFlowsApp.TOUR_STEPS.length) {
|
||||
this.endTour();
|
||||
} else {
|
||||
this.renderTourOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
private endTour() {
|
||||
this.tourActive = false;
|
||||
this.tourStep = 0;
|
||||
localStorage.setItem("rflows_tour_done", "1");
|
||||
const overlay = this.shadow.getElementById("flows-tour-overlay");
|
||||
if (overlay) overlay.remove();
|
||||
}
|
||||
|
||||
private renderTourOverlay() {
|
||||
// Remove existing overlay
|
||||
let overlay = this.shadow.getElementById("flows-tour-overlay");
|
||||
if (!overlay) {
|
||||
overlay = document.createElement("div");
|
||||
overlay.id = "flows-tour-overlay";
|
||||
overlay.className = "flows-tour-overlay";
|
||||
const container = this.shadow.getElementById("canvas-container");
|
||||
if (container) container.appendChild(overlay);
|
||||
else this.shadow.appendChild(overlay);
|
||||
}
|
||||
|
||||
const step = FolkFlowsApp.TOUR_STEPS[this.tourStep];
|
||||
const targetEl = this.shadow.querySelector(step.target) as HTMLElement | null;
|
||||
|
||||
// Compute spotlight position
|
||||
let spotX = 0, spotY = 0, spotW = 120, spotH = 40;
|
||||
if (targetEl) {
|
||||
const containerEl = this.shadow.getElementById("canvas-container") || this.shadow.host as HTMLElement;
|
||||
const containerRect = containerEl.getBoundingClientRect();
|
||||
const rect = targetEl.getBoundingClientRect();
|
||||
spotX = rect.left - containerRect.left - 6;
|
||||
spotY = rect.top - containerRect.top - 6;
|
||||
spotW = rect.width + 12;
|
||||
spotH = rect.height + 12;
|
||||
}
|
||||
|
||||
const isLast = this.tourStep >= FolkFlowsApp.TOUR_STEPS.length - 1;
|
||||
const stepNum = this.tourStep + 1;
|
||||
const totalSteps = FolkFlowsApp.TOUR_STEPS.length;
|
||||
|
||||
// Position tooltip below target
|
||||
const tooltipTop = spotY + spotH + 12;
|
||||
const tooltipLeft = Math.max(8, spotX);
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="flows-tour-backdrop" style="clip-path: polygon(
|
||||
0% 0%, 0% 100%, ${spotX}px 100%, ${spotX}px ${spotY}px,
|
||||
${spotX + spotW}px ${spotY}px, ${spotX + spotW}px ${spotY + spotH}px,
|
||||
${spotX}px ${spotY + spotH}px, ${spotX}px 100%, 100% 100%, 100% 0%
|
||||
)"></div>
|
||||
<div class="flows-tour-spotlight" style="left:${spotX}px;top:${spotY}px;width:${spotW}px;height:${spotH}px"></div>
|
||||
<div class="flows-tour-tooltip" style="top:${tooltipTop}px;left:${tooltipLeft}px">
|
||||
<div class="flows-tour-tooltip__step">${stepNum} / ${totalSteps}</div>
|
||||
<div class="flows-tour-tooltip__title">${step.title}</div>
|
||||
<div class="flows-tour-tooltip__msg">${step.message}</div>
|
||||
<div class="flows-tour-tooltip__nav">
|
||||
${this.tourStep > 0 ? '<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--prev" data-tour="prev">Back</button>' : ''}
|
||||
${step.advanceOnClick
|
||||
? `<span class="flows-tour-tooltip__hint">or click the button above</span>`
|
||||
: `<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--next" data-tour="next">${isLast ? 'Finish' : 'Next'}</button>`
|
||||
}
|
||||
<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--skip" data-tour="skip">Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire tour navigation
|
||||
overlay.querySelectorAll("[data-tour]").forEach(btn => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const action = (btn as HTMLElement).dataset.tour;
|
||||
if (action === "next") this.advanceTour();
|
||||
else if (action === "prev") { this.tourStep = Math.max(0, this.tourStep - 1); this.renderTourOverlay(); }
|
||||
else if (action === "skip") this.endTour();
|
||||
});
|
||||
});
|
||||
|
||||
// For advanceOnClick steps, listen for the target button click
|
||||
if (step.advanceOnClick && targetEl) {
|
||||
const handler = () => {
|
||||
targetEl.removeEventListener("click", handler);
|
||||
// Delay slightly so the action completes first
|
||||
setTimeout(() => this.advanceTour(), 300);
|
||||
};
|
||||
targetEl.addEventListener("click", handler);
|
||||
}
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { ViewHistory } from "../../../shared/view-history.js";
|
||||
|
||||
class FolkForumDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
|
|
@ -18,19 +20,37 @@ class FolkForumDashboard extends HTMLElement {
|
|||
private pollTimer: number | null = null;
|
||||
private space = "";
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _history = new ViewHistory<"list" | "detail" | "create">("list");
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.instance-card', title: "Forum Instances", message: "View your deployed Discourse forums — click one for status and logs.", advanceOnClick: false },
|
||||
{ target: '[data-action="show-create"]', title: "New Forum", message: "Deploy a new self-hosted Discourse forum in minutes.", advanceOnClick: true },
|
||||
{ target: '.price-card', title: "Choose a Plan", message: "Select a hosting tier based on your community size and needs.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkForumDashboard.TOUR_STEPS,
|
||||
"rforum_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
if (this.space === "demo") { this.loadDemoData(); }
|
||||
else {
|
||||
this.subscribeOffline();
|
||||
this.render();
|
||||
this.loadInstances();
|
||||
}
|
||||
if (!localStorage.getItem("rforum_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.instances = [
|
||||
|
|
@ -318,6 +338,11 @@ class FolkForumDashboard extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.attachEvents();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private renderList(): string {
|
||||
|
|
@ -325,6 +350,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Forum Instances</span>
|
||||
<button class="rapp-nav__btn" data-action="show-create">+ New Forum</button>
|
||||
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
||||
</div>
|
||||
|
||||
${this.loading ? '<div class="loading">Loading...</div>' : ""}
|
||||
|
|
@ -332,7 +358,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
|
||||
<div class="instance-list">
|
||||
${this.instances.map((inst) => `
|
||||
<div class="instance-card" data-action="detail" data-id="${inst.id}">
|
||||
<div class="instance-card" data-action="detail" data-id="${inst.id}" data-collab-id="forum:${inst.id}">
|
||||
<div class="instance-header">
|
||||
<span class="instance-name">${this.esc(inst.name)}</span>
|
||||
${this.statusBadge(inst.status)}
|
||||
|
|
@ -353,7 +379,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-action="back">← Forums</button>
|
||||
${this._history.canGoBack ? '<button class="rapp-nav__back" data-action="back">← Forums</button>' : ''}
|
||||
<span class="rapp-nav__title">${this.esc(inst.name)}</span>
|
||||
${inst.status !== "destroyed" ? `<button class="danger" data-action="destroy" data-id="${inst.id}">Destroy</button>` : ""}
|
||||
</div>
|
||||
|
|
@ -398,7 +424,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
private renderCreate(): string {
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-action="back">← Forums</button>
|
||||
${this._history.canGoBack ? '<button class="rapp-nav__back" data-action="back">← Forums</button>' : ''}
|
||||
<span class="rapp-nav__title">Deploy New Forum</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -466,16 +492,17 @@ class FolkForumDashboard extends HTMLElement {
|
|||
}
|
||||
|
||||
private attachEvents() {
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
this.shadow.querySelectorAll("[data-action]").forEach((el) => {
|
||||
const action = (el as HTMLElement).dataset.action!;
|
||||
const id = (el as HTMLElement).dataset.id;
|
||||
el.addEventListener("click", () => {
|
||||
if (action === "show-create") { this.view = "create"; this.render(); }
|
||||
if (action === "show-create") { this._history.push(this.view); this._history.push("create"); this.view = "create"; this.render(); }
|
||||
else if (action === "back") {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
this.view = "list"; this.loadInstances();
|
||||
this.goBack();
|
||||
}
|
||||
else if (action === "detail" && id) { this.loadInstanceDetail(id); }
|
||||
else if (action === "detail" && id) { this._history.push(this.view); this._history.push("detail", { id }); this.loadInstanceDetail(id); }
|
||||
else if (action === "destroy" && id) { this.handleDestroy(id); }
|
||||
});
|
||||
});
|
||||
|
|
@ -493,6 +520,15 @@ class FolkForumDashboard extends HTMLElement {
|
|||
if (form) form.addEventListener("submit", (e) => this.handleCreate(e));
|
||||
}
|
||||
|
||||
private goBack() {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
this.view = prev.view;
|
||||
if (prev.view === "list") this.loadInstances();
|
||||
else this.render();
|
||||
}
|
||||
|
||||
private formatStep(step: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
create_vps: "Create Server",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rforum" class="rl-cta-primary" id="ml-primary">Get Started</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-forum-dashboard')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- How It Works -->
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ export function renderLanding(): string {
|
|||
</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-inbox-client')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- What rInbox Does -->
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
import { RoomSync, type RoomState, type ParticipantState, type LocationState } from "./map-sync";
|
||||
import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history";
|
||||
import { MapPushManager } from "./map-push";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { ViewHistory } from "../../../shared/view-history.js";
|
||||
|
||||
// MapLibre loaded via CDN — use window access with type assertion
|
||||
|
||||
|
|
@ -88,6 +90,16 @@ class FolkMapViewer extends HTMLElement {
|
|||
private pushManager: MapPushManager | null = null;
|
||||
private thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _themeObserver: MutationObserver | null = null;
|
||||
private _history = new ViewHistory<"lobby" | "map">("lobby");
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '#create-room', title: "Create a Room", message: "Start a new map room to share locations with others in real time.", advanceOnClick: true },
|
||||
{ target: '.room-card, .history-card', title: "Join a Room", message: "Click any room card to enter and see participants on the map.", advanceOnClick: false },
|
||||
{ target: '#share-location', title: "Share Location", message: "Toggle location sharing so others in the room can see where you are.", advanceOnClick: false },
|
||||
{ target: '#drop-waypoint', title: "Drop a Pin", message: "Drop a waypoint pin on the map to mark a point of interest for everyone.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
private isDarkTheme(): boolean {
|
||||
const theme = document.documentElement.getAttribute("data-theme");
|
||||
|
|
@ -98,6 +110,12 @@ class FolkMapViewer extends HTMLElement {
|
|||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkMapViewer.TOUR_STEPS,
|
||||
"rmaps_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
|
@ -105,17 +123,20 @@ class FolkMapViewer extends HTMLElement {
|
|||
this.room = this.getAttribute("room") || "";
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.loadUserProfile();
|
||||
this.pushManager = new MapPushManager(this.getApiBase());
|
||||
if (this.room) {
|
||||
this.joinRoom(this.room);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.checkSyncHealth();
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
if (!localStorage.getItem("rmaps_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.leaveRoom();
|
||||
|
|
@ -1447,15 +1468,18 @@ class FolkMapViewer extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.attachListeners();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
private renderLobby(): string {
|
||||
const history = loadRoomHistory();
|
||||
const historyCards = history.length > 0 ? `
|
||||
<div class="section-label">Recent Rooms</div>
|
||||
<div class="room-history-grid">
|
||||
${history.map((h) => `
|
||||
<div class="history-card" data-room="${this.esc(h.slug)}">
|
||||
<div class="history-card" data-room="${this.esc(h.slug)}" data-collab-id="room:${this.esc(h.slug)}">
|
||||
${h.thumbnail
|
||||
? `<img class="history-thumb" src="${h.thumbnail}" alt="">`
|
||||
: `<div class="history-thumb-placeholder">🌐</div>`
|
||||
|
|
@ -1476,12 +1500,13 @@ class FolkMapViewer extends HTMLElement {
|
|||
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
|
||||
<span style="font-size:12px;color:var(--rs-text-muted);margin-right:12px">${this.syncStatus === "connected" ? "Sync online" : "Sync offline"}</span>
|
||||
<button class="rapp-nav__btn" id="create-room">+ New Room</button>
|
||||
<button class="rapp-nav__btn" id="btn-tour" style="background:transparent;border:1px solid var(--rs-border,#334155);color:var(--rs-text-secondary,#94a3b8);font-weight:500">Tour</button>
|
||||
</div>
|
||||
|
||||
${this.rooms.length > 0 ? `
|
||||
<div class="section-label">Active Rooms</div>
|
||||
${this.rooms.map((r) => `
|
||||
<div class="room-card" data-room="${r}">
|
||||
<div class="room-card" data-room="${r}" data-collab-id="room:${r}">
|
||||
<span class="room-icon">🗺</span>
|
||||
<span class="room-name">${this.esc(r)}</span>
|
||||
</div>
|
||||
|
|
@ -1501,7 +1526,7 @@ class FolkMapViewer extends HTMLElement {
|
|||
const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`;
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="lobby">← Rooms</button>
|
||||
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="lobby">← Rooms</button>' : ''}
|
||||
<span class="rapp-nav__title">🗺 ${this.esc(this.room)}</span>
|
||||
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
|
||||
</div>
|
||||
|
|
@ -1532,20 +1557,21 @@ class FolkMapViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom());
|
||||
|
||||
this.shadow.querySelectorAll("[data-room]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const room = (el as HTMLElement).dataset.room!;
|
||||
this._history.push("lobby");
|
||||
this._history.push("map", { room });
|
||||
this.joinRoom(room);
|
||||
});
|
||||
});
|
||||
|
||||
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
this.leaveRoom();
|
||||
this.view = "lobby";
|
||||
this.loadStats();
|
||||
this.goBack();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1578,6 +1604,16 @@ class FolkMapViewer extends HTMLElement {
|
|||
});
|
||||
}
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
if (prev.view === "lobby" && this.view === "map") {
|
||||
this.leaveRoom();
|
||||
}
|
||||
this.view = prev.view;
|
||||
if (prev.view === "lobby") this.loadStats();
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rmaps" class="rl-cta-primary" id="ml-primary">Open Maps</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-map-viewer')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function renderLanding(): string {
|
|||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📄</div>
|
||||
<h3>Meeting Notes & Docs</h3>
|
||||
<p>Capture notes in rNotes, attach files in rFiles, link action items to rWork — all within the same space, all self-hosted.</p>
|
||||
<p>Capture notes in rNotes, attach files in rFiles, link action items to rTasks — all within the same space, all self-hosted.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📅</div>
|
||||
|
|
@ -121,7 +121,7 @@ export function renderLanding(): string {
|
|||
<div class="rl-icon-box">🔌</div>
|
||||
<h3>Data Integrations</h3>
|
||||
<span class="rl-badge">Coming Soon</span>
|
||||
<p>Auto-link recordings to documents, push summaries to rNotes, and sync action items to rWork task boards.</p>
|
||||
<p>Auto-link recordings to documents, push summaries to rNotes, and sync action items to rTasks task boards.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ const STAGE_LABELS: Record<string, string> = {
|
|||
CLOSED_LOST: "Lost",
|
||||
};
|
||||
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
|
||||
class FolkCrmView extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
|
|
@ -67,15 +69,34 @@ class FolkCrmView extends HTMLElement {
|
|||
private loading = true;
|
||||
private error = "";
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-tab="pipeline"]', title: "Pipeline", message: "Track deals through stages — from incoming leads to closed-won. Drag cards to update their stage.", advanceOnClick: true },
|
||||
{ target: '[data-tab="contacts"]', title: "Contacts", message: "View and search your contact directory. Click a contact to see their details.", advanceOnClick: true },
|
||||
{ target: '#crm-search', title: "Search", message: "Search across contacts and companies by name, email, or city. Results filter in real time.", advanceOnClick: false },
|
||||
{ target: '[data-tab="graph"]', title: "Relationship Graph", message: "Visualise connections between people and companies as an interactive network graph.", advanceOnClick: true },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkCrmView.TOUR_STEPS,
|
||||
"rnetwork_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.render();
|
||||
this.loadData();
|
||||
// Auto-start tour on first visit
|
||||
if (!localStorage.getItem("rnetwork_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
|
|
@ -165,7 +186,7 @@ class FolkCrmView extends HTMLElement {
|
|||
</div>
|
||||
${total > 0 ? `<div class="pipeline-total">${this.formatAmount({ amountMicros: total * 1_000_000, currencyCode: "USD" })}</div>` : ""}
|
||||
<div class="pipeline-cards">
|
||||
${opps.map(opp => `<div class="pipeline-card">
|
||||
${opps.map(opp => `<div class="pipeline-card" data-collab-id="opp:${opp.id}">
|
||||
<div class="card-name">${this.esc(opp.name)}</div>
|
||||
<div class="card-amount">${this.formatAmount(opp.amount)}</div>
|
||||
${opp.company?.name ? `<div class="card-company">${this.esc(opp.company.name)}</div>` : ""}
|
||||
|
|
@ -220,7 +241,7 @@ class FolkCrmView extends HTMLElement {
|
|||
<th>Phone</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${filtered.map(p => `<tr>
|
||||
${filtered.map(p => `<tr data-collab-id="contact:${p.id}">
|
||||
<td class="cell-name">${this.esc(this.personName(p.name))}</td>
|
||||
<td class="cell-email">${p.email?.primaryEmail ? this.esc(p.email.primaryEmail) : "-"}</td>
|
||||
<td>${this.esc(this.companyName(p.company))}</td>
|
||||
|
|
@ -273,7 +294,7 @@ class FolkCrmView extends HTMLElement {
|
|||
<th>Country</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${filtered.map(c => `<tr>
|
||||
${filtered.map(c => `<tr data-collab-id="company:${c.id}">
|
||||
<td class="cell-name">${this.esc(c.name || "-")}</td>
|
||||
<td class="cell-domain">${c.domainName?.primaryLinkUrl ? `<a href="${this.esc(c.domainName.primaryLinkUrl)}" target="_blank" rel="noopener">${this.esc(c.domainName.primaryLinkUrl)}</a>` : "-"}</td>
|
||||
<td>${c.employees ?? "-"}</td>
|
||||
|
|
@ -432,6 +453,7 @@ class FolkCrmView extends HTMLElement {
|
|||
|
||||
<div class="crm-header">
|
||||
<span class="crm-title">CRM</span>
|
||||
<button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
|
|
@ -448,9 +470,15 @@ class FolkCrmView extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.attachListeners();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
private attachListeners() {
|
||||
// Tour button
|
||||
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Tab switching
|
||||
this.shadow.querySelectorAll("[data-tab]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,168 @@ export function renderLanding(): string {
|
|||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary" id="ml-primary">Open Notebook</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
<a href="#transcription-demo" class="rl-cta-secondary">Transcribe</a>
|
||||
<a href="#extension-download" class="rl-cta-secondary">Get Extension</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-notes-app')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Live Transcription Demo -->
|
||||
<section class="rl-section" id="transcription-demo">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Live Transcription Demo</h2>
|
||||
<p class="rl-subtext" style="text-align:center">Try it right here — click the mic and start speaking.</p>
|
||||
|
||||
<div style="max-width:640px;margin:2rem auto;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:1rem;padding:1.5rem;position:relative">
|
||||
<!-- Unsupported fallback (hidden by default, shown via JS) -->
|
||||
<div id="transcription-unsupported" style="display:none;text-align:center;padding:2rem 1rem;color:#94a3b8">
|
||||
<div style="font-size:2rem;margin-bottom:0.75rem">⚠️</div>
|
||||
<p style="margin:0 0 0.5rem">Live transcription requires <strong>Chrome</strong> or <strong>Edge</strong> (Web Speech API).</p>
|
||||
<p style="margin:0;font-size:0.85rem;color:#64748b">Try opening this page in a Chromium-based browser to test the demo.</p>
|
||||
</div>
|
||||
|
||||
<!-- Demo UI (hidden if unsupported) -->
|
||||
<div id="transcription-ui">
|
||||
<!-- Controls -->
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.25rem">
|
||||
<button id="mic-btn" style="width:56px;height:56px;border-radius:50%;border:2px solid rgba(245,158,11,0.4);background:rgba(245,158,11,0.1);color:#f59e0b;font-size:1.5rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.2s" title="Start transcription">
|
||||
🎤
|
||||
</button>
|
||||
<div style="text-align:left">
|
||||
<div id="mic-status" style="font-size:0.9rem;color:#94a3b8">Click mic to start</div>
|
||||
<div id="mic-timer" style="font-size:0.75rem;color:#64748b;font-variant-numeric:tabular-nums">00:00</div>
|
||||
</div>
|
||||
<div id="live-indicator" style="display:none;background:rgba(239,68,68,0.15);color:#ef4444;font-size:0.7rem;font-weight:600;padding:0.2rem 0.6rem;border-radius:9999px;text-transform:uppercase;letter-spacing:0.05em">
|
||||
● Live
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transcript area -->
|
||||
<div id="transcript-area" style="min-height:120px;max-height:240px;overflow-y:auto;background:rgba(0,0,0,0.2);border-radius:0.5rem;padding:1rem;font-size:0.9rem;line-height:1.6;color:#e2e8f0">
|
||||
<span style="color:#64748b;font-style:italic">Your transcript will appear here…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capability badges -->
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;justify-content:center;margin-top:1.25rem">
|
||||
<span class="rl-badge" style="background:rgba(34,197,94,0.15);color:#22c55e">● Live streaming</span>
|
||||
<span class="rl-badge" style="background:rgba(59,130,246,0.15);color:#3b82f6">🎵 Audio file upload</span>
|
||||
<span class="rl-badge" style="background:rgba(168,85,247,0.15);color:#a855f7">🎥 Video transcription</span>
|
||||
<span class="rl-badge" style="background:rgba(245,158,11,0.15);color:#f59e0b">🔌 Offline (Parakeet.js)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Transcription Demo Script -->
|
||||
<script>
|
||||
(function() {
|
||||
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
var ui = document.getElementById('transcription-ui');
|
||||
var unsupported = document.getElementById('transcription-unsupported');
|
||||
if (!SpeechRecognition) {
|
||||
if (ui) ui.style.display = 'none';
|
||||
if (unsupported) unsupported.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
var recognition = new SpeechRecognition();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = 'en-US';
|
||||
|
||||
var micBtn = document.getElementById('mic-btn');
|
||||
var micStatus = document.getElementById('mic-status');
|
||||
var micTimer = document.getElementById('mic-timer');
|
||||
var liveIndicator = document.getElementById('live-indicator');
|
||||
var transcriptArea = document.getElementById('transcript-area');
|
||||
|
||||
var isListening = false;
|
||||
var timerInterval = null;
|
||||
var seconds = 0;
|
||||
var finalTranscript = '';
|
||||
|
||||
function formatTime(s) {
|
||||
var m = Math.floor(s / 60);
|
||||
var sec = s % 60;
|
||||
return (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
seconds = 0;
|
||||
micTimer.textContent = '00:00';
|
||||
timerInterval = setInterval(function() {
|
||||
seconds++;
|
||||
micTimer.textContent = formatTime(seconds);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
||||
}
|
||||
|
||||
micBtn.addEventListener('click', function() {
|
||||
if (!isListening) {
|
||||
finalTranscript = '';
|
||||
transcriptArea.innerHTML = '';
|
||||
recognition.start();
|
||||
} else {
|
||||
recognition.stop();
|
||||
}
|
||||
});
|
||||
|
||||
recognition.onstart = function() {
|
||||
isListening = true;
|
||||
micBtn.style.background = 'rgba(239,68,68,0.2)';
|
||||
micBtn.style.borderColor = '#ef4444';
|
||||
micBtn.style.color = '#ef4444';
|
||||
micBtn.title = 'Stop transcription';
|
||||
micStatus.textContent = 'Listening...';
|
||||
micStatus.style.color = '#ef4444';
|
||||
liveIndicator.style.display = 'block';
|
||||
startTimer();
|
||||
};
|
||||
|
||||
recognition.onend = function() {
|
||||
isListening = false;
|
||||
micBtn.style.background = 'rgba(245,158,11,0.1)';
|
||||
micBtn.style.borderColor = 'rgba(245,158,11,0.4)';
|
||||
micBtn.style.color = '#f59e0b';
|
||||
micBtn.title = 'Start transcription';
|
||||
micStatus.textContent = 'Click mic to start';
|
||||
micStatus.style.color = '#94a3b8';
|
||||
liveIndicator.style.display = 'none';
|
||||
stopTimer();
|
||||
};
|
||||
|
||||
recognition.onresult = function(event) {
|
||||
var interim = '';
|
||||
for (var i = event.resultIndex; i < event.results.length; i++) {
|
||||
var transcript = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalTranscript += transcript + ' ';
|
||||
} else {
|
||||
interim += transcript;
|
||||
}
|
||||
}
|
||||
transcriptArea.innerHTML = finalTranscript +
|
||||
(interim ? '<span style="color:#94a3b8">' + interim + '</span>' : '');
|
||||
transcriptArea.scrollTop = transcriptArea.scrollHeight;
|
||||
};
|
||||
|
||||
recognition.onerror = function(event) {
|
||||
if (event.error === 'not-allowed') {
|
||||
micStatus.textContent = 'Microphone access denied';
|
||||
micStatus.style.color = '#ef4444';
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
|
|
@ -42,32 +200,75 @@ export function renderLanding(): string {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Chrome Extension -->
|
||||
<section class="rl-section rl-section--alt" id="extension-download">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Chrome Extension</h2>
|
||||
<p class="rl-subtext" style="text-align:center">Clip pages, record voice notes, and transcribe — right from the toolbar.</p>
|
||||
<div class="rl-grid-2" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1.5rem;max-width:860px;margin:2rem auto">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📋</div>
|
||||
<h3>Web Clipper</h3>
|
||||
<p>Save any page as a note with one click — article text, selection, or full HTML.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🎤</div>
|
||||
<h3>Voice Recording</h3>
|
||||
<p>Press <kbd style="background:rgba(255,255,255,0.1);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.8rem">Ctrl+Shift+V</kbd> to start recording and transcribing from any tab.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔓</div>
|
||||
<h3>Article Unlock</h3>
|
||||
<p>Bypass soft paywalls by fetching archived versions — read the article, then save it to your notebook.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔌</div>
|
||||
<h3>Offline Transcription</h3>
|
||||
<p>Parakeet.js runs entirely in-browser — your audio never leaves the device.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:1.5rem">
|
||||
<a href="/rnotes/extension/download" class="rl-cta-primary" style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
⬇ Download Extension
|
||||
</a>
|
||||
<p style="margin-top:0.75rem;font-size:0.8rem;color:#64748b">
|
||||
Unzip, then load unpacked at <code style="font-size:0.75rem;color:#94a3b8">chrome://extensions</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-grid-4" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem">
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">1</div>
|
||||
<h3>Create a Notebook</h3>
|
||||
<p>Name your notebook and start capturing. Organize by project, topic, or however you think.</p>
|
||||
<h3>Live Transcribe</h3>
|
||||
<p>Speak and watch words appear in real time via the Web Speech API. No uploads, no waiting.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">2</div>
|
||||
<h3>Capture Notes, Voice, or Clips</h3>
|
||||
<p>Type rich text, record voice with live transcription, or drop in audio and video files to transcribe offline.</p>
|
||||
<h3>Audio & Video</h3>
|
||||
<p>Drop files and get full transcripts via Parakeet, running entirely in-browser. Supports MP3, WAV, MP4, and more.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">3</div>
|
||||
<h3>Search, Tag, and Share</h3>
|
||||
<p>Find anything instantly with full-text search. Tag and filter your cards, then share notebooks with your team.</p>
|
||||
<h3>Notebooks & Tags</h3>
|
||||
<p>Organize transcripts alongside notes, clips, code, and files. Tag, search, and filter across everything.</p>
|
||||
</div>
|
||||
<div class="rl-step">
|
||||
<div class="rl-step__num">4</div>
|
||||
<h3>Private & Offline</h3>
|
||||
<p>Parakeet.js runs in-browser — audio never leaves your device. Works offline once the model is cached.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Memory Cards -->
|
||||
<section class="rl-section">
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Memory Cards</h2>
|
||||
<p class="rl-subtext" style="text-align:center">
|
||||
|
|
@ -160,19 +361,23 @@ export function renderLanding(): string {
|
|||
</section>
|
||||
|
||||
<!-- Built on Open Source -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
||||
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rNotes.</p>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-grid-4" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem">
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>PostgreSQL</h3>
|
||||
<p>Notebooks, notes, and tags stored in a battle-tested relational database with full-text search.</p>
|
||||
<h3>Automerge</h3>
|
||||
<p>Local-first CRDT for conflict-free real-time collaboration. Your notes sync across devices without a central server.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>Web Speech API</h3>
|
||||
<p>Browser-native live transcription — speak and watch your words appear in real time.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>Parakeet.js</h3>
|
||||
<p>NVIDIA’s in-browser speech recognition. Transcribe audio and video files offline — nothing leaves your device.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>Hono</h3>
|
||||
<p>Ultra-fast, lightweight API framework powering the rNotes backend.</p>
|
||||
|
|
@ -182,7 +387,7 @@ export function renderLanding(): string {
|
|||
</section>
|
||||
|
||||
<!-- Your Data, Protected -->
|
||||
<section class="rl-section">
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Your Data, Protected</h2>
|
||||
<p class="rl-subtext">How rNotes keeps your information safe.</p>
|
||||
|
|
@ -209,12 +414,13 @@ export function renderLanding(): string {
|
|||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<section class="rl-section">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">(You)rNotes, your thoughts unbound.</h2>
|
||||
<p class="rl-subtext">Try the demo or create a space to get started.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary">Open Notebook</a>
|
||||
<a href="#transcription-demo" class="rl-cta-secondary">Transcribe</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
* space — space slug (default: "demo")
|
||||
*/
|
||||
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { ViewHistory } from "../../../shared/view-history.js";
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
albumName: string;
|
||||
|
|
@ -44,20 +47,36 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
private loading = false;
|
||||
private error = "";
|
||||
private showingSampleData = false;
|
||||
private _tour!: TourEngine;
|
||||
private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery");
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false },
|
||||
{ target: '.photo-cell', title: "Photo Grid", message: "Click any photo to open it in the lightbox viewer.", advanceOnClick: false },
|
||||
{ target: '.rapp-nav__btn', title: "Open Immich", message: "Launch the full Immich interface for uploads and management.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkPhotoGallery.TOUR_STEPS,
|
||||
"rphotos_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.loadGallery();
|
||||
}
|
||||
if (!localStorage.getItem("rphotos_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
this.albums = [
|
||||
|
|
@ -189,6 +208,18 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
this.render();
|
||||
}
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
this.view = prev.view;
|
||||
if (prev.view === "gallery") {
|
||||
this.selectedAlbum = null;
|
||||
this.albumAssets = [];
|
||||
}
|
||||
this.lightboxAsset = null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private thumbUrl(assetId: string): string {
|
||||
const base = this.getApiBase();
|
||||
return `${base}/api/assets/${assetId}/thumbnail`;
|
||||
|
|
@ -353,6 +384,11 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.attachListeners();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private renderView(): string {
|
||||
|
|
@ -370,6 +406,7 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
<a class="rapp-nav__btn" href="${this.getImmichUrl()}">
|
||||
Open Immich
|
||||
</a>
|
||||
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -391,7 +428,7 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
<div class="section-title">Shared Albums</div>
|
||||
<div class="albums-grid">
|
||||
${this.albums.map((a) => `
|
||||
<div class="album-card" data-album-id="${a.id}">
|
||||
<div class="album-card" data-album-id="${a.id}" data-collab-id="album:${a.id}">
|
||||
<div class="album-thumb">
|
||||
${this.isDemo()
|
||||
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(a.albumName)}</div>`
|
||||
|
|
@ -413,7 +450,7 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
<div class="section-title">Recent Photos</div>
|
||||
<div class="photo-grid">
|
||||
${this.assets.map((a) => `
|
||||
<div class="photo-cell" data-asset-id="${a.id}">
|
||||
<div class="photo-cell" data-asset-id="${a.id}" data-collab-id="photo:${a.id}">
|
||||
${this.isDemo()
|
||||
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
|
||||
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
|
||||
|
|
@ -428,7 +465,7 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
const album = this.selectedAlbum!;
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="gallery">← Photos</button>
|
||||
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="gallery">\u2190 Photos</button>' : ''}
|
||||
<span class="rapp-nav__title">${this.esc(album.albumName)}</span>
|
||||
<div class="rapp-nav__actions">
|
||||
<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">
|
||||
|
|
@ -446,7 +483,7 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
` : `
|
||||
<div class="photo-grid">
|
||||
${this.albumAssets.map((a) => `
|
||||
<div class="photo-cell" data-asset-id="${a.id}">
|
||||
<div class="photo-cell" data-asset-id="${a.id}" data-collab-id="photo:${a.id}">
|
||||
${this.isDemo()
|
||||
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
|
||||
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
|
||||
|
|
@ -483,12 +520,18 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Album cards
|
||||
this.shadow.querySelectorAll("[data-album-id]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const id = (el as HTMLElement).dataset.albumId!;
|
||||
const album = this.albums.find((a) => a.id === id);
|
||||
if (album) this.loadAlbum(album);
|
||||
if (album) {
|
||||
this._history.push(this.view);
|
||||
this._history.push("album", { albumId: id });
|
||||
this.loadAlbum(album);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -498,24 +541,23 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
const id = (el as HTMLElement).dataset.assetId!;
|
||||
const assets = this.view === "album" ? this.albumAssets : this.assets;
|
||||
const asset = assets.find((a) => a.id === id);
|
||||
if (asset) this.openLightbox(asset);
|
||||
if (asset) {
|
||||
this._history.push(this.view);
|
||||
this._history.push("lightbox", { assetId: id });
|
||||
this.openLightbox(asset);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Back button
|
||||
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
this.selectedAlbum = null;
|
||||
this.albumAssets = [];
|
||||
this.view = "gallery";
|
||||
this.render();
|
||||
});
|
||||
el.addEventListener("click", () => this.goBack());
|
||||
});
|
||||
|
||||
// Lightbox close
|
||||
this.shadow.querySelector("[data-close-lightbox]")?.addEventListener("click", () => this.closeLightbox());
|
||||
this.shadow.querySelector("[data-close-lightbox]")?.addEventListener("click", () => this.goBack());
|
||||
this.shadow.querySelector("[data-lightbox]")?.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).matches("[data-lightbox]")) this.closeLightbox();
|
||||
if ((e.target as HTMLElement).matches("[data-lightbox]")) this.goBack();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rphotos" class="rl-cta-primary" id="ml-primary">Browse Photos</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-photo-gallery')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { pubsDraftSchema, pubsDocId } from '../schemas';
|
||||
import type { PubsDoc } from '../schemas';
|
||||
import type { DocumentId } from '../../../shared/local-first/document';
|
||||
import { TourEngine } from '../../../shared/tour-engine';
|
||||
|
||||
interface BookFormat {
|
||||
id: string;
|
||||
|
|
@ -78,6 +79,13 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
private _isRemoteUpdate = false;
|
||||
private _syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _syncConnected = false;
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.content-area', title: "Editor", message: "Write or paste markdown content here. Drag-and-drop text files also works.", advanceOnClick: false },
|
||||
{ target: '.format-btn', title: "Format", message: "Choose a pocket-book format — digest, half-letter, A6, and more.", advanceOnClick: false },
|
||||
{ target: '.btn-generate', title: "Generate PDF", message: "Generate a print-ready PDF in the selected format.", advanceOnClick: false },
|
||||
{ target: '.btn-new-draft', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
set formats(val: BookFormat[]) {
|
||||
this._formats = val;
|
||||
|
|
@ -90,6 +98,12 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
|
||||
async connectedCallback() {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadowRoot!,
|
||||
FolkPubsEditor.TOUR_STEPS,
|
||||
"rpubs_tour_done",
|
||||
() => this.shadowRoot!.host as HTMLElement,
|
||||
);
|
||||
this.render();
|
||||
|
||||
const space = this.getAttribute("space") || "";
|
||||
|
|
@ -101,6 +115,9 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
if (space === "demo" && !this._activeDocId) {
|
||||
this.loadDemoContent();
|
||||
}
|
||||
if (!localStorage.getItem("rpubs_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
private async initRuntime() {
|
||||
|
|
@ -381,6 +398,7 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
|
||||
Open File
|
||||
</label>
|
||||
<button class="btn-sample" id="btn-tour" style="font-size:0.78rem;padding:2px 8px;opacity:0.7">Tour</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
|
||||
|
|
@ -440,6 +458,7 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.bindEvents();
|
||||
this._tour.renderOverlay();
|
||||
|
||||
// Populate fields from current doc after render
|
||||
if (this._runtime && this._activeDocId) {
|
||||
|
|
@ -448,9 +467,15 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
if (!this.shadowRoot) return;
|
||||
|
||||
this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
|
||||
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
|
||||
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export function renderLanding(): string {
|
|||
</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-pubs-editor')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- How it works -->
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { ViewHistory } from "../../../shared/view-history.js";
|
||||
|
||||
interface JobData {
|
||||
id: string;
|
||||
|
|
@ -82,11 +84,19 @@ class FolkScheduleApp extends HTMLElement {
|
|||
private log: LogEntry[] = [];
|
||||
private reminders: ReminderData[] = [];
|
||||
private view: "jobs" | "log" | "form" | "reminders" | "reminder-form" = "jobs";
|
||||
private _history = new ViewHistory<"jobs" | "log" | "form" | "reminders" | "reminder-form">("jobs");
|
||||
private editingJob: JobData | null = null;
|
||||
private editingReminder: ReminderData | null = null;
|
||||
private loading = false;
|
||||
private runningJobId: string | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-view="jobs"]', title: "Scheduled Jobs", message: "View and manage automated jobs that run on a cron schedule — email alerts, webhooks, and more.", advanceOnClick: true },
|
||||
{ target: '[data-view="reminders"]', title: "Reminders", message: "Set personal reminders with optional email notifications and calendar sync.", advanceOnClick: true },
|
||||
{ target: '[data-view="log"]', title: "Execution Log", message: "Review the history of all job runs with status, duration, and error details.", advanceOnClick: true },
|
||||
{ target: '[data-action="create"]', title: "Create Job", message: "Create a new scheduled job with cron expressions and configurable actions.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
// Reminder form state
|
||||
private rFormTitle = "";
|
||||
|
|
@ -109,12 +119,21 @@ class FolkScheduleApp extends HTMLElement {
|
|||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkScheduleApp.TOUR_STEPS,
|
||||
"rschedule_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.subscribeOffline();
|
||||
this.loadJobs();
|
||||
if (!localStorage.getItem("rschedule_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -309,7 +328,9 @@ class FolkScheduleApp extends HTMLElement {
|
|||
this.rFormAllDay = true;
|
||||
this.rFormEmail = "";
|
||||
this.rFormSyncCal = true;
|
||||
this._history.push(this.view);
|
||||
this.view = "reminder-form";
|
||||
this._history.push("reminder-form");
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -323,7 +344,9 @@ class FolkScheduleApp extends HTMLElement {
|
|||
this.rFormAllDay = r.allDay;
|
||||
this.rFormEmail = r.notifyEmail || "";
|
||||
this.rFormSyncCal = true;
|
||||
this._history.push(this.view);
|
||||
this.view = "reminder-form";
|
||||
this._history.push("reminder-form");
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -401,7 +424,9 @@ class FolkScheduleApp extends HTMLElement {
|
|||
this.formActionType = "email";
|
||||
this.formEnabled = true;
|
||||
this.formConfig = {};
|
||||
this._history.push(this.view);
|
||||
this.view = "form";
|
||||
this._history.push("form");
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -419,10 +444,21 @@ class FolkScheduleApp extends HTMLElement {
|
|||
this.formConfig[k] = String(v);
|
||||
}
|
||||
}
|
||||
this._history.push(this.view);
|
||||
this.view = "form";
|
||||
this._history.push("form");
|
||||
this.render();
|
||||
}
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
this.view = prev.view;
|
||||
if (prev.view === "reminders") this.loadReminders();
|
||||
else if (prev.view === "log") this.loadLog();
|
||||
else this.render();
|
||||
}
|
||||
|
||||
private formatTime(ts: number | null): string {
|
||||
if (!ts) return "—";
|
||||
const d = new Date(ts);
|
||||
|
|
@ -584,12 +620,18 @@ class FolkScheduleApp extends HTMLElement {
|
|||
<button class="s-tab ${activeTab === "reminders" ? "active" : ""}" data-view="reminders">Reminders</button>
|
||||
<button class="s-tab ${activeTab === "log" ? "active" : ""}" data-view="log">Execution Log</button>
|
||||
</div>
|
||||
<button class="s-btn s-btn-secondary s-btn-sm" id="btn-tour">Tour</button>
|
||||
${headerAction}
|
||||
</div>
|
||||
${content}
|
||||
`;
|
||||
|
||||
this.attachListeners();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private renderJobList(): string {
|
||||
|
|
@ -598,7 +640,7 @@ class FolkScheduleApp extends HTMLElement {
|
|||
}
|
||||
|
||||
const rows = this.jobs.map((j) => `
|
||||
<tr>
|
||||
<tr data-collab-id="job:${j.id}">
|
||||
<td>
|
||||
<label class="s-toggle">
|
||||
<input type="checkbox" ${j.enabled ? "checked" : ""} data-toggle="${j.id}">
|
||||
|
|
@ -707,7 +749,7 @@ class FolkScheduleApp extends HTMLElement {
|
|||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:24px">
|
||||
<button class="s-btn s-btn-primary" data-action="submit">${isEdit ? "Update Job" : "Create Job"}</button>
|
||||
<button class="s-btn s-btn-secondary" data-action="cancel">Cancel</button>
|
||||
<button class="s-btn s-btn-secondary" data-action="cancel">\u2190 Back</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -727,7 +769,7 @@ class FolkScheduleApp extends HTMLElement {
|
|||
? '<span style="background:rgba(59,130,246,0.15);color:#3b82f6;padding:2px 8px;border-radius:4px;font-size:12px">Sent</span>'
|
||||
: '<span style="background:rgba(245,158,11,0.15);color:#f59e0b;padding:2px 8px;border-radius:4px;font-size:12px">Pending</span>';
|
||||
|
||||
return `<tr>
|
||||
return `<tr data-collab-id="reminder:${r.id}">
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${r.sourceColor || "#f59e0b"};flex-shrink:0"></span>
|
||||
|
|
@ -797,19 +839,24 @@ class FolkScheduleApp extends HTMLElement {
|
|||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:24px">
|
||||
<button class="s-btn s-btn-primary" data-action="submit-reminder">${isEdit ? "Update Reminder" : "Create Reminder"}</button>
|
||||
<button class="s-btn s-btn-secondary" data-action="cancel-reminder">Cancel</button>
|
||||
<button class="s-btn s-btn-secondary" data-action="cancel-reminder">\u2190 Back</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Tab switching
|
||||
this.shadow.querySelectorAll<HTMLButtonElement>("[data-view]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
this.view = btn.dataset.view as "jobs" | "log" | "reminders";
|
||||
if (this.view === "log") this.loadLog();
|
||||
else if (this.view === "reminders") this.loadReminders();
|
||||
const newView = btn.dataset.view as "jobs" | "log" | "reminders";
|
||||
this._history.push(this.view);
|
||||
this.view = newView;
|
||||
this._history.push(newView);
|
||||
if (newView === "log") this.loadLog();
|
||||
else if (newView === "reminders") this.loadReminders();
|
||||
else this.render();
|
||||
});
|
||||
});
|
||||
|
|
@ -844,10 +891,9 @@ class FolkScheduleApp extends HTMLElement {
|
|||
btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!));
|
||||
});
|
||||
|
||||
// Form: cancel
|
||||
// Form: cancel / back
|
||||
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel']")?.addEventListener("click", () => {
|
||||
this.view = "jobs";
|
||||
this.render();
|
||||
this.goBack();
|
||||
});
|
||||
|
||||
// Form: submit
|
||||
|
|
@ -906,10 +952,9 @@ class FolkScheduleApp extends HTMLElement {
|
|||
btn.addEventListener("click", () => this.deleteReminder(btn.dataset.rDelete!));
|
||||
});
|
||||
|
||||
// Reminder form: cancel
|
||||
// Reminder form: cancel / back
|
||||
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel-reminder']")?.addEventListener("click", () => {
|
||||
this.view = "reminders";
|
||||
this.loadReminders();
|
||||
this.goBack();
|
||||
});
|
||||
|
||||
// Reminder form: submit
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ export function renderLanding(): string {
|
|||
</a>
|
||||
<a href="#features" class="rl-cta-secondary">Learn More</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-schedule-app')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features (4-card grid) -->
|
||||
|
|
|
|||
|
|
@ -59,9 +59,9 @@ export interface Reminder {
|
|||
completed: boolean; // dismissed by user?
|
||||
|
||||
// Cross-module reference (null for free-form reminders)
|
||||
sourceModule: string | null; // "rwork", "rnotes", etc.
|
||||
sourceModule: string | null; // "rtasks", "rnotes", etc.
|
||||
sourceEntityId: string | null;
|
||||
sourceLabel: string | null; // "rWork Task"
|
||||
sourceLabel: string | null; // "rTasks Task"
|
||||
sourceColor: string | null; // "#f97316"
|
||||
|
||||
// Optional recurrence
|
||||
|
|
@ -172,7 +172,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
|
|||
inputs: [],
|
||||
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] },
|
||||
{ key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] },
|
||||
{ key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' },
|
||||
],
|
||||
},
|
||||
|
|
@ -315,7 +315,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
|
|||
category: 'action',
|
||||
label: 'Create Task',
|
||||
icon: '✅',
|
||||
description: 'Create a task in rWork',
|
||||
description: 'Create a task in rTasks',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }],
|
||||
configSchema: [
|
||||
|
|
@ -347,7 +347,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
|
|||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork'] },
|
||||
{ key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork'] },
|
||||
{ key: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] },
|
||||
{ key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -9,16 +9,32 @@ import { socialsSchema, socialsDocId } from '../schemas';
|
|||
import type { SocialsDoc, Campaign, CampaignPost } from '../schemas';
|
||||
import type { DocumentId } from '../../../shared/local-first/document';
|
||||
import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
|
||||
import { TourEngine } from '../../../shared/tour-engine';
|
||||
|
||||
export class FolkCampaignManager extends HTMLElement {
|
||||
private _space = 'demo';
|
||||
private _campaigns: Campaign[] = [];
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.campaign-header', title: "Campaign Overview", message: "This is your campaign dashboard — see the title, description, platforms, and post count at a glance.", advanceOnClick: false },
|
||||
{ target: '.phase:first-of-type', title: "View Posts by Phase", message: "Posts are organised into phases. Each phase has a timeline and its own set of scheduled posts.", advanceOnClick: false },
|
||||
{ target: 'a[href*="thread-editor"]', title: "Open Thread Editor", message: "Jump to the thread editor to compose and preview tweet threads with live card preview.", advanceOnClick: true },
|
||||
{ target: '#import-md-btn', title: "Import from Markdown", message: "Paste tweets separated by --- to bulk-import content into the campaign.", advanceOnClick: true },
|
||||
];
|
||||
|
||||
static get observedAttributes() { return ['space']; }
|
||||
|
||||
connectedCallback() {
|
||||
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
|
||||
this._tour = new TourEngine(
|
||||
this.shadowRoot!,
|
||||
FolkCampaignManager.TOUR_STEPS,
|
||||
"rsocials_tour_done",
|
||||
() => this.shadowRoot!.querySelector('.container') as HTMLElement,
|
||||
);
|
||||
this._space = this.getAttribute('space') || 'demo';
|
||||
// Start with demo campaign
|
||||
this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }];
|
||||
|
|
@ -26,6 +42,10 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
if (this._space !== 'demo') {
|
||||
this.subscribeOffline();
|
||||
}
|
||||
// Auto-start tour on first visit
|
||||
if (!localStorage.getItem("rsocials_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -141,6 +161,7 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
<div class="actions">
|
||||
<a href="${this.basePath}thread-editor" class="btn btn--outline">Open Thread Editor</a>
|
||||
<button class="btn btn--primary" id="import-md-btn">Import from Markdown</button>
|
||||
<button class="btn btn--outline" id="btn-tour" style="margin-left:auto">Tour</button>
|
||||
</div>
|
||||
${phaseHTML}
|
||||
<div id="imported-posts"></div>`;
|
||||
|
|
@ -250,11 +271,17 @@ export class FolkCampaignManager extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.bindEvents();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
private bindEvents() {
|
||||
if (!this.shadowRoot) return;
|
||||
|
||||
// Tour button
|
||||
this.shadowRoot.getElementById('btn-tour')?.addEventListener('click', () => this.startTour());
|
||||
|
||||
const modal = this.shadowRoot.getElementById('import-modal') as HTMLElement;
|
||||
const openBtn = this.shadowRoot.getElementById('import-md-btn');
|
||||
const closeBtn = this.shadowRoot.getElementById('import-modal-close');
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
const href = this._isDemoFallback
|
||||
? `${this.basePath}thread-editor`
|
||||
: `${this.basePath}thread-editor/${this.esc(t.id)}/edit`;
|
||||
return `<a href="${href}" class="card">
|
||||
return `<a href="${href}" class="card" data-collab-id="thread:${this.esc(t.id)}">
|
||||
${imageTag}
|
||||
<h3 class="card__title">${this.esc(t.title || 'Untitled Thread')}</h3>
|
||||
<p class="card__preview">${preview}</p>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rsocials" class="rl-cta-primary" id="ml-primary">Try Demo</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-campaign-manager')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
: `<span>${s.view_count} views</span>`;
|
||||
|
||||
return `
|
||||
<${tag} class="splat-card${statusClass}"${href}>
|
||||
<${tag} class="splat-card${statusClass}" data-collab-id="splat:${s.id}"${href}>
|
||||
<div class="splat-card__preview">
|
||||
${overlay}
|
||||
<span>${isReady ? "🔮" : "📸"}</span>
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ function posterMockupSvg(): string {
|
|||
|
||||
// --- Component ---
|
||||
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
|
||||
class FolkSwagDesigner extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
|
|
@ -162,10 +164,23 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
private demoStep: 1 | 2 | 3 | 4 = 1;
|
||||
private progressStep = 0;
|
||||
private usedSampleDesign = false;
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.product', title: "Choose Product", message: "Select a product type — tee, sticker, poster, or hoodie.", advanceOnClick: true },
|
||||
{ target: '.steps-bar', title: "Design Flow", message: "Follow the 4-step flow: Product, Design, Generate, Pipeline.", advanceOnClick: false },
|
||||
{ target: '.sample-btn', title: "Sample Design", message: "Try the demo with a pre-made sample design to see the full pipeline.", advanceOnClick: true },
|
||||
{ target: '.generate-btn', title: "Generate", message: "Generate print-ready files and see provider matching + revenue splits.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkSwagDesigner.TOUR_STEPS,
|
||||
"rswag_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
|
@ -177,10 +192,13 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
this.designTitle = "Cosmolocal Network Tee";
|
||||
this.demoStep = 1;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.render();
|
||||
}
|
||||
if (!localStorage.getItem("rswag_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
|
|
@ -334,6 +352,11 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
} else {
|
||||
this.renderFull();
|
||||
}
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
// ---- Demo mode rendering (4-step flow) ----
|
||||
|
|
@ -345,6 +368,8 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
this.shadow.innerHTML = `
|
||||
<style>${this.getDemoStyles()}</style>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:4px"><button id="btn-tour" style="background:transparent;border:1px solid rgba(255,255,255,0.15);color:var(--rs-text-secondary,#94a3b8);border-radius:6px;padding:2px 10px;font-size:0.78rem;cursor:pointer">Tour</button></div>
|
||||
|
||||
<!-- Step indicators -->
|
||||
<div class="steps-bar">
|
||||
${[
|
||||
|
|
@ -362,7 +387,7 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
<section class="step-section ${this.demoStep >= 1 ? 'visible' : ''}">
|
||||
<div class="products">
|
||||
${DEMO_PRODUCTS.map(dp => `
|
||||
<div class="product ${this.selectedProduct === dp.id ? 'active' : ''}" data-product="${dp.id}">
|
||||
<div class="product ${this.selectedProduct === dp.id ? 'active' : ''}" data-product="${dp.id}" data-collab-id="product:${dp.id}">
|
||||
<div class="product-icon">${this.productIcon(dp.id)}</div>
|
||||
<div class="product-name">${dp.name}</div>
|
||||
<div class="product-specs">${dp.printArea}</div>
|
||||
|
|
@ -541,6 +566,8 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
}
|
||||
|
||||
private bindDemoEvents() {
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Product selection
|
||||
this.shadow.querySelectorAll(".product").forEach(el => {
|
||||
el.addEventListener("click", () => this.demoSelectProduct((el as HTMLElement).dataset.product || "tee"));
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rswag" class="rl-cta-primary" id="ml-primary">Start Designing</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-swag-designer')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* <folk-work-board> — kanban board for workspace task management.
|
||||
* <folk-tasks-board> — kanban board for workspace task management.
|
||||
*
|
||||
* Views: workspace list → board with draggable columns.
|
||||
* Supports task creation, status changes, and priority labels.
|
||||
|
|
@ -7,8 +7,10 @@
|
|||
|
||||
import { boardSchema, type BoardDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { ViewHistory } from "../../../shared/view-history.js";
|
||||
|
||||
class FolkWorkBoard extends HTMLElement {
|
||||
class FolkTasksBoard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private view: "list" | "board" = "list";
|
||||
|
|
@ -24,19 +26,38 @@ class FolkWorkBoard extends HTMLElement {
|
|||
private showCreateForm = false;
|
||||
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _history = new ViewHistory<"list" | "board">("list");
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.workspace-card', title: "Workspaces", message: "Select a workspace to open its kanban board.", advanceOnClick: true },
|
||||
{ target: '#create-task', title: "New Task", message: "Create a new task with title, priority, and description.", advanceOnClick: false },
|
||||
{ target: '.board', title: "Kanban Board", message: "Drag tasks between columns — TODO, In Progress, Review, Done.", advanceOnClick: false },
|
||||
{ target: '.badge.clickable', title: "Priority", message: "Click the priority badge on a task to cycle through levels.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkTasksBoard.TOUR_STEPS,
|
||||
"rtasks_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
if (this.space === "demo") { this.loadDemoData(); }
|
||||
else {
|
||||
this.subscribeOffline();
|
||||
this.loadWorkspaces();
|
||||
this.render();
|
||||
}
|
||||
if (!localStorage.getItem("rtasks_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
|
|
@ -48,7 +69,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
if (!runtime?.isInitialized) return;
|
||||
|
||||
try {
|
||||
const docs = await runtime.subscribeModule('work', 'boards', boardSchema);
|
||||
const docs = await runtime.subscribeModule('tasks', 'boards', boardSchema);
|
||||
// Build workspace list from cached boards
|
||||
if (docs.size > 0 && this.workspaces.length === 0) {
|
||||
const boards: any[] = [];
|
||||
|
|
@ -70,7 +91,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime || !this.workspaceSlug) return;
|
||||
// Reload tasks for current board from runtime
|
||||
const docId = runtime.makeDocId('work', 'boards', this.workspaceSlug);
|
||||
const docId = runtime.makeDocId('tasks', 'boards', this.workspaceSlug);
|
||||
const doc = runtime.get(docId) as BoardDoc | undefined;
|
||||
if (doc?.tasks && Object.keys(doc.tasks).length > 0) {
|
||||
this.tasks = Object.values(doc.tasks).map(t => ({
|
||||
|
|
@ -105,7 +126,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^(\/[^/]+)?\/rwork/);
|
||||
const match = path.match(/^(\/[^/]+)?\/rtasks/);
|
||||
return match ? match[0] : "";
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +246,14 @@ class FolkWorkBoard extends HTMLElement {
|
|||
this.loadTasks();
|
||||
}
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
this.view = prev.view;
|
||||
if (prev.view === "list") this.loadWorkspaces();
|
||||
else this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
|
|
@ -307,6 +336,11 @@ class FolkWorkBoard extends HTMLElement {
|
|||
${this.view === "list" ? this.renderList() : this.renderBoard()}
|
||||
`;
|
||||
this.attachListeners();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private renderList(): string {
|
||||
|
|
@ -314,6 +348,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Workspaces</span>
|
||||
<button class="rapp-nav__btn" id="create-ws">+ New Workspace</button>
|
||||
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
||||
</div>
|
||||
${this.workspaces.length > 0 ? `<div class="workspace-grid">
|
||||
${this.workspaces.map(ws => `
|
||||
|
|
@ -351,7 +386,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
private renderBoard(): string {
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>
|
||||
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>' : ''}
|
||||
<span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span>
|
||||
<button class="rapp-nav__btn" id="create-task">+ New Task</button>
|
||||
</div>
|
||||
|
|
@ -381,7 +416,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
return map[p] ? `<span class="badge clickable ${map[p]}" data-cycle-priority="${task.id}">${this.esc(p.toLowerCase())}</span>` : "";
|
||||
};
|
||||
return `
|
||||
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}">
|
||||
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}" data-collab-id="task:${task.id}">
|
||||
${isEditing
|
||||
? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">`
|
||||
: `<div class="task-title" data-start-edit="${task.id}" style="cursor:text">${this.esc(task.title)}</div>`}
|
||||
|
|
@ -398,6 +433,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
this.shadow.getElementById("create-ws")?.addEventListener("click", () => this.createWorkspace());
|
||||
this.shadow.getElementById("create-task")?.addEventListener("click", () => {
|
||||
this.showCreateForm = !this.showCreateForm;
|
||||
|
|
@ -461,10 +497,14 @@ class FolkWorkBoard extends HTMLElement {
|
|||
});
|
||||
|
||||
this.shadow.querySelectorAll("[data-ws]").forEach(el => {
|
||||
el.addEventListener("click", () => this.openBoard((el as HTMLElement).dataset.ws!));
|
||||
el.addEventListener("click", () => {
|
||||
this._history.push("list");
|
||||
this._history.push("board", { ws: (el as HTMLElement).dataset.ws });
|
||||
this.openBoard((el as HTMLElement).dataset.ws!);
|
||||
});
|
||||
});
|
||||
this.shadow.querySelectorAll("[data-back]").forEach(el => {
|
||||
el.addEventListener("click", () => { this.view = "list"; this.loadWorkspaces(); });
|
||||
el.addEventListener("click", () => this.goBack());
|
||||
});
|
||||
this.shadow.querySelectorAll("[data-move]").forEach(el => {
|
||||
el.addEventListener("click", (e) => {
|
||||
|
|
@ -485,10 +525,10 @@ class FolkWorkBoard extends HTMLElement {
|
|||
const task = this.tasks.find(t => t.id === this.dragTaskId);
|
||||
dt.setData("text/plain", task?.title || this.dragTaskId);
|
||||
dt.setData("application/rspace-item", JSON.stringify({
|
||||
module: "rwork",
|
||||
module: "rtasks",
|
||||
entityId: this.dragTaskId,
|
||||
title: task?.title || "",
|
||||
label: "rWork Task",
|
||||
label: "rTasks",
|
||||
color: "#f97316",
|
||||
}));
|
||||
}
|
||||
|
|
@ -526,4 +566,4 @@ class FolkWorkBoard extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-work-board", FolkWorkBoard);
|
||||
customElements.define("folk-tasks-board", FolkTasksBoard);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/* Work module — dark theme */
|
||||
folk-work-board {
|
||||
/* Tasks module — dark theme */
|
||||
folk-tasks-board {
|
||||
display: block;
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
-- rWork module schema
|
||||
CREATE SCHEMA IF NOT EXISTS rwork;
|
||||
-- rTasks module schema
|
||||
CREATE SCHEMA IF NOT EXISTS rtasks;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rwork.users (
|
||||
CREATE TABLE IF NOT EXISTS rtasks.users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
did TEXT UNIQUE NOT NULL,
|
||||
username TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rwork.spaces (
|
||||
CREATE TABLE IF NOT EXISTS rtasks.spaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
|
|
@ -21,40 +21,40 @@ CREATE TABLE IF NOT EXISTS rwork.spaces (
|
|||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rwork.space_members (
|
||||
CREATE TABLE IF NOT EXISTS rtasks.space_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
space_id UUID NOT NULL REFERENCES rwork.spaces(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES rwork.users(id) ON DELETE CASCADE,
|
||||
space_id UUID NOT NULL REFERENCES rtasks.spaces(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES rtasks.users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'MEMBER' CHECK (role IN ('ADMIN','MODERATOR','MEMBER','VIEWER')),
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(space_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rwork.tasks (
|
||||
CREATE TABLE IF NOT EXISTS rtasks.tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
space_id UUID NOT NULL REFERENCES rwork.spaces(id) ON DELETE CASCADE,
|
||||
space_id UUID NOT NULL REFERENCES rtasks.spaces(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'TODO',
|
||||
priority TEXT DEFAULT 'MEDIUM' CHECK (priority IN ('LOW','MEDIUM','HIGH','URGENT')),
|
||||
labels TEXT[] DEFAULT '{}',
|
||||
assignee_id UUID REFERENCES rwork.users(id),
|
||||
created_by UUID REFERENCES rwork.users(id),
|
||||
assignee_id UUID REFERENCES rtasks.users(id),
|
||||
created_by UUID REFERENCES rtasks.users(id),
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rwork.activity_log (
|
||||
CREATE TABLE IF NOT EXISTS rtasks.activity_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
space_id UUID REFERENCES rwork.spaces(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES rwork.users(id),
|
||||
space_id UUID REFERENCES rtasks.spaces(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES rtasks.users(id),
|
||||
action TEXT NOT NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rwork_tasks_space ON rwork.tasks(space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rwork_tasks_status ON rwork.tasks(space_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rwork_activity_space ON rwork.activity_log(space_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_rwork_members_user ON rwork.space_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rtasks_tasks_space ON rtasks.tasks(space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rtasks_tasks_status ON rtasks.tasks(space_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rtasks_activity_space ON rtasks.activity_log(space_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_rtasks_members_user ON rtasks.space_members(user_id);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* rWork landing page — collective task management.
|
||||
* Ported from rwork-online Next.js page.tsx (shadcn/ui + Lucide).
|
||||
* rTasks landing page — collective task management.
|
||||
* Ported from rtasks-online Next.js page.tsx (shadcn/ui + Lucide).
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
|
|
@ -16,11 +16,16 @@ export function renderLanding(): string {
|
|||
by markdown-native task management.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rwork" class="rl-cta-primary" id="ml-primary">
|
||||
<a href="https://demo.rspace.online/rtasks" class="rl-cta-primary" id="ml-primary">
|
||||
Get Started →
|
||||
</a>
|
||||
<a href="https://demo.rspace.online/rwork" class="rl-cta-secondary">View Dashboard</a>
|
||||
<a href="https://demo.rspace.online/rtasks" class="rl-cta-secondary">View Dashboard</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-tasks-board')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- How It Works -->
|
||||
|
|
@ -28,7 +33,7 @@ export function renderLanding(): string {
|
|||
<div class="rl-container">
|
||||
<div style="text-align:center;margin-bottom:1.5rem">
|
||||
<span class="rl-badge">How It Works</span>
|
||||
<h2 class="rl-heading" style="margin-top:0.75rem">rWork in 30 Seconds</h2>
|
||||
<h2 class="rl-heading" style="margin-top:0.75rem">rTasks in 30 Seconds</h2>
|
||||
<p class="rl-subtext">
|
||||
<strong style="color:#3b82f6">Create a Space</strong> for your community,
|
||||
<strong style="color:#14b8a6">add tasks</strong> to your pipeline, and
|
||||
|
|
@ -173,7 +178,7 @@ export function renderLanding(): string {
|
|||
Invite members, configure your pipeline, and ship faster as a team.
|
||||
</p>
|
||||
<div class="rl-cta-row" style="margin-top:1.5rem">
|
||||
<a href="https://demo.rspace.online/rwork" class="rl-cta-primary">
|
||||
<a href="https://demo.rspace.online/rtasks" class="rl-cta-primary">
|
||||
Create a Space →
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* rWork Local-First Client
|
||||
* rTasks Local-First Client
|
||||
*
|
||||
* Wraps the shared local-first stack into a work/kanban-specific API.
|
||||
* Wraps the shared local-first stack into a tasks/kanban-specific API.
|
||||
*/
|
||||
|
||||
import * as Automerge from '@automerge/automerge';
|
||||
|
|
@ -13,7 +13,7 @@ import { DocCrypto } from '../../shared/local-first/crypto';
|
|||
import { boardSchema, boardDocId } from './schemas';
|
||||
import type { BoardDoc, TaskItem, BoardMeta } from './schemas';
|
||||
|
||||
export class WorkLocalFirstClient {
|
||||
export class TasksLocalFirstClient {
|
||||
#space: string;
|
||||
#documents: DocumentManager;
|
||||
#store: EncryptedDocStore;
|
||||
|
|
@ -37,7 +37,7 @@ export class WorkLocalFirstClient {
|
|||
async init(): Promise<void> {
|
||||
if (this.#initialized) return;
|
||||
await this.#store.open();
|
||||
const cachedIds = await this.#store.listByModule('work', 'boards');
|
||||
const cachedIds = await this.#store.listByModule('tasks', 'boards');
|
||||
const cached = await this.#store.loadMany(cachedIds);
|
||||
for (const [docId, binary] of cached) {
|
||||
this.#documents.open<BoardDoc>(docId, boardSchema, binary);
|
||||
|
|
@ -45,7 +45,7 @@ export class WorkLocalFirstClient {
|
|||
await this.#sync.preloadSyncStates(cachedIds);
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
|
||||
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[WorkClient] Working offline'); }
|
||||
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[TasksClient] Working offline'); }
|
||||
this.#initialized = true;
|
||||
}
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Work module — kanban workspace boards.
|
||||
* Tasks module — kanban workspace boards.
|
||||
*
|
||||
* Multi-tenant collaborative workspace with drag-and-drop kanban,
|
||||
* configurable statuses, and activity logging.
|
||||
|
|
@ -51,7 +51,7 @@ function ensureDoc(space: string, boardId?: string): BoardDoc {
|
|||
* Get all board doc IDs for a given space.
|
||||
*/
|
||||
function getBoardDocIds(space: string): string[] {
|
||||
return _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:work:boards:`));
|
||||
return _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:tasks:boards:`));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,15 +59,15 @@ function getBoardDocIds(space: string): string[] {
|
|||
*/
|
||||
function seedDemoIfEmpty(space: string = 'rspace-dev') {
|
||||
if (!_syncServer) return;
|
||||
// Check if this space already has work boards
|
||||
const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:work:boards:`));
|
||||
// Check if this space already has tasks boards
|
||||
const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:tasks:boards:`));
|
||||
if (spaceWorkDocs.length > 0) return;
|
||||
|
||||
const docId = boardDocId(space, space);
|
||||
|
||||
const doc = Automerge.change(Automerge.init<BoardDoc>(), 'seed demo board', (d) => {
|
||||
const now = Date.now();
|
||||
d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: space, createdAt: now };
|
||||
d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: space, createdAt: now };
|
||||
d.board = {
|
||||
id: space,
|
||||
name: 'rSpace Development',
|
||||
|
|
@ -109,14 +109,14 @@ function seedDemoIfEmpty(space: string = 'rspace-dev') {
|
|||
});
|
||||
|
||||
_syncServer!.setDoc(docId, doc);
|
||||
console.log(`[Work] Demo data seeded for "${space}": 1 board, 11 tasks`);
|
||||
console.log(`[Tasks] Demo data seeded for "${space}": 1 board, 11 tasks`);
|
||||
}
|
||||
|
||||
// ── API: Spaces (Boards) ──
|
||||
|
||||
// GET /api/spaces — list workspaces (boards)
|
||||
routes.get("/api/spaces", async (c) => {
|
||||
const allIds = _syncServer!.getDocIds().filter((id) => id.includes(':work:boards:'));
|
||||
const allIds = _syncServer!.getDocIds().filter((id) => id.includes(':tasks:boards:'));
|
||||
const rows = allIds.map((docId) => {
|
||||
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
||||
if (!doc) return null;
|
||||
|
|
@ -161,7 +161,7 @@ routes.post("/api/spaces", async (c) => {
|
|||
|
||||
const now = Date.now();
|
||||
const doc = Automerge.change(Automerge.init<BoardDoc>(), 'create board', (d) => {
|
||||
d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: slug, createdAt: now };
|
||||
d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: slug, createdAt: now };
|
||||
d.board = {
|
||||
id: slug,
|
||||
name: name.trim(),
|
||||
|
|
@ -314,7 +314,7 @@ routes.patch("/api/tasks/:id", async (c) => {
|
|||
}
|
||||
|
||||
// Find which board doc contains this task
|
||||
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':work:boards:'));
|
||||
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':tasks:boards:'));
|
||||
let targetDocId: string | null = null;
|
||||
for (const docId of allBoardIds) {
|
||||
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
||||
|
|
@ -362,7 +362,7 @@ routes.delete("/api/tasks/:id", async (c) => {
|
|||
const id = c.req.param("id");
|
||||
|
||||
// Find which board doc contains this task
|
||||
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':work:boards:'));
|
||||
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':tasks:boards:'));
|
||||
let targetDocId: string | null = null;
|
||||
for (const docId of allBoardIds) {
|
||||
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
||||
|
|
@ -395,26 +395,26 @@ routes.get("/", (c) => {
|
|||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||
return c.html(renderShell({
|
||||
title: `${space} — Work | rSpace`,
|
||||
moduleId: "rwork",
|
||||
title: `${space} — Tasks | rSpace`,
|
||||
moduleId: "rtasks",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-work-board space="${space}"></folk-work-board>`,
|
||||
scripts: `<script type="module" src="/modules/rwork/folk-work-board.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rwork/work.css">`,
|
||||
body: `<folk-tasks-board space="${space}"></folk-tasks-board>`,
|
||||
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
export const workModule: RSpaceModule = {
|
||||
id: "rwork",
|
||||
name: "rWork",
|
||||
export const tasksModule: RSpaceModule = {
|
||||
id: "rtasks",
|
||||
name: "rTasks",
|
||||
icon: "📋",
|
||||
description: "Kanban workspace boards for collaborative task management",
|
||||
scoping: { defaultScope: 'space', userConfigurable: false },
|
||||
docSchemas: [{ pattern: '{space}:work:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }],
|
||||
docSchemas: [{ pattern: '{space}:tasks:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }],
|
||||
routes,
|
||||
standaloneDomain: "rwork.online",
|
||||
standaloneDomain: "rtasks.online",
|
||||
landingPage: renderLanding,
|
||||
seedTemplate: seedDemoIfEmpty,
|
||||
async onInit(ctx) {
|
||||
|
|
@ -426,7 +426,7 @@ export const workModule: RSpaceModule = {
|
|||
const docId = boardDocId(ctx.spaceSlug, ctx.spaceSlug);
|
||||
const doc = Automerge.init<BoardDoc>();
|
||||
const initialized = Automerge.change(doc, 'Init board', (d) => {
|
||||
d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: ctx.spaceSlug, createdAt: Date.now() };
|
||||
d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: ctx.spaceSlug, createdAt: Date.now() };
|
||||
d.board = { id: ctx.spaceSlug, name: ctx.spaceSlug, slug: ctx.spaceSlug, description: '', icon: null, ownerDid: ctx.ownerDID, statuses: ['TODO', 'IN_PROGRESS', 'DONE'], labels: [], createdAt: Date.now(), updatedAt: Date.now() };
|
||||
d.tasks = {};
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* rWork Automerge document schemas.
|
||||
* rTasks Automerge document schemas.
|
||||
*
|
||||
* Granularity: one Automerge document per board/workspace.
|
||||
* DocId format: {space}:work:boards:{boardId}
|
||||
* DocId format: {space}:tasks:boards:{boardId}
|
||||
*/
|
||||
|
||||
import type { DocSchema } from '../../shared/local-first/document';
|
||||
|
|
@ -52,12 +52,12 @@ export interface BoardDoc {
|
|||
// ── Schema registration ──
|
||||
|
||||
export const boardSchema: DocSchema<BoardDoc> = {
|
||||
module: 'work',
|
||||
module: 'tasks',
|
||||
collection: 'boards',
|
||||
version: 1,
|
||||
init: (): BoardDoc => ({
|
||||
meta: {
|
||||
module: 'work',
|
||||
module: 'tasks',
|
||||
collection: 'boards',
|
||||
version: 1,
|
||||
spaceSlug: '',
|
||||
|
|
@ -82,7 +82,7 @@ export const boardSchema: DocSchema<BoardDoc> = {
|
|||
// ── Helpers ──
|
||||
|
||||
export function boardDocId(space: string, boardId: string) {
|
||||
return `${space}:work:boards:${boardId}` as const;
|
||||
return `${space}:tasks:boards:${boardId}` as const;
|
||||
}
|
||||
|
||||
export function createTaskItem(
|
||||
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
import { tripSchema, type TripDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { ViewHistory } from "../../../shared/view-history.js";
|
||||
|
||||
class FolkTripsPlanner extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
|
|
@ -18,19 +20,37 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
|
||||
private error = "";
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _history = new ViewHistory<"list" | "detail">("list");
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '#create-trip', title: "Plan a Trip", message: "Start planning a new trip — add destinations, itinerary, and budget.", advanceOnClick: false },
|
||||
{ target: '.trip-card', title: "Trip Cards", message: "View trip details, status, budget progress, and collaborators.", advanceOnClick: true },
|
||||
{ target: '.tab', title: "Detail Tabs", message: "Explore destinations, itinerary, bookings, expenses, and packing lists.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkTripsPlanner.TOUR_STEPS,
|
||||
"rtrips_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
if (this.space === "demo") { this.loadDemoData(); }
|
||||
else {
|
||||
this.subscribeOffline();
|
||||
this.loadTrips();
|
||||
this.render();
|
||||
}
|
||||
if (!localStorage.getItem("rtrips_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
|
|
@ -472,6 +492,11 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
${this.view === "list" ? this.renderList() : this.renderDetail()}
|
||||
`;
|
||||
this.attachListeners();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private renderList(): string {
|
||||
|
|
@ -479,6 +504,7 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">My Trips</span>
|
||||
<button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button>
|
||||
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
||||
</div>
|
||||
${this.trips.length > 0 ? `<div class="trip-grid">
|
||||
${this.trips.map(t => {
|
||||
|
|
@ -488,7 +514,7 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
|
||||
const budgetColor = pct > 90 ? "#ef4444" : pct > 70 ? "#fbbf24" : "#14b8a6";
|
||||
return `
|
||||
<div class="trip-card" data-trip="${t.id}">
|
||||
<div class="trip-card" data-trip="${t.id}" data-collab-id="trip:${t.id}">
|
||||
<div class="trip-card-header">
|
||||
<span class="trip-name">${this.esc(t.title)}</span>
|
||||
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
|
||||
|
|
@ -518,7 +544,7 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
const st = this.getStatusStyle(t.status || "PLANNING");
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>
|
||||
${this._history.canGoBack ? `<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>` : ""}
|
||||
<span class="rapp-nav__title">${this.esc(t.title)}</span>
|
||||
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
|
||||
</div>
|
||||
|
|
@ -573,7 +599,7 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
case "destinations":
|
||||
return (t.destinations || []).length > 0
|
||||
? (t.destinations || []).map((d: any, i: number) => `
|
||||
<div class="dest-card">
|
||||
<div class="dest-card" data-collab-id="dest:${d.id || i}">
|
||||
<span class="dest-pin">${i === 0 ? "\u{1F6EB}" : i === (t.destinations.length - 1) ? "\u{1F6EC}" : "\u{1F4CD}"}</span>
|
||||
<div class="dest-info">
|
||||
<div class="dest-name">${this.esc(d.name)}</div>
|
||||
|
|
@ -650,20 +676,20 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
|
||||
|
||||
this.shadow.querySelectorAll("[data-trip]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
this._history.push("list");
|
||||
this.view = "detail";
|
||||
this.tab = "overview";
|
||||
this._history.push("detail", { tripId: (el as HTMLElement).dataset.trip });
|
||||
this.loadTrip((el as HTMLElement).dataset.trip!);
|
||||
});
|
||||
});
|
||||
this.shadow.querySelectorAll("[data-back]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
this.view = "list";
|
||||
if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); }
|
||||
});
|
||||
el.addEventListener("click", () => this.goBack());
|
||||
});
|
||||
this.shadow.querySelectorAll("[data-tab]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
|
|
@ -686,6 +712,17 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
});
|
||||
}
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
this.view = prev.view;
|
||||
if (this.view === "list") {
|
||||
if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); }
|
||||
} else {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rtrips" class="rl-cta-primary" id="ml-primary">Start Planning</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-trips-planner')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- How It Works -->
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
* and provides HLS live stream viewing.
|
||||
*/
|
||||
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
|
||||
class FolkVideoPlayer extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "demo";
|
||||
|
|
@ -14,16 +16,31 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
private streamKey = "";
|
||||
private searchTerm = "";
|
||||
private isDemo = false;
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false },
|
||||
{ target: '[data-input="search"]', title: "Search Videos", message: "Filter videos by name to quickly find what you need.", advanceOnClick: false },
|
||||
{ target: '.player-area', title: "Video Player", message: "Select a video from the sidebar to start playback.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkVideoPlayer.TOUR_STEPS,
|
||||
"rtube_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.loadVideos();
|
||||
if (this.space === "demo") { this.loadDemoData(); }
|
||||
else { this.loadVideos(); }
|
||||
if (!localStorage.getItem("rtube_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
|
|
@ -112,11 +129,17 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
<button class="tab ${this.mode === "library" ? "active" : ""}" data-mode="library">Video Library</button>
|
||||
${!this.isDemo ? `<button class="tab ${this.mode === "live" ? "active" : ""}" data-mode="live">Live Stream</button>` : ""}
|
||||
<span class="rapp-nav__title">${this.isDemo ? `${this.videos.length} recordings` : ""}</span>
|
||||
<button class="tab" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
||||
</div>
|
||||
${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
|
||||
</div>
|
||||
`;
|
||||
this.bindEvents();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private renderLibrary(): string {
|
||||
|
|
@ -129,7 +152,7 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
const videoList = filteredVideos.length === 0
|
||||
? `<div class="empty">${this.videos.length === 0 ? "No videos yet" : "No matches"}</div>`
|
||||
: filteredVideos.map((v) => `
|
||||
<div class="video-item ${this.currentVideo === v.name ? "active" : ""}" data-name="${v.name}">
|
||||
<div class="video-item ${this.currentVideo === v.name ? "active" : ""}" data-name="${v.name}" data-collab-id="video:${v.name}">
|
||||
<div class="video-name">${v.name}</div>
|
||||
<div class="video-meta">${this.getExtension(v.name).toUpperCase()} · ${this.formatSize(v.size)}${v.duration ? ` · ${v.duration}` : ""}${v.date ? `<br>${v.date}` : ""}</div>
|
||||
</div>
|
||||
|
|
@ -202,6 +225,8 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
}
|
||||
|
||||
private bindEvents() {
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
this.shadow.querySelectorAll(".tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
this.mode = (btn as HTMLElement).dataset.mode as "library" | "live";
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rtube" class="rl-cta-secondary" id="ml-primary">Browse Videos</a>
|
||||
<a href="https://demo.rspace.online/rtube" class="rl-cta-primary" style="background:#dc2626">Start Streaming</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-video-player')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- How It Works -->
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import { proposalSchema, type ProposalDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { ViewHistory } from "../../../shared/view-history.js";
|
||||
|
||||
interface VoteSpace {
|
||||
slug: string;
|
||||
|
|
@ -54,17 +56,35 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
private showTrendChart = true;
|
||||
private scoreHistory: ScoreSnapshot[] = [];
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _history = new ViewHistory<"spaces" | "proposals" | "proposal">("spaces");
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-space]', title: "Voting Spaces", message: "Each space has its own proposals and conviction parameters. Click a space to explore.", advanceOnClick: true },
|
||||
{ target: '[data-toggle-create]', title: "Create Proposal", message: "Submit a new proposal for the community to vote on with conviction-weighted ranking.", advanceOnClick: true },
|
||||
{ target: '[data-proposal]', title: "View Proposal", message: "Click a proposal to see its details, cast your vote, and watch conviction accumulate over time.", advanceOnClick: true },
|
||||
{ target: '[data-toggle-trend]', title: "Trend Chart", message: "Toggle the conviction trend chart to see how proposal scores evolve as votes accumulate.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkVoteDashboard.TOUR_STEPS,
|
||||
"rvote_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.subscribeOffline();
|
||||
this.loadSpaces();
|
||||
if (this.space === "demo") { this.loadDemoData(); }
|
||||
else { this.subscribeOffline(); this.loadSpaces(); }
|
||||
if (!localStorage.getItem("rvote_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -593,8 +613,11 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.attachListeners();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
private renderView(): string {
|
||||
if (this.view === "proposal" && this.selectedProposal) {
|
||||
return this.renderProposal();
|
||||
|
|
@ -609,6 +632,7 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
return `
|
||||
<div class="header">
|
||||
<span class="header-title">Voting Spaces</span>
|
||||
<button class="header-back" id="btn-tour">Tour</button>
|
||||
</div>
|
||||
${this.spaces.length === 0 ? '<div class="empty"><div class="empty-icon">🗳</div><div class="empty-text">No voting spaces yet. Create one to get started.</div></div>' : ""}
|
||||
${this.spaces.map((s) => `
|
||||
|
|
@ -658,7 +682,7 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
|
||||
return `
|
||||
<div class="header">
|
||||
${this.spaces.length > 1 ? `<button class="header-back" data-back="spaces">←</button>` : ""}
|
||||
${this._history.canGoBack && this.spaces.length > 1 ? '<button class="header-back" data-back="spaces">←</button>' : ''}
|
||||
<span class="header-title">${this.esc(s.name)}</span>
|
||||
<span class="header-sub">${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""}</span>
|
||||
<button class="btn-new" data-toggle-create>+ New Proposal</button>
|
||||
|
|
@ -710,7 +734,7 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
const downChevron = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`;
|
||||
|
||||
return `
|
||||
<div class="proposal" data-pid="${p.id}">
|
||||
<div class="proposal" data-pid="${p.id}" data-collab-id="proposal:${p.id}">
|
||||
${p.status === "RANKING" ? `
|
||||
<div class="vote-col">
|
||||
<button class="vote-chevron up" data-vote-weight="1" data-vote-id="${p.id}" title="Upvote (+1 credit)">${upChevron}</button>
|
||||
|
|
@ -892,7 +916,7 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
|
||||
return `
|
||||
<div class="header">
|
||||
<button class="header-back" data-back="proposals">← Proposals</button>
|
||||
${this._history.canGoBack ? '<button class="header-back" data-back="proposals">← Proposals</button>' : ''}
|
||||
<span class="header-title">${this.esc(p.title)}</span>
|
||||
<span class="badge" style="background:${this.getStatusColor(p.status)}18;color:${this.getStatusColor(p.status)}">${this.getStatusIcon(p.status)} ${p.status}</span>
|
||||
</div>
|
||||
|
|
@ -966,10 +990,13 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
}
|
||||
|
||||
private attachListeners() {
|
||||
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
// Space cards
|
||||
this.shadow.querySelectorAll("[data-space]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const slug = (el as HTMLElement).dataset.space!;
|
||||
this._history.push(this.view);
|
||||
this._history.push("proposals", { space: slug });
|
||||
this.selectedSpace = this.spaces.find((s) => s.slug === slug) || null;
|
||||
this.view = "proposals";
|
||||
if (this.space === "demo") this.render();
|
||||
|
|
@ -982,6 +1009,8 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const id = (el as HTMLElement).dataset.proposal!;
|
||||
this._history.push(this.view);
|
||||
this._history.push("proposal", { proposalId: id });
|
||||
this.view = "proposal";
|
||||
if (this.space === "demo") {
|
||||
this.selectedProposal = this.proposals.find((p) => p.id === id) || null;
|
||||
|
|
@ -996,9 +1025,7 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const target = (el as HTMLElement).dataset.back;
|
||||
if (target === "spaces") { this.view = "spaces"; this.render(); }
|
||||
else if (target === "proposals") { this.view = "proposals"; this.render(); }
|
||||
this.goBack();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1051,6 +1078,13 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
});
|
||||
}
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
this.view = prev.view;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ export function renderLanding(): string {
|
|||
</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-vote-dashboard')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ELI5 Section: rVote in 30 Seconds -->
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { transformToTimelineData, transformToSankeyData, transformToMultichainDa
|
|||
import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-transform";
|
||||
import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz";
|
||||
import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
|
||||
interface ChainInfo {
|
||||
chainId: string;
|
||||
|
|
@ -108,24 +109,41 @@ class FolkWalletViewer extends HTMLElement {
|
|||
multichain?: MultichainData;
|
||||
} = {};
|
||||
|
||||
// Guided tour
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '#address-input', title: "Enter Address", message: "Paste any wallet or Safe multisig address to load balances across all supported chains.", advanceOnClick: false },
|
||||
{ target: '#testnet-toggle', title: "Testnet Toggle", message: "Include testnet chains in the scan. Useful for checking Safe deployments on Sepolia or Goerli.", advanceOnClick: true },
|
||||
{ target: '.chain-btn', title: "Chain Selector", message: "Click a chain to load balances for that specific network. Active chains are highlighted.", advanceOnClick: false },
|
||||
{ target: '.view-tab', title: "Dashboard Views", message: "Switch between balance view, transfer timeline, and flow visualisations for deeper analysis.", advanceOnClick: false },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
FolkWalletViewer.TOUR_STEPS,
|
||||
"rwallet_tour_done",
|
||||
() => this.shadow.host as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const space = this.getAttribute("space") || "";
|
||||
if (space === "demo") {
|
||||
this.loadDemoData();
|
||||
return;
|
||||
}
|
||||
// Check URL params for initial address
|
||||
} else {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
this.address = params.get("address") || "";
|
||||
this.checkAuthState();
|
||||
this.render();
|
||||
if (this.address) this.detectChains();
|
||||
}
|
||||
if (!localStorage.getItem("rwallet_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
private checkAuthState() {
|
||||
try {
|
||||
|
|
@ -979,7 +997,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
</div>
|
||||
<div class="wallet-list">
|
||||
${this.passKeyEOA ? `
|
||||
<div class="wallet-item" data-view-address="${this.esc(this.passKeyEOA)}">
|
||||
<div class="wallet-item" data-view-address="${this.esc(this.passKeyEOA)}" data-collab-id="wallet:${this.esc(this.passKeyEOA)}">
|
||||
<div class="wallet-item-info">
|
||||
<div class="wallet-item-label">
|
||||
<span class="wallet-badge encryptid">EncryptID</span>
|
||||
|
|
@ -990,7 +1008,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
</div>
|
||||
` : ""}
|
||||
${this.linkedWallets.map(w => `
|
||||
<div class="wallet-item" data-view-address="${this.esc(w.address)}">
|
||||
<div class="wallet-item" data-view-address="${this.esc(w.address)}" data-collab-id="wallet:${this.esc(w.address)}">
|
||||
<div class="wallet-item-info">
|
||||
<div class="wallet-item-label">
|
||||
<span class="wallet-badge ${w.type}">${this.esc(w.providerName || w.type.toUpperCase())}</span>
|
||||
|
|
@ -1205,6 +1223,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
<div class="toggle-track"><div class="toggle-thumb"></div></div>
|
||||
<span>Include testnets</span>
|
||||
</div>
|
||||
<button class="view-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
|
||||
${this.walletType ? `
|
||||
<div class="wallet-badge ${this.walletType}">
|
||||
${this.walletType === "safe" ? "⛓ Safe Multisig" : "👤 EOA Wallet"}
|
||||
|
|
@ -1296,10 +1315,18 @@ class FolkWalletViewer extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Draw visualization if active
|
||||
if (this.activeView !== "balances" && this.hasData()) {
|
||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||
}
|
||||
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
startTour() {
|
||||
this._tour.start();
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ export function renderLanding(): string {
|
|||
<a href="https://demo.rspace.online/rwallet" class="rl-cta-primary" id="ml-primary">View Treasury</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;margin-top:0.5rem">
|
||||
<a href="#" onclick="document.querySelector('folk-wallet-viewer')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
|
||||
Start Guided Tour →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import { walletModule } from "../modules/rwallet/mod";
|
|||
import { voteModule } from "../modules/rvote/mod";
|
||||
import { notesModule } from "../modules/rnotes/mod";
|
||||
import { mapsModule } from "../modules/rmaps/mod";
|
||||
import { workModule } from "../modules/rwork/mod";
|
||||
import { tasksModule } from "../modules/rtasks/mod";
|
||||
import { tripsModule } from "../modules/rtrips/mod";
|
||||
import { calModule } from "../modules/rcal/mod";
|
||||
import { networkModule } from "../modules/rnetwork/mod";
|
||||
|
|
@ -99,7 +99,7 @@ registerModule(walletModule);
|
|||
registerModule(voteModule);
|
||||
registerModule(notesModule);
|
||||
registerModule(mapsModule);
|
||||
registerModule(workModule);
|
||||
registerModule(tasksModule);
|
||||
registerModule(tripsModule);
|
||||
registerModule(calModule);
|
||||
registerModule(networkModule);
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ export const notesMigration: ModuleMigration = {
|
|||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Work: rwork.spaces + rwork.tasks
|
||||
* Tasks: rtasks.spaces + rtasks.tasks
|
||||
* Granularity: 1 doc per space/board ({space}:work:boards:{spaceId})
|
||||
*/
|
||||
export const workMigration: ModuleMigration = {
|
||||
|
|
@ -340,14 +340,14 @@ export const workMigration: ModuleMigration = {
|
|||
|
||||
try {
|
||||
const { rows: spaces } = await pool.query(
|
||||
`SELECT * FROM rwork.spaces WHERE slug = $1`,
|
||||
`SELECT * FROM rtasks.spaces WHERE slug = $1`,
|
||||
[space]
|
||||
);
|
||||
|
||||
for (const ws of spaces) {
|
||||
try {
|
||||
const { rows: tasks } = await pool.query(
|
||||
`SELECT * FROM rwork.tasks WHERE space_id = $1 ORDER BY sort_order, created_at`,
|
||||
`SELECT * FROM rtasks.tasks WHERE space_id = $1 ORDER BY sort_order, created_at`,
|
||||
[ws.id]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -615,10 +615,10 @@ const DEMO_SHAPES: Record<string, unknown>[] = [
|
|||
ordersFulfilled: 127,
|
||||
},
|
||||
|
||||
// ─── rWork: Task Board ─────────────────────────────────────
|
||||
// ─── rTasks: Task Board ─────────────────────────────────────
|
||||
{
|
||||
id: "demo-work-board",
|
||||
type: "folk-work-board",
|
||||
id: "demo-tasks-board",
|
||||
type: "folk-tasks-board",
|
||||
x: 750, y: 1350, width: 500, height: 280, rotation: 0,
|
||||
boardTitle: "Trip Preparation Tasks",
|
||||
columns: [
|
||||
|
|
|
|||
|
|
@ -277,10 +277,10 @@ const TEMPLATE_SHAPES: Record<string, unknown>[] = [
|
|||
ordersFulfilled: 0,
|
||||
},
|
||||
|
||||
// ─── rWork: Task Board ──────────────────────────────────────
|
||||
// ─── rTasks: Task Board ──────────────────────────────────────
|
||||
{
|
||||
id: "tmpl-work-board",
|
||||
type: "folk-work-board",
|
||||
id: "tmpl-tasks-board",
|
||||
type: "folk-tasks-board",
|
||||
x: 750, y: 780, width: 500, height: 280, rotation: 0,
|
||||
boardTitle: "Getting Started Tasks",
|
||||
columns: [
|
||||
|
|
|
|||
|
|
@ -578,6 +578,17 @@ export function renderShell(opts: ShellOptions): string {
|
|||
}
|
||||
});
|
||||
|
||||
tabBar.addEventListener('layer-reorder', (e) => {
|
||||
const { layerId, newIndex } = e.detail;
|
||||
const oldIdx = layers.findIndex(l => l.id === layerId);
|
||||
if (oldIdx === -1 || oldIdx === newIndex) return;
|
||||
const [moved] = layers.splice(oldIdx, 1);
|
||||
layers.splice(newIndex, 0, moved);
|
||||
layers.forEach((l, i) => l.order = i);
|
||||
saveTabs();
|
||||
tabBar.setLayers(layers);
|
||||
});
|
||||
|
||||
// ── Dashboard navigate: user clicked a space/action on the dashboard ──
|
||||
document.addEventListener('dashboard-navigate', (e) => {
|
||||
const { moduleId: targetModule, spaceSlug: targetSpace } = e.detail;
|
||||
|
|
@ -782,9 +793,12 @@ export function renderShell(opts: ShellOptions): string {
|
|||
});
|
||||
tabBar.addEventListener('layer-reorder', (e) => {
|
||||
const { layerId, newIndex } = e.detail;
|
||||
sync.updateLayer(layerId, { order: newIndex });
|
||||
const all = sync.getLayers();
|
||||
all.forEach((l, i) => { if (l.order !== i) sync.updateLayer(l.id, { order: i }); });
|
||||
const all = sync.getLayers(); // already sorted by order
|
||||
const oldIdx = all.findIndex(l => l.id === layerId);
|
||||
if (oldIdx === -1 || oldIdx === newIndex) return;
|
||||
const [moved] = all.splice(oldIdx, 1);
|
||||
all.splice(newIndex, 0, moved);
|
||||
all.forEach((l, i) => sync.updateLayer(l.id, { order: i }));
|
||||
});
|
||||
tabBar.addEventListener('flow-create', (e) => { sync.addFlow(e.detail.flow); });
|
||||
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow(e.detail.flowId); });
|
||||
|
|
@ -927,6 +941,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
|
|||
tabBar.addEventListener('layer-switch', (e) => { saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, e.detail.moduleId); });
|
||||
tabBar.addEventListener('layer-add', (e) => { const { moduleId } = e.detail; if (!layers.find(l => l.moduleId === moduleId)) layers.push(makeLayer(moduleId, layers.length)); saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); });
|
||||
tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); });
|
||||
tabBar.addEventListener('layer-reorder', (e) => { const { layerId, newIndex } = e.detail; const oldIdx = layers.findIndex(l => l.id === layerId); if (oldIdx === -1 || oldIdx === newIndex) return; const [moved] = layers.splice(oldIdx, 1); layers.splice(newIndex, 0, moved); layers.forEach((l, i) => l.order = i); saveTabs(); tabBar.setLayers(layers); });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
|
|||
// Observing
|
||||
rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300
|
||||
// Work & Productivity
|
||||
rwork: { badge: "r📋", color: "#cbd5e1" }, // slate-300
|
||||
rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300
|
||||
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
|
||||
// Identity & Infrastructure
|
||||
rids: { badge: "r🪪", color: "#6ee7b7" }, // emerald-300
|
||||
|
|
@ -88,7 +88,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
|
|||
rfiles: "Sharing",
|
||||
rbooks: "Sharing",
|
||||
rdata: "Observing",
|
||||
rwork: "Work & Productivity",
|
||||
rtasks: "Tasks & Productivity",
|
||||
rschedule: "Work & Productivity",
|
||||
rids: "Identity & Infrastructure",
|
||||
rstack: "Identity & Infrastructure",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,487 @@
|
|||
/**
|
||||
* <rstack-collab-overlay> — drop-in multiplayer presence for all rApps.
|
||||
*
|
||||
* Features:
|
||||
* - "N online" badge (top-right pill with colored dots)
|
||||
* - Remote cursors (SVG arrows with username labels, viewport-relative)
|
||||
* - Focus highlighting (colored outline rings on data-collab-id elements)
|
||||
* - Auto-discovery via rspace-doc-subscribe events from runtime
|
||||
* - Hides on canvas page (rSpace has its own CommunitySync)
|
||||
*
|
||||
* Attributes:
|
||||
* module-id — current module identifier
|
||||
* space — current space slug
|
||||
* doc-id — explicit doc ID (fallback if auto-discovery misses)
|
||||
* mode — "badge-only" for iframe/proxy modules (no cursors)
|
||||
*/
|
||||
|
||||
import type { AwarenessMessage } from '../local-first/sync';
|
||||
|
||||
// ── Peer color palette (same as canvas PresenceManager) ──
|
||||
const PEER_COLORS = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#f97316', '#14b8a6', '#6366f1',
|
||||
];
|
||||
|
||||
interface PeerState {
|
||||
peerId: string;
|
||||
username: string;
|
||||
color: string;
|
||||
cursor: { x: number; y: number } | null;
|
||||
selection: string | null;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export class RStackCollabOverlay extends HTMLElement {
|
||||
#shadow: ShadowRoot;
|
||||
#peers = new Map<string, PeerState>();
|
||||
#docId: string | null = null;
|
||||
#moduleId: string | null = null;
|
||||
#localPeerId: string | null = null;
|
||||
#localColor = PEER_COLORS[0];
|
||||
#localUsername = 'Anonymous';
|
||||
#unsubAwareness: (() => void) | null = null;
|
||||
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
#lastCursor = { x: 0, y: 0 };
|
||||
#gcInterval: ReturnType<typeof setInterval> | null = null;
|
||||
#badgeOnly = false;
|
||||
#hidden = false; // true on canvas page
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#shadow = this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#moduleId = this.getAttribute('module-id');
|
||||
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
|
||||
|
||||
// Hide on canvas page — it has its own CommunitySync + PresenceManager
|
||||
if (this.#moduleId === 'rspace') {
|
||||
this.#hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Explicit doc-id attribute (fallback)
|
||||
const explicitDocId = this.getAttribute('doc-id');
|
||||
if (explicitDocId) this.#docId = explicitDocId;
|
||||
|
||||
// Listen for runtime doc subscriptions (auto-discovery)
|
||||
window.addEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
|
||||
|
||||
// Resolve local identity
|
||||
this.#resolveIdentity();
|
||||
|
||||
// Render initial (empty badge)
|
||||
this.#render();
|
||||
|
||||
// Try connecting to runtime
|
||||
this.#tryConnect();
|
||||
|
||||
// GC stale peers every 5s
|
||||
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.#hidden) return;
|
||||
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
|
||||
this.#unsubAwareness?.();
|
||||
this.#stopMouseTracking();
|
||||
this.#stopFocusTracking();
|
||||
if (this.#gcInterval) clearInterval(this.#gcInterval);
|
||||
this.#gcInterval = null;
|
||||
}
|
||||
|
||||
// ── Auto-discovery ──
|
||||
|
||||
#onDocSubscribe = (e: Event) => {
|
||||
const { docId } = (e as CustomEvent).detail;
|
||||
if (!this.#docId && docId) {
|
||||
this.#docId = docId;
|
||||
this.#connectToDoc();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Runtime connection ──
|
||||
|
||||
#tryConnect() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (runtime?.isInitialized) {
|
||||
this.#onRuntimeReady(runtime);
|
||||
} else {
|
||||
// Retry until runtime is ready
|
||||
const check = setInterval(() => {
|
||||
const rt = (window as any).__rspaceOfflineRuntime;
|
||||
if (rt?.isInitialized) {
|
||||
clearInterval(check);
|
||||
this.#onRuntimeReady(rt);
|
||||
}
|
||||
}, 500);
|
||||
// Give up after 15s
|
||||
setTimeout(() => clearInterval(check), 15000);
|
||||
}
|
||||
}
|
||||
|
||||
#onRuntimeReady(runtime: any) {
|
||||
this.#localPeerId = runtime.peerId;
|
||||
// Assign a deterministic color from peer ID
|
||||
this.#localColor = this.#colorForPeer(this.#localPeerId!);
|
||||
|
||||
if (this.#docId) {
|
||||
this.#connectToDoc();
|
||||
}
|
||||
}
|
||||
|
||||
#connectToDoc() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime || !this.#docId) return;
|
||||
|
||||
// Unsubscribe from previous
|
||||
this.#unsubAwareness?.();
|
||||
|
||||
// Listen for remote awareness
|
||||
this.#unsubAwareness = runtime.onAwareness(this.#docId, (msg: AwarenessMessage) => {
|
||||
if (msg.peer === this.#localPeerId) return; // ignore self
|
||||
this.#handleRemoteAwareness(msg);
|
||||
});
|
||||
|
||||
// Start broadcasting local presence
|
||||
if (!this.#badgeOnly) {
|
||||
this.#startMouseTracking();
|
||||
this.#startFocusTracking();
|
||||
}
|
||||
|
||||
// Send initial presence (announce we're here)
|
||||
this.#broadcastPresence();
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
|
||||
#resolveIdentity() {
|
||||
try {
|
||||
const raw = localStorage.getItem('encryptid_session');
|
||||
if (raw) {
|
||||
const session = JSON.parse(raw);
|
||||
if (session?.username) this.#localUsername = session.username;
|
||||
else if (session?.displayName) this.#localUsername = session.displayName;
|
||||
}
|
||||
} catch { /* no session */ }
|
||||
}
|
||||
|
||||
// ── Remote awareness handling ──
|
||||
|
||||
#handleRemoteAwareness(msg: AwarenessMessage) {
|
||||
const existing = this.#peers.get(msg.peer);
|
||||
const peer: PeerState = {
|
||||
peerId: msg.peer,
|
||||
username: msg.username || existing?.username || 'Anonymous',
|
||||
color: msg.color || existing?.color || this.#colorForPeer(msg.peer),
|
||||
cursor: msg.cursor ?? existing?.cursor ?? null,
|
||||
selection: msg.selection ?? existing?.selection ?? null,
|
||||
lastSeen: Date.now(),
|
||||
};
|
||||
this.#peers.set(msg.peer, peer);
|
||||
this.#renderBadge();
|
||||
if (!this.#badgeOnly) {
|
||||
this.#renderCursors();
|
||||
this.#renderFocusRings();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local broadcasting ──
|
||||
|
||||
#broadcastPresence(cursor?: { x: number; y: number }, selection?: string) {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime || !this.#docId) return;
|
||||
|
||||
runtime.sendAwareness(this.#docId, {
|
||||
cursor,
|
||||
selection,
|
||||
username: this.#localUsername,
|
||||
color: this.#localColor,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mouse tracking (15Hz throttle) ──
|
||||
|
||||
#mouseHandler = (e: MouseEvent) => {
|
||||
this.#lastCursor = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
|
||||
#startMouseTracking() {
|
||||
document.addEventListener('mousemove', this.#mouseHandler, { passive: true });
|
||||
// Broadcast at 15Hz
|
||||
this.#mouseMoveTimer = setInterval(() => {
|
||||
this.#broadcastPresence(this.#lastCursor, undefined);
|
||||
}, 67); // ~15Hz
|
||||
}
|
||||
|
||||
#stopMouseTracking() {
|
||||
document.removeEventListener('mousemove', this.#mouseHandler);
|
||||
if (this.#mouseMoveTimer) clearInterval(this.#mouseMoveTimer);
|
||||
this.#mouseMoveTimer = null;
|
||||
}
|
||||
|
||||
// ── Focus tracking on data-collab-id elements ──
|
||||
|
||||
#focusHandler = (e: FocusEvent) => {
|
||||
const target = (e.target as HTMLElement)?.closest?.('[data-collab-id]');
|
||||
if (target) {
|
||||
const collabId = target.getAttribute('data-collab-id');
|
||||
if (collabId) this.#broadcastPresence(undefined, collabId);
|
||||
}
|
||||
};
|
||||
|
||||
#blurHandler = () => {
|
||||
this.#broadcastPresence(undefined, '');
|
||||
};
|
||||
|
||||
#startFocusTracking() {
|
||||
document.addEventListener('focusin', this.#focusHandler, { passive: true });
|
||||
document.addEventListener('click', this.#clickHandler, { passive: true });
|
||||
document.addEventListener('focusout', this.#blurHandler, { passive: true });
|
||||
}
|
||||
|
||||
#stopFocusTracking() {
|
||||
document.removeEventListener('focusin', this.#focusHandler);
|
||||
document.removeEventListener('click', this.#clickHandler);
|
||||
document.removeEventListener('focusout', this.#blurHandler);
|
||||
}
|
||||
|
||||
// Also track clicks on data-collab-id (many elements aren't focusable)
|
||||
#clickHandler = (e: MouseEvent) => {
|
||||
const target = (e.target as HTMLElement)?.closest?.('[data-collab-id]');
|
||||
if (target) {
|
||||
const collabId = target.getAttribute('data-collab-id');
|
||||
if (collabId) this.#broadcastPresence(undefined, collabId);
|
||||
}
|
||||
};
|
||||
|
||||
// ── GC stale peers ──
|
||||
|
||||
#gcPeers() {
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
for (const [id, peer] of this.#peers) {
|
||||
if (now - peer.lastSeen > 15000) {
|
||||
this.#peers.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.#renderBadge();
|
||||
if (!this.#badgeOnly) {
|
||||
this.#renderCursors();
|
||||
this.#renderFocusRings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Color assignment ──
|
||||
|
||||
#colorForPeer(peerId: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < peerId.length; i++) {
|
||||
hash = ((hash << 5) - hash + peerId.charCodeAt(i)) | 0;
|
||||
}
|
||||
return PEER_COLORS[Math.abs(hash) % PEER_COLORS.length];
|
||||
}
|
||||
|
||||
// ── Rendering ──
|
||||
|
||||
#render() {
|
||||
this.#shadow.innerHTML = `
|
||||
<style>${OVERLAY_CSS}</style>
|
||||
<div class="collab-badge" id="badge"></div>
|
||||
<div class="collab-cursors" id="cursors"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderBadge() {
|
||||
const badge = this.#shadow.getElementById('badge');
|
||||
if (!badge) return;
|
||||
|
||||
const count = this.#peers.size + 1; // +1 for self
|
||||
if (count <= 1) {
|
||||
badge.innerHTML = '';
|
||||
badge.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
const dots = Array.from(this.#peers.values())
|
||||
.slice(0, 5) // show max 5 dots
|
||||
.map(p => `<span class="dot" style="background:${p.color}"></span>`)
|
||||
.join('');
|
||||
|
||||
badge.innerHTML = `
|
||||
<span class="dot" style="background:${this.#localColor}"></span>
|
||||
${dots}
|
||||
<span class="count">${count} online</span>
|
||||
`;
|
||||
badge.classList.add('visible');
|
||||
}
|
||||
|
||||
#renderCursors() {
|
||||
const container = this.#shadow.getElementById('cursors');
|
||||
if (!container) return;
|
||||
|
||||
const now = Date.now();
|
||||
const fragments: string[] = [];
|
||||
|
||||
for (const peer of this.#peers.values()) {
|
||||
if (!peer.cursor) continue;
|
||||
const age = now - peer.lastSeen;
|
||||
const opacity = age > 5000 ? 0.3 : 1;
|
||||
|
||||
fragments.push(`
|
||||
<div class="cursor" style="left:${peer.cursor.x}px;top:${peer.cursor.y}px;opacity:${opacity}">
|
||||
<svg width="16" height="20" viewBox="0 0 16 20" fill="none">
|
||||
<path d="M1 1L6.5 18L8.5 11L15 9L1 1Z" fill="${peer.color}" stroke="white" stroke-width="1.5"/>
|
||||
</svg>
|
||||
<span class="cursor-label" style="background:${peer.color}">${this.#escHtml(peer.username)}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
container.innerHTML = fragments.join('');
|
||||
}
|
||||
|
||||
#renderFocusRings() {
|
||||
// Remove all existing focus rings from the document
|
||||
document.querySelectorAll('.rstack-collab-focus-ring').forEach(el => el.remove());
|
||||
|
||||
for (const peer of this.#peers.values()) {
|
||||
if (!peer.selection) continue;
|
||||
const target = document.querySelector(`[data-collab-id="${CSS.escape(peer.selection)}"]`);
|
||||
if (!target) continue;
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const ring = document.createElement('div');
|
||||
ring.className = 'rstack-collab-focus-ring';
|
||||
ring.style.cssText = `
|
||||
position: fixed;
|
||||
left: ${rect.left - 3}px;
|
||||
top: ${rect.top - 3}px;
|
||||
width: ${rect.width + 6}px;
|
||||
height: ${rect.height + 6}px;
|
||||
border: 2px solid ${peer.color};
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
box-shadow: 0 0 0 1px ${peer.color}33;
|
||||
transition: all 0.15s ease;
|
||||
`;
|
||||
|
||||
// Username label on the ring
|
||||
const label = document.createElement('span');
|
||||
label.textContent = peer.username;
|
||||
label.style.cssText = `
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
left: 4px;
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
background: ${peer.color};
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
line-height: 14px;
|
||||
`;
|
||||
ring.appendChild(label);
|
||||
|
||||
document.body.appendChild(ring);
|
||||
}
|
||||
}
|
||||
|
||||
#escHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
static define() {
|
||||
if (!customElements.get('rstack-collab-overlay')) {
|
||||
customElements.define('rstack-collab-overlay', RStackCollabOverlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Styles (inside shadow DOM) ──
|
||||
|
||||
const OVERLAY_CSS = `
|
||||
:host {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.collab-badge {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 80px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px 4px 6px;
|
||||
border-radius: 16px;
|
||||
background: var(--rs-bg-secondary, rgba(30, 30, 30, 0.85));
|
||||
backdrop-filter: blur(8px);
|
||||
font-size: 11px;
|
||||
color: var(--rs-text-secondary, #ccc);
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
z-index: 10000;
|
||||
border: 1px solid var(--rs-border, rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
.collab-badge.visible {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.collab-cursors {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
transition: left 0.1s linear, top 0.1s linear, opacity 0.3s ease;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.cursor-label {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 14px;
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
line-height: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
`;
|
||||
|
|
@ -51,7 +51,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
|
|||
rfiles: { badge: "r📁", color: "#67e8f9" },
|
||||
rbooks: { badge: "r📚", color: "#fda4af" },
|
||||
rdata: { badge: "r📊", color: "#d8b4fe" },
|
||||
rwork: { badge: "r📋", color: "#cbd5e1" },
|
||||
rtasks: { badge: "r📋", color: "#cbd5e1" },
|
||||
rschedule: { badge: "r⏱", color: "#a5b4fc" },
|
||||
rids: { badge: "r🪪", color: "#6ee7b7" },
|
||||
rstack: { badge: "r✨", color: "" },
|
||||
|
|
@ -67,7 +67,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
|
|||
rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce",
|
||||
rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing",
|
||||
rdata: "Observing",
|
||||
rwork: "Work & Productivity",
|
||||
rtasks: "Tasks & Productivity",
|
||||
rids: "Identity & Infrastructure", rstack: "Identity & Infrastructure",
|
||||
};
|
||||
|
||||
|
|
@ -1501,7 +1501,8 @@ const STYLES = `
|
|||
|
||||
/* ── Drag states ── */
|
||||
|
||||
.tab.dragging { opacity: 0.4; }
|
||||
.tab { cursor: grab; }
|
||||
.tab.dragging { opacity: 0.4; cursor: grabbing; }
|
||||
.tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; }
|
||||
|
||||
/* ── Add button ── */
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
DocumentManager,
|
||||
} from './document';
|
||||
import { EncryptedDocStore } from './storage';
|
||||
import { DocSyncManager } from './sync';
|
||||
import { DocSyncManager, type AwarenessMessage } from './sync';
|
||||
import { DocCrypto } from './crypto';
|
||||
import {
|
||||
getStorageInfo,
|
||||
|
|
@ -72,6 +72,7 @@ export class RSpaceOfflineRuntime {
|
|||
get isInitialized(): boolean { return this.#initialized; }
|
||||
get isOnline(): boolean { return this.#sync.isConnected; }
|
||||
get status(): RuntimeStatus { return this.#status; }
|
||||
get peerId(): string { return this.#sync.peerId; }
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
|
|
@ -131,6 +132,11 @@ export class RSpaceOfflineRuntime {
|
|||
// Subscribe for sync (sends subscribe + initial sync to server)
|
||||
await this.#sync.subscribe([docId]);
|
||||
|
||||
// Notify overlay / other listeners about this subscription
|
||||
window.dispatchEvent(new CustomEvent('rspace-doc-subscribe', {
|
||||
detail: { docId, module: docId.split(':')[1] ?? '' },
|
||||
}));
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
|
|
@ -178,6 +184,21 @@ export class RSpaceOfflineRuntime {
|
|||
return () => { unsub1(); unsub2(); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send awareness/presence data for a document (cursor, selection, etc.).
|
||||
*/
|
||||
sendAwareness(docId: DocumentId, data: Partial<AwarenessMessage>): void {
|
||||
this.#sync.sendAwareness(docId, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for awareness updates on a document from remote peers.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onAwareness(docId: DocumentId, cb: (msg: AwarenessMessage) => void): () => void {
|
||||
return this.#sync.onAwareness(docId, cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure module scope information from the page's module list.
|
||||
* Call once after init with the modules config for this space.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* Shared guided-tour engine for shadow-DOM rApp components.
|
||||
*
|
||||
* Designed to survive full innerHTML replacement — call `renderOverlay()`
|
||||
* at the end of each render cycle to re-inject the tour UI.
|
||||
*/
|
||||
|
||||
export interface TourStep {
|
||||
target: string; // CSS selector within shadow root
|
||||
title: string;
|
||||
message: string;
|
||||
advanceOnClick?: boolean; // auto-advance when target is clicked
|
||||
}
|
||||
|
||||
export class TourEngine {
|
||||
private shadowRoot: ShadowRoot;
|
||||
private steps: TourStep[];
|
||||
private storageKey: string;
|
||||
private getContainer: () => HTMLElement | null;
|
||||
|
||||
private _active = false;
|
||||
private _step = 0;
|
||||
private _clickHandler: (() => void) | null = null;
|
||||
private _clickTarget: HTMLElement | null = null;
|
||||
|
||||
get isActive() { return this._active; }
|
||||
|
||||
constructor(
|
||||
shadowRoot: ShadowRoot,
|
||||
steps: TourStep[],
|
||||
storageKey: string,
|
||||
getContainer: () => HTMLElement | null,
|
||||
) {
|
||||
this.shadowRoot = shadowRoot;
|
||||
this.steps = steps;
|
||||
this.storageKey = storageKey;
|
||||
this.getContainer = getContainer;
|
||||
}
|
||||
|
||||
/** Start (or restart) the tour from step 0. */
|
||||
start() {
|
||||
this._active = true;
|
||||
this._step = 0;
|
||||
this.renderOverlay();
|
||||
}
|
||||
|
||||
/** Advance to next step or finish if at the end. */
|
||||
advance() {
|
||||
this._detachClickHandler();
|
||||
this._step++;
|
||||
if (this._step >= this.steps.length) {
|
||||
this.end();
|
||||
} else {
|
||||
this.renderOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/** End the tour and remove the overlay. */
|
||||
end() {
|
||||
this._detachClickHandler();
|
||||
this._active = false;
|
||||
this._step = 0;
|
||||
localStorage.setItem(this.storageKey, "1");
|
||||
this.shadowRoot.getElementById("rspace-tour-overlay")?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-inject / update the tour overlay.
|
||||
* Call this at the end of every host render() so the overlay survives
|
||||
* full innerHTML replacement. Safe to call when tour is inactive (no-op).
|
||||
*/
|
||||
renderOverlay() {
|
||||
if (!this._active) return;
|
||||
|
||||
// Ensure styles exist
|
||||
this._ensureStyles();
|
||||
|
||||
// Skip steps whose target doesn't exist (e.g. mic button when unsupported)
|
||||
const step = this.steps[this._step];
|
||||
const targetEl = this.shadowRoot.querySelector(step.target) as HTMLElement | null;
|
||||
if (!targetEl && this._step < this.steps.length - 1) {
|
||||
this._step++;
|
||||
this.renderOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get or create overlay element
|
||||
let overlay = this.shadowRoot.getElementById("rspace-tour-overlay");
|
||||
if (!overlay) {
|
||||
overlay = document.createElement("div");
|
||||
overlay.id = "rspace-tour-overlay";
|
||||
overlay.className = "rspace-tour-overlay";
|
||||
const container = this.getContainer();
|
||||
if (container) container.appendChild(overlay);
|
||||
else this.shadowRoot.appendChild(overlay);
|
||||
}
|
||||
|
||||
// Compute spotlight position relative to container
|
||||
let spotX = 0, spotY = 0, spotW = 120, spotH = 40;
|
||||
if (targetEl) {
|
||||
const container = this.getContainer() || this.shadowRoot.host as HTMLElement;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rect = targetEl.getBoundingClientRect();
|
||||
spotX = rect.left - containerRect.left - 6;
|
||||
spotY = rect.top - containerRect.top - 6;
|
||||
spotW = rect.width + 12;
|
||||
spotH = rect.height + 12;
|
||||
}
|
||||
|
||||
const isLast = this._step >= this.steps.length - 1;
|
||||
const stepNum = this._step + 1;
|
||||
const totalSteps = this.steps.length;
|
||||
|
||||
// Position tooltip below target, clamped within container
|
||||
const tooltipTop = spotY + spotH + 12;
|
||||
const tooltipLeft = Math.max(8, spotX);
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="rspace-tour-backdrop" style="clip-path: polygon(
|
||||
0% 0%, 0% 100%, ${spotX}px 100%, ${spotX}px ${spotY}px,
|
||||
${spotX + spotW}px ${spotY}px, ${spotX + spotW}px ${spotY + spotH}px,
|
||||
${spotX}px ${spotY + spotH}px, ${spotX}px 100%, 100% 100%, 100% 0%
|
||||
)"></div>
|
||||
<div class="rspace-tour-spotlight" style="left:${spotX}px;top:${spotY}px;width:${spotW}px;height:${spotH}px"></div>
|
||||
<div class="rspace-tour-tooltip" style="top:${tooltipTop}px;left:${tooltipLeft}px">
|
||||
<div class="rspace-tour-tooltip__step">${stepNum} / ${totalSteps}</div>
|
||||
<div class="rspace-tour-tooltip__title">${step.title}</div>
|
||||
<div class="rspace-tour-tooltip__msg">${step.message}</div>
|
||||
<div class="rspace-tour-tooltip__nav">
|
||||
${this._step > 0 ? '<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--prev" data-tour="prev">Back</button>' : ''}
|
||||
${step.advanceOnClick
|
||||
? `<span class="rspace-tour-tooltip__hint">or click the button above</span>`
|
||||
: `<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--next" data-tour="next">${isLast ? 'Finish' : 'Next'}</button>`
|
||||
}
|
||||
<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--skip" data-tour="skip">Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire tour navigation buttons
|
||||
overlay.querySelectorAll("[data-tour]").forEach(btn => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const action = (btn as HTMLElement).dataset.tour;
|
||||
if (action === "next") this.advance();
|
||||
else if (action === "prev") {
|
||||
this._detachClickHandler();
|
||||
this._step = Math.max(0, this._step - 1);
|
||||
this.renderOverlay();
|
||||
}
|
||||
else if (action === "skip") this.end();
|
||||
});
|
||||
});
|
||||
|
||||
// For advanceOnClick steps, attach listener on the target
|
||||
this._detachClickHandler();
|
||||
if (step.advanceOnClick && targetEl) {
|
||||
this._clickHandler = () => {
|
||||
this._detachClickHandler();
|
||||
setTimeout(() => this.advance(), 300);
|
||||
};
|
||||
this._clickTarget = targetEl;
|
||||
targetEl.addEventListener("click", this._clickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the click-to-advance listener from the previous target. */
|
||||
private _detachClickHandler() {
|
||||
if (this._clickHandler && this._clickTarget) {
|
||||
this._clickTarget.removeEventListener("click", this._clickHandler);
|
||||
}
|
||||
this._clickHandler = null;
|
||||
this._clickTarget = null;
|
||||
}
|
||||
|
||||
/** Inject the shared tour CSS once into the shadow root. */
|
||||
private _ensureStyles() {
|
||||
if (this.shadowRoot.querySelector("[data-tour-styles]")) return;
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-tour-styles", "");
|
||||
style.textContent = TOUR_CSS;
|
||||
this.shadowRoot.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
const TOUR_CSS = `
|
||||
.rspace-tour-overlay {
|
||||
position: absolute; inset: 0; z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.rspace-tour-backdrop {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
pointer-events: auto;
|
||||
transition: clip-path 0.3s ease;
|
||||
}
|
||||
.rspace-tour-spotlight {
|
||||
position: absolute;
|
||||
border: 2px solid var(--rs-primary, #06b6d4);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 4px rgba(6, 182, 212, 0.25);
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.rspace-tour-tooltip {
|
||||
position: absolute;
|
||||
width: min(320px, calc(100% - 24px));
|
||||
background: var(--rs-bg-surface, #1e293b);
|
||||
border: 1px solid var(--rs-border, #334155);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||
color: var(--rs-text-primary, #f1f5f9);
|
||||
pointer-events: auto;
|
||||
animation: rspace-tour-pop 0.25s ease-out;
|
||||
}
|
||||
@keyframes rspace-tour-pop {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.rspace-tour-tooltip__step {
|
||||
font-size: 0.7rem;
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.rspace-tour-tooltip__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
background: linear-gradient(135deg, #06b6d4, #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.rspace-tour-tooltip__msg {
|
||||
font-size: 0.85rem;
|
||||
color: var(--rs-text-secondary, #94a3b8);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.rspace-tour-tooltip__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.rspace-tour-tooltip__btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
.rspace-tour-tooltip__btn:hover { transform: translateY(-1px); }
|
||||
.rspace-tour-tooltip__btn--next {
|
||||
background: linear-gradient(135deg, #06b6d4, #7c3aed);
|
||||
color: white;
|
||||
}
|
||||
.rspace-tour-tooltip__btn--prev {
|
||||
background: var(--rs-btn-secondary-bg, #334155);
|
||||
color: var(--rs-text-secondary, #94a3b8);
|
||||
}
|
||||
.rspace-tour-tooltip__btn--skip {
|
||||
background: none;
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
margin-left: auto;
|
||||
}
|
||||
.rspace-tour-tooltip__btn--skip:hover { color: var(--rs-text-primary, #f1f5f9); }
|
||||
.rspace-tour-tooltip__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
font-style: italic;
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* ViewHistory — lightweight in-app navigation stack for rApps.
|
||||
*
|
||||
* Each rApp with hierarchical views instantiates one, calls push()
|
||||
* on forward navigation, and back() from the back button. Replaces
|
||||
* hardcoded data-back targets with a proper history stack.
|
||||
*/
|
||||
|
||||
export interface ViewEntry<V extends string> {
|
||||
view: V;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 20;
|
||||
|
||||
export class ViewHistory<V extends string> {
|
||||
private stack: ViewEntry<V>[] = [];
|
||||
private root: V;
|
||||
|
||||
constructor(rootView: V) {
|
||||
this.root = rootView;
|
||||
}
|
||||
|
||||
/** Record a forward navigation. Skips if top of stack is same view+context. */
|
||||
push(view: V, context?: Record<string, unknown>): void {
|
||||
const top = this.stack[this.stack.length - 1];
|
||||
if (top && top.view === view) return; // skip duplicate
|
||||
this.stack.push({ view, context });
|
||||
if (this.stack.length > MAX_DEPTH) this.stack.shift();
|
||||
}
|
||||
|
||||
/** Pop and return the previous entry, or null if at root. */
|
||||
back(): ViewEntry<V> | null {
|
||||
if (this.stack.length <= 1) {
|
||||
this.stack = [];
|
||||
return { view: this.root };
|
||||
}
|
||||
this.stack.pop(); // remove current
|
||||
return this.stack[this.stack.length - 1] ?? { view: this.root };
|
||||
}
|
||||
|
||||
/** True when there's history to go back to. */
|
||||
get canGoBack(): boolean {
|
||||
return this.stack.length > 1;
|
||||
}
|
||||
|
||||
/** Peek at the previous entry without popping. */
|
||||
peekBack(): ViewEntry<V> | null {
|
||||
if (this.stack.length <= 1) return { view: this.root };
|
||||
return this.stack[this.stack.length - 2] ?? null;
|
||||
}
|
||||
|
||||
/** Clear the stack and reset to a root view (e.g. on space switch). */
|
||||
reset(rootView?: V): void {
|
||||
if (rootView !== undefined) this.root = rootView;
|
||||
this.stack = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -170,7 +170,7 @@ const CONFIG = {
|
|||
'https://demo.rsocials.online',
|
||||
'https://socials.crypto-commons.org',
|
||||
'https://socials.p2pfoundation.net',
|
||||
'https://rwork.online',
|
||||
'https://rtasks.online',
|
||||
'https://rforum.online',
|
||||
'https://rchoices.online',
|
||||
'https://rswag.online',
|
||||
|
|
|
|||
|
|
@ -472,31 +472,31 @@ export default defineConfig({
|
|||
resolve(__dirname, "dist/modules/rmaps/maps.css"),
|
||||
);
|
||||
|
||||
// Build work module component
|
||||
// Build tasks module component
|
||||
await build({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/rwork/components"),
|
||||
root: resolve(__dirname, "modules/rtasks/components"),
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/rwork"),
|
||||
outDir: resolve(__dirname, "dist/modules/rtasks"),
|
||||
lib: {
|
||||
entry: resolve(__dirname, "modules/rwork/components/folk-work-board.ts"),
|
||||
entry: resolve(__dirname, "modules/rtasks/components/folk-tasks-board.ts"),
|
||||
formats: ["es"],
|
||||
fileName: () => "folk-work-board.js",
|
||||
fileName: () => "folk-tasks-board.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: "folk-work-board.js",
|
||||
entryFileNames: "folk-tasks-board.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Copy work CSS
|
||||
mkdirSync(resolve(__dirname, "dist/modules/rwork"), { recursive: true });
|
||||
// Copy tasks CSS
|
||||
mkdirSync(resolve(__dirname, "dist/modules/rtasks"), { recursive: true });
|
||||
copyFileSync(
|
||||
resolve(__dirname, "modules/rwork/components/work.css"),
|
||||
resolve(__dirname, "dist/modules/rwork/work.css"),
|
||||
resolve(__dirname, "modules/rtasks/components/tasks.css"),
|
||||
resolve(__dirname, "dist/modules/rtasks/tasks.css"),
|
||||
);
|
||||
|
||||
// Build trips module component
|
||||
|
|
|
|||
|
|
@ -2210,7 +2210,7 @@
|
|||
<button id="embed-books" title="Embed rBooks">📚 rBooks</button>
|
||||
<button id="embed-forum" title="Embed rForum">💬 rForum</button>
|
||||
<button id="embed-files" title="Embed rFiles">📁 rFiles</button>
|
||||
<button id="embed-work" title="Embed rWork">📋 rWork</button>
|
||||
<button id="embed-tasks" title="Embed rTasks">📋 rTasks</button>
|
||||
<button id="embed-inbox" title="Embed rInbox">📧 rInbox</button>
|
||||
<button id="embed-cart" title="Embed rCart">🛒 rCart</button>
|
||||
<button id="embed-data" title="Embed rData">📊 rData</button>
|
||||
|
|
@ -2794,9 +2794,12 @@
|
|||
});
|
||||
tabBar.addEventListener('layer-reorder', (e) => {
|
||||
const { layerId, newIndex } = e.detail;
|
||||
sync.updateLayer?.(layerId, { order: newIndex });
|
||||
const all = sync.getLayers?.() || [];
|
||||
all.forEach((l, i) => { if (l.order !== i) sync.updateLayer?.(l.id, { order: i }); });
|
||||
const oldIdx = all.findIndex(l => l.id === layerId);
|
||||
if (oldIdx === -1 || oldIdx === newIndex) return;
|
||||
const [moved] = all.splice(oldIdx, 1);
|
||||
all.splice(newIndex, 0, moved);
|
||||
all.forEach((l, i) => sync.updateLayer?.(l.id, { order: i }));
|
||||
});
|
||||
tabBar.addEventListener('flow-create', (e) => { sync.addFlow?.(e.detail.flow); });
|
||||
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow?.(e.detail.flowId); });
|
||||
|
|
@ -4403,7 +4406,7 @@
|
|||
{ btnId: "embed-books", moduleId: "rbooks" },
|
||||
{ btnId: "embed-pubs", moduleId: "rpubs" },
|
||||
{ btnId: "embed-files", moduleId: "rfiles" },
|
||||
{ btnId: "embed-work", moduleId: "rwork" },
|
||||
{ btnId: "embed-tasks", moduleId: "rtasks" },
|
||||
{ btnId: "embed-forum", moduleId: "rforum" },
|
||||
{ btnId: "embed-inbox", moduleId: "rinbox" },
|
||||
{ btnId: "embed-tube", moduleId: "rtube" },
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -17,6 +17,7 @@ import { RStackSpaceSettings } from "../shared/components/rstack-space-settings"
|
|||
import { RStackModuleSetup } from "../shared/components/rstack-module-setup";
|
||||
import { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
|
||||
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
|
||||
import { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay";
|
||||
import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
|
||||
import { rspaceNavUrl } from "../shared/url-helpers";
|
||||
import { TabCache } from "../shared/tab-cache";
|
||||
|
|
@ -39,6 +40,7 @@ RStackSpaceSettings.define();
|
|||
RStackModuleSetup.define();
|
||||
RStackHistoryPanel.define();
|
||||
RStackOfflineIndicator.define();
|
||||
RStackCollabOverlay.define();
|
||||
RStackUserDashboard.define();
|
||||
|
||||
// ── Offline Runtime ──
|
||||
|
|
|
|||
Loading…
Reference in New Issue