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
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
open-notebook.env
|
||||||
|
|
||||||
# Bun
|
# Bun
|
||||||
bun.lockb
|
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 |
|
| **rCal** | rcal.online | Spatio-temporal calendar with map + lunar overlay |
|
||||||
| **rMaps** | rmaps.online | Geographic mapping & location hierarchy |
|
| **rMaps** | rmaps.online | Geographic mapping & location hierarchy |
|
||||||
| **rTrips** | rtrips.online | Trip planning with itineraries |
|
| **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 |
|
| **rSchedule** | rschedule.online | Persistent cron-based job scheduling with email, webhooks & briefings |
|
||||||
|
|
||||||
### Communication
|
### 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
|
be positioned, connected, resized, and composed spatially. This enables
|
||||||
non-linear knowledge work: a funding proposal (rFunds) sits next to
|
non-linear knowledge work: a funding proposal (rFunds) sits next to
|
||||||
the vote (rVote), the budget spreadsheet (rData), and the project
|
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
|
### Offline-First
|
||||||
Every interaction works offline. Changes queue locally and merge
|
Every interaction works offline. Changes queue locally and merge
|
||||||
|
|
|
||||||
|
|
@ -197,16 +197,16 @@ services:
|
||||||
traefik.http.routers.rmaps-sa.entrypoints: web
|
traefik.http.routers.rmaps-sa.entrypoints: web
|
||||||
traefik.http.services.rmaps-sa.loadbalancer.server.port: "3000"
|
traefik.http.services.rmaps-sa.loadbalancer.server.port: "3000"
|
||||||
|
|
||||||
# ── rWork ──
|
# ── rTasks ──
|
||||||
rwork-standalone:
|
rtasks-standalone:
|
||||||
<<: *standalone-base
|
<<: *standalone-base
|
||||||
container_name: rwork-standalone
|
container_name: rtasks-standalone
|
||||||
command: ["bun", "run", "modules/rwork/standalone.ts"]
|
command: ["bun", "run", "modules/rtasks/standalone.ts"]
|
||||||
labels:
|
labels:
|
||||||
<<: *traefik-enabled
|
<<: *traefik-enabled
|
||||||
traefik.http.routers.rwork-sa.rule: Host(`rwork.online`)
|
traefik.http.routers.rtasks-sa.rule: Host(`rtasks.online`)
|
||||||
traefik.http.routers.rwork-sa.entrypoints: web
|
traefik.http.routers.rtasks-sa.entrypoints: web
|
||||||
traefik.http.services.rwork-sa.loadbalancer.server.port: "3000"
|
traefik.http.services.rtasks-sa.loadbalancer.server.port: "3000"
|
||||||
|
|
||||||
# ── rTrips ──
|
# ── rTrips ──
|
||||||
rtrips-standalone:
|
rtrips-standalone:
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ services:
|
||||||
- INFISICAL_AI_PROJECT_SLUG=claude-ops
|
- INFISICAL_AI_PROJECT_SLUG=claude-ops
|
||||||
- INFISICAL_AI_SECRET_PATH=/ai
|
- INFISICAL_AI_SECRET_PATH=/ai
|
||||||
- LISTMONK_URL=https://newsletter.cosmolocal.world
|
- LISTMONK_URL=https://newsletter.cosmolocal.world
|
||||||
|
- NOTEBOOK_API_URL=http://open-notebook:5055
|
||||||
depends_on:
|
depends_on:
|
||||||
rspace-db:
|
rspace-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -88,10 +89,10 @@ services:
|
||||||
- "traefik.http.routers.rspace-rvote.entrypoints=web"
|
- "traefik.http.routers.rspace-rvote.entrypoints=web"
|
||||||
- "traefik.http.routers.rspace-rvote.priority=120"
|
- "traefik.http.routers.rspace-rvote.priority=120"
|
||||||
- "traefik.http.routers.rspace-rvote.service=rspace-online"
|
- "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-rtasks.rule=Host(`rtasks.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rtasks.online`)"
|
||||||
- "traefik.http.routers.rspace-rwork.entrypoints=web"
|
- "traefik.http.routers.rspace-rtasks.entrypoints=web"
|
||||||
- "traefik.http.routers.rspace-rwork.priority=120"
|
- "traefik.http.routers.rspace-rtasks.priority=120"
|
||||||
- "traefik.http.routers.rspace-rwork.service=rspace-online"
|
- "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.rule=Host(`rcal.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rcal.online`)"
|
||||||
- "traefik.http.routers.rspace-rcal.entrypoints=web"
|
- "traefik.http.routers.rspace-rcal.entrypoints=web"
|
||||||
- "traefik.http.routers.rspace-rcal.priority=120"
|
- "traefik.http.routers.rspace-rcal.priority=120"
|
||||||
|
|
@ -252,6 +253,32 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
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:
|
volumes:
|
||||||
rspace-data:
|
rspace-data:
|
||||||
rspace-books:
|
rspace-books:
|
||||||
|
|
@ -262,6 +289,8 @@ volumes:
|
||||||
rspace-backups:
|
rspace-backups:
|
||||||
rspace-pgdata:
|
rspace-pgdata:
|
||||||
encryptid-pgdata:
|
encryptid-pgdata:
|
||||||
|
open-notebook-data:
|
||||||
|
open-notebook-db:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-public:
|
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: "📚" },
|
rbooks: { badge: "rB", color: "#fda4af", name: "rBooks", icon: "📚" },
|
||||||
rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" },
|
rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" },
|
||||||
rfiles: { badge: "rFi", color: "#67e8f9", name: "rFiles", 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: "💬" },
|
rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" },
|
||||||
rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" },
|
rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" },
|
||||||
rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", 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",
|
path: "/api/spaces",
|
||||||
transform: (data) => {
|
transform: (data) => {
|
||||||
const spaces = Array.isArray(data) ? data : [];
|
const spaces = Array.isArray(data) ? data : [];
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas";
|
import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas";
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
|
||||||
interface BookData {
|
interface BookData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -31,6 +32,13 @@ export class FolkBookShelf extends HTMLElement {
|
||||||
private _searchTerm = "";
|
private _searchTerm = "";
|
||||||
private _selectedTag: string | null = null;
|
private _selectedTag: string | null = null;
|
||||||
private _offlineUnsub: (() => void) | 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() {
|
static get observedAttributes() {
|
||||||
return ["space-slug"];
|
return ["space-slug"];
|
||||||
|
|
@ -52,6 +60,12 @@ export class FolkBookShelf extends HTMLElement {
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.attachShadow({ mode: "open" });
|
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._spaceSlug = this.getAttribute("space-slug") || this._spaceSlug;
|
||||||
this.render();
|
this.render();
|
||||||
if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") {
|
if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") {
|
||||||
|
|
@ -59,6 +73,9 @@ export class FolkBookShelf extends HTMLElement {
|
||||||
} else {
|
} else {
|
||||||
this.subscribeOffline();
|
this.subscribeOffline();
|
||||||
}
|
}
|
||||||
|
if (!localStorage.getItem("rbooks_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
|
@ -426,6 +443,7 @@ export class FolkBookShelf extends HTMLElement {
|
||||||
<span class="rapp-nav__title">Library</span>
|
<span class="rapp-nav__title">Library</span>
|
||||||
<div class="rapp-nav__actions">
|
<div class="rapp-nav__actions">
|
||||||
<button class="rapp-nav__btn upload-btn">+ Add Book</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -446,7 +464,7 @@ export class FolkBookShelf extends HTMLElement {
|
||||||
</div>`
|
</div>`
|
||||||
: `<div class="grid">
|
: `<div class="grid">
|
||||||
${books.map((b) => `
|
${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}">
|
<div class="book-cover" style="background:${b.cover_color}">
|
||||||
<span class="book-cover-title">${this.escapeHtml(b.title)}</span>
|
<span class="book-cover-title">${this.escapeHtml(b.title)}</span>
|
||||||
${b.featured ? '<span class="featured-badge">Featured</span>' : ""}
|
${b.featured ? '<span class="featured-badge">Featured</span>' : ""}
|
||||||
|
|
@ -498,11 +516,18 @@ export class FolkBookShelf extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTour() {
|
||||||
|
this._tour.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindEvents() {
|
private bindEvents() {
|
||||||
if (!this.shadowRoot) return;
|
if (!this.shadowRoot) return;
|
||||||
|
|
||||||
|
this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const searchInput = this.shadowRoot.querySelector(".search-input") as HTMLInputElement;
|
const searchInput = this.shadowRoot.querySelector(".search-input") as HTMLInputElement;
|
||||||
searchInput?.addEventListener("input", () => {
|
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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ function leafletZoomToSpatial(zoom: number): number {
|
||||||
// ── Offline-first imports ──
|
// ── Offline-first imports ──
|
||||||
import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
|
import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
|
|
@ -155,19 +156,35 @@ class FolkCalendarView extends HTMLElement {
|
||||||
private mapMarkerLayer: any = null;
|
private mapMarkerLayer: any = null;
|
||||||
private transitLineLayer: 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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkCalendarView.TOUR_STEPS,
|
||||||
|
"rcal_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
|
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
|
||||||
document.addEventListener("keydown", this.boundKeyHandler);
|
document.addEventListener("keydown", this.boundKeyHandler);
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); }
|
||||||
this.subscribeOffline();
|
else { this.subscribeOffline(); this.loadMonth(); this.render(); }
|
||||||
this.loadMonth();
|
if (!localStorage.getItem("rcal_tour_done")) {
|
||||||
this.render();
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
|
@ -784,6 +801,7 @@ class FolkCalendarView extends HTMLElement {
|
||||||
<button class="nav-btn" id="today">Today</button>
|
<button class="nav-btn" id="today">Today</button>
|
||||||
<span class="nav-title">${this.getViewLabel()}</span>
|
<span class="nav-title">${this.getViewLabel()}</span>
|
||||||
<button class="nav-btn" id="next">\u2192</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
${this.renderSources()}
|
${this.renderSources()}
|
||||||
|
|
@ -806,6 +824,7 @@ class FolkCalendarView extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
|
||||||
// Play transition if pending
|
// Play transition if pending
|
||||||
if (this._pendingTransition && this._ghostHtml) {
|
if (this._pendingTransition && this._ghostHtml) {
|
||||||
|
|
@ -1499,7 +1518,7 @@ class FolkCalendarView extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
${allDay.length > 0 ? `<div class="day-allday">
|
${allDay.length > 0 ? `<div class="day-allday">
|
||||||
<div class="day-allday-label">All Day</div>
|
<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-color" style="background:${e.source_color || "#6366f1"}"></div>
|
||||||
<div class="dd-info"><div class="dd-title">${this.esc(e.title)}</div></div>
|
<div class="dd-info"><div class="dd-title">${this.esc(e.title)}</div></div>
|
||||||
</div>`).join("")}
|
</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 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 es = this.getEventStyles(e);
|
||||||
const likelihoodBadge = es.isTentative ? `<span class="dd-likelihood">${es.likelihoodLabel}</span>` : "";
|
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-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-info">
|
||||||
<div class="dd-title">${this.esc(e.title)}${likelihoodBadge}${srcTag}</div>
|
<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 ──
|
// ── Attach Listeners ──
|
||||||
|
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
const $ = (id: string) => this.shadow.getElementById(id);
|
const $ = (id: string) => this.shadow.getElementById(id);
|
||||||
const $$ = (sel: string) => this.shadow.querySelectorAll(sel);
|
const $$ = (sel: string) => this.shadow.querySelectorAll(sel);
|
||||||
|
|
||||||
|
$("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
// Nav
|
// Nav
|
||||||
$("prev")?.addEventListener("click", () => this.navigate(-1));
|
$("prev")?.addEventListener("click", () => this.navigate(-1));
|
||||||
$("next")?.addEventListener("click", () => this.navigate(1));
|
$("next")?.addEventListener("click", () => this.navigate(1));
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ export function renderLanding(): string {
|
||||||
</a>
|
</a>
|
||||||
<a href="#features" class="rl-cta-secondary">Learn More</a>
|
<a href="#features" class="rl-cta-secondary">Learn More</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Principles (4-card grid) -->
|
<!-- Principles (4-card grid) -->
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
shoppingCartSchema, shoppingCartDocId, type ShoppingCartDoc,
|
shoppingCartSchema, shoppingCartDocId, type ShoppingCartDoc,
|
||||||
} from "../schemas";
|
} from "../schemas";
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
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 {
|
class FolkCartShop extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
|
|
@ -26,10 +28,26 @@ class FolkCartShop extends HTMLElement {
|
||||||
private extensionInstalled = false;
|
private extensionInstalled = false;
|
||||||
private bannerDismissed = false;
|
private bannerDismissed = false;
|
||||||
private _offlineUnsubs: (() => void)[] = [];
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkCartShop.TOUR_STEPS,
|
||||||
|
"rcart_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -46,13 +64,16 @@ class FolkCartShop extends HTMLElement {
|
||||||
|
|
||||||
if (this.space === "demo") {
|
if (this.space === "demo") {
|
||||||
this.loadDemoData();
|
this.loadDemoData();
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
this.subscribeOffline();
|
this.subscribeOffline();
|
||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
// Auto-start tour on first visit
|
||||||
|
if (!localStorage.getItem("rcart_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
for (const unsub of this._offlineUnsubs) unsub();
|
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 === '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>
|
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">📋 Orders (${this.orders.length})</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.8rem">Tour</button>
|
||||||
</div>
|
</div>
|
||||||
${content}
|
${content}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.bindEvents();
|
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() {
|
private bindEvents() {
|
||||||
|
|
@ -354,12 +387,19 @@ class FolkCartShop extends HTMLElement {
|
||||||
this.shadow.querySelectorAll(".tab").forEach((el) => {
|
this.shadow.querySelectorAll(".tab").forEach((el) => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
const v = (el as HTMLElement).dataset.view as any;
|
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;
|
this.view = v;
|
||||||
if (v === 'carts') this.selectedCartId = null;
|
if (v === 'carts') this.selectedCartId = null;
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tour button
|
||||||
|
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
// Extension banner dismiss
|
// Extension banner dismiss
|
||||||
this.shadow.querySelector("[data-action='dismiss-banner']")?.addEventListener("click", () => {
|
this.shadow.querySelector("[data-action='dismiss-banner']")?.addEventListener("click", () => {
|
||||||
this.bannerDismissed = true;
|
this.bannerDismissed = true;
|
||||||
|
|
@ -370,15 +410,15 @@ class FolkCartShop extends HTMLElement {
|
||||||
// Cart card clicks
|
// Cart card clicks
|
||||||
this.shadow.querySelectorAll("[data-cart-id]").forEach((el) => {
|
this.shadow.querySelectorAll("[data-cart-id]").forEach((el) => {
|
||||||
el.addEventListener("click", () => {
|
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!);
|
this.loadCartDetail((el as HTMLElement).dataset.cartId!);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Back button
|
// Back button
|
||||||
this.shadow.querySelector("[data-action='back']")?.addEventListener("click", () => {
|
this.shadow.querySelector("[data-action='back']")?.addEventListener("click", () => {
|
||||||
this.view = "carts";
|
this.goBack();
|
||||||
this.selectedCartId = null;
|
|
||||||
this.render();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// New cart form
|
// New cart form
|
||||||
|
|
@ -527,9 +567,7 @@ class FolkCartShop extends HTMLElement {
|
||||||
`).join("") : `<div class="card-meta">No contributions yet.</div>`;
|
`).join("") : `<div class="card-meta">No contributions yet.</div>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="margin-bottom:1rem">
|
${this._history.canGoBack ? '<div style="margin-bottom:1rem"><button data-action="back" class="btn btn-sm">\u2190 Back</button></div>' : ''}
|
||||||
<button data-action="back" class="btn btn-sm">← Back to Carts</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-layout">
|
<div class="detail-layout">
|
||||||
<div class="detail-left">
|
<div class="detail-left">
|
||||||
|
|
@ -582,7 +620,7 @@ class FolkCartShop extends HTMLElement {
|
||||||
|
|
||||||
return `<div class="grid">
|
return `<div class="grid">
|
||||||
${this.catalog.map((entry) => `
|
${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>
|
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
|
${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">
|
return `<div class="grid">
|
||||||
${this.orders.map((order) => `
|
${this.orders.map((order) => `
|
||||||
<div class="card">
|
<div class="card" data-collab-id="order:${order.id}">
|
||||||
<div class="order-card">
|
<div class="order-card">
|
||||||
<div class="order-info">
|
<div class="order-info">
|
||||||
<h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3>
|
<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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
* from the current space and links to the canvas to create/interact with them.
|
* from the current space and links to the canvas to create/interact with them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
|
||||||
class FolkChoicesDashboard extends HTMLElement {
|
class FolkChoicesDashboard extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private choices: any[] = [];
|
private choices: any[] = [];
|
||||||
|
|
@ -19,20 +21,37 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
private votedId: string | null = null;
|
private votedId: string | null = null;
|
||||||
private simTimer: number | 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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkChoicesDashboard.TOUR_STEPS,
|
||||||
|
"rchoices_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
if (this.space === "demo") {
|
if (this.space === "demo") {
|
||||||
this.loadDemoData();
|
this.loadDemoData();
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
this.render();
|
this.render();
|
||||||
this.loadChoices();
|
this.loadChoices();
|
||||||
}
|
}
|
||||||
|
if (!localStorage.getItem("rchoices_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (this.simTimer !== null) {
|
if (this.simTimer !== null) {
|
||||||
|
|
@ -124,7 +143,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
|
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
|
||||||
return `<div class="grid">
|
return `<div class="grid">
|
||||||
${this.choices.map((ch) => `
|
${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-icon">${icons[ch.type] || "☑"}</div>
|
||||||
<div class="card-type">${labels[ch.type] || ch.type}</div>
|
<div class="card-type">${labels[ch.type] || ch.type}</div>
|
||||||
<h3 class="card-title">${this.esc(ch.title)}</h3>
|
<h3 class="card-title">${this.esc(ch.title)}</h3>
|
||||||
|
|
@ -247,6 +266,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
<div class="rapp-nav">
|
<div class="rapp-nav">
|
||||||
<span class="rapp-nav__title">Choices</span>
|
<span class="rapp-nav__title">Choices</span>
|
||||||
<span class="demo-badge">DEMO</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>
|
||||||
|
|
||||||
<div class="demo-tabs">
|
<div class="demo-tabs">
|
||||||
|
|
@ -259,8 +279,11 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.bindDemoEvents();
|
this.bindDemoEvents();
|
||||||
|
this._tour.renderOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTour() { this._tour.start(); }
|
||||||
|
|
||||||
/* -- Spider Chart -- */
|
/* -- Spider Chart -- */
|
||||||
|
|
||||||
private polarToXY(cx: number, cy: number, radius: number, angleDeg: number): { x: number; y: number } {
|
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 -- */
|
/* -- Demo event binding -- */
|
||||||
|
|
||||||
private bindDemoEvents() {
|
private bindDemoEvents() {
|
||||||
|
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
// Tab switching
|
// Tab switching
|
||||||
this.shadow.querySelectorAll<HTMLButtonElement>(".demo-tab").forEach((btn) => {
|
this.shadow.querySelectorAll<HTMLButtonElement>(".demo-tab").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Three tools -->
|
<!-- Three tools -->
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class FolkAnalyticsView extends HTMLElement {
|
||||||
cookiesSet: 0,
|
cookiesSet: 0,
|
||||||
scriptSize: "~2KB",
|
scriptSize: "~2KB",
|
||||||
selfHosted: true,
|
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",
|
dashboardUrl: "https://analytics.rspace.online",
|
||||||
};
|
};
|
||||||
this.render();
|
this.render();
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6
|
||||||
const TRACKED_APPS = [
|
const TRACKED_APPS = [
|
||||||
"rSpace", "rNotes", "rVote", "rFlows", "rCart", "rWallet",
|
"rSpace", "rNotes", "rVote", "rFlows", "rCart", "rWallet",
|
||||||
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
|
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
|
||||||
"rTrips", "rTube", "rWork", "rNetwork", "rData",
|
"rTrips", "rTube", "rTasks", "rNetwork", "rData",
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── API routes ──
|
// ── API routes ──
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import { filesSchema, type FilesDoc } from "../schemas";
|
import { filesSchema, type FilesDoc } from "../schemas";
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
|
||||||
class FolkFileBrowser extends HTMLElement {
|
class FolkFileBrowser extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
|
|
@ -16,10 +17,22 @@ class FolkFileBrowser extends HTMLElement {
|
||||||
private tab: "files" | "cards" = "files";
|
private tab: "files" | "cards" = "files";
|
||||||
private loading = false;
|
private loading = false;
|
||||||
private _offlineUnsubs: (() => void)[] = [];
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkFileBrowser.TOUR_STEPS,
|
||||||
|
"rfiles_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -27,14 +40,16 @@ class FolkFileBrowser extends HTMLElement {
|
||||||
|
|
||||||
if (this.space === "demo") {
|
if (this.space === "demo") {
|
||||||
this.loadDemoData();
|
this.loadDemoData();
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
this.subscribeOffline();
|
this.subscribeOffline();
|
||||||
this.loadFiles();
|
this.loadFiles();
|
||||||
this.loadCards();
|
this.loadCards();
|
||||||
}
|
}
|
||||||
|
if (!localStorage.getItem("rfiles_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
for (const unsub of this._offlineUnsubs) unsub();
|
for (const unsub of this._offlineUnsubs) unsub();
|
||||||
|
|
@ -429,11 +444,14 @@ class FolkFileBrowser extends HTMLElement {
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab-btn" data-tab="files">📁 Files</div>
|
<div class="tab-btn" data-tab="files">📁 Files</div>
|
||||||
<div class="tab-btn" data-tab="cards">🎴 Memory Cards</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>
|
</div>
|
||||||
|
|
||||||
${filesActive ? this.renderFilesTab() : this.renderCardsTab()}
|
${filesActive ? this.renderFilesTab() : this.renderCardsTab()}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
this.shadow.querySelectorAll(".tab-btn").forEach((btn) => {
|
this.shadow.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
this.tab = (btn as HTMLElement).dataset.tab as "files" | "cards";
|
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 {
|
private renderFilesTab(): string {
|
||||||
|
|
@ -487,7 +511,7 @@ class FolkFileBrowser extends HTMLElement {
|
||||||
${this.files
|
${this.files
|
||||||
.map(
|
.map(
|
||||||
(f) => `
|
(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-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-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>
|
<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
|
${this.cards
|
||||||
.map(
|
.map(
|
||||||
(c) => `
|
(c) => `
|
||||||
<div class="memory-card">
|
<div class="memory-card" data-collab-id="card:${c.id}">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)}</span>
|
<span class="card-title">${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)}</span>
|
||||||
<span class="card-type">${c.card_type}</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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
|
||||||
import { PORT_DEFS, deriveThresholds } 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 { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
||||||
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
|
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
|
||||||
import { mapFlowToNodes } from "../lib/map-flow";
|
import { mapFlowToNodes } from "../lib/map-flow";
|
||||||
|
|
@ -154,9 +155,8 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
private flowManagerOpen = false;
|
private flowManagerOpen = false;
|
||||||
private _lfcUnsub: (() => void) | null = null;
|
private _lfcUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
// Tour state
|
// Tour engine
|
||||||
private tourActive = false;
|
private _tour!: TourEngine;
|
||||||
private tourStep = 0;
|
|
||||||
private static readonly TOUR_STEPS = [
|
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-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 },
|
{ 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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkFlowsApp.TOUR_STEPS,
|
||||||
|
"rflows_tour_done",
|
||||||
|
() => this.shadow.getElementById("canvas-container"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -1777,7 +1783,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
}).join("");
|
}).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="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}"/>
|
<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>
|
<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 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";
|
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>
|
<defs>
|
||||||
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
|
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
@ -1982,7 +1988,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
|
|
||||||
const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`;
|
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}"/>
|
<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}">
|
<foreignObject x="0" y="0" width="${w}" height="${h}">
|
||||||
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card outcome-card ${selected ? "selected" : ""}">
|
<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 ──
|
// ── Guided Tour ──
|
||||||
|
|
||||||
startTour() {
|
startTour() {
|
||||||
this.tourActive = true;
|
this._tour.start();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private esc(s: string): string {
|
private esc(s: string): string {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
|
|
||||||
import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas";
|
import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas";
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
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 {
|
class FolkForumDashboard extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
|
|
@ -18,19 +20,37 @@ class FolkForumDashboard extends HTMLElement {
|
||||||
private pollTimer: number | null = null;
|
private pollTimer: number | null = null;
|
||||||
private space = "";
|
private space = "";
|
||||||
private _offlineUnsub: (() => void) | null = null;
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkForumDashboard.TOUR_STEPS,
|
||||||
|
"rforum_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "";
|
this.space = this.getAttribute("space") || "";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); }
|
||||||
|
else {
|
||||||
this.subscribeOffline();
|
this.subscribeOffline();
|
||||||
this.render();
|
this.render();
|
||||||
this.loadInstances();
|
this.loadInstances();
|
||||||
}
|
}
|
||||||
|
if (!localStorage.getItem("rforum_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private loadDemoData() {
|
private loadDemoData() {
|
||||||
this.instances = [
|
this.instances = [
|
||||||
|
|
@ -318,6 +338,11 @@ class FolkForumDashboard extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.attachEvents();
|
this.attachEvents();
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTour() {
|
||||||
|
this._tour.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderList(): string {
|
private renderList(): string {
|
||||||
|
|
@ -325,6 +350,7 @@ class FolkForumDashboard extends HTMLElement {
|
||||||
<div class="rapp-nav">
|
<div class="rapp-nav">
|
||||||
<span class="rapp-nav__title">Forum Instances</span>
|
<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" 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>
|
</div>
|
||||||
|
|
||||||
${this.loading ? '<div class="loading">Loading...</div>' : ""}
|
${this.loading ? '<div class="loading">Loading...</div>' : ""}
|
||||||
|
|
@ -332,7 +358,7 @@ class FolkForumDashboard extends HTMLElement {
|
||||||
|
|
||||||
<div class="instance-list">
|
<div class="instance-list">
|
||||||
${this.instances.map((inst) => `
|
${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">
|
<div class="instance-header">
|
||||||
<span class="instance-name">${this.esc(inst.name)}</span>
|
<span class="instance-name">${this.esc(inst.name)}</span>
|
||||||
${this.statusBadge(inst.status)}
|
${this.statusBadge(inst.status)}
|
||||||
|
|
@ -353,7 +379,7 @@ class FolkForumDashboard extends HTMLElement {
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="rapp-nav">
|
<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>
|
<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>` : ""}
|
${inst.status !== "destroyed" ? `<button class="danger" data-action="destroy" data-id="${inst.id}">Destroy</button>` : ""}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -398,7 +424,7 @@ class FolkForumDashboard extends HTMLElement {
|
||||||
private renderCreate(): string {
|
private renderCreate(): string {
|
||||||
return `
|
return `
|
||||||
<div class="rapp-nav">
|
<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>
|
<span class="rapp-nav__title">Deploy New Forum</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -466,16 +492,17 @@ class FolkForumDashboard extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachEvents() {
|
private attachEvents() {
|
||||||
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
this.shadow.querySelectorAll("[data-action]").forEach((el) => {
|
this.shadow.querySelectorAll("[data-action]").forEach((el) => {
|
||||||
const action = (el as HTMLElement).dataset.action!;
|
const action = (el as HTMLElement).dataset.action!;
|
||||||
const id = (el as HTMLElement).dataset.id;
|
const id = (el as HTMLElement).dataset.id;
|
||||||
el.addEventListener("click", () => {
|
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") {
|
else if (action === "back") {
|
||||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
this.goBack();
|
||||||
this.view = "list"; this.loadInstances();
|
|
||||||
}
|
}
|
||||||
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); }
|
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));
|
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 {
|
private formatStep(step: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
create_vps: "Create Server",
|
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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- How It Works -->
|
<!-- How It Works -->
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ export function renderLanding(): string {
|
||||||
</a>
|
</a>
|
||||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- What rInbox Does -->
|
<!-- What rInbox Does -->
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
import { RoomSync, type RoomState, type ParticipantState, type LocationState } from "./map-sync";
|
import { RoomSync, type RoomState, type ParticipantState, type LocationState } from "./map-sync";
|
||||||
import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history";
|
import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history";
|
||||||
import { MapPushManager } from "./map-push";
|
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
|
// MapLibre loaded via CDN — use window access with type assertion
|
||||||
|
|
||||||
|
|
@ -88,6 +90,16 @@ class FolkMapViewer extends HTMLElement {
|
||||||
private pushManager: MapPushManager | null = null;
|
private pushManager: MapPushManager | null = null;
|
||||||
private thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
|
private thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private _themeObserver: MutationObserver | 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 {
|
private isDarkTheme(): boolean {
|
||||||
const theme = document.documentElement.getAttribute("data-theme");
|
const theme = document.documentElement.getAttribute("data-theme");
|
||||||
|
|
@ -98,6 +110,12 @@ class FolkMapViewer extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkMapViewer.TOUR_STEPS,
|
||||||
|
"rmaps_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -105,17 +123,20 @@ class FolkMapViewer extends HTMLElement {
|
||||||
this.room = this.getAttribute("room") || "";
|
this.room = this.getAttribute("room") || "";
|
||||||
if (this.space === "demo") {
|
if (this.space === "demo") {
|
||||||
this.loadDemoData();
|
this.loadDemoData();
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
this.loadUserProfile();
|
this.loadUserProfile();
|
||||||
this.pushManager = new MapPushManager(this.getApiBase());
|
this.pushManager = new MapPushManager(this.getApiBase());
|
||||||
if (this.room) {
|
if (this.room) {
|
||||||
this.joinRoom(this.room);
|
this.joinRoom(this.room);
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
this.checkSyncHealth();
|
this.checkSyncHealth();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!localStorage.getItem("rmaps_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this.leaveRoom();
|
this.leaveRoom();
|
||||||
|
|
@ -1447,15 +1468,18 @@ class FolkMapViewer extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
this._tour.renderOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTour() { this._tour.start(); }
|
||||||
|
|
||||||
private renderLobby(): string {
|
private renderLobby(): string {
|
||||||
const history = loadRoomHistory();
|
const history = loadRoomHistory();
|
||||||
const historyCards = history.length > 0 ? `
|
const historyCards = history.length > 0 ? `
|
||||||
<div class="section-label">Recent Rooms</div>
|
<div class="section-label">Recent Rooms</div>
|
||||||
<div class="room-history-grid">
|
<div class="room-history-grid">
|
||||||
${history.map((h) => `
|
${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
|
${h.thumbnail
|
||||||
? `<img class="history-thumb" src="${h.thumbnail}" alt="">`
|
? `<img class="history-thumb" src="${h.thumbnail}" alt="">`
|
||||||
: `<div class="history-thumb-placeholder">🌐</div>`
|
: `<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 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>
|
<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="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>
|
</div>
|
||||||
|
|
||||||
${this.rooms.length > 0 ? `
|
${this.rooms.length > 0 ? `
|
||||||
<div class="section-label">Active Rooms</div>
|
<div class="section-label">Active Rooms</div>
|
||||||
${this.rooms.map((r) => `
|
${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-icon">🗺</span>
|
||||||
<span class="room-name">${this.esc(r)}</span>
|
<span class="room-name">${this.esc(r)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1501,7 +1526,7 @@ class FolkMapViewer extends HTMLElement {
|
||||||
const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`;
|
const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`;
|
||||||
return `
|
return `
|
||||||
<div class="rapp-nav">
|
<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="rapp-nav__title">🗺 ${this.esc(this.room)}</span>
|
||||||
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
|
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1532,20 +1557,21 @@ class FolkMapViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
|
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom());
|
this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom());
|
||||||
|
|
||||||
this.shadow.querySelectorAll("[data-room]").forEach((el) => {
|
this.shadow.querySelectorAll("[data-room]").forEach((el) => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
const room = (el as HTMLElement).dataset.room!;
|
const room = (el as HTMLElement).dataset.room!;
|
||||||
|
this._history.push("lobby");
|
||||||
|
this._history.push("map", { room });
|
||||||
this.joinRoom(room);
|
this.joinRoom(room);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
this.leaveRoom();
|
this.goBack();
|
||||||
this.view = "lobby";
|
|
||||||
this.loadStats();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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 {
|
private esc(s: string): string {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
d.textContent = s || "";
|
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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export function renderLanding(): string {
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">📄</div>
|
<div class="rl-icon-box">📄</div>
|
||||||
<h3>Meeting Notes & Docs</h3>
|
<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>
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">📅</div>
|
<div class="rl-icon-box">📅</div>
|
||||||
|
|
@ -121,7 +121,7 @@ export function renderLanding(): string {
|
||||||
<div class="rl-icon-box">🔌</div>
|
<div class="rl-icon-box">🔌</div>
|
||||||
<h3>Data Integrations</h3>
|
<h3>Data Integrations</h3>
|
||||||
<span class="rl-badge">Coming Soon</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ const STAGE_LABELS: Record<string, string> = {
|
||||||
CLOSED_LOST: "Lost",
|
CLOSED_LOST: "Lost",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
|
||||||
class FolkCrmView extends HTMLElement {
|
class FolkCrmView extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "";
|
private space = "";
|
||||||
|
|
@ -67,15 +69,34 @@ class FolkCrmView extends HTMLElement {
|
||||||
private loading = true;
|
private loading = true;
|
||||||
private error = "";
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkCrmView.TOUR_STEPS,
|
||||||
|
"rnetwork_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
this.render();
|
this.render();
|
||||||
this.loadData();
|
this.loadData();
|
||||||
|
// Auto-start tour on first visit
|
||||||
|
if (!localStorage.getItem("rnetwork_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getApiBase(): string {
|
private getApiBase(): string {
|
||||||
|
|
@ -165,7 +186,7 @@ class FolkCrmView extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
${total > 0 ? `<div class="pipeline-total">${this.formatAmount({ amountMicros: total * 1_000_000, currencyCode: "USD" })}</div>` : ""}
|
${total > 0 ? `<div class="pipeline-total">${this.formatAmount({ amountMicros: total * 1_000_000, currencyCode: "USD" })}</div>` : ""}
|
||||||
<div class="pipeline-cards">
|
<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-name">${this.esc(opp.name)}</div>
|
||||||
<div class="card-amount">${this.formatAmount(opp.amount)}</div>
|
<div class="card-amount">${this.formatAmount(opp.amount)}</div>
|
||||||
${opp.company?.name ? `<div class="card-company">${this.esc(opp.company.name)}</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>
|
<th>Phone</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<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-name">${this.esc(this.personName(p.name))}</td>
|
||||||
<td class="cell-email">${p.email?.primaryEmail ? this.esc(p.email.primaryEmail) : "-"}</td>
|
<td class="cell-email">${p.email?.primaryEmail ? this.esc(p.email.primaryEmail) : "-"}</td>
|
||||||
<td>${this.esc(this.companyName(p.company))}</td>
|
<td>${this.esc(this.companyName(p.company))}</td>
|
||||||
|
|
@ -273,7 +294,7 @@ class FolkCrmView extends HTMLElement {
|
||||||
<th>Country</th>
|
<th>Country</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<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-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 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>
|
<td>${c.employees ?? "-"}</td>
|
||||||
|
|
@ -432,6 +453,7 @@ class FolkCrmView extends HTMLElement {
|
||||||
|
|
||||||
<div class="crm-header">
|
<div class="crm-header">
|
||||||
<span class="crm-title">CRM</span>
|
<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>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
|
|
@ -448,9 +470,15 @@ class FolkCrmView extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
this._tour.renderOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTour() { this._tour.start(); }
|
||||||
|
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
|
// Tour button
|
||||||
|
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
this.shadow.querySelectorAll("[data-tab]").forEach(el => {
|
this.shadow.querySelectorAll("[data-tab]").forEach(el => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,168 @@ export function renderLanding(): string {
|
||||||
</p>
|
</p>
|
||||||
<div class="rl-cta-row">
|
<div class="rl-cta-row">
|
||||||
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary" id="ml-primary">Open Notebook</a>
|
<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>
|
</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>
|
</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 -->
|
<!-- Features -->
|
||||||
<section class="rl-section">
|
<section class="rl-section">
|
||||||
<div class="rl-container">
|
<div class="rl-container">
|
||||||
|
|
@ -42,32 +200,75 @@ export function renderLanding(): string {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- How It Works -->
|
||||||
<section class="rl-section rl-section--alt">
|
<section class="rl-section">
|
||||||
<div class="rl-container">
|
<div class="rl-container">
|
||||||
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
<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">
|
||||||
<div class="rl-step__num">1</div>
|
<div class="rl-step__num">1</div>
|
||||||
<h3>Create a Notebook</h3>
|
<h3>Live Transcribe</h3>
|
||||||
<p>Name your notebook and start capturing. Organize by project, topic, or however you think.</p>
|
<p>Speak and watch words appear in real time via the Web Speech API. No uploads, no waiting.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-step">
|
<div class="rl-step">
|
||||||
<div class="rl-step__num">2</div>
|
<div class="rl-step__num">2</div>
|
||||||
<h3>Capture Notes, Voice, or Clips</h3>
|
<h3>Audio & Video</h3>
|
||||||
<p>Type rich text, record voice with live transcription, or drop in audio and video files to transcribe offline.</p>
|
<p>Drop files and get full transcripts via Parakeet, running entirely in-browser. Supports MP3, WAV, MP4, and more.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-step">
|
<div class="rl-step">
|
||||||
<div class="rl-step__num">3</div>
|
<div class="rl-step__num">3</div>
|
||||||
<h3>Search, Tag, and Share</h3>
|
<h3>Notebooks & Tags</h3>
|
||||||
<p>Find anything instantly with full-text search. Tag and filter your cards, then share notebooks with your team.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Memory Cards -->
|
<!-- Memory Cards -->
|
||||||
<section class="rl-section">
|
<section class="rl-section rl-section--alt">
|
||||||
<div class="rl-container">
|
<div class="rl-container">
|
||||||
<h2 class="rl-heading" style="text-align:center">Memory Cards</h2>
|
<h2 class="rl-heading" style="text-align:center">Memory Cards</h2>
|
||||||
<p class="rl-subtext" style="text-align:center">
|
<p class="rl-subtext" style="text-align:center">
|
||||||
|
|
@ -160,19 +361,23 @@ export function renderLanding(): string {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Built on Open Source -->
|
<!-- Built on Open Source -->
|
||||||
<section class="rl-section rl-section--alt">
|
<section class="rl-section">
|
||||||
<div class="rl-container">
|
<div class="rl-container">
|
||||||
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
<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>
|
<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">
|
<div class="rl-card rl-card--center">
|
||||||
<h3>PostgreSQL</h3>
|
<h3>Automerge</h3>
|
||||||
<p>Notebooks, notes, and tags stored in a battle-tested relational database with full-text search.</p>
|
<p>Local-first CRDT for conflict-free real-time collaboration. Your notes sync across devices without a central server.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<h3>Web Speech API</h3>
|
<h3>Web Speech API</h3>
|
||||||
<p>Browser-native live transcription — speak and watch your words appear in real time.</p>
|
<p>Browser-native live transcription — speak and watch your words appear in real time.</p>
|
||||||
</div>
|
</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">
|
<div class="rl-card rl-card--center">
|
||||||
<h3>Hono</h3>
|
<h3>Hono</h3>
|
||||||
<p>Ultra-fast, lightweight API framework powering the rNotes backend.</p>
|
<p>Ultra-fast, lightweight API framework powering the rNotes backend.</p>
|
||||||
|
|
@ -182,7 +387,7 @@ export function renderLanding(): string {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Your Data, Protected -->
|
<!-- Your Data, Protected -->
|
||||||
<section class="rl-section">
|
<section class="rl-section rl-section--alt">
|
||||||
<div class="rl-container" style="text-align:center">
|
<div class="rl-container" style="text-align:center">
|
||||||
<h2 class="rl-heading">Your Data, Protected</h2>
|
<h2 class="rl-heading">Your Data, Protected</h2>
|
||||||
<p class="rl-subtext">How rNotes keeps your information safe.</p>
|
<p class="rl-subtext">How rNotes keeps your information safe.</p>
|
||||||
|
|
@ -209,12 +414,13 @@ export function renderLanding(): string {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- CTA -->
|
<!-- CTA -->
|
||||||
<section class="rl-section rl-section--alt">
|
<section class="rl-section">
|
||||||
<div class="rl-container" style="text-align:center">
|
<div class="rl-container" style="text-align:center">
|
||||||
<h2 class="rl-heading">(You)rNotes, your thoughts unbound.</h2>
|
<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>
|
<p class="rl-subtext">Try the demo or create a space to get started.</p>
|
||||||
<div class="rl-cta-row">
|
<div class="rl-cta-row">
|
||||||
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary">Open Notebook</a>
|
<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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
* space — space slug (default: "demo")
|
* space — space slug (default: "demo")
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
import { ViewHistory } from "../../../shared/view-history.js";
|
||||||
|
|
||||||
interface Album {
|
interface Album {
|
||||||
id: string;
|
id: string;
|
||||||
albumName: string;
|
albumName: string;
|
||||||
|
|
@ -44,20 +47,36 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
private loading = false;
|
private loading = false;
|
||||||
private error = "";
|
private error = "";
|
||||||
private showingSampleData = false;
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkPhotoGallery.TOUR_STEPS,
|
||||||
|
"rphotos_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") {
|
if (this.space === "demo") {
|
||||||
this.loadDemoData();
|
this.loadDemoData();
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
this.loadGallery();
|
this.loadGallery();
|
||||||
}
|
}
|
||||||
|
if (!localStorage.getItem("rphotos_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private loadDemoData() {
|
private loadDemoData() {
|
||||||
this.albums = [
|
this.albums = [
|
||||||
|
|
@ -189,6 +208,18 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
this.render();
|
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 {
|
private thumbUrl(assetId: string): string {
|
||||||
const base = this.getApiBase();
|
const base = this.getApiBase();
|
||||||
return `${base}/api/assets/${assetId}/thumbnail`;
|
return `${base}/api/assets/${assetId}/thumbnail`;
|
||||||
|
|
@ -353,6 +384,11 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTour() {
|
||||||
|
this._tour.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderView(): string {
|
private renderView(): string {
|
||||||
|
|
@ -370,6 +406,7 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
<a class="rapp-nav__btn" href="${this.getImmichUrl()}">
|
<a class="rapp-nav__btn" href="${this.getImmichUrl()}">
|
||||||
Open Immich
|
Open Immich
|
||||||
</a>
|
</a>
|
||||||
|
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -391,7 +428,7 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
<div class="section-title">Shared Albums</div>
|
<div class="section-title">Shared Albums</div>
|
||||||
<div class="albums-grid">
|
<div class="albums-grid">
|
||||||
${this.albums.map((a) => `
|
${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">
|
<div class="album-thumb">
|
||||||
${this.isDemo()
|
${this.isDemo()
|
||||||
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(a.albumName)}</div>`
|
? `<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="section-title">Recent Photos</div>
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
${this.assets.map((a) => `
|
${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()
|
${this.isDemo()
|
||||||
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
|
? `<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">`}
|
: `<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!;
|
const album = this.selectedAlbum!;
|
||||||
return `
|
return `
|
||||||
<div class="rapp-nav">
|
<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>
|
<span class="rapp-nav__title">${this.esc(album.albumName)}</span>
|
||||||
<div class="rapp-nav__actions">
|
<div class="rapp-nav__actions">
|
||||||
<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">
|
<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">
|
||||||
|
|
@ -446,7 +483,7 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
` : `
|
` : `
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
${this.albumAssets.map((a) => `
|
${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()
|
${this.isDemo()
|
||||||
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
|
? `<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">`}
|
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
|
||||||
|
|
@ -483,12 +520,18 @@ class FolkPhotoGallery extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
// Album cards
|
// Album cards
|
||||||
this.shadow.querySelectorAll("[data-album-id]").forEach((el) => {
|
this.shadow.querySelectorAll("[data-album-id]").forEach((el) => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
const id = (el as HTMLElement).dataset.albumId!;
|
const id = (el as HTMLElement).dataset.albumId!;
|
||||||
const album = this.albums.find((a) => a.id === id);
|
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 id = (el as HTMLElement).dataset.assetId!;
|
||||||
const assets = this.view === "album" ? this.albumAssets : this.assets;
|
const assets = this.view === "album" ? this.albumAssets : this.assets;
|
||||||
const asset = assets.find((a) => a.id === id);
|
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
|
// Back button
|
||||||
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => this.goBack());
|
||||||
this.selectedAlbum = null;
|
|
||||||
this.albumAssets = [];
|
|
||||||
this.view = "gallery";
|
|
||||||
this.render();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lightbox close
|
// 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) => {
|
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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import { pubsDraftSchema, pubsDocId } from '../schemas';
|
import { pubsDraftSchema, pubsDocId } from '../schemas';
|
||||||
import type { PubsDoc } from '../schemas';
|
import type { PubsDoc } from '../schemas';
|
||||||
import type { DocumentId } from '../../../shared/local-first/document';
|
import type { DocumentId } from '../../../shared/local-first/document';
|
||||||
|
import { TourEngine } from '../../../shared/tour-engine';
|
||||||
|
|
||||||
interface BookFormat {
|
interface BookFormat {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -78,6 +79,13 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
private _isRemoteUpdate = false;
|
private _isRemoteUpdate = false;
|
||||||
private _syncTimer: ReturnType<typeof setTimeout> | null = null;
|
private _syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private _syncConnected = false;
|
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[]) {
|
set formats(val: BookFormat[]) {
|
||||||
this._formats = val;
|
this._formats = val;
|
||||||
|
|
@ -90,6 +98,12 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
this.attachShadow({ mode: "open" });
|
this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadowRoot!,
|
||||||
|
FolkPubsEditor.TOUR_STEPS,
|
||||||
|
"rpubs_tour_done",
|
||||||
|
() => this.shadowRoot!.host as HTMLElement,
|
||||||
|
);
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
const space = this.getAttribute("space") || "";
|
const space = this.getAttribute("space") || "";
|
||||||
|
|
@ -101,6 +115,9 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
if (space === "demo" && !this._activeDocId) {
|
if (space === "demo" && !this._activeDocId) {
|
||||||
this.loadDemoContent();
|
this.loadDemoContent();
|
||||||
}
|
}
|
||||||
|
if (!localStorage.getItem("rpubs_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initRuntime() {
|
private async initRuntime() {
|
||||||
|
|
@ -381,6 +398,7 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
|
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
|
||||||
Open File
|
Open File
|
||||||
</label>
|
</label>
|
||||||
|
<button class="btn-sample" id="btn-tour" style="font-size:0.78rem;padding:2px 8px;opacity:0.7">Tour</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
|
<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.bindEvents();
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
|
||||||
// Populate fields from current doc after render
|
// Populate fields from current doc after render
|
||||||
if (this._runtime && this._activeDocId) {
|
if (this._runtime && this._activeDocId) {
|
||||||
|
|
@ -448,9 +467,15 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTour() {
|
||||||
|
this._tour.start();
|
||||||
|
}
|
||||||
|
|
||||||
private bindEvents() {
|
private bindEvents() {
|
||||||
if (!this.shadowRoot) return;
|
if (!this.shadowRoot) return;
|
||||||
|
|
||||||
|
this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
|
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
|
||||||
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
|
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
|
||||||
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;
|
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ export function renderLanding(): string {
|
||||||
</a>
|
</a>
|
||||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- How it works -->
|
<!-- How it works -->
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
|
|
||||||
import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas";
|
import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas";
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
import { ViewHistory } from "../../../shared/view-history.js";
|
||||||
|
|
||||||
interface JobData {
|
interface JobData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -82,11 +84,19 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
private log: LogEntry[] = [];
|
private log: LogEntry[] = [];
|
||||||
private reminders: ReminderData[] = [];
|
private reminders: ReminderData[] = [];
|
||||||
private view: "jobs" | "log" | "form" | "reminders" | "reminder-form" = "jobs";
|
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 editingJob: JobData | null = null;
|
||||||
private editingReminder: ReminderData | null = null;
|
private editingReminder: ReminderData | null = null;
|
||||||
private loading = false;
|
private loading = false;
|
||||||
private runningJobId: string | null = null;
|
private runningJobId: string | null = null;
|
||||||
private _offlineUnsub: (() => void) | 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
|
// Reminder form state
|
||||||
private rFormTitle = "";
|
private rFormTitle = "";
|
||||||
|
|
@ -109,12 +119,21 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkScheduleApp.TOUR_STEPS,
|
||||||
|
"rschedule_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
this.subscribeOffline();
|
this.subscribeOffline();
|
||||||
this.loadJobs();
|
this.loadJobs();
|
||||||
|
if (!localStorage.getItem("rschedule_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
|
@ -309,7 +328,9 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
this.rFormAllDay = true;
|
this.rFormAllDay = true;
|
||||||
this.rFormEmail = "";
|
this.rFormEmail = "";
|
||||||
this.rFormSyncCal = true;
|
this.rFormSyncCal = true;
|
||||||
|
this._history.push(this.view);
|
||||||
this.view = "reminder-form";
|
this.view = "reminder-form";
|
||||||
|
this._history.push("reminder-form");
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,7 +344,9 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
this.rFormAllDay = r.allDay;
|
this.rFormAllDay = r.allDay;
|
||||||
this.rFormEmail = r.notifyEmail || "";
|
this.rFormEmail = r.notifyEmail || "";
|
||||||
this.rFormSyncCal = true;
|
this.rFormSyncCal = true;
|
||||||
|
this._history.push(this.view);
|
||||||
this.view = "reminder-form";
|
this.view = "reminder-form";
|
||||||
|
this._history.push("reminder-form");
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,7 +424,9 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
this.formActionType = "email";
|
this.formActionType = "email";
|
||||||
this.formEnabled = true;
|
this.formEnabled = true;
|
||||||
this.formConfig = {};
|
this.formConfig = {};
|
||||||
|
this._history.push(this.view);
|
||||||
this.view = "form";
|
this.view = "form";
|
||||||
|
this._history.push("form");
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,10 +444,21 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
this.formConfig[k] = String(v);
|
this.formConfig[k] = String(v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this._history.push(this.view);
|
||||||
this.view = "form";
|
this.view = "form";
|
||||||
|
this._history.push("form");
|
||||||
this.render();
|
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 {
|
private formatTime(ts: number | null): string {
|
||||||
if (!ts) return "—";
|
if (!ts) return "—";
|
||||||
const d = new Date(ts);
|
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 === "reminders" ? "active" : ""}" data-view="reminders">Reminders</button>
|
||||||
<button class="s-tab ${activeTab === "log" ? "active" : ""}" data-view="log">Execution Log</button>
|
<button class="s-tab ${activeTab === "log" ? "active" : ""}" data-view="log">Execution Log</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="s-btn s-btn-secondary s-btn-sm" id="btn-tour">Tour</button>
|
||||||
${headerAction}
|
${headerAction}
|
||||||
</div>
|
</div>
|
||||||
${content}
|
${content}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTour() {
|
||||||
|
this._tour.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderJobList(): string {
|
private renderJobList(): string {
|
||||||
|
|
@ -598,7 +640,7 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = this.jobs.map((j) => `
|
const rows = this.jobs.map((j) => `
|
||||||
<tr>
|
<tr data-collab-id="job:${j.id}">
|
||||||
<td>
|
<td>
|
||||||
<label class="s-toggle">
|
<label class="s-toggle">
|
||||||
<input type="checkbox" ${j.enabled ? "checked" : ""} data-toggle="${j.id}">
|
<input type="checkbox" ${j.enabled ? "checked" : ""} data-toggle="${j.id}">
|
||||||
|
|
@ -707,7 +749,7 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;margin-top:24px">
|
<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-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>
|
||||||
</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(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>';
|
: '<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>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:8px">
|
<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>
|
<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>
|
||||||
<div style="display:flex;gap:8px;margin-top:24px">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
this.shadow.querySelectorAll<HTMLButtonElement>("[data-view]").forEach((btn) => {
|
this.shadow.querySelectorAll<HTMLButtonElement>("[data-view]").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
this.view = btn.dataset.view as "jobs" | "log" | "reminders";
|
const newView = btn.dataset.view as "jobs" | "log" | "reminders";
|
||||||
if (this.view === "log") this.loadLog();
|
this._history.push(this.view);
|
||||||
else if (this.view === "reminders") this.loadReminders();
|
this.view = newView;
|
||||||
|
this._history.push(newView);
|
||||||
|
if (newView === "log") this.loadLog();
|
||||||
|
else if (newView === "reminders") this.loadReminders();
|
||||||
else this.render();
|
else this.render();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -844,10 +891,9 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!));
|
btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form: cancel
|
// Form: cancel / back
|
||||||
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel']")?.addEventListener("click", () => {
|
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel']")?.addEventListener("click", () => {
|
||||||
this.view = "jobs";
|
this.goBack();
|
||||||
this.render();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form: submit
|
// Form: submit
|
||||||
|
|
@ -906,10 +952,9 @@ class FolkScheduleApp extends HTMLElement {
|
||||||
btn.addEventListener("click", () => this.deleteReminder(btn.dataset.rDelete!));
|
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.shadow.querySelector<HTMLButtonElement>("[data-action='cancel-reminder']")?.addEventListener("click", () => {
|
||||||
this.view = "reminders";
|
this.goBack();
|
||||||
this.loadReminders();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reminder form: submit
|
// Reminder form: submit
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ export function renderLanding(): string {
|
||||||
</a>
|
</a>
|
||||||
<a href="#features" class="rl-cta-secondary">Learn More</a>
|
<a href="#features" class="rl-cta-secondary">Learn More</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Features (4-card grid) -->
|
<!-- Features (4-card grid) -->
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,9 @@ export interface Reminder {
|
||||||
completed: boolean; // dismissed by user?
|
completed: boolean; // dismissed by user?
|
||||||
|
|
||||||
// Cross-module reference (null for free-form reminders)
|
// Cross-module reference (null for free-form reminders)
|
||||||
sourceModule: string | null; // "rwork", "rnotes", etc.
|
sourceModule: string | null; // "rtasks", "rnotes", etc.
|
||||||
sourceEntityId: string | null;
|
sourceEntityId: string | null;
|
||||||
sourceLabel: string | null; // "rWork Task"
|
sourceLabel: string | null; // "rTasks Task"
|
||||||
sourceColor: string | null; // "#f97316"
|
sourceColor: string | null; // "#f97316"
|
||||||
|
|
||||||
// Optional recurrence
|
// Optional recurrence
|
||||||
|
|
@ -172,7 +172,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
|
||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||||
configSchema: [
|
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' },
|
{ key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -315,7 +315,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
|
||||||
category: 'action',
|
category: 'action',
|
||||||
label: 'Create Task',
|
label: 'Create Task',
|
||||||
icon: '✅',
|
icon: '✅',
|
||||||
description: 'Create a task in rWork',
|
description: 'Create a task in rTasks',
|
||||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||||
outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }],
|
outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }],
|
||||||
configSchema: [
|
configSchema: [
|
||||||
|
|
@ -347,7 +347,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
|
||||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||||
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
|
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
|
||||||
configSchema: [
|
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: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] },
|
||||||
{ key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' },
|
{ 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 { SocialsDoc, Campaign, CampaignPost } from '../schemas';
|
||||||
import type { DocumentId } from '../../../shared/local-first/document';
|
import type { DocumentId } from '../../../shared/local-first/document';
|
||||||
import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
|
import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
|
||||||
|
import { TourEngine } from '../../../shared/tour-engine';
|
||||||
|
|
||||||
export class FolkCampaignManager extends HTMLElement {
|
export class FolkCampaignManager extends HTMLElement {
|
||||||
private _space = 'demo';
|
private _space = 'demo';
|
||||||
private _campaigns: Campaign[] = [];
|
private _campaigns: Campaign[] = [];
|
||||||
private _offlineUnsub: (() => void) | null = null;
|
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']; }
|
static get observedAttributes() { return ['space']; }
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
|
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';
|
this._space = this.getAttribute('space') || 'demo';
|
||||||
// Start with demo campaign
|
// Start with demo campaign
|
||||||
this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }];
|
this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }];
|
||||||
|
|
@ -26,6 +42,10 @@ export class FolkCampaignManager extends HTMLElement {
|
||||||
if (this._space !== 'demo') {
|
if (this._space !== 'demo') {
|
||||||
this.subscribeOffline();
|
this.subscribeOffline();
|
||||||
}
|
}
|
||||||
|
// Auto-start tour on first visit
|
||||||
|
if (!localStorage.getItem("rsocials_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
|
@ -141,6 +161,7 @@ export class FolkCampaignManager extends HTMLElement {
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a href="${this.basePath}thread-editor" class="btn btn--outline">Open Thread Editor</a>
|
<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--primary" id="import-md-btn">Import from Markdown</button>
|
||||||
|
<button class="btn btn--outline" id="btn-tour" style="margin-left:auto">Tour</button>
|
||||||
</div>
|
</div>
|
||||||
${phaseHTML}
|
${phaseHTML}
|
||||||
<div id="imported-posts"></div>`;
|
<div id="imported-posts"></div>`;
|
||||||
|
|
@ -250,11 +271,17 @@ export class FolkCampaignManager extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
this._tour.renderOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTour() { this._tour.start(); }
|
||||||
|
|
||||||
private bindEvents() {
|
private bindEvents() {
|
||||||
if (!this.shadowRoot) return;
|
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 modal = this.shadowRoot.getElementById('import-modal') as HTMLElement;
|
||||||
const openBtn = this.shadowRoot.getElementById('import-md-btn');
|
const openBtn = this.shadowRoot.getElementById('import-md-btn');
|
||||||
const closeBtn = this.shadowRoot.getElementById('import-modal-close');
|
const closeBtn = this.shadowRoot.getElementById('import-modal-close');
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ export class FolkThreadGallery extends HTMLElement {
|
||||||
const href = this._isDemoFallback
|
const href = this._isDemoFallback
|
||||||
? `${this.basePath}thread-editor`
|
? `${this.basePath}thread-editor`
|
||||||
: `${this.basePath}thread-editor/${this.esc(t.id)}/edit`;
|
: `${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}
|
${imageTag}
|
||||||
<h3 class="card__title">${this.esc(t.title || 'Untitled Thread')}</h3>
|
<h3 class="card__title">${this.esc(t.title || 'Untitled Thread')}</h3>
|
||||||
<p class="card__preview">${preview}</p>
|
<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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
: `<span>${s.view_count} views</span>`;
|
: `<span>${s.view_count} views</span>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<${tag} class="splat-card${statusClass}"${href}>
|
<${tag} class="splat-card${statusClass}" data-collab-id="splat:${s.id}"${href}>
|
||||||
<div class="splat-card__preview">
|
<div class="splat-card__preview">
|
||||||
${overlay}
|
${overlay}
|
||||||
<span>${isReady ? "🔮" : "📸"}</span>
|
<span>${isReady ? "🔮" : "📸"}</span>
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,8 @@ function posterMockupSvg(): string {
|
||||||
|
|
||||||
// --- Component ---
|
// --- Component ---
|
||||||
|
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
|
||||||
class FolkSwagDesigner extends HTMLElement {
|
class FolkSwagDesigner extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "";
|
private space = "";
|
||||||
|
|
@ -162,10 +164,23 @@ class FolkSwagDesigner extends HTMLElement {
|
||||||
private demoStep: 1 | 2 | 3 | 4 = 1;
|
private demoStep: 1 | 2 | 3 | 4 = 1;
|
||||||
private progressStep = 0;
|
private progressStep = 0;
|
||||||
private usedSampleDesign = false;
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkSwagDesigner.TOUR_STEPS,
|
||||||
|
"rswag_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -177,10 +192,13 @@ class FolkSwagDesigner extends HTMLElement {
|
||||||
this.designTitle = "Cosmolocal Network Tee";
|
this.designTitle = "Cosmolocal Network Tee";
|
||||||
this.demoStep = 1;
|
this.demoStep = 1;
|
||||||
this.render();
|
this.render();
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
if (!localStorage.getItem("rswag_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getApiBase(): string {
|
private getApiBase(): string {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
|
|
@ -334,6 +352,11 @@ class FolkSwagDesigner extends HTMLElement {
|
||||||
} else {
|
} else {
|
||||||
this.renderFull();
|
this.renderFull();
|
||||||
}
|
}
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTour() {
|
||||||
|
this._tour.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Demo mode rendering (4-step flow) ----
|
// ---- Demo mode rendering (4-step flow) ----
|
||||||
|
|
@ -345,6 +368,8 @@ class FolkSwagDesigner extends HTMLElement {
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
<style>${this.getDemoStyles()}</style>
|
<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 -->
|
<!-- Step indicators -->
|
||||||
<div class="steps-bar">
|
<div class="steps-bar">
|
||||||
${[
|
${[
|
||||||
|
|
@ -362,7 +387,7 @@ class FolkSwagDesigner extends HTMLElement {
|
||||||
<section class="step-section ${this.demoStep >= 1 ? 'visible' : ''}">
|
<section class="step-section ${this.demoStep >= 1 ? 'visible' : ''}">
|
||||||
<div class="products">
|
<div class="products">
|
||||||
${DEMO_PRODUCTS.map(dp => `
|
${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-icon">${this.productIcon(dp.id)}</div>
|
||||||
<div class="product-name">${dp.name}</div>
|
<div class="product-name">${dp.name}</div>
|
||||||
<div class="product-specs">${dp.printArea}</div>
|
<div class="product-specs">${dp.printArea}</div>
|
||||||
|
|
@ -541,6 +566,8 @@ class FolkSwagDesigner extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindDemoEvents() {
|
private bindDemoEvents() {
|
||||||
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
// Product selection
|
// Product selection
|
||||||
this.shadow.querySelectorAll(".product").forEach(el => {
|
this.shadow.querySelectorAll(".product").forEach(el => {
|
||||||
el.addEventListener("click", () => this.demoSelectProduct((el as HTMLElement).dataset.product || "tee"));
|
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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- 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.
|
* Views: workspace list → board with draggable columns.
|
||||||
* Supports task creation, status changes, and priority labels.
|
* Supports task creation, status changes, and priority labels.
|
||||||
|
|
@ -7,8 +7,10 @@
|
||||||
|
|
||||||
import { boardSchema, type BoardDoc } from "../schemas";
|
import { boardSchema, type BoardDoc } from "../schemas";
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
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 shadow: ShadowRoot;
|
||||||
private space = "";
|
private space = "";
|
||||||
private view: "list" | "board" = "list";
|
private view: "list" | "board" = "list";
|
||||||
|
|
@ -24,19 +26,38 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
private showCreateForm = false;
|
private showCreateForm = false;
|
||||||
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||||
private _offlineUnsubs: (() => void)[] = [];
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkTasksBoard.TOUR_STEPS,
|
||||||
|
"rtasks_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); }
|
||||||
|
else {
|
||||||
this.subscribeOffline();
|
this.subscribeOffline();
|
||||||
this.loadWorkspaces();
|
this.loadWorkspaces();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
if (!localStorage.getItem("rtasks_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
for (const unsub of this._offlineUnsubs) unsub();
|
for (const unsub of this._offlineUnsubs) unsub();
|
||||||
|
|
@ -48,7 +69,7 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
if (!runtime?.isInitialized) return;
|
if (!runtime?.isInitialized) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const docs = await runtime.subscribeModule('work', 'boards', boardSchema);
|
const docs = await runtime.subscribeModule('tasks', 'boards', boardSchema);
|
||||||
// Build workspace list from cached boards
|
// Build workspace list from cached boards
|
||||||
if (docs.size > 0 && this.workspaces.length === 0) {
|
if (docs.size > 0 && this.workspaces.length === 0) {
|
||||||
const boards: any[] = [];
|
const boards: any[] = [];
|
||||||
|
|
@ -70,7 +91,7 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
if (!runtime || !this.workspaceSlug) return;
|
if (!runtime || !this.workspaceSlug) return;
|
||||||
// Reload tasks for current board from runtime
|
// 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;
|
const doc = runtime.get(docId) as BoardDoc | undefined;
|
||||||
if (doc?.tasks && Object.keys(doc.tasks).length > 0) {
|
if (doc?.tasks && Object.keys(doc.tasks).length > 0) {
|
||||||
this.tasks = Object.values(doc.tasks).map(t => ({
|
this.tasks = Object.values(doc.tasks).map(t => ({
|
||||||
|
|
@ -105,7 +126,7 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
|
|
||||||
private getApiBase(): string {
|
private getApiBase(): string {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const match = path.match(/^(\/[^/]+)?\/rwork/);
|
const match = path.match(/^(\/[^/]+)?\/rtasks/);
|
||||||
return match ? match[0] : "";
|
return match ? match[0] : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,6 +246,14 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
this.loadTasks();
|
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() {
|
private render() {
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -307,6 +336,11 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
${this.view === "list" ? this.renderList() : this.renderBoard()}
|
${this.view === "list" ? this.renderList() : this.renderBoard()}
|
||||||
`;
|
`;
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTour() {
|
||||||
|
this._tour.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderList(): string {
|
private renderList(): string {
|
||||||
|
|
@ -314,6 +348,7 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
<div class="rapp-nav">
|
<div class="rapp-nav">
|
||||||
<span class="rapp-nav__title">Workspaces</span>
|
<span class="rapp-nav__title">Workspaces</span>
|
||||||
<button class="rapp-nav__btn" id="create-ws">+ New Workspace</button>
|
<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>
|
</div>
|
||||||
${this.workspaces.length > 0 ? `<div class="workspace-grid">
|
${this.workspaces.length > 0 ? `<div class="workspace-grid">
|
||||||
${this.workspaces.map(ws => `
|
${this.workspaces.map(ws => `
|
||||||
|
|
@ -351,7 +386,7 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
private renderBoard(): string {
|
private renderBoard(): string {
|
||||||
return `
|
return `
|
||||||
<div class="rapp-nav">
|
<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>
|
<span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span>
|
||||||
<button class="rapp-nav__btn" id="create-task">+ New Task</button>
|
<button class="rapp-nav__btn" id="create-task">+ New Task</button>
|
||||||
</div>
|
</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 map[p] ? `<span class="badge clickable ${map[p]}" data-cycle-priority="${task.id}">${this.esc(p.toLowerCase())}</span>` : "";
|
||||||
};
|
};
|
||||||
return `
|
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
|
${isEditing
|
||||||
? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">`
|
? `<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>`}
|
: `<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() {
|
private attachListeners() {
|
||||||
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
this.shadow.getElementById("create-ws")?.addEventListener("click", () => this.createWorkspace());
|
this.shadow.getElementById("create-ws")?.addEventListener("click", () => this.createWorkspace());
|
||||||
this.shadow.getElementById("create-task")?.addEventListener("click", () => {
|
this.shadow.getElementById("create-task")?.addEventListener("click", () => {
|
||||||
this.showCreateForm = !this.showCreateForm;
|
this.showCreateForm = !this.showCreateForm;
|
||||||
|
|
@ -461,10 +497,14 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.shadow.querySelectorAll("[data-ws]").forEach(el => {
|
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 => {
|
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 => {
|
this.shadow.querySelectorAll("[data-move]").forEach(el => {
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
|
|
@ -485,10 +525,10 @@ class FolkWorkBoard extends HTMLElement {
|
||||||
const task = this.tasks.find(t => t.id === this.dragTaskId);
|
const task = this.tasks.find(t => t.id === this.dragTaskId);
|
||||||
dt.setData("text/plain", task?.title || this.dragTaskId);
|
dt.setData("text/plain", task?.title || this.dragTaskId);
|
||||||
dt.setData("application/rspace-item", JSON.stringify({
|
dt.setData("application/rspace-item", JSON.stringify({
|
||||||
module: "rwork",
|
module: "rtasks",
|
||||||
entityId: this.dragTaskId,
|
entityId: this.dragTaskId,
|
||||||
title: task?.title || "",
|
title: task?.title || "",
|
||||||
label: "rWork Task",
|
label: "rTasks",
|
||||||
color: "#f97316",
|
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 */
|
/* Tasks module — dark theme */
|
||||||
folk-work-board {
|
folk-tasks-board {
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
-- rWork module schema
|
-- rTasks module schema
|
||||||
CREATE SCHEMA IF NOT EXISTS rwork;
|
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(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
did TEXT UNIQUE NOT NULL,
|
did TEXT UNIQUE NOT NULL,
|
||||||
username TEXT,
|
username TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
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(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
slug TEXT UNIQUE NOT NULL,
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
|
@ -21,40 +21,40 @@ CREATE TABLE IF NOT EXISTS rwork.spaces (
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
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(),
|
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,
|
||||||
user_id UUID NOT NULL REFERENCES rwork.users(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')),
|
role TEXT NOT NULL DEFAULT 'MEMBER' CHECK (role IN ('ADMIN','MODERATOR','MEMBER','VIEWER')),
|
||||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(space_id, user_id)
|
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(),
|
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,
|
title TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'TODO',
|
status TEXT NOT NULL DEFAULT 'TODO',
|
||||||
priority TEXT DEFAULT 'MEDIUM' CHECK (priority IN ('LOW','MEDIUM','HIGH','URGENT')),
|
priority TEXT DEFAULT 'MEDIUM' CHECK (priority IN ('LOW','MEDIUM','HIGH','URGENT')),
|
||||||
labels TEXT[] DEFAULT '{}',
|
labels TEXT[] DEFAULT '{}',
|
||||||
assignee_id UUID REFERENCES rwork.users(id),
|
assignee_id UUID REFERENCES rtasks.users(id),
|
||||||
created_by UUID REFERENCES rwork.users(id),
|
created_by UUID REFERENCES rtasks.users(id),
|
||||||
sort_order INT DEFAULT 0,
|
sort_order INT DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_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(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
space_id UUID REFERENCES rwork.spaces(id) ON DELETE CASCADE,
|
space_id UUID REFERENCES rtasks.spaces(id) ON DELETE CASCADE,
|
||||||
user_id UUID REFERENCES rwork.users(id),
|
user_id UUID REFERENCES rtasks.users(id),
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
metadata JSONB DEFAULT '{}',
|
metadata JSONB DEFAULT '{}',
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
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_rtasks_tasks_space ON rtasks.tasks(space_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_rwork_tasks_status ON rwork.tasks(space_id, status);
|
CREATE INDEX IF NOT EXISTS idx_rtasks_tasks_status ON rtasks.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_rtasks_activity_space ON rtasks.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_members_user ON rtasks.space_members(user_id);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* rWork landing page — collective task management.
|
* rTasks landing page — collective task management.
|
||||||
* Ported from rwork-online Next.js page.tsx (shadcn/ui + Lucide).
|
* Ported from rtasks-online Next.js page.tsx (shadcn/ui + Lucide).
|
||||||
*/
|
*/
|
||||||
export function renderLanding(): string {
|
export function renderLanding(): string {
|
||||||
return `
|
return `
|
||||||
|
|
@ -16,11 +16,16 @@ export function renderLanding(): string {
|
||||||
by markdown-native task management.
|
by markdown-native task management.
|
||||||
</p>
|
</p>
|
||||||
<div class="rl-cta-row">
|
<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 →
|
Get Started →
|
||||||
</a>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- How It Works -->
|
<!-- How It Works -->
|
||||||
|
|
@ -28,7 +33,7 @@ export function renderLanding(): string {
|
||||||
<div class="rl-container">
|
<div class="rl-container">
|
||||||
<div style="text-align:center;margin-bottom:1.5rem">
|
<div style="text-align:center;margin-bottom:1.5rem">
|
||||||
<span class="rl-badge">How It Works</span>
|
<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">
|
<p class="rl-subtext">
|
||||||
<strong style="color:#3b82f6">Create a Space</strong> for your community,
|
<strong style="color:#3b82f6">Create a Space</strong> for your community,
|
||||||
<strong style="color:#14b8a6">add tasks</strong> to your pipeline, and
|
<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.
|
Invite members, configure your pipeline, and ship faster as a team.
|
||||||
</p>
|
</p>
|
||||||
<div class="rl-cta-row" style="margin-top:1.5rem">
|
<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 →
|
Create a Space →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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';
|
import * as Automerge from '@automerge/automerge';
|
||||||
|
|
@ -13,7 +13,7 @@ import { DocCrypto } from '../../shared/local-first/crypto';
|
||||||
import { boardSchema, boardDocId } from './schemas';
|
import { boardSchema, boardDocId } from './schemas';
|
||||||
import type { BoardDoc, TaskItem, BoardMeta } from './schemas';
|
import type { BoardDoc, TaskItem, BoardMeta } from './schemas';
|
||||||
|
|
||||||
export class WorkLocalFirstClient {
|
export class TasksLocalFirstClient {
|
||||||
#space: string;
|
#space: string;
|
||||||
#documents: DocumentManager;
|
#documents: DocumentManager;
|
||||||
#store: EncryptedDocStore;
|
#store: EncryptedDocStore;
|
||||||
|
|
@ -37,7 +37,7 @@ export class WorkLocalFirstClient {
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
if (this.#initialized) return;
|
if (this.#initialized) return;
|
||||||
await this.#store.open();
|
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);
|
const cached = await this.#store.loadMany(cachedIds);
|
||||||
for (const [docId, binary] of cached) {
|
for (const [docId, binary] of cached) {
|
||||||
this.#documents.open<BoardDoc>(docId, boardSchema, binary);
|
this.#documents.open<BoardDoc>(docId, boardSchema, binary);
|
||||||
|
|
@ -45,7 +45,7 @@ export class WorkLocalFirstClient {
|
||||||
await this.#sync.preloadSyncStates(cachedIds);
|
await this.#sync.preloadSyncStates(cachedIds);
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
|
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;
|
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,
|
* Multi-tenant collaborative workspace with drag-and-drop kanban,
|
||||||
* configurable statuses, and activity logging.
|
* 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.
|
* Get all board doc IDs for a given space.
|
||||||
*/
|
*/
|
||||||
function getBoardDocIds(space: string): string[] {
|
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') {
|
function seedDemoIfEmpty(space: string = 'rspace-dev') {
|
||||||
if (!_syncServer) return;
|
if (!_syncServer) return;
|
||||||
// Check if this space already has work boards
|
// Check if this space already has tasks boards
|
||||||
const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:work:boards:`));
|
const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:tasks:boards:`));
|
||||||
if (spaceWorkDocs.length > 0) return;
|
if (spaceWorkDocs.length > 0) return;
|
||||||
|
|
||||||
const docId = boardDocId(space, space);
|
const docId = boardDocId(space, space);
|
||||||
|
|
||||||
const doc = Automerge.change(Automerge.init<BoardDoc>(), 'seed demo board', (d) => {
|
const doc = Automerge.change(Automerge.init<BoardDoc>(), 'seed demo board', (d) => {
|
||||||
const now = Date.now();
|
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 = {
|
d.board = {
|
||||||
id: space,
|
id: space,
|
||||||
name: 'rSpace Development',
|
name: 'rSpace Development',
|
||||||
|
|
@ -109,14 +109,14 @@ function seedDemoIfEmpty(space: string = 'rspace-dev') {
|
||||||
});
|
});
|
||||||
|
|
||||||
_syncServer!.setDoc(docId, doc);
|
_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) ──
|
// ── API: Spaces (Boards) ──
|
||||||
|
|
||||||
// GET /api/spaces — list workspaces (boards)
|
// GET /api/spaces — list workspaces (boards)
|
||||||
routes.get("/api/spaces", async (c) => {
|
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 rows = allIds.map((docId) => {
|
||||||
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
|
|
@ -161,7 +161,7 @@ routes.post("/api/spaces", async (c) => {
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const doc = Automerge.change(Automerge.init<BoardDoc>(), 'create board', (d) => {
|
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 = {
|
d.board = {
|
||||||
id: slug,
|
id: slug,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
|
|
@ -314,7 +314,7 @@ routes.patch("/api/tasks/:id", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find which board doc contains this task
|
// 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;
|
let targetDocId: string | null = null;
|
||||||
for (const docId of allBoardIds) {
|
for (const docId of allBoardIds) {
|
||||||
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
||||||
|
|
@ -362,7 +362,7 @@ routes.delete("/api/tasks/:id", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
// Find which board doc contains this task
|
// 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;
|
let targetDocId: string | null = null;
|
||||||
for (const docId of allBoardIds) {
|
for (const docId of allBoardIds) {
|
||||||
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
||||||
|
|
@ -395,26 +395,26 @@ routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — Work | rSpace`,
|
title: `${space} — Tasks | rSpace`,
|
||||||
moduleId: "rwork",
|
moduleId: "rtasks",
|
||||||
spaceSlug: space,
|
spaceSlug: space,
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
body: `<folk-work-board space="${space}"></folk-work-board>`,
|
body: `<folk-tasks-board space="${space}"></folk-tasks-board>`,
|
||||||
scripts: `<script type="module" src="/modules/rwork/folk-work-board.js"></script>`,
|
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rwork/work.css">`,
|
styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
export const workModule: RSpaceModule = {
|
export const tasksModule: RSpaceModule = {
|
||||||
id: "rwork",
|
id: "rtasks",
|
||||||
name: "rWork",
|
name: "rTasks",
|
||||||
icon: "📋",
|
icon: "📋",
|
||||||
description: "Kanban workspace boards for collaborative task management",
|
description: "Kanban workspace boards for collaborative task management",
|
||||||
scoping: { defaultScope: 'space', userConfigurable: false },
|
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,
|
routes,
|
||||||
standaloneDomain: "rwork.online",
|
standaloneDomain: "rtasks.online",
|
||||||
landingPage: renderLanding,
|
landingPage: renderLanding,
|
||||||
seedTemplate: seedDemoIfEmpty,
|
seedTemplate: seedDemoIfEmpty,
|
||||||
async onInit(ctx) {
|
async onInit(ctx) {
|
||||||
|
|
@ -426,7 +426,7 @@ export const workModule: RSpaceModule = {
|
||||||
const docId = boardDocId(ctx.spaceSlug, ctx.spaceSlug);
|
const docId = boardDocId(ctx.spaceSlug, ctx.spaceSlug);
|
||||||
const doc = Automerge.init<BoardDoc>();
|
const doc = Automerge.init<BoardDoc>();
|
||||||
const initialized = Automerge.change(doc, 'Init board', (d) => {
|
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.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 = {};
|
d.tasks = {};
|
||||||
});
|
});
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* rWork Automerge document schemas.
|
* rTasks Automerge document schemas.
|
||||||
*
|
*
|
||||||
* Granularity: one Automerge document per board/workspace.
|
* 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';
|
import type { DocSchema } from '../../shared/local-first/document';
|
||||||
|
|
@ -52,12 +52,12 @@ export interface BoardDoc {
|
||||||
// ── Schema registration ──
|
// ── Schema registration ──
|
||||||
|
|
||||||
export const boardSchema: DocSchema<BoardDoc> = {
|
export const boardSchema: DocSchema<BoardDoc> = {
|
||||||
module: 'work',
|
module: 'tasks',
|
||||||
collection: 'boards',
|
collection: 'boards',
|
||||||
version: 1,
|
version: 1,
|
||||||
init: (): BoardDoc => ({
|
init: (): BoardDoc => ({
|
||||||
meta: {
|
meta: {
|
||||||
module: 'work',
|
module: 'tasks',
|
||||||
collection: 'boards',
|
collection: 'boards',
|
||||||
version: 1,
|
version: 1,
|
||||||
spaceSlug: '',
|
spaceSlug: '',
|
||||||
|
|
@ -82,7 +82,7 @@ export const boardSchema: DocSchema<BoardDoc> = {
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
export function boardDocId(space: string, boardId: string) {
|
export function boardDocId(space: string, boardId: string) {
|
||||||
return `${space}:work:boards:${boardId}` as const;
|
return `${space}:tasks:boards:${boardId}` as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTaskItem(
|
export function createTaskItem(
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
import { tripSchema, type TripDoc } from "../schemas";
|
import { tripSchema, type TripDoc } from "../schemas";
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
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 {
|
class FolkTripsPlanner extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
|
|
@ -18,19 +20,37 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
|
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
|
||||||
private error = "";
|
private error = "";
|
||||||
private _offlineUnsubs: (() => void)[] = [];
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkTripsPlanner.TOUR_STEPS,
|
||||||
|
"rtrips_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); }
|
||||||
|
else {
|
||||||
this.subscribeOffline();
|
this.subscribeOffline();
|
||||||
this.loadTrips();
|
this.loadTrips();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
if (!localStorage.getItem("rtrips_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
for (const unsub of this._offlineUnsubs) unsub();
|
for (const unsub of this._offlineUnsubs) unsub();
|
||||||
|
|
@ -472,6 +492,11 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
${this.view === "list" ? this.renderList() : this.renderDetail()}
|
${this.view === "list" ? this.renderList() : this.renderDetail()}
|
||||||
`;
|
`;
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTour() {
|
||||||
|
this._tour.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderList(): string {
|
private renderList(): string {
|
||||||
|
|
@ -479,6 +504,7 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
<div class="rapp-nav">
|
<div class="rapp-nav">
|
||||||
<span class="rapp-nav__title">My Trips</span>
|
<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="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>
|
</div>
|
||||||
${this.trips.length > 0 ? `<div class="trip-grid">
|
${this.trips.length > 0 ? `<div class="trip-grid">
|
||||||
${this.trips.map(t => {
|
${this.trips.map(t => {
|
||||||
|
|
@ -488,7 +514,7 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
|
const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
|
||||||
const budgetColor = pct > 90 ? "#ef4444" : pct > 70 ? "#fbbf24" : "#14b8a6";
|
const budgetColor = pct > 90 ? "#ef4444" : pct > 70 ? "#fbbf24" : "#14b8a6";
|
||||||
return `
|
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">
|
<div class="trip-card-header">
|
||||||
<span class="trip-name">${this.esc(t.title)}</span>
|
<span class="trip-name">${this.esc(t.title)}</span>
|
||||||
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</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");
|
const st = this.getStatusStyle(t.status || "PLANNING");
|
||||||
return `
|
return `
|
||||||
<div class="rapp-nav">
|
<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="rapp-nav__title">${this.esc(t.title)}</span>
|
||||||
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
|
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -573,7 +599,7 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
case "destinations":
|
case "destinations":
|
||||||
return (t.destinations || []).length > 0
|
return (t.destinations || []).length > 0
|
||||||
? (t.destinations || []).map((d: any, i: number) => `
|
? (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>
|
<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-info">
|
||||||
<div class="dest-name">${this.esc(d.name)}</div>
|
<div class="dest-name">${this.esc(d.name)}</div>
|
||||||
|
|
@ -650,20 +676,20 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
|
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
|
||||||
|
|
||||||
this.shadow.querySelectorAll("[data-trip]").forEach(el => {
|
this.shadow.querySelectorAll("[data-trip]").forEach(el => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
|
this._history.push("list");
|
||||||
this.view = "detail";
|
this.view = "detail";
|
||||||
this.tab = "overview";
|
this.tab = "overview";
|
||||||
|
this._history.push("detail", { tripId: (el as HTMLElement).dataset.trip });
|
||||||
this.loadTrip((el as HTMLElement).dataset.trip!);
|
this.loadTrip((el as HTMLElement).dataset.trip!);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.shadow.querySelectorAll("[data-back]").forEach(el => {
|
this.shadow.querySelectorAll("[data-back]").forEach(el => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => this.goBack());
|
||||||
this.view = "list";
|
|
||||||
if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); }
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
this.shadow.querySelectorAll("[data-tab]").forEach(el => {
|
this.shadow.querySelectorAll("[data-tab]").forEach(el => {
|
||||||
el.addEventListener("click", () => {
|
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 {
|
private esc(s: string): string {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
d.textContent = s || "";
|
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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- How It Works -->
|
<!-- How It Works -->
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
* and provides HLS live stream viewing.
|
* and provides HLS live stream viewing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
|
||||||
class FolkVideoPlayer extends HTMLElement {
|
class FolkVideoPlayer extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "demo";
|
private space = "demo";
|
||||||
|
|
@ -14,16 +16,31 @@ class FolkVideoPlayer extends HTMLElement {
|
||||||
private streamKey = "";
|
private streamKey = "";
|
||||||
private searchTerm = "";
|
private searchTerm = "";
|
||||||
private isDemo = false;
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkVideoPlayer.TOUR_STEPS,
|
||||||
|
"rtube_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); }
|
||||||
this.loadVideos();
|
else { this.loadVideos(); }
|
||||||
|
if (!localStorage.getItem("rtube_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadDemoData() {
|
private loadDemoData() {
|
||||||
|
|
@ -112,11 +129,17 @@ class FolkVideoPlayer extends HTMLElement {
|
||||||
<button class="tab ${this.mode === "library" ? "active" : ""}" data-mode="library">Video Library</button>
|
<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>` : ""}
|
${!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>
|
<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>
|
</div>
|
||||||
${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
|
${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTour() {
|
||||||
|
this._tour.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderLibrary(): string {
|
private renderLibrary(): string {
|
||||||
|
|
@ -129,7 +152,7 @@ class FolkVideoPlayer extends HTMLElement {
|
||||||
const videoList = filteredVideos.length === 0
|
const videoList = filteredVideos.length === 0
|
||||||
? `<div class="empty">${this.videos.length === 0 ? "No videos yet" : "No matches"}</div>`
|
? `<div class="empty">${this.videos.length === 0 ? "No videos yet" : "No matches"}</div>`
|
||||||
: filteredVideos.map((v) => `
|
: 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-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 class="video-meta">${this.getExtension(v.name).toUpperCase()} · ${this.formatSize(v.size)}${v.duration ? ` · ${v.duration}` : ""}${v.date ? `<br>${v.date}` : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -202,6 +225,8 @@ class FolkVideoPlayer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindEvents() {
|
private bindEvents() {
|
||||||
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
this.shadow.querySelectorAll(".tab").forEach((btn) => {
|
this.shadow.querySelectorAll(".tab").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
this.mode = (btn as HTMLElement).dataset.mode as "library" | "live";
|
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-secondary" id="ml-primary">Browse Videos</a>
|
||||||
<a href="https://demo.rspace.online/rtube" class="rl-cta-primary" style="background:#dc2626">Start Streaming</a>
|
<a href="https://demo.rspace.online/rtube" class="rl-cta-primary" style="background:#dc2626">Start Streaming</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- How It Works -->
|
<!-- How It Works -->
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
|
|
||||||
import { proposalSchema, type ProposalDoc } from "../schemas";
|
import { proposalSchema, type ProposalDoc } from "../schemas";
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
import { ViewHistory } from "../../../shared/view-history.js";
|
||||||
|
|
||||||
interface VoteSpace {
|
interface VoteSpace {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -54,17 +56,35 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
private showTrendChart = true;
|
private showTrendChart = true;
|
||||||
private scoreHistory: ScoreSnapshot[] = [];
|
private scoreHistory: ScoreSnapshot[] = [];
|
||||||
private _offlineUnsubs: (() => void)[] = [];
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkVoteDashboard.TOUR_STEPS,
|
||||||
|
"rvote_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); }
|
||||||
this.subscribeOffline();
|
else { this.subscribeOffline(); this.loadSpaces(); }
|
||||||
this.loadSpaces();
|
if (!localStorage.getItem("rvote_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
|
@ -593,8 +613,11 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
this._tour.renderOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTour() { this._tour.start(); }
|
||||||
|
|
||||||
private renderView(): string {
|
private renderView(): string {
|
||||||
if (this.view === "proposal" && this.selectedProposal) {
|
if (this.view === "proposal" && this.selectedProposal) {
|
||||||
return this.renderProposal();
|
return this.renderProposal();
|
||||||
|
|
@ -609,6 +632,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
return `
|
return `
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="header-title">Voting Spaces</span>
|
<span class="header-title">Voting Spaces</span>
|
||||||
|
<button class="header-back" id="btn-tour">Tour</button>
|
||||||
</div>
|
</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.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) => `
|
${this.spaces.map((s) => `
|
||||||
|
|
@ -658,7 +682,7 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="header">
|
<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-title">${this.esc(s.name)}</span>
|
||||||
<span class="header-sub">${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""}</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>
|
<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>`;
|
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 `
|
return `
|
||||||
<div class="proposal" data-pid="${p.id}">
|
<div class="proposal" data-pid="${p.id}" data-collab-id="proposal:${p.id}">
|
||||||
${p.status === "RANKING" ? `
|
${p.status === "RANKING" ? `
|
||||||
<div class="vote-col">
|
<div class="vote-col">
|
||||||
<button class="vote-chevron up" data-vote-weight="1" data-vote-id="${p.id}" title="Upvote (+1 credit)">${upChevron}</button>
|
<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 `
|
return `
|
||||||
<div class="header">
|
<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="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>
|
<span class="badge" style="background:${this.getStatusColor(p.status)}18;color:${this.getStatusColor(p.status)}">${this.getStatusIcon(p.status)} ${p.status}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -966,10 +990,13 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
|
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
// Space cards
|
// Space cards
|
||||||
this.shadow.querySelectorAll("[data-space]").forEach((el) => {
|
this.shadow.querySelectorAll("[data-space]").forEach((el) => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
const slug = (el as HTMLElement).dataset.space!;
|
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.selectedSpace = this.spaces.find((s) => s.slug === slug) || null;
|
||||||
this.view = "proposals";
|
this.view = "proposals";
|
||||||
if (this.space === "demo") this.render();
|
if (this.space === "demo") this.render();
|
||||||
|
|
@ -982,6 +1009,8 @@ class FolkVoteDashboard extends HTMLElement {
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const id = (el as HTMLElement).dataset.proposal!;
|
const id = (el as HTMLElement).dataset.proposal!;
|
||||||
|
this._history.push(this.view);
|
||||||
|
this._history.push("proposal", { proposalId: id });
|
||||||
this.view = "proposal";
|
this.view = "proposal";
|
||||||
if (this.space === "demo") {
|
if (this.space === "demo") {
|
||||||
this.selectedProposal = this.proposals.find((p) => p.id === id) || null;
|
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) => {
|
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const target = (el as HTMLElement).dataset.back;
|
this.goBack();
|
||||||
if (target === "spaces") { this.view = "spaces"; this.render(); }
|
|
||||||
else if (target === "proposals") { this.view = "proposals"; this.render(); }
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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 {
|
private esc(s: string): string {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
d.textContent = s || "";
|
d.textContent = s || "";
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ export function renderLanding(): string {
|
||||||
</a>
|
</a>
|
||||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ELI5 Section: rVote in 30 Seconds -->
|
<!-- ELI5 Section: rVote in 30 Seconds -->
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { transformToTimelineData, transformToSankeyData, transformToMultichainDa
|
||||||
import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-transform";
|
import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-transform";
|
||||||
import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz";
|
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 { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data";
|
||||||
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
|
|
||||||
interface ChainInfo {
|
interface ChainInfo {
|
||||||
chainId: string;
|
chainId: string;
|
||||||
|
|
@ -108,24 +109,41 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
multichain?: MultichainData;
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
this._tour = new TourEngine(
|
||||||
|
this.shadow,
|
||||||
|
FolkWalletViewer.TOUR_STEPS,
|
||||||
|
"rwallet_tour_done",
|
||||||
|
() => this.shadow.host as HTMLElement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const space = this.getAttribute("space") || "";
|
const space = this.getAttribute("space") || "";
|
||||||
if (space === "demo") {
|
if (space === "demo") {
|
||||||
this.loadDemoData();
|
this.loadDemoData();
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
// Check URL params for initial address
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
this.address = params.get("address") || "";
|
this.address = params.get("address") || "";
|
||||||
this.checkAuthState();
|
this.checkAuthState();
|
||||||
this.render();
|
this.render();
|
||||||
if (this.address) this.detectChains();
|
if (this.address) this.detectChains();
|
||||||
}
|
}
|
||||||
|
if (!localStorage.getItem("rwallet_tour_done")) {
|
||||||
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private checkAuthState() {
|
private checkAuthState() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -979,7 +997,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
<div class="wallet-list">
|
<div class="wallet-list">
|
||||||
${this.passKeyEOA ? `
|
${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-info">
|
||||||
<div class="wallet-item-label">
|
<div class="wallet-item-label">
|
||||||
<span class="wallet-badge encryptid">EncryptID</span>
|
<span class="wallet-badge encryptid">EncryptID</span>
|
||||||
|
|
@ -990,7 +1008,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : ""}
|
||||||
${this.linkedWallets.map(w => `
|
${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-info">
|
||||||
<div class="wallet-item-label">
|
<div class="wallet-item-label">
|
||||||
<span class="wallet-badge ${w.type}">${this.esc(w.providerName || w.type.toUpperCase())}</span>
|
<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>
|
<div class="toggle-track"><div class="toggle-thumb"></div></div>
|
||||||
<span>Include testnets</span>
|
<span>Include testnets</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="view-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
|
||||||
${this.walletType ? `
|
${this.walletType ? `
|
||||||
<div class="wallet-badge ${this.walletType}">
|
<div class="wallet-badge ${this.walletType}">
|
||||||
${this.walletType === "safe" ? "⛓ Safe Multisig" : "👤 EOA Wallet"}
|
${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
|
// Draw visualization if active
|
||||||
if (this.activeView !== "balances" && this.hasData()) {
|
if (this.activeView !== "balances" && this.hasData()) {
|
||||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._tour.renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTour() {
|
||||||
|
this._tour.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private esc(s: string): string {
|
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="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>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ import { walletModule } from "../modules/rwallet/mod";
|
||||||
import { voteModule } from "../modules/rvote/mod";
|
import { voteModule } from "../modules/rvote/mod";
|
||||||
import { notesModule } from "../modules/rnotes/mod";
|
import { notesModule } from "../modules/rnotes/mod";
|
||||||
import { mapsModule } from "../modules/rmaps/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 { tripsModule } from "../modules/rtrips/mod";
|
||||||
import { calModule } from "../modules/rcal/mod";
|
import { calModule } from "../modules/rcal/mod";
|
||||||
import { networkModule } from "../modules/rnetwork/mod";
|
import { networkModule } from "../modules/rnetwork/mod";
|
||||||
|
|
@ -99,7 +99,7 @@ registerModule(walletModule);
|
||||||
registerModule(voteModule);
|
registerModule(voteModule);
|
||||||
registerModule(notesModule);
|
registerModule(notesModule);
|
||||||
registerModule(mapsModule);
|
registerModule(mapsModule);
|
||||||
registerModule(workModule);
|
registerModule(tasksModule);
|
||||||
registerModule(tripsModule);
|
registerModule(tripsModule);
|
||||||
registerModule(calModule);
|
registerModule(calModule);
|
||||||
registerModule(networkModule);
|
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})
|
* Granularity: 1 doc per space/board ({space}:work:boards:{spaceId})
|
||||||
*/
|
*/
|
||||||
export const workMigration: ModuleMigration = {
|
export const workMigration: ModuleMigration = {
|
||||||
|
|
@ -340,14 +340,14 @@ export const workMigration: ModuleMigration = {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows: spaces } = await pool.query(
|
const { rows: spaces } = await pool.query(
|
||||||
`SELECT * FROM rwork.spaces WHERE slug = $1`,
|
`SELECT * FROM rtasks.spaces WHERE slug = $1`,
|
||||||
[space]
|
[space]
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const ws of spaces) {
|
for (const ws of spaces) {
|
||||||
try {
|
try {
|
||||||
const { rows: tasks } = await pool.query(
|
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]
|
[ws.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -615,10 +615,10 @@ const DEMO_SHAPES: Record<string, unknown>[] = [
|
||||||
ordersFulfilled: 127,
|
ordersFulfilled: 127,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── rWork: Task Board ─────────────────────────────────────
|
// ─── rTasks: Task Board ─────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: "demo-work-board",
|
id: "demo-tasks-board",
|
||||||
type: "folk-work-board",
|
type: "folk-tasks-board",
|
||||||
x: 750, y: 1350, width: 500, height: 280, rotation: 0,
|
x: 750, y: 1350, width: 500, height: 280, rotation: 0,
|
||||||
boardTitle: "Trip Preparation Tasks",
|
boardTitle: "Trip Preparation Tasks",
|
||||||
columns: [
|
columns: [
|
||||||
|
|
|
||||||
|
|
@ -277,10 +277,10 @@ const TEMPLATE_SHAPES: Record<string, unknown>[] = [
|
||||||
ordersFulfilled: 0,
|
ordersFulfilled: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── rWork: Task Board ──────────────────────────────────────
|
// ─── rTasks: Task Board ──────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: "tmpl-work-board",
|
id: "tmpl-tasks-board",
|
||||||
type: "folk-work-board",
|
type: "folk-tasks-board",
|
||||||
x: 750, y: 780, width: 500, height: 280, rotation: 0,
|
x: 750, y: 780, width: 500, height: 280, rotation: 0,
|
||||||
boardTitle: "Getting Started Tasks",
|
boardTitle: "Getting Started Tasks",
|
||||||
columns: [
|
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 ──
|
// ── Dashboard navigate: user clicked a space/action on the dashboard ──
|
||||||
document.addEventListener('dashboard-navigate', (e) => {
|
document.addEventListener('dashboard-navigate', (e) => {
|
||||||
const { moduleId: targetModule, spaceSlug: targetSpace } = e.detail;
|
const { moduleId: targetModule, spaceSlug: targetSpace } = e.detail;
|
||||||
|
|
@ -782,9 +793,12 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
});
|
});
|
||||||
tabBar.addEventListener('layer-reorder', (e) => {
|
tabBar.addEventListener('layer-reorder', (e) => {
|
||||||
const { layerId, newIndex } = e.detail;
|
const { layerId, newIndex } = e.detail;
|
||||||
sync.updateLayer(layerId, { order: newIndex });
|
const all = sync.getLayers(); // already sorted by order
|
||||||
const all = sync.getLayers();
|
const oldIdx = all.findIndex(l => l.id === layerId);
|
||||||
all.forEach((l, i) => { if (l.order !== i) sync.updateLayer(l.id, { order: i }); });
|
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-create', (e) => { sync.addFlow(e.detail.flow); });
|
||||||
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow(e.detail.flowId); });
|
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-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-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-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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
|
||||||
// Observing
|
// Observing
|
||||||
rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300
|
rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300
|
||||||
// Work & Productivity
|
// Work & Productivity
|
||||||
rwork: { badge: "r📋", color: "#cbd5e1" }, // slate-300
|
rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300
|
||||||
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
|
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
|
||||||
// Identity & Infrastructure
|
// Identity & Infrastructure
|
||||||
rids: { badge: "r🪪", color: "#6ee7b7" }, // emerald-300
|
rids: { badge: "r🪪", color: "#6ee7b7" }, // emerald-300
|
||||||
|
|
@ -88,7 +88,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
|
||||||
rfiles: "Sharing",
|
rfiles: "Sharing",
|
||||||
rbooks: "Sharing",
|
rbooks: "Sharing",
|
||||||
rdata: "Observing",
|
rdata: "Observing",
|
||||||
rwork: "Work & Productivity",
|
rtasks: "Tasks & Productivity",
|
||||||
rschedule: "Work & Productivity",
|
rschedule: "Work & Productivity",
|
||||||
rids: "Identity & Infrastructure",
|
rids: "Identity & Infrastructure",
|
||||||
rstack: "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" },
|
rfiles: { badge: "r📁", color: "#67e8f9" },
|
||||||
rbooks: { badge: "r📚", color: "#fda4af" },
|
rbooks: { badge: "r📚", color: "#fda4af" },
|
||||||
rdata: { badge: "r📊", color: "#d8b4fe" },
|
rdata: { badge: "r📊", color: "#d8b4fe" },
|
||||||
rwork: { badge: "r📋", color: "#cbd5e1" },
|
rtasks: { badge: "r📋", color: "#cbd5e1" },
|
||||||
rschedule: { badge: "r⏱", color: "#a5b4fc" },
|
rschedule: { badge: "r⏱", color: "#a5b4fc" },
|
||||||
rids: { badge: "r🪪", color: "#6ee7b7" },
|
rids: { badge: "r🪪", color: "#6ee7b7" },
|
||||||
rstack: { badge: "r✨", color: "" },
|
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",
|
rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce",
|
||||||
rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing",
|
rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing",
|
||||||
rdata: "Observing",
|
rdata: "Observing",
|
||||||
rwork: "Work & Productivity",
|
rtasks: "Tasks & Productivity",
|
||||||
rids: "Identity & Infrastructure", rstack: "Identity & Infrastructure",
|
rids: "Identity & Infrastructure", rstack: "Identity & Infrastructure",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1501,7 +1501,8 @@ const STYLES = `
|
||||||
|
|
||||||
/* ── Drag states ── */
|
/* ── 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; }
|
.tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; }
|
||||||
|
|
||||||
/* ── Add button ── */
|
/* ── Add button ── */
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
DocumentManager,
|
DocumentManager,
|
||||||
} from './document';
|
} from './document';
|
||||||
import { EncryptedDocStore } from './storage';
|
import { EncryptedDocStore } from './storage';
|
||||||
import { DocSyncManager } from './sync';
|
import { DocSyncManager, type AwarenessMessage } from './sync';
|
||||||
import { DocCrypto } from './crypto';
|
import { DocCrypto } from './crypto';
|
||||||
import {
|
import {
|
||||||
getStorageInfo,
|
getStorageInfo,
|
||||||
|
|
@ -72,6 +72,7 @@ export class RSpaceOfflineRuntime {
|
||||||
get isInitialized(): boolean { return this.#initialized; }
|
get isInitialized(): boolean { return this.#initialized; }
|
||||||
get isOnline(): boolean { return this.#sync.isConnected; }
|
get isOnline(): boolean { return this.#sync.isConnected; }
|
||||||
get status(): RuntimeStatus { return this.#status; }
|
get status(): RuntimeStatus { return this.#status; }
|
||||||
|
get peerId(): string { return this.#sync.peerId; }
|
||||||
|
|
||||||
// ── Lifecycle ──
|
// ── Lifecycle ──
|
||||||
|
|
||||||
|
|
@ -131,6 +132,11 @@ export class RSpaceOfflineRuntime {
|
||||||
// Subscribe for sync (sends subscribe + initial sync to server)
|
// Subscribe for sync (sends subscribe + initial sync to server)
|
||||||
await this.#sync.subscribe([docId]);
|
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;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,6 +184,21 @@ export class RSpaceOfflineRuntime {
|
||||||
return () => { unsub1(); unsub2(); };
|
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.
|
* Configure module scope information from the page's module list.
|
||||||
* Call once after init with the modules config for this space.
|
* 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://demo.rsocials.online',
|
||||||
'https://socials.crypto-commons.org',
|
'https://socials.crypto-commons.org',
|
||||||
'https://socials.p2pfoundation.net',
|
'https://socials.p2pfoundation.net',
|
||||||
'https://rwork.online',
|
'https://rtasks.online',
|
||||||
'https://rforum.online',
|
'https://rforum.online',
|
||||||
'https://rchoices.online',
|
'https://rchoices.online',
|
||||||
'https://rswag.online',
|
'https://rswag.online',
|
||||||
|
|
|
||||||
|
|
@ -472,31 +472,31 @@ export default defineConfig({
|
||||||
resolve(__dirname, "dist/modules/rmaps/maps.css"),
|
resolve(__dirname, "dist/modules/rmaps/maps.css"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build work module component
|
// Build tasks module component
|
||||||
await build({
|
await build({
|
||||||
configFile: false,
|
configFile: false,
|
||||||
root: resolve(__dirname, "modules/rwork/components"),
|
root: resolve(__dirname, "modules/rtasks/components"),
|
||||||
build: {
|
build: {
|
||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
outDir: resolve(__dirname, "dist/modules/rwork"),
|
outDir: resolve(__dirname, "dist/modules/rtasks"),
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, "modules/rwork/components/folk-work-board.ts"),
|
entry: resolve(__dirname, "modules/rtasks/components/folk-tasks-board.ts"),
|
||||||
formats: ["es"],
|
formats: ["es"],
|
||||||
fileName: () => "folk-work-board.js",
|
fileName: () => "folk-tasks-board.js",
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: "folk-work-board.js",
|
entryFileNames: "folk-tasks-board.js",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy work CSS
|
// Copy tasks CSS
|
||||||
mkdirSync(resolve(__dirname, "dist/modules/rwork"), { recursive: true });
|
mkdirSync(resolve(__dirname, "dist/modules/rtasks"), { recursive: true });
|
||||||
copyFileSync(
|
copyFileSync(
|
||||||
resolve(__dirname, "modules/rwork/components/work.css"),
|
resolve(__dirname, "modules/rtasks/components/tasks.css"),
|
||||||
resolve(__dirname, "dist/modules/rwork/work.css"),
|
resolve(__dirname, "dist/modules/rtasks/tasks.css"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build trips module component
|
// Build trips module component
|
||||||
|
|
|
||||||
|
|
@ -2210,7 +2210,7 @@
|
||||||
<button id="embed-books" title="Embed rBooks">📚 rBooks</button>
|
<button id="embed-books" title="Embed rBooks">📚 rBooks</button>
|
||||||
<button id="embed-forum" title="Embed rForum">💬 rForum</button>
|
<button id="embed-forum" title="Embed rForum">💬 rForum</button>
|
||||||
<button id="embed-files" title="Embed rFiles">📁 rFiles</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-inbox" title="Embed rInbox">📧 rInbox</button>
|
||||||
<button id="embed-cart" title="Embed rCart">🛒 rCart</button>
|
<button id="embed-cart" title="Embed rCart">🛒 rCart</button>
|
||||||
<button id="embed-data" title="Embed rData">📊 rData</button>
|
<button id="embed-data" title="Embed rData">📊 rData</button>
|
||||||
|
|
@ -2794,9 +2794,12 @@
|
||||||
});
|
});
|
||||||
tabBar.addEventListener('layer-reorder', (e) => {
|
tabBar.addEventListener('layer-reorder', (e) => {
|
||||||
const { layerId, newIndex } = e.detail;
|
const { layerId, newIndex } = e.detail;
|
||||||
sync.updateLayer?.(layerId, { order: newIndex });
|
|
||||||
const all = sync.getLayers?.() || [];
|
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-create', (e) => { sync.addFlow?.(e.detail.flow); });
|
||||||
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow?.(e.detail.flowId); });
|
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow?.(e.detail.flowId); });
|
||||||
|
|
@ -4403,7 +4406,7 @@
|
||||||
{ btnId: "embed-books", moduleId: "rbooks" },
|
{ btnId: "embed-books", moduleId: "rbooks" },
|
||||||
{ btnId: "embed-pubs", moduleId: "rpubs" },
|
{ btnId: "embed-pubs", moduleId: "rpubs" },
|
||||||
{ btnId: "embed-files", moduleId: "rfiles" },
|
{ btnId: "embed-files", moduleId: "rfiles" },
|
||||||
{ btnId: "embed-work", moduleId: "rwork" },
|
{ btnId: "embed-tasks", moduleId: "rtasks" },
|
||||||
{ btnId: "embed-forum", moduleId: "rforum" },
|
{ btnId: "embed-forum", moduleId: "rforum" },
|
||||||
{ btnId: "embed-inbox", moduleId: "rinbox" },
|
{ btnId: "embed-inbox", moduleId: "rinbox" },
|
||||||
{ btnId: "embed-tube", moduleId: "rtube" },
|
{ 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 { RStackModuleSetup } from "../shared/components/rstack-module-setup";
|
||||||
import { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
|
import { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
|
||||||
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
|
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 { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
|
||||||
import { rspaceNavUrl } from "../shared/url-helpers";
|
import { rspaceNavUrl } from "../shared/url-helpers";
|
||||||
import { TabCache } from "../shared/tab-cache";
|
import { TabCache } from "../shared/tab-cache";
|
||||||
|
|
@ -39,6 +40,7 @@ RStackSpaceSettings.define();
|
||||||
RStackModuleSetup.define();
|
RStackModuleSetup.define();
|
||||||
RStackHistoryPanel.define();
|
RStackHistoryPanel.define();
|
||||||
RStackOfflineIndicator.define();
|
RStackOfflineIndicator.define();
|
||||||
|
RStackCollabOverlay.define();
|
||||||
RStackUserDashboard.define();
|
RStackUserDashboard.define();
|
||||||
|
|
||||||
// ── Offline Runtime ──
|
// ── Offline Runtime ──
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue