diff --git a/server/index.ts b/server/index.ts index e041b1d..f8ff506 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1682,9 +1682,9 @@ const server = Bun.serve({ if (spaceConfig) { const vis = spaceConfig.visibility; - if (vis === "authenticated" || vis === "members_only") { + if (vis === "permissioned" || vis === "private") { if (!claims) return new Response("Authentication required", { status: 401 }); - } else if (vis === "public_read") { + } else if (vis === "public") { readOnly = !claims; } } @@ -1701,8 +1701,7 @@ const server = Bun.serve({ // Non-member defaults by visibility const vis = spaceConfig?.visibility; if (vis === 'public' && claims) spaceRole = 'member'; - else if (vis === 'public_read' && claims) spaceRole = 'member'; - else if (vis === 'authenticated' && claims) spaceRole = 'viewer'; + else if (vis === 'permissioned' && claims) spaceRole = 'viewer'; else if (claims) spaceRole = 'viewer'; else spaceRole = 'viewer'; // anonymous } @@ -1781,7 +1780,7 @@ const server = Bun.serve({ name: `${claims.username}'s Space`, slug: subdomain, ownerDID: claims.sub, - visibility: "members_only", + visibility: "private", source: 'subdomain', }); } diff --git a/server/spaces.ts b/server/spaces.ts index f1a2474..7c45c61 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -623,7 +623,6 @@ spaces.patch("/:slug", async (c) => { if (body.visibility) { const valid: SpaceVisibility[] = ["public", "permissioned", "private"]; - // Note: the create endpoint (line ~276) already validates correctly if (!valid.includes(body.visibility)) { return c.json({ error: `Invalid visibility. Must be one of: ${valid.join(", ")}` }, 400); } diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index f689d1d..51f06bb 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -115,20 +115,20 @@ export class RStackSpaceSwitcher extends HTMLElement { } #visibilityInfo(s: SpaceInfo): { cls: string; label: string } { - const v = s.visibility || "public_read"; - if (v === "members_only") return { cls: "vis-private", label: "🔒" }; - if (v === "authenticated") return { cls: "vis-permissioned", label: "🔑" }; + const v = s.visibility || "public"; + if (v === "private") return { cls: "vis-private", label: "🔒" }; + if (v === "permissioned") return { cls: "vis-permissioned", label: "🔑" }; return { cls: "vis-public", label: "👁" }; } /** Format display name based on visibility type */ #displayName(s: SpaceInfo): string { - const v = s.visibility || "public_read"; - if (v === "members_only") { + const v = s.visibility || "public"; + if (v === "private") { const username = getUsername(); - return username ? `${username}'s (you)rSpace` : "(you)rSpace"; + return username ? `${username}'s Space` : "My Space"; } - return `${s.name} rSpace`; + return s.name; } #renderMenu(menu: HTMLElement, current: string) { @@ -140,7 +140,7 @@ export class RStackSpaceSwitcher extends HTMLElement { cta = this.#yourSpaceCTAhtml("Sign in to create →"); } else { const username = getUsername(); - const label = username ? `Create ${username}'s Space →` : "Create (you)rSpace →"; + const label = username ? `Create ${username}'s Space →` : "Create My Space →"; cta = this.#yourSpaceCTAhtml(label); } menu.innerHTML = ` @@ -149,9 +149,11 @@ export class RStackSpaceSwitcher extends HTMLElement { - + Create new space + + ${this.#createFormHTML()} `; this.#attachYourSpaceCTA(menu); + this.#attachCreatePopout(menu); return; } @@ -166,13 +168,13 @@ export class RStackSpaceSwitcher extends HTMLElement { let html = ""; - // ── Create (you)rSpace CTA — only if user has no owned spaces ── + // ── Create personal space CTA — only if user has no owned spaces ── if (!auth) { html += this.#yourSpaceCTAhtml("Sign in to create →"); html += `
`; } else if (!hasOwnedSpace) { const username = getUsername(); - const label = username ? `Create ${username}'s Space →` : "Create (you)rSpace →"; + const label = username ? `Create ${username}'s Space →` : "Create My Space →"; html += this.#yourSpaceCTAhtml(label); html += `
`; } @@ -237,7 +239,8 @@ export class RStackSpaceSwitcher extends HTMLElement { } html += `
`; - html += `+ Create new space`; + html += ``; + html += this.#createFormHTML(); menu.innerHTML = html; @@ -260,19 +263,41 @@ export class RStackSpaceSwitcher extends HTMLElement { }); }); - // Attach "(you)rSpace" CTA listener + // Attach personal space CTA listener this.#attachYourSpaceCTA(menu); + + // Attach create-space popout + this.#attachCreatePopout(menu); } #yourSpaceCTAhtml(buttonLabel: string): string { + const username = getUsername(); + const title = username ? `${username}'s Space` : "My Space"; return `
🔒 - (you)rSpace + ${title}
`; } + #createFormHTML(): string { + return ``; + } + #attachYourSpaceCTA(menu: HTMLElement) { const btn = menu.querySelector("#yourspace-cta"); if (!btn) return; @@ -294,6 +319,79 @@ export class RStackSpaceSwitcher extends HTMLElement { }); } + #attachCreatePopout(menu: HTMLElement) { + const toggle = menu.querySelector("#create-toggle") as HTMLElement; + const popout = menu.querySelector("#create-popout") as HTMLElement; + const nameInput = menu.querySelector("#create-name") as HTMLInputElement; + const slugPreview = menu.querySelector("#create-slug") as HTMLElement; + const submitBtn = menu.querySelector("#create-submit") as HTMLButtonElement; + const cancelBtn = menu.querySelector("#create-cancel") as HTMLElement; + const status = menu.querySelector("#create-status") as HTMLElement; + if (!toggle || !popout) return; + + const toSlug = (name: string) => + name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40); + + toggle.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + const showing = popout.classList.toggle("hidden"); + if (!showing) nameInput.focus(); + }); + + nameInput.addEventListener("input", () => { + const slug = toSlug(nameInput.value); + slugPreview.textContent = slug ? `slug: ${slug}` : ""; + }); + + nameInput.addEventListener("click", (e) => e.stopPropagation()); + + cancelBtn.addEventListener("click", (e) => { + e.stopPropagation(); + popout.classList.add("hidden"); + nameInput.value = ""; + slugPreview.textContent = ""; + status.textContent = ""; + }); + + submitBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + const name = nameInput.value.trim(); + if (!name) { status.textContent = "Name required"; return; } + + const token = getAccessToken(); + if (!token) { + const identity = document.querySelector("rstack-identity") as any; + if (identity?.showAuthModal) identity.showAuthModal(); + return; + } + + const slug = toSlug(name); + const vis = (menu.querySelector('input[name="create-vis"]:checked') as HTMLInputElement)?.value || "public"; + + submitBtn.disabled = true; + status.textContent = "Creating..."; + + try { + const res = await fetch("/api/spaces", { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ name, slug, visibility: vis }), + }); + const data = await res.json(); + if (res.ok && data.slug) { + window.location.href = rspaceNavUrl(data.slug, "canvas"); + } else { + status.textContent = data.error || "Failed to create space"; + submitBtn.disabled = false; + } + } catch { + status.textContent = "Network error"; + submitBtn.disabled = false; + } + }); + } + async #autoProvision() { const token = getAccessToken(); if (!token) return; @@ -407,10 +505,9 @@ export class RStackSpaceSwitcher extends HTMLElement { @@ -910,10 +1007,58 @@ const STYLES = ` .item--create { font-size: 0.85rem; font-weight: 600; color: #06b6d4 !important; border-left-color: transparent !important; + background: none; border: none; width: 100%; text-align: left; cursor: pointer; + font-family: inherit; } .item--create:hover { background: rgba(6,182,212,0.08) !important; } -/* (you)rSpace CTA */ +/* Create-space popout */ +.create-popout { padding: 8px 12px; } +.create-popout.hidden { display: none; } +.create-input { + width: 100%; padding: 8px 10px; border-radius: 6px; + border: 1px solid var(--rs-border); background: var(--rs-bg-surface-sunken); + color: var(--rs-text-primary); font-size: 0.85rem; outline: none; + box-sizing: border-box; font-family: inherit; +} +.create-input:focus { border-color: #06b6d4; } +.create-slug { + font-size: 0.7rem; color: var(--rs-text-muted); padding: 2px 2px 6px; + min-height: 1em; +} +.create-vis-row { + display: flex; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; +} +.create-vis-opt { + display: flex; align-items: center; gap: 4px; + font-size: 0.78rem; color: var(--rs-text-secondary); cursor: pointer; +} +.create-vis-opt input { margin: 0; } +.vis-dot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; +} +.vis-dot--public { background: #34d399; } +.vis-dot--permissioned { background: #fbbf24; } +.vis-dot--private { background: #f87171; } +.create-actions { display: flex; gap: 8px; } +.create-btn { + padding: 6px 16px; border-radius: 6px; border: none; + font-size: 0.8rem; font-weight: 600; cursor: pointer; + background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; +} +.create-btn:hover { opacity: 0.85; } +.create-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.create-cancel { + padding: 6px 12px; border-radius: 6px; border: none; + font-size: 0.8rem; cursor: pointer; + background: var(--rs-btn-secondary-bg); color: var(--rs-text-secondary); +} +.create-cancel:hover { background: var(--rs-bg-hover); } +.create-status { + font-size: 0.75rem; color: #f87171; margin-top: 4px; min-height: 1em; +} + +/* Personal space CTA */ .item--yourspace { border-left-color: #f87171; padding: 12px 14px; background: var(--rs-bg-hover); diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index 9dbaceb..3d7c1d6 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -110,6 +110,8 @@ export class RStackTabBar extends HTMLElement { #wiringSourceFeedId = ""; #wiringSourceKind: FlowKind | null = null; #escHandler: ((e: KeyboardEvent) => void) | null = null; + // Recent apps: moduleId → last-used timestamp + #recentApps: Map = new Map(); constructor() { super(); @@ -141,6 +143,7 @@ export class RStackTabBar extends HTMLElement { } connectedCallback() { + this.#loadRecentApps(); this.#render(); } @@ -152,6 +155,12 @@ export class RStackTabBar extends HTMLElement { /** Set the layer list (call from outside) */ setLayers(layers: Layer[]) { this.#layers = [...layers].sort((a, b) => a.order - b.order); + // Seed recent apps from existing layers + for (const l of layers) { + if (!this.#recentApps.has(l.moduleId)) { + this.#recentApps.set(l.moduleId, l.createdAt || 0); + } + } this.#render(); } @@ -160,6 +169,36 @@ export class RStackTabBar extends HTMLElement { this.#modules = modules; } + // ── Recent apps persistence ── + + #recentAppsKey(): string { + return `rspace_recent_apps_${this.space || "default"}`; + } + + #loadRecentApps() { + try { + const raw = localStorage.getItem(this.#recentAppsKey()); + if (raw) { + const obj = JSON.parse(raw) as Record; + this.#recentApps = new Map(Object.entries(obj)); + } + } catch { /* ignore */ } + } + + #saveRecentApps() { + try { + const obj: Record = {}; + for (const [k, v] of this.#recentApps) obj[k] = v; + localStorage.setItem(this.#recentAppsKey(), JSON.stringify(obj)); + } catch { /* ignore */ } + } + + /** Record a module as recently used (call from outside or internally) */ + trackRecent(moduleId: string) { + this.#recentApps.set(moduleId, Date.now()); + this.#saveRecentApps(); + } + /** Set the inter-layer flows (for stack view) */ setFlows(flows: LayerFlow[]) { this.#flows = flows; @@ -302,7 +341,23 @@ export class RStackTabBar extends HTMLElement { return `
No rApps available
`; } - // Group by category + let html = ""; + + // ── Recent apps section (top of menu) ── + if (this.#recentApps.size > 0) { + const recentEntries = [...this.#recentApps.entries()] + .sort((a, b) => b[1] - a[1]) // newest first + .slice(0, 6); // show up to 6 recent + const recentModules = recentEntries + .map(([id]) => allModules.find(m => m.id === id)) + .filter((m): m is typeof allModules[0] => !!m); + if (recentModules.length > 0) { + html += `
Recent
`; + html += recentModules.map(m => this.#renderAddMenuItem(m, existingModuleIds.has(m.id))).join(""); + } + } + + // ── Group by category ── const groups = new Map(); const uncategorized: typeof allModules = []; for (const m of allModules) { @@ -315,7 +370,6 @@ export class RStackTabBar extends HTMLElement { } } - let html = ""; for (const cat of CATEGORY_ORDER) { const items = groups.get(cat); if (!items || items.length === 0) continue; @@ -741,6 +795,7 @@ export class RStackTabBar extends HTMLElement { const layerId = tab.dataset.layerId!; const moduleId = tab.dataset.moduleId!; this.active = layerId; + this.trackRecent(moduleId); this.dispatchEvent(new CustomEvent("layer-switch", { detail: { layerId, moduleId }, bubbles: true, @@ -794,21 +849,26 @@ export class RStackTabBar extends HTMLElement { }); }); - // Add button + // Add button — click + touch support const addBtn = this.#shadow.getElementById("add-btn"); - addBtn?.addEventListener("click", (e) => { + const toggleAddMenu = (e: Event) => { e.stopPropagation(); + e.preventDefault(); this.#addMenuOpen = !this.#addMenuOpen; this.#render(); - }); + }; + addBtn?.addEventListener("click", toggleAddMenu); + addBtn?.addEventListener("touchend", toggleAddMenu); - // Add menu items + // Add menu items — click + touch support this.#shadow.querySelectorAll(".add-menu-item").forEach(item => { - item.addEventListener("click", (e) => { + const handleSelect = (e: Event) => { e.stopPropagation(); + e.preventDefault(); const moduleId = item.dataset.addModule!; const isOpen = item.dataset.moduleOpen === "true"; this.#addMenuOpen = false; + this.trackRecent(moduleId); if (isOpen) { // Surface existing tab instead of adding a duplicate @@ -826,17 +886,23 @@ export class RStackTabBar extends HTMLElement { bubbles: true, })); } - }); + }; + item.addEventListener("click", handleSelect); + item.addEventListener("touchend", handleSelect); }); - // Close add menu on outside click + // Close add menu on outside click/touch if (this.#addMenuOpen) { const handler = () => { this.#addMenuOpen = false; this.#render(); document.removeEventListener("click", handler); + document.removeEventListener("touchend", handler); }; - setTimeout(() => document.addEventListener("click", handler), 0); + setTimeout(() => { + document.addEventListener("click", handler); + document.addEventListener("touchend", handler); + }, 0); } // View toggle @@ -1114,9 +1180,9 @@ const STYLES = ` display: flex; align-items: center; gap: 0; - height: 36px; + min-height: 36px; padding: 0 8px; - overflow: hidden; + overflow: visible; } .tabs-scroll { @@ -1126,6 +1192,7 @@ const STYLES = ` flex: 1; min-width: 0; overflow-x: auto; + overflow-y: visible; scrollbar-width: none; position: relative; } @@ -1229,19 +1296,25 @@ const STYLES = ` display: flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; + min-width: 44px; + min-height: 44px; border: 1px dashed rgba(148,163,184,0.3); border-radius: 5px; background: transparent; color: #64748b; - font-size: 0.9rem; + font-size: 1rem; cursor: pointer; transition: border-color 0.15s, color 0.15s, background 0.15s; flex-shrink: 0; margin-left: 4px; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + position: relative; + z-index: 2; } -.tab-add:hover { +.tab-add:hover, .tab-add:active { border-color: #22d3ee; color: #22d3ee; background: rgba(34,211,238,0.08); @@ -1287,7 +1360,8 @@ const STYLES = ` align-items: center; gap: 8px; width: 100%; - padding: 6px 10px; + padding: 8px 10px; + min-height: 40px; border: none; border-radius: 6px; background: transparent; @@ -1296,8 +1370,10 @@ const STYLES = ` cursor: pointer; text-align: left; transition: background 0.12s; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; } -.add-menu-item:hover { background: var(--rs-bg-hover); } +.add-menu-item:hover, .add-menu-item:active { background: var(--rs-bg-hover); } .add-menu-badge { display: inline-flex;