feat: standardize AppSwitcher and EcosystemFooter across all rApps

- Update AppSwitcher with all 26 r*Apps in 8 categories
- Add EcosystemFooter component with consistent ecosystem links
- Categories: Creating, Planning, Communicating, Deciding,
  Funding & Commerce, Social & Media, Work & Productivity,
  Identity & Infrastructure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 22:48:11 -08:00
parent ee6d57a245
commit 068d9a9368
9 changed files with 484 additions and 223 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

View File

@ -1,31 +0,0 @@
name: "Fungi Flows Logo Tee"
slug: fungi-logo-tee
description: "The original Fungi Flows logo on a premium tee. Gold and bioluminescent green on deep purple — rep the mycelium movement. Revenue supports the Fungi Flows community."
tags: [fungiflows, mushroom, logo, hip-hop, pittsburgh, tee, community]
space: all
category: shirts
created: "2026-02-18"
author: fungi-flows
source:
file: fungi-logo-tee.png
format: png
dimensions:
width: 3600
height: 4800
dpi: 300
color_profile: sRGB
products:
- type: shirt
provider: printful
sku: "71"
variants: [S, M, L, XL, 2XL]
retail_price: 29.99
- type: hoodie
provider: printful
sku: "146"
variants: [S, M, L, XL, 2XL]
retail_price: 49.99
status: active

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,26 +0,0 @@
name: "Spore Print Sticker"
slug: fungi-spore
description: "Bioluminescent mushroom spore print design. Neon green on deep purple — the signature Fungi Flows aesthetic. Revenue supports the Fungi Flows community."
tags: [fungiflows, mushroom, spore, psychedelic, sticker, community]
space: all
category: stickers
created: "2026-02-18"
author: fungi-flows
source:
file: fungi-spore.png
format: png
dimensions:
width: 1200
height: 1200
dpi: 300
color_profile: sRGB
products:
- type: sticker
provider: prodigi
sku: GLOBAL-STI-KIS-4X4
variants: [matte, gloss]
retail_price: 4.99
status: active

View File

@ -5,6 +5,7 @@ import "./globals.css";
import type { SpaceConfig } from "@/lib/spaces";
import { themeToCSS } from "@/lib/spaces";
import { HeaderBar } from "@/components/HeaderBar";
import { EcosystemFooter } from "@/components/EcosystemFooter";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
@ -31,26 +32,6 @@ export async function generateMetadata(): Promise<Metadata> {
};
}
const ECOSYSTEM_LINKS = [
{ name: "rSpace", href: "https://rspace.online" },
{ name: "rSwag", href: "https://rswag.online", active: true },
{ name: "rWork", href: "https://rwork.online" },
{ name: "rMaps", href: "https://rmaps.online" },
{ name: "rNotes", href: "https://rnotes.online" },
{ name: "rVote", href: "https://rvote.online" },
{ name: "rFunds", href: "https://rfunds.online" },
{ name: "rTrips", href: "https://rtrips.online" },
{ name: "rCart", href: "https://rcart.online" },
{ name: "rWallet", href: "https://rwallet.online" },
{ name: "rFiles", href: "https://rfiles.online" },
{ name: "rTube", href: "https://rtube.online" },
{ name: "rCal", href: "https://rcal.online" },
{ name: "rNetwork", href: "https://rnetwork.online" },
{ name: "rInbox", href: "https://rinbox.online" },
{ name: "rStack", href: "https://rstack.online" },
{ name: "rAuctions", href: "https://rauctions.online" },
{ name: "rPubs", href: "https://rpubs.online" },
];
export default async function RootLayout({
children,
@ -84,31 +65,7 @@ export default async function RootLayout({
<main className="flex-1">{children}</main>
{/* ── Ecosystem Footer ────────────────────────────── */}
<footer className="border-t py-8 mt-8">
<div className="max-w-5xl mx-auto px-4">
<div className="flex flex-wrap items-center justify-center gap-3 text-sm text-muted-foreground mb-4">
<span className="font-medium text-foreground/70">
r* Ecosystem
</span>
{ECOSYSTEM_LINKS.map((link) => (
<a
key={link.name}
href={link.href}
className={`hover:text-foreground transition-colors ${
link.active
? "font-medium text-primary"
: ""
}`}
>
{link.name}
</a>
))}
</div>
<p className="text-center text-xs text-muted-foreground/60">
Part of the r* ecosystem collaborative tools for communities.
</p>
</div>
</footer>
<EcosystemFooter current="rSwag" />
</div>
</body>
</html>

View File

@ -1,6 +1,7 @@
import Link from "next/link";
import { cookies } from "next/headers";
import type { SpaceConfig } from "@/lib/spaces";
import { RevenueFlowSankey } from "@/components/RevenueFlowSankey";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
@ -248,107 +249,43 @@ export default async function HomePage() {
</div>
</section>
{/* ── Community Revenue Model ───────────────────────────── */}
{/* ── Community Revenue Model — Interactive Sankey ─────── */}
<section className="py-16 bg-muted/30">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-2xl sm:text-3xl font-bold mb-4">
<div className="text-center mb-10">
<span className="inline-flex items-center rounded-full text-sm px-4 py-1.5 bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-medium">
Revenue Model
</span>
<h2 className="mt-3 text-2xl sm:text-3xl font-bold">
Merch That Funds Your Mission
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
<p className="mt-2 text-lg text-muted-foreground max-w-2xl mx-auto">
Every purchase feeds revenue directly into your community&apos;s
funding streams. No middlemen, no platform fees eating your margins.
funding streams. Drag the sliders to explore how the money flows.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
{/* Revenue Flow */}
<div className="border border-primary/20 rounded-xl p-6 bg-card">
<div className="flex items-center gap-3 mb-4">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 flex items-center justify-center shrink-0">
<svg
className="h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941"
/>
</svg>
</div>
<h3 className="font-bold text-lg">Direct Revenue Stream</h3>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
Set your own margins. Printful handles production at cost, and
the markup goes straight into your community&apos;s treasury, DAO,
or project fund.
</p>
<div className="bg-muted/50 rounded-lg p-4 text-sm space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Production cost</span>
<span>$9.25</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Your price</span>
<span>$29.99</span>
</div>
<div className="border-t border-border pt-2 flex justify-between font-bold text-primary">
<span>Community revenue</span>
<span>$20.74 per sale</span>
</div>
</div>
</div>
<div className="border border-primary/20 rounded-xl p-6 sm:p-8 bg-card">
<RevenueFlowSankey />
</div>
{/* Community Benefits */}
<div className="border border-primary/20 rounded-xl p-6 bg-card">
<div className="flex items-center gap-3 mb-4">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center shrink-0">
<svg
className="h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
</div>
<h3 className="font-bold text-lg">Built for Communities</h3>
</div>
<ul className="text-sm text-muted-foreground space-y-3">
<li className="flex items-start gap-2">
<svg className="h-5 w-5 text-primary shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span><strong className="text-foreground">Branded Spaces</strong> each community gets their own storefront with custom theme, logo, and catalog</span>
</li>
<li className="flex items-start gap-2">
<svg className="h-5 w-5 text-primary shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span><strong className="text-foreground">Zero inventory risk</strong> items printed on demand, no upfront costs for your community</span>
</li>
<li className="flex items-start gap-2">
<svg className="h-5 w-5 text-primary shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span><strong className="text-foreground">Revenue routing</strong> connect to rFunds, DAOs, or any wallet to stream merch revenue directly into community funding</span>
</li>
<li className="flex items-start gap-2">
<svg className="h-5 w-5 text-primary shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span><strong className="text-foreground">Local production</strong> Printful fulfills from the nearest center, reducing shipping distance and carbon footprint</span>
</li>
</ul>
{/* Key benefits row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
<div className="text-2xl font-bold text-primary">$0</div>
<div className="text-xs text-muted-foreground mt-1">Platform Fees</div>
</div>
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
<div className="text-2xl font-bold text-emerald-400">100%</div>
<div className="text-xs text-muted-foreground mt-1">Margin to Community</div>
</div>
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
<div className="text-2xl font-bold text-purple-400">You</div>
<div className="text-xs text-muted-foreground mt-1">Set the Splits</div>
</div>
<div className="text-center p-4 rounded-lg border border-primary/10 bg-card/50">
<div className="text-2xl font-bold text-blue-400">0</div>
<div className="text-xs text-muted-foreground mt-1">Inventory Risk</div>
</div>
</div>
</div>

View File

@ -2,12 +2,12 @@
import { useState, useRef, useEffect } from 'react';
interface AppModule {
export interface AppModule {
id: string;
name: string;
badge: string;
color: string;
emoji: string;
badge: string; // favicon-style abbreviation: rS, rN, rP, etc.
color: string; // Tailwind bg class for the pastel badge
emoji: string; // function emoji shown right of title
description: string;
domain?: string;
}
@ -16,27 +16,37 @@ const MODULES: AppModule[] = [
// Creating
{ id: 'space', name: 'rSpace', badge: 'rS', color: 'bg-teal-300', emoji: '🎨', description: 'Real-time collaborative canvas', domain: 'rspace.online' },
{ id: 'notes', name: 'rNotes', badge: 'rN', color: 'bg-amber-300', emoji: '📝', description: 'Group note-taking & knowledge capture', domain: 'rnotes.online' },
{ id: 'pubs', name: 'rPubs', badge: 'rP', color: 'bg-rose-300', emoji: '📰', description: 'Collaborative publishing platform', domain: 'rpubs.online' },
{ id: 'pubs', name: 'rPubs', badge: 'rP', color: 'bg-rose-300', emoji: '📰', description: 'Collaborative publishing platform', domain: 'rpubs.online' },
// Planning
{ id: 'cal', name: 'rCal', badge: 'rC', color: 'bg-sky-300', emoji: '📅', description: 'Collaborative scheduling & events', domain: 'rcal.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' },
// Discussing & Deciding
{ id: 'inbox', name: 'rInbox', badge: 'rI', color: 'bg-indigo-300', emoji: '📬', description: 'Private group messaging', domain: 'rinbox.online' },
{ id: 'choices', name: 'rChoices', badge: 'rCh', color: 'bg-fuchsia-300', emoji: '🔀', description: 'Collaborative decision making', domain: 'rchoices.online' },
// Communicating
{ id: 'chats', name: 'rChats', badge: 'rCh', color: 'bg-emerald-200', emoji: '💬', description: 'Real-time encrypted messaging', domain: 'rchats.online' },
{ id: 'inbox', name: 'rInbox', badge: 'rI', color: 'bg-indigo-300', emoji: '📬', description: 'Private group messaging', domain: 'rinbox.online' },
{ id: 'mail', name: 'rMail', badge: 'rMa', color: 'bg-blue-200', emoji: '✉️', description: 'Community email & newsletters', domain: 'rmail.online' },
{ id: 'forum', name: 'rForum', badge: 'rFo', color: 'bg-amber-200', emoji: '💭', description: 'Threaded community discussions', domain: 'rforum.online' },
// Deciding
{ id: 'choices', name: 'rChoices', badge: 'rCo', color: 'bg-fuchsia-300', emoji: '🔀', description: 'Collaborative decision making', domain: 'rchoices.online' },
{ id: 'vote', name: 'rVote', badge: 'rV', color: 'bg-violet-300', emoji: '🗳️', description: 'Real-time polls & governance', domain: 'rvote.online' },
// Funding & Commerce
{ id: 'funds', name: 'rFunds', badge: 'rF', color: 'bg-lime-300', emoji: '💸', description: 'Collaborative fundraising & grants', domain: 'rfunds.online' },
{ id: 'wallet', name: 'rWallet', badge: 'rW', color: 'bg-yellow-300', emoji: '💰', description: 'Multi-chain crypto wallet', domain: 'rwallet.online' },
{ id: 'wallet', name: 'rWallet', badge: 'rW', color: 'bg-yellow-300', emoji: '💰', description: 'Multi-chain crypto wallet', domain: 'rwallet.online' },
{ id: 'cart', name: 'rCart', badge: 'rCt', color: 'bg-orange-300', emoji: '🛒', description: 'Group commerce & shared shopping', domain: 'rcart.online' },
{ id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-cyan-300', emoji: '👕', description: 'Community merch & print-on-demand', domain: 'rswag.online' },
{ id: 'auctions', name: 'rAuctions', badge: 'rA', color: 'bg-red-300', emoji: '🔨', description: 'Live auction platform', domain: 'rauctions.online' },
// Social & Sharing
{ id: 'network', name: 'rNetwork', badge: 'rNe', color: 'bg-blue-300', emoji: '🌐', description: 'Community network & social graph', domain: 'rnetwork.online' },
{ id: 'files', name: 'rFiles', badge: 'rFi', color: 'bg-cyan-300', emoji: '📁', description: 'Collaborative file storage', domain: 'rfiles.online' },
{ id: 'auctions', name: 'rAuctions', badge: 'rA', color: 'bg-red-300', emoji: '🔨', description: 'Live auction platform', domain: 'rauctions.online' },
{ id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-red-200', emoji: '👕', description: 'Community merch & swag store', domain: 'rswag.online' },
// Social & Media
{ id: 'photos', name: 'rPhotos', badge: 'rPh', color: 'bg-pink-200', emoji: '📸', description: 'Shared community photo albums', domain: 'rphotos.online' },
{ id: 'tube', name: 'rTube', badge: 'rTu', color: 'bg-pink-300', emoji: '🎬', description: 'Group video platform', domain: 'rtube.online' },
{ id: 'network', name: 'rNetwork', badge: 'rNe', color: 'bg-blue-300', emoji: '🌐', description: 'Community network & social graph', domain: 'rnetwork.online' },
{ id: 'socials', name: 'rSocials', badge: 'rSo', color: 'bg-sky-200', emoji: '📢', description: 'Social media management', domain: 'rsocials.online' },
{ id: 'files', name: 'rFiles', badge: 'rFi', color: 'bg-cyan-300', emoji: '📁', description: 'Collaborative file storage', domain: 'rfiles.online' },
{ id: 'data', name: 'rData', badge: 'rD', color: 'bg-purple-300', emoji: '📊', description: 'Analytics & insights dashboard', domain: 'rdata.online' },
{ id: 'work', name: 'rWork', badge: 'rWk', color: 'bg-slate-300', emoji: '📋', description: 'Collective task management', domain: 'rwork.online' },
// Work & Productivity
{ id: 'work', name: 'rWork', badge: 'rWo', color: 'bg-slate-300', emoji: '💼', description: 'Project & task management', domain: 'rwork.online' },
// Identity & Infrastructure
{ id: 'ids', name: 'rIDs', badge: 'rId', color: 'bg-emerald-300', emoji: '🔑', description: 'Passkey identity & zero-knowledge auth', domain: 'ridentity.online' },
{ id: 'stack', name: 'rStack', badge: 'r*', color: 'bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300', emoji: '📦', description: 'Open-source community infrastructure', domain: 'rstack.online' },
];
const MODULE_CATEGORIES: Record<string, string> = {
@ -46,35 +56,56 @@ const MODULE_CATEGORIES: Record<string, string> = {
cal: 'Planning',
trips: 'Planning',
maps: 'Planning',
inbox: 'Discussing & Deciding',
choices: 'Discussing & Deciding',
vote: 'Discussing & Deciding',
chats: 'Communicating',
inbox: 'Communicating',
mail: 'Communicating',
forum: 'Communicating',
choices: 'Deciding',
vote: 'Deciding',
funds: 'Funding & Commerce',
wallet: 'Funding & Commerce',
cart: 'Funding & Commerce',
swag: 'Funding & Commerce',
auctions: 'Funding & Commerce',
network: 'Social & Sharing',
files: 'Social & Sharing',
tube: 'Social & Sharing',
data: 'Social & Sharing',
work: 'Social & Sharing',
swag: 'Funding & Commerce',
photos: 'Social & Media',
tube: 'Social & Media',
network: 'Social & Media',
socials: 'Social & Media',
files: 'Social & Media',
data: 'Social & Media',
work: 'Work & Productivity',
ids: 'Identity & Infrastructure',
stack: 'Identity & Infrastructure',
};
const CATEGORY_ORDER = [
'Creating',
'Planning',
'Discussing & Deciding',
'Communicating',
'Deciding',
'Funding & Commerce',
'Social & Sharing',
'Social & Media',
'Work & Productivity',
'Identity & Infrastructure',
];
/** Build the URL for a module, using username subdomain if logged in */
function getModuleUrl(m: AppModule, username: string | null): string {
if (!m.domain) return '#';
if (username) {
// Generate <username>.<domain> URL
return `https://${username}.${m.domain}`;
}
return `https://${m.domain}`;
}
interface AppSwitcherProps {
current?: string;
}
export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
const [open, setOpen] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -87,8 +118,21 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
return () => document.removeEventListener('click', handleClick);
}, []);
// Fetch current user's username for subdomain links
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 currentMod = MODULES.find((m) => m.id === current);
// Group modules by category
const groups = new Map<string, AppModule[]>();
for (const m of MODULES) {
const cat = MODULE_CATEGORIES[m.id] || 'Other';
@ -98,6 +142,7 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
return (
<div className="relative" ref={ref}>
{/* Trigger button */}
<button
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm font-semibold bg-white/[0.08] hover:bg-white/[0.12] text-slate-200 transition-colors"
@ -107,12 +152,14 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
{currentMod.badge}
</span>
)}
<span className="hidden sm:inline">{currentMod?.name || 'rStack'}</span>
<span>{currentMod?.name || 'rStack'}</span>
<span className="text-[0.7em] opacity-60">&#9662;</span>
</button>
{/* Dropdown */}
{open && (
<div className="absolute top-full left-0 mt-1.5 w-[300px] max-h-[70vh] overflow-y-auto rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200]">
{/* rStack header */}
<div className="px-3.5 py-3 border-b border-white/[0.08] flex items-center gap-2.5">
<span className="w-7 h-7 rounded-lg bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300 flex items-center justify-center text-[11px] font-black text-slate-900 leading-none">
r*
@ -123,6 +170,7 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
</div>
</div>
{/* Categories */}
{CATEGORY_ORDER.map((cat) => {
const items = groups.get(cat);
if (!items || items.length === 0) return null;
@ -139,13 +187,15 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
} transition-colors`}
>
<a
href={m.domain ? `https://${m.domain}` : '#'}
href={getModuleUrl(m, username)}
className="flex items-center gap-2.5 flex-1 px-3.5 py-2 text-slate-200 no-underline min-w-0"
onClick={() => setOpen(false)}
>
{/* Pastel favicon badge */}
<span className={`w-7 h-7 rounded-md ${m.color} flex items-center justify-center text-[10px] font-black text-slate-900 leading-none flex-shrink-0`}>
{m.badge}
</span>
{/* Name + description */}
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">{m.name}</span>
@ -172,6 +222,7 @@ export function AppSwitcher({ current = 'swag' }: AppSwitcherProps) {
);
})}
{/* Footer */}
<div className="px-3.5 py-2.5 border-t border-white/[0.08] text-center">
<a
href="https://rstack.online"

View File

@ -0,0 +1,62 @@
'use client';
const FOOTER_LINKS = [
{ name: 'rSpace', href: 'https://rspace.online' },
{ name: 'rNotes', href: 'https://rnotes.online' },
{ name: 'rPubs', href: 'https://rpubs.online' },
{ name: 'rCal', href: 'https://rcal.online' },
{ name: 'rTrips', href: 'https://rtrips.online' },
{ name: 'rMaps', href: 'https://rmaps.online' },
{ name: 'rChats', href: 'https://rchats.online' },
{ name: 'rInbox', href: 'https://rinbox.online' },
{ name: 'rMail', href: 'https://rmail.online' },
{ name: 'rForum', href: 'https://rforum.online' },
{ name: 'rChoices', href: 'https://rchoices.online' },
{ name: 'rVote', href: 'https://rvote.online' },
{ name: 'rFunds', href: 'https://rfunds.online' },
{ name: 'rWallet', href: 'https://rwallet.online' },
{ name: 'rCart', href: 'https://rcart.online' },
{ name: 'rAuctions', href: 'https://rauctions.online' },
{ name: 'rSwag', href: 'https://rswag.online' },
{ name: 'rPhotos', href: 'https://rphotos.online' },
{ name: 'rTube', href: 'https://rtube.online' },
{ name: 'rNetwork', href: 'https://rnetwork.online' },
{ name: 'rSocials', href: 'https://rsocials.online' },
{ name: 'rFiles', href: 'https://rfiles.online' },
{ name: 'rData', href: 'https://rdata.online' },
{ name: 'rWork', href: 'https://rwork.online' },
{ name: 'rIDs', href: 'https://ridentity.online' },
{ name: 'rStack', href: 'https://rstack.online' },
];
interface EcosystemFooterProps {
current?: string;
}
export function EcosystemFooter({ current }: EcosystemFooterProps) {
return (
<footer className="border-t border-slate-800 py-8">
<div className="max-w-7xl mx-auto px-6">
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm text-slate-500 mb-4">
<span className="font-medium text-slate-400">r* Ecosystem</span>
{FOOTER_LINKS.map((link) => (
<a
key={link.name}
href={link.href}
className={`hover:text-slate-300 transition-colors ${
current && link.name.toLowerCase() === current.toLowerCase()
? 'font-medium text-slate-300'
: ''
}`}
>
{link.name}
</a>
))}
</div>
<p className="text-center text-xs text-slate-600">
Part of the <a href="https://rstack.online" className="hover:text-slate-400 transition-colors">r* ecosystem</a> open source, self-hosted, community-owned
</p>
</div>
</footer>
);
}

View File

@ -0,0 +1,311 @@
"use client";
import { useState, useCallback } from "react";
interface FlowSplits {
printer: number;
creator: number;
community: number;
}
const DEFAULT_SALE_PRICE = 29.99;
const DEFAULT_PRODUCTION_COST = 9.25;
// Min production cost as fraction of price (Printful cost floor)
const MIN_PRINTER_FRACTION = 0.15;
export function RevenueFlowSankey() {
const [salePrice] = useState(DEFAULT_SALE_PRICE);
const [splits, setSplits] = useState<FlowSplits>(() => {
const printer = DEFAULT_PRODUCTION_COST / DEFAULT_SALE_PRICE;
const remaining = 1 - printer;
return {
printer,
creator: remaining * 0.35,
community: remaining * 0.65,
};
});
const handleSplitChange = useCallback(
(key: keyof FlowSplits, newValue: number) => {
setSplits((prev) => {
const updated = { ...prev, [key]: newValue };
// Enforce minimum printer cost
if (updated.printer < MIN_PRINTER_FRACTION) {
updated.printer = MIN_PRINTER_FRACTION;
}
// Normalize so all splits sum to 1
const total = updated.printer + updated.creator + updated.community;
if (total === 0) return prev;
return {
printer: updated.printer / total,
creator: updated.creator / total,
community: updated.community / total,
};
});
},
[]
);
const printerAmount = salePrice * splits.printer;
const creatorAmount = salePrice * splits.creator;
const communityAmount = salePrice * splits.community;
return (
<div className="space-y-8">
{/* SVG Sankey Diagram */}
<div className="w-full overflow-hidden">
<svg
viewBox="0 0 700 320"
className="w-full h-auto max-w-2xl mx-auto"
role="img"
aria-label="Revenue flow diagram showing how sale price splits between printer, creator, and community"
>
<defs>
<linearGradient id="flowPrinter" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.7" />
</linearGradient>
<linearGradient id="flowCreator" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
<stop offset="100%" stopColor="#a855f7" stopOpacity="0.7" />
</linearGradient>
<linearGradient id="flowCommunity" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0.7" />
</linearGradient>
{/* Glow filters */}
<filter id="glowGreen">
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* ── Source node: Sale ── */}
<rect
x="30"
y="110"
width="90"
height="100"
rx="8"
className="fill-primary/20 stroke-primary"
strokeWidth="2"
/>
<text
x="75"
y="150"
textAnchor="middle"
className="fill-foreground text-sm font-bold"
style={{ fontSize: "14px" }}
>
Sale
</text>
<text
x="75"
y="175"
textAnchor="middle"
className="fill-primary"
style={{ fontSize: "18px", fontWeight: 700 }}
>
${salePrice.toFixed(2)}
</text>
{/* ── Flow paths (Bezier curves) ── */}
<SankeyFlow
startX={120}
startY={135}
endX={480}
endY={60}
width={splits.printer * 80 + 4}
gradient="url(#flowPrinter)"
/>
<SankeyFlow
startX={120}
startY={160}
endX={480}
endY={160}
width={splits.creator * 80 + 4}
gradient="url(#flowCreator)"
/>
<SankeyFlow
startX={120}
startY={185}
endX={480}
endY={260}
width={splits.community * 80 + 4}
gradient="url(#flowCommunity)"
/>
{/* ── Target nodes ── */}
{/* Printer */}
<rect x="480" y="25" width="190" height="70" rx="8" fill="#3b82f620" stroke="#3b82f6" strokeWidth="1.5" />
<circle cx="505" cy="50" r="10" fill="#3b82f6" />
<text x="503" y="54" textAnchor="middle" fill="white" style={{ fontSize: "11px" }}>P</text>
<text x="525" y="50" className="fill-foreground" style={{ fontSize: "13px", fontWeight: 600 }} dominantBaseline="middle">
Printer
</text>
<text x="525" y="72" fill="#3b82f6" style={{ fontSize: "16px", fontWeight: 700 }}>
${printerAmount.toFixed(2)}
</text>
<text x="605" y="72" className="fill-muted-foreground" style={{ fontSize: "11px" }}>
({(splits.printer * 100).toFixed(0)}%)
</text>
{/* Creator */}
<rect x="480" y="125" width="190" height="70" rx="8" fill="#a855f720" stroke="#a855f7" strokeWidth="1.5" />
<circle cx="505" cy="150" r="10" fill="#a855f7" />
<text x="503" y="154" textAnchor="middle" fill="white" style={{ fontSize: "11px" }}>C</text>
<text x="525" y="150" className="fill-foreground" style={{ fontSize: "13px", fontWeight: 600 }} dominantBaseline="middle">
Creator
</text>
<text x="525" y="172" fill="#a855f7" style={{ fontSize: "16px", fontWeight: 700 }}>
${creatorAmount.toFixed(2)}
</text>
<text x="605" y="172" className="fill-muted-foreground" style={{ fontSize: "11px" }}>
({(splits.creator * 100).toFixed(0)}%)
</text>
{/* Community */}
<rect x="480" y="225" width="190" height="70" rx="8" fill="#10b98120" stroke="#10b981" strokeWidth="1.5" />
<circle cx="505" cy="250" r="10" fill="#10b981" filter="url(#glowGreen)" />
<text x="503" y="254" textAnchor="middle" fill="white" style={{ fontSize: "11px" }}>$</text>
<text x="525" y="250" className="fill-foreground" style={{ fontSize: "13px", fontWeight: 600 }} dominantBaseline="middle">
Community
</text>
<text x="525" y="272" fill="#10b981" style={{ fontSize: "16px", fontWeight: 700 }}>
${communityAmount.toFixed(2)}
</text>
<text x="612" y="272" className="fill-muted-foreground" style={{ fontSize: "11px" }}>
({(splits.community * 100).toFixed(0)}%)
</text>
</svg>
</div>
{/* ── Interactive Sliders ── */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 max-w-2xl mx-auto">
<FlowSlider
label="Printer"
sublabel="Production"
value={splits.printer}
amount={printerAmount}
color="#3b82f6"
onChange={(v) => handleSplitChange("printer", v)}
/>
<FlowSlider
label="Creator"
sublabel="Design Margin"
value={splits.creator}
amount={creatorAmount}
color="#a855f7"
onChange={(v) => handleSplitChange("creator", v)}
/>
<FlowSlider
label="Community"
sublabel="Revenue Fund"
value={splits.community}
amount={communityAmount}
color="#10b981"
onChange={(v) => handleSplitChange("community", v)}
/>
</div>
<p className="text-center text-xs text-muted-foreground max-w-lg mx-auto">
Drag the sliders to see how revenue flows between production, creator, and
community. The community sets its own margin every dollar above production
cost funds collective work.
</p>
</div>
);
}
/* ── Bezier flow path ── */
function SankeyFlow({
startX,
startY,
endX,
endY,
width,
gradient,
}: {
startX: number;
startY: number;
endX: number;
endY: number;
width: number;
gradient: string;
}) {
const midX = (startX + endX) / 2;
const d = `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`;
return (
<path
d={d}
stroke={gradient}
strokeWidth={Math.max(width, 2)}
fill="none"
strokeLinecap="round"
opacity="0.8"
style={{ transition: "stroke-width 0.3s ease, d 0.3s ease" }}
/>
);
}
/* ── Slider for a single flow channel ── */
function FlowSlider({
label,
sublabel,
value,
amount,
color,
onChange,
}: {
label: string;
sublabel: string;
value: number;
amount: number;
color: string;
onChange: (v: number) => void;
}) {
return (
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<div>
<span className="text-sm font-semibold" style={{ color }}>
{label}
</span>
<span className="text-xs text-muted-foreground ml-1">
{sublabel}
</span>
</div>
<span className="text-lg font-bold tabular-nums" style={{ color }}>
${amount.toFixed(2)}
</span>
</div>
<input
type="range"
min={0}
max={100}
step={1}
value={Math.round(value * 100)}
onChange={(e) => onChange(Number(e.target.value) / 100)}
className="w-full h-2 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, ${color} ${value * 100}%, hsl(var(--muted)) ${value * 100}%)`,
accentColor: color,
}}
aria-label={`${label} share: ${(value * 100).toFixed(0)}%`}
/>
<div className="text-center text-xs font-medium text-muted-foreground tabular-nums">
{(value * 100).toFixed(0)}%
</div>
</div>
);
}