From 3232ced90f00e93797b479512bf8c8c803b378f5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 18:27:51 -0800 Subject: [PATCH] feat: add rApp and Space switcher dropdowns to all page headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces per-page inline navbars with a shared Header component that includes AppSwitcher (18 rApps grouped by category) and SpaceSwitcher (fetches user spaces from /api/spaces) dropdowns β€” matching the rspace.online header pattern. All 9 pages updated. Co-Authored-By: Claude Opus 4.6 --- src/app/api/spaces/route.ts | 34 +++++++ src/app/auth/signin/page.tsx | 13 +-- src/app/demo/demo-content.tsx | 40 ++------ src/app/notebooks/[id]/page.tsx | 30 +++--- src/app/notebooks/new/page.tsx | 17 +--- src/app/notebooks/page.tsx | 26 ++---- src/app/notes/[id]/page.tsx | 40 +++----- src/app/notes/new/page.tsx | 16 +--- src/app/page.tsx | 22 ++--- src/app/voice/page.tsx | 56 ++++++----- src/components/AppSwitcher.tsx | 156 +++++++++++++++++++++++++++++++ src/components/Header.tsx | 59 ++++++++++++ src/components/SpaceSwitcher.tsx | 154 ++++++++++++++++++++++++++++++ 13 files changed, 490 insertions(+), 173 deletions(-) create mode 100644 src/app/api/spaces/route.ts create mode 100644 src/components/AppSwitcher.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/SpaceSwitcher.tsx diff --git a/src/app/api/spaces/route.ts b/src/app/api/spaces/route.ts new file mode 100644 index 0000000..aff72df --- /dev/null +++ b/src/app/api/spaces/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; + +/** + * GET /api/spaces β€” List spaces available to the current user. + * Proxies to rSpace API when available, otherwise returns empty list. + */ +export async function GET(request: Request) { + const rspaceUrl = process.env.RSPACE_INTERNAL_URL || process.env.NEXT_PUBLIC_RSPACE_URL; + + if (!rspaceUrl) { + return NextResponse.json({ spaces: [] }); + } + + try { + // Forward auth header to rSpace + const authHeader = request.headers.get('Authorization'); + const headers: Record = {}; + if (authHeader) headers['Authorization'] = authHeader; + + const res = await fetch(`${rspaceUrl}/api/spaces`, { + headers, + next: { revalidate: 60 }, + }); + + if (res.ok) { + const data = await res.json(); + return NextResponse.json(data); + } + } catch { + // rSpace not reachable + } + + return NextResponse.json({ spaces: [] }); +} diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 7b4c172..9ddbd9e 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -2,8 +2,8 @@ import { useState, useEffect, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; import { useEncryptID } from '@encryptid/sdk/ui/react'; +import { Header } from '@/components/Header'; function SignInForm() { const router = useRouter(); @@ -68,16 +68,7 @@ function SignInForm() { return (
- +
diff --git a/src/app/demo/demo-content.tsx b/src/app/demo/demo-content.tsx index a57a44e..303dd54 100644 --- a/src/app/demo/demo-content.tsx +++ b/src/app/demo/demo-content.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { useState, useMemo, useCallback } from 'react' import { useDemoSync, type DemoShape } from '@/lib/demo-sync' import { TranscriptionDemo } from '@/components/TranscriptionDemo' +import { Header } from '@/components/Header' /* --- Types -------------------------------------------------------------- */ @@ -587,21 +588,11 @@ export default function DemoContent() { return (
- {/* Nav */} - + + } + /> {/* Hero Section */}
diff --git a/src/app/notebooks/[id]/page.tsx b/src/app/notebooks/[id]/page.tsx index acd4b68..62658d5 100644 --- a/src/app/notebooks/[id]/page.tsx +++ b/src/app/notebooks/[id]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { NoteCard } from '@/components/NoteCard'; import { CanvasEmbed } from '@/components/CanvasEmbed'; -import { UserMenu } from '@/components/UserMenu'; +import { Header } from '@/components/Header'; import { authFetch } from '@/lib/authFetch'; import type { CanvasShapeMessage } from '@/lib/canvas-sync'; @@ -116,20 +116,13 @@ export default function NotebookDetailPage() { return (
- + + } + />
{/* Notes panel */} diff --git a/src/app/notebooks/new/page.tsx b/src/app/notebooks/new/page.tsx index 43d4318..1a6a65d 100644 --- a/src/app/notebooks/new/page.tsx +++ b/src/app/notebooks/new/page.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { UserMenu } from '@/components/UserMenu'; +import { Header } from '@/components/Header'; import { authFetch } from '@/lib/authFetch'; const COVER_COLORS = [ @@ -42,20 +42,7 @@ export default function NewNotebookPage() { return (
- +

Create Notebook

diff --git a/src/app/notebooks/page.tsx b/src/app/notebooks/page.tsx index 3e6380d..2ff150c 100644 --- a/src/app/notebooks/page.tsx +++ b/src/app/notebooks/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { NotebookCard } from '@/components/NotebookCard'; import { SearchBar } from '@/components/SearchBar'; -import { UserMenu } from '@/components/UserMenu'; +import { Header } from '@/components/Header'; interface NotebookData { id: string; @@ -29,19 +29,10 @@ export default function NotebooksPage() { return (
- + + } + /> {/* Mobile search */}
diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index 24097f2..3d5d870 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -2,10 +2,9 @@ import { useEffect, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import Link from 'next/link'; import { NoteEditor } from '@/components/NoteEditor'; import { TagBadge } from '@/components/TagBadge'; -import { UserMenu } from '@/components/UserMenu'; +import { Header } from '@/components/Header'; import { authFetch } from '@/lib/authFetch'; const TYPE_COLORS: Record = { @@ -183,26 +182,16 @@ export default function NoteDetailPage() { return (
- + + } + />
{/* Metadata */} diff --git a/src/app/notes/new/page.tsx b/src/app/notes/new/page.tsx index 05c1c75..3a13cb6 100644 --- a/src/app/notes/new/page.tsx +++ b/src/app/notes/new/page.tsx @@ -2,11 +2,10 @@ import { Suspense, useState, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; import { NoteEditor } from '@/components/NoteEditor'; import { FileUpload } from '@/components/FileUpload'; import { VoiceRecorder } from '@/components/VoiceRecorder'; -import { UserMenu } from '@/components/UserMenu'; +import { Header } from '@/components/Header'; import { authFetch } from '@/lib/authFetch'; const NOTE_TYPES = [ @@ -113,18 +112,7 @@ function NewNoteForm() { return (
- +

Create Note

diff --git a/src/app/page.tsx b/src/app/page.tsx index 84fb67c..c566531 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { NotebookCard } from '@/components/NotebookCard'; import { SearchBar } from '@/components/SearchBar'; -import { UserMenu } from '@/components/UserMenu'; +import { Header } from '@/components/Header'; import { TranscriptionDemo } from '@/components/TranscriptionDemo'; interface NotebookData { @@ -30,16 +30,9 @@ export default function HomePage() { return (
- {/* Nav */} - + + } + /> {/* Mobile search */}
diff --git a/src/app/voice/page.tsx b/src/app/voice/page.tsx index 65f9260..bed4aae 100644 --- a/src/app/voice/page.tsx +++ b/src/app/voice/page.tsx @@ -2,6 +2,9 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import { AppSwitcher } from '@/components/AppSwitcher'; +import { SpaceSwitcher } from '@/components/SpaceSwitcher'; +import { UserMenu } from '@/components/UserMenu'; import { authFetch } from '@/lib/authFetch'; // --- Types --- @@ -509,33 +512,38 @@ export default function VoicePage() { return (
{/* Header */} -
-
-
- - - - +
+
+
+ + + / +
+
+ + + + +
+ rVoice +
-
-

rVoice

-

Voice notes for rNotes

+
+ {streaming && ( + + + Live + + )} + {getSpeechRecognition() && state === 'recording' && !streaming && ( + + + Local + + )} +
-
- {streaming && ( - - - Live - - )} - {getSpeechRecognition() && state === 'recording' && !streaming && ( - - - Local - - )} -
{/* Main content */} diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx new file mode 100644 index 0000000..e6b148a --- /dev/null +++ b/src/components/AppSwitcher.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; + +export interface AppModule { + id: string; + name: string; + icon: string; + description: string; + domain?: string; +} + +const MODULES: AppModule[] = [ + // Creating + { id: 'canvas', name: 'Canvas', icon: '🎨', description: 'Collaborative workspace', domain: 'rspace.online' }, + { id: 'notes', name: 'Notes', icon: 'πŸ“', description: 'Rich note-taking', domain: 'rnotes.online' }, + { id: 'pubs', name: 'Pubs', icon: 'πŸ“°', description: 'Publishing platform', domain: 'rpubs.online' }, + // Planning + { id: 'cal', name: 'Calendar', icon: 'πŸ“…', description: 'Scheduling & events', domain: 'rcal.online' }, + { id: 'trips', name: 'Trips', icon: '✈️', description: 'Travel planning', domain: 'rtrips.online' }, + { id: 'stack', name: 'Stack', icon: 'πŸ“‹', description: 'Task management', domain: 'rstack.online' }, + // Discussing & Deciding + { id: 'inbox', name: 'Inbox', icon: 'πŸ“¬', description: 'Messaging & email', domain: 'rinbox.online' }, + { id: 'choices', name: 'Choices', icon: 'πŸ”€', description: 'Decision making', domain: 'rchoices.online' }, + { id: 'vote', name: 'Vote', icon: 'πŸ—³οΈ', description: 'Polls & voting', domain: 'rvote.online' }, + // Funding & Commerce + { id: 'funds', name: 'Funds', icon: 'πŸ’°', description: 'Fundraising', domain: 'rfunds.online' }, + { id: 'wallet', name: 'Wallet', icon: 'πŸ‘›', description: 'Crypto wallet', domain: 'rwallet.online' }, + { id: 'cart', name: 'Cart', icon: 'πŸ›’', description: 'Shopping & commerce', domain: 'rcart.online' }, + { id: 'auctions', name: 'Auctions', icon: 'πŸ”¨', description: 'Auction platform', domain: 'rauctions.online' }, + // Sharing & Media + { id: 'files', name: 'Files', icon: 'πŸ“', description: 'File storage', domain: 'rfiles.online' }, + { id: 'tube', name: 'Tube', icon: '🎬', description: 'Video platform', domain: 'rtube.online' }, + { id: 'data', name: 'Data', icon: 'πŸ“Š', description: 'Analytics', domain: 'rdata.online' }, + { id: 'maps', name: 'Maps', icon: 'πŸ—ΊοΈ', description: 'Mapping tool', domain: 'rmaps.online' }, + { id: 'network', name: 'Network', icon: '🌐', description: 'Social network', domain: 'rnetwork.online' }, +]; + +const MODULE_CATEGORIES: Record = { + canvas: 'Creating', + notes: 'Creating', + pubs: 'Creating', + cal: 'Planning', + trips: 'Planning', + stack: 'Planning', + inbox: 'Discussing & Deciding', + choices: 'Discussing & Deciding', + vote: 'Discussing & Deciding', + funds: 'Funding & Commerce', + wallet: 'Funding & Commerce', + cart: 'Funding & Commerce', + auctions: 'Funding & Commerce', + files: 'Sharing & Media', + tube: 'Sharing & Media', + data: 'Sharing & Media', + maps: 'Sharing & Media', + network: 'Sharing & Media', +}; + +const CATEGORY_ORDER = [ + 'Creating', + 'Planning', + 'Discussing & Deciding', + 'Funding & Commerce', + 'Sharing & Media', +]; + +interface AppSwitcherProps { + current?: string; +} + +export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + }, []); + + const currentMod = MODULES.find((m) => m.id === current); + const label = currentMod ? `${currentMod.icon} ${currentMod.name}` : 'πŸ“ rNotes'; + + // Group modules by category + const groups = new Map(); + for (const m of MODULES) { + const cat = MODULE_CATEGORIES[m.id] || 'Other'; + if (!groups.has(cat)) groups.set(cat, []); + groups.get(cat)!.push(m); + } + + return ( +
+ + + {open && ( +
+ {CATEGORY_ORDER.map((cat) => { + const items = groups.get(cat); + if (!items || items.length === 0) return null; + return ( +
+
+ {cat} +
+ {items.map((m) => ( + + ))} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..eac671c --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,59 @@ +'use client'; + +import Link from 'next/link'; +import { AppSwitcher } from './AppSwitcher'; +import { SpaceSwitcher } from './SpaceSwitcher'; +import { UserMenu } from './UserMenu'; + +export interface BreadcrumbItem { + label: string; + href?: string; +} + +interface HeaderProps { + /** Breadcrumb trail after the switchers (e.g. [{label: 'Notebooks', href: '/notebooks'}, {label: 'My Notebook'}]) */ + breadcrumbs?: BreadcrumbItem[]; + /** Right-side actions (rendered between breadcrumbs and UserMenu) */ + actions?: React.ReactNode; + /** Max width class for the inner container */ + maxWidth?: string; +} + +export function Header({ breadcrumbs, actions, maxWidth = 'max-w-6xl' }: HeaderProps) { + return ( + + ); +} diff --git a/src/components/SpaceSwitcher.tsx b/src/components/SpaceSwitcher.tsx new file mode 100644 index 0000000..a9362a5 --- /dev/null +++ b/src/components/SpaceSwitcher.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { useEncryptID } from '@encryptid/sdk/ui/react'; + +interface SpaceInfo { + slug: string; + name: string; + icon?: string; + role?: string; +} + +interface SpaceSwitcherProps { + current?: string; + name?: string; +} + +export function SpaceSwitcher({ current = 'personal', name }: SpaceSwitcherProps) { + const [open, setOpen] = useState(false); + const [spaces, setSpaces] = useState([]); + const [loaded, setLoaded] = useState(false); + const ref = useRef(null); + const { isAuthenticated } = useEncryptID(); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + }, []); + + const loadSpaces = async () => { + if (loaded) return; + try { + const res = await fetch('/api/spaces'); + if (res.ok) { + const data = await res.json(); + setSpaces(data.spaces || []); + } + } catch { + // API not available β€” show empty + } + setLoaded(true); + }; + + const handleOpen = async () => { + const nowOpen = !open; + setOpen(nowOpen); + if (nowOpen && !loaded) { + await loadSpaces(); + } + }; + + const displayName = name || current; + + const mySpaces = spaces.filter((s) => s.role); + const publicSpaces = spaces.filter((s) => !s.role); + + return ( +
+ + + {open && ( +
+ {!loaded ? ( +
Loading spaces...
+ ) : spaces.length === 0 ? ( + <> +
+ {isAuthenticated ? 'No spaces yet' : 'Sign in to see your spaces'} +
+ setOpen(false)} + > + + Create new space + + + ) : ( + <> + {mySpaces.length > 0 && ( + <> +
+ Your spaces +
+ {mySpaces.map((s) => ( + setOpen(false)} + > + {s.icon || '🌐'} + {s.name} + {s.role && ( + + {s.role} + + )} + + ))} + + )} + + {publicSpaces.length > 0 && ( + <> + {mySpaces.length > 0 &&
} +
+ Public spaces +
+ {publicSpaces.map((s) => ( + setOpen(false)} + > + {s.icon || '🌐'} + {s.name} + + ))} + + )} + + + )} +
+ ); +}