From c788dfcb8b002dca44f64dba1f1c14d8de59ed04 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 21 Mar 2026 21:19:50 +0000 Subject: [PATCH] feat: update AppSwitcher and SpaceSwitcher with new modules and session handling AppSwitcher: add rEvents and rBooks modules, read username from EncryptID localStorage session with /api/me fallback. SpaceSwitcher: add personal space entry for logged-in users, show current space from cookie, highlight active space, handle both array and object API response formats. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/AppSwitcher.tsx | 44 +++++++++--- src/components/SpaceSwitcher.tsx | 113 ++++++++++++++++++++++++++----- 2 files changed, 130 insertions(+), 27 deletions(-) diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 24f2dcc..3bc9495 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -21,6 +21,7 @@ const MODULES: AppModule[] = [ { id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-red-200', emoji: 'πŸ‘•', description: 'Community merch & swag store', domain: 'rswag.online' }, // Planning { id: 'cal', name: 'rCal', badge: 'rC', color: 'bg-sky-300', emoji: 'πŸ“…', description: 'Collaborative scheduling & events', domain: 'rcal.online' }, + { id: 'events', name: 'rEvents', badge: 'rEv', color: 'bg-violet-200', emoji: 'πŸŽͺ', description: 'Event aggregation & discovery', domain: 'revents.online' }, { id: 'trips', name: 'rTrips', badge: 'rT', color: 'bg-emerald-300', emoji: '✈️', description: 'Group travel planning in real time', domain: 'rtrips.online' }, { id: 'maps', name: 'rMaps', badge: 'rM', color: 'bg-green-300', emoji: 'πŸ—ΊοΈ', description: 'Collaborative real-time mapping', domain: 'rmaps.online' }, // Communicating @@ -43,6 +44,8 @@ const MODULES: AppModule[] = [ { id: 'socials', name: 'rSocials', badge: 'rSo', color: 'bg-sky-200', emoji: 'πŸ“’', description: 'Social media management', domain: 'rsocials.online' }, // Observing { id: 'data', name: 'rData', badge: 'rD', color: 'bg-purple-300', emoji: 'πŸ“Š', description: 'Analytics & insights dashboard', domain: 'rdata.online' }, + // Learning + { id: 'books', name: 'rBooks', badge: 'rB', color: 'bg-amber-200', emoji: 'πŸ“š', description: 'Collaborative library', domain: 'rbooks.online' }, // Work & Productivity { id: 'work', name: 'rWork', badge: 'rWo', color: 'bg-slate-300', emoji: 'πŸ“‹', description: 'Project & task management', domain: 'rwork.online' }, // Identity & Infrastructure @@ -57,6 +60,7 @@ const MODULE_CATEGORIES: Record = { tube: 'Creating', swag: 'Creating', cal: 'Planning', + events: 'Planning', trips: 'Planning', maps: 'Planning', chats: 'Communicating', @@ -74,6 +78,7 @@ const MODULE_CATEGORIES: Record = { files: 'Sharing', socials: 'Sharing', data: 'Observing', + books: 'Learning', work: 'Work & Productivity', ids: 'Identity & Infrastructure', stack: 'Identity & Infrastructure', @@ -87,10 +92,25 @@ const CATEGORY_ORDER = [ 'Funding & Commerce', 'Sharing', 'Observing', + 'Learning', 'Work & Productivity', 'Identity & Infrastructure', ]; +/** Read the username from the EncryptID session in localStorage */ +function getSessionUsername(): string | null { + if (typeof window === 'undefined') return null; + try { + const stored = localStorage.getItem('encryptid_session'); + if (!stored) return null; + const parsed = JSON.parse(stored); + const claims = parsed?.claims || parsed; + return claims?.eid?.username || claims?.username || null; + } catch { + return null; + } +} + /** Build the URL for a module, using username subdomain if logged in */ function getModuleUrl(m: AppModule, username: string | null): string { if (!m.domain) return '#'; @@ -120,16 +140,22 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) { return () => document.removeEventListener('click', handleClick); }, []); - // Fetch current user's username for subdomain links + // Read username from EncryptID session in localStorage useEffect(() => { - fetch('/api/me') - .then((r) => r.json()) - .then((data) => { - if (data.authenticated && data.user?.username) { - setUsername(data.user.username); - } - }) - .catch(() => { /* not logged in */ }); + const sessionUsername = getSessionUsername(); + if (sessionUsername) { + setUsername(sessionUsername); + } else { + // Fallback: check /api/me + fetch('/api/me') + .then((r) => r.json()) + .then((data) => { + if (data.authenticated && data.user?.username) { + setUsername(data.user.username); + } + }) + .catch(() => {}); + } }, []); const currentMod = MODULES.find((m) => m.id === current); diff --git a/src/components/SpaceSwitcher.tsx b/src/components/SpaceSwitcher.tsx index b23e813..dcc1581 100644 --- a/src/components/SpaceSwitcher.tsx +++ b/src/components/SpaceSwitcher.tsx @@ -10,7 +10,7 @@ interface SpaceInfo { } interface SpaceSwitcherProps { - /** Current app domain, e.g. 'rchats.online'. Space links become . */ + /** Current app domain, e.g. 'rcal.online'. Space links become . */ domain?: string; } @@ -24,11 +24,33 @@ function getEncryptIDToken(): string | null { } } +/** Read the username from the EncryptID session in localStorage */ +function getSessionUsername(): string | null { + if (typeof window === 'undefined') return null; + try { + const stored = localStorage.getItem('encryptid_session'); + if (!stored) return null; + const parsed = JSON.parse(stored); + const claims = parsed?.claims || parsed; + return claims?.eid?.username || claims?.username || null; + } catch { + return null; + } +} + +/** Read the current space_id from the cookie set by middleware */ +function getCurrentSpaceId(): string { + if (typeof document === 'undefined') return 'default'; + const match = document.cookie.match(/(?:^|;\s*)space_id=([^;]*)/); + return match ? decodeURIComponent(match[1]) : 'default'; +} + export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { const [open, setOpen] = useState(false); const [spaces, setSpaces] = useState([]); const [loaded, setLoaded] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false); + const [username, setUsername] = useState(null); const ref = useRef(null); // Derive domain from window.location if not provided @@ -36,6 +58,8 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { ? window.location.hostname.split('.').slice(-2).join('.') : 'rspace.online'); + const currentSpaceId = getCurrentSpaceId(); + useEffect(() => { function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { @@ -49,14 +73,21 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { // Check auth status on mount useEffect(() => { const token = getEncryptIDToken(); + const sessionUsername = getSessionUsername(); if (token) { setIsAuthenticated(true); + if (sessionUsername) { + setUsername(sessionUsername); + } } else { // Fallback: check /api/me fetch('/api/me') .then((r) => r.json()) .then((data) => { - if (data.authenticated) setIsAuthenticated(true); + if (data.authenticated) { + setIsAuthenticated(true); + if (data.user?.username) setUsername(data.user.username); + } }) .catch(() => {}); } @@ -73,7 +104,15 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { const res = await fetch('/api/spaces', { headers }); if (res.ok) { const data = await res.json(); - setSpaces(data.spaces || []); + // Handle both flat array and { spaces: [...] } response formats + const raw: Array<{ id?: string; slug?: string; name: string; icon?: string; role?: string }> = + Array.isArray(data) ? data : (data.spaces || []); + setSpaces(raw.map((s) => ({ + slug: s.slug || s.id || '', + name: s.name, + icon: s.icon, + role: s.role, + }))); } } catch { // API not available @@ -92,8 +131,24 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { /** Build URL for a space: . */ const spaceUrl = (slug: string) => `https://${slug}.${appDomain}`; - const mySpaces = spaces.filter((s) => s.role); - const publicSpaces = spaces.filter((s) => !s.role); + // Build personal space entry for logged-in user + const personalSpace: SpaceInfo | null = + isAuthenticated && username + ? { slug: username, name: 'Personal', icon: 'πŸ‘€', role: 'owner' } + : null; + + // Deduplicate: remove personal space from fetched list if it already appears + const dedupedSpaces = personalSpace + ? spaces.filter((s) => s.slug !== personalSpace.slug) + : spaces; + + const mySpaces = dedupedSpaces.filter((s) => s.role); + const publicSpaces = dedupedSpaces.filter((s) => !s.role); + + // Determine what to show in the button + const currentLabel = currentSpaceId === 'default' + ? (personalSpace ? 'personal' : 'public') + : currentSpaceId; return (
@@ -102,7 +157,7 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium text-slate-400 hover:bg-white/[0.05] transition-colors" > / - personal + {currentLabel} @@ -110,23 +165,40 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) {
{!loaded ? (
Loading spaces...
- ) : spaces.length === 0 ? ( + ) : !isAuthenticated && spaces.length === 0 ? ( <>
- {isAuthenticated ? 'No spaces yet' : 'Sign in to see your spaces'} + Sign in to see your spaces
- setOpen(false)} - > - + Create new space - ) : ( <> + {/* Personal space β€” always first when logged in */} + {personalSpace && ( + <> +
+ Personal +
+ setOpen(false)} + > + {personalSpace.icon} + {username} + + owner + + + + )} + + {/* Other spaces the user belongs to */} {mySpaces.length > 0 && ( <> + {personalSpace &&