Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: new dashboard

Hugo a0ba82ca 4fcf848e

+308 -55
+2 -2
packages/app/src/app.tsx
··· 62 62 ); 63 63 } 64 64 65 - /** Reactive default page for multi-sphere mode — reads auth state on each render. */ 65 + /** Reactive default page for multi-sphere mode — public landing/dashboard. */ 66 66 function MultiSphereDefaultPage() { 67 - return auth.value.authenticated ? <Dashboard /> : <SignInPage />; 67 + return <Dashboard />; 68 68 } 69 69 70 70 /** Watches the :sphereHandle route param and reloads sphere data when it changes. */
+159
packages/app/src/pages/dashboard.css.ts
··· 1 + import { style, globalStyle } from "@vanilla-extract/css"; 2 + import { vars } from "@exosphere/client/theme.css"; 3 + 4 + const bp = { 5 + sm: "screen and (min-width: 480px)", 6 + md: "screen and (min-width: 768px)", 7 + }; 8 + 9 + export const hero = style({ 10 + textAlign: "center", 11 + paddingBlockStart: vars.space.xl, 12 + paddingBlockEnd: vars.space.xxl, 13 + }); 14 + 15 + export const headline = style({ 16 + fontFamily: vars.font.heading, 17 + fontSize: "1.5rem", 18 + fontWeight: 700, 19 + lineHeight: 1.3, 20 + letterSpacing: "-0.02em", 21 + marginBlockEnd: vars.space.lg, 22 + "@media": { 23 + [bp.sm]: { 24 + fontSize: "2rem", 25 + }, 26 + }, 27 + }); 28 + 29 + globalStyle(`${headline} a`, { 30 + color: vars.color.primary, 31 + textDecoration: "none", 32 + fontWeight: 700, 33 + }); 34 + 35 + globalStyle(`${headline} a:hover`, { 36 + textDecoration: "underline", 37 + }); 38 + 39 + export const heroBody = style({ 40 + color: vars.color.textMuted, 41 + fontSize: "0.9375rem", 42 + lineHeight: 1.7, 43 + maxWidth: "600px", 44 + marginInline: "auto", 45 + "@media": { 46 + [bp.sm]: { 47 + fontSize: "1rem", 48 + }, 49 + }, 50 + }); 51 + 52 + export const toolGrid = style({ 53 + display: "grid", 54 + gap: vars.space.md, 55 + "@media": { 56 + [bp.sm]: { 57 + gridTemplateColumns: "1fr 1fr", 58 + }, 59 + }, 60 + }); 61 + 62 + export const toolCard = style({ 63 + backgroundColor: vars.color.surface, 64 + border: `1px solid ${vars.color.border}`, 65 + borderRadius: vars.radius.lg, 66 + paddingBlock: vars.space.lg, 67 + paddingInline: vars.space.lg, 68 + boxShadow: `0 1px 3px ${vars.color.shadow}`, 69 + transition: "background-color 0.2s, border-color 0.2s", 70 + }); 71 + 72 + const toolCardLabelBase = { 73 + display: "inline-block" as const, 74 + fontSize: "0.6875rem", 75 + fontWeight: 600, 76 + textTransform: "uppercase" as const, 77 + letterSpacing: "0.05em", 78 + paddingBlock: "2px", 79 + paddingInline: vars.space.sm, 80 + borderRadius: vars.radius.sm, 81 + marginBlockEnd: vars.space.sm, 82 + }; 83 + 84 + export const toolCardLabelAvailable = style({ 85 + ...toolCardLabelBase, 86 + backgroundColor: vars.color.successLight, 87 + color: vars.color.success, 88 + }); 89 + 90 + export const toolCardLabelSoon = style({ 91 + ...toolCardLabelBase, 92 + backgroundColor: vars.color.primaryLight, 93 + color: vars.color.primary, 94 + }); 95 + 96 + export const toolCardTitle = style({ 97 + fontFamily: vars.font.heading, 98 + fontSize: "1rem", 99 + fontWeight: 600, 100 + marginBlockEnd: vars.space.xs, 101 + }); 102 + 103 + export const toolCardDesc = style({ 104 + color: vars.color.textMuted, 105 + fontSize: "0.8125rem", 106 + lineHeight: 1.5, 107 + }); 108 + 109 + export const ctaCard = style({ 110 + display: "flex", 111 + flexDirection: "column", 112 + alignItems: "center", 113 + textAlign: "center", 114 + gap: vars.space.sm, 115 + backgroundColor: vars.color.primaryLight, 116 + border: `1px solid ${vars.color.primary}`, 117 + borderRadius: vars.radius.lg, 118 + paddingBlock: vars.space.lg, 119 + paddingInline: vars.space.lg, 120 + textDecoration: "none", 121 + color: "inherit", 122 + transition: "box-shadow 0.2s, transform 0.15s", 123 + ":hover": { 124 + boxShadow: `0 4px 12px ${vars.color.shadowStrong}`, 125 + transform: "translateY(-1px)", 126 + textDecoration: "none", 127 + }, 128 + "@media": { 129 + [bp.sm]: { 130 + paddingBlock: vars.space.xl, 131 + paddingInline: vars.space.xl, 132 + }, 133 + }, 134 + }); 135 + 136 + export const ctaTitle = style({ 137 + fontFamily: vars.font.heading, 138 + fontSize: "1.0625rem", 139 + fontWeight: 600, 140 + color: vars.color.text, 141 + "@media": { 142 + [bp.sm]: { 143 + fontSize: "1.125rem", 144 + }, 145 + }, 146 + }); 147 + 148 + export const ctaDesc = style({ 149 + color: vars.color.textMuted, 150 + fontSize: "0.8125rem", 151 + lineHeight: 1.5, 152 + maxWidth: "360px", 153 + }); 154 + 155 + export const ctaArrow = style({ 156 + color: vars.color.primary, 157 + fontSize: "0.875rem", 158 + fontWeight: 500, 159 + });
+99 -28
packages/app/src/pages/dashboard.tsx
··· 1 1 import { useQuery } from "@exosphere/client/hooks"; 2 2 import { apiFetch } from "@exosphere/client/api"; 3 + import { ssrPageData } from "@exosphere/client/ssr-data"; 4 + import { auth } from "@exosphere/client/auth"; 3 5 import * as ui from "@exosphere/client/ui.css"; 6 + import * as s from "./dashboard.css.ts"; 4 7 5 8 interface SphereListItem { 6 9 id: string; ··· 8 11 name: string; 9 12 description: string | null; 10 13 visibility: string; 14 + role: string; 11 15 } 12 16 17 + type MySpheresData = { spheres: SphereListItem[] }; 18 + 13 19 function getMySpheres() { 14 - return apiFetch<{ spheres: SphereListItem[] }>("/api/spheres?member=true"); 20 + return apiFetch<MySpheresData>("/api/spheres?member=true"); 21 + } 22 + 23 + function MySpheres() { 24 + const ssrData = ssrPageData.value?.["my-spheres"] as MySpheresData | undefined; 25 + const { data, pending } = useQuery(() => getMySpheres(), [], { 26 + initialData: ssrData, 27 + }); 28 + 29 + const spheres = data?.spheres.filter((sp) => sp.role === "owner" || sp.role === "admin") ?? []; 30 + 31 + if (pending || spheres.length === 0) return null; 32 + 33 + return ( 34 + <div class={ui.section}> 35 + <h2 class={ui.sectionTitle}>My Spheres</h2> 36 + <div class={ui.stackSm}> 37 + {spheres.map((sphere) => ( 38 + <a key={sphere.id} href={`/s/${sphere.handle}`} class={ui.cardLink}> 39 + <div class={ui.row}> 40 + <strong>{sphere.name}</strong> 41 + <span class={ui.badge}>{sphere.visibility}</span> 42 + </div> 43 + {sphere.description && <p class={ui.muted}>{sphere.description}</p>} 44 + </a> 45 + ))} 46 + </div> 47 + </div> 48 + ); 15 49 } 16 50 17 51 export function Dashboard() { 18 - const { data, pending, loading } = useQuery(() => getMySpheres(), []); 52 + const { authenticated } = auth.value; 19 53 20 54 return ( 21 55 <div class={ui.container}> 56 + <div class={s.hero}> 57 + <h1 class={s.headline}>Tools for teams that build in public.</h1> 58 + <p class={s.heroBody}> 59 + Open-source tools for communities. Host your own instance or use exposphere.site. Built on 60 + AT Protocol, so your data always stays yours. 61 + </p> 62 + </div> 63 + 64 + {authenticated && ( 65 + <> 66 + <MySpheres /> 67 + <div class={ui.section}> 68 + <a href="/spheres/new" class={ui.button}> 69 + Create a sphere 70 + </a> 71 + </div> 72 + </> 73 + )} 74 + 22 75 <div class={ui.section}> 23 - <div class={ui.row}> 24 - <h1 class={ui.pageTitle}>My Spheres</h1> 25 - <a href="/spheres/new" class={ui.button}> 26 - Create sphere 27 - </a> 28 - </div> 76 + <h2 class={ui.sectionTitle}>Tools</h2> 77 + 78 + <div class={s.toolGrid}> 79 + <div class={s.toolCard}> 80 + <span class={s.toolCardLabelAvailable}>Available now</span> 81 + <h3 class={s.toolCardTitle}>Infuse</h3> 82 + <p class={s.toolCardDesc}> 83 + Collect and prioritize feature requests from your community. Let members vote and 84 + discuss, so you always know what to build next. 85 + </p> 86 + </div> 29 87 30 - {pending ? ( 31 - loading ? ( 32 - <p class={ui.muted}>Loading...</p> 33 - ) : null 34 - ) : !data || data.spheres.length === 0 ? ( 35 - <div class={ui.card}> 36 - <p class={ui.description}> 37 - You're not a member of any sphere yet. Create one to get started. 88 + <div class={s.toolCard}> 89 + <span class={s.toolCardLabelSoon}>Coming soon</span> 90 + <h3 class={s.toolCardTitle}>Feeds & Discussions</h3> 91 + <p class={s.toolCardDesc}> 92 + A shared space for conversations — announcements, Q&A, and open-ended threads, all in 93 + one place. 94 + </p> 95 + </div> 96 + 97 + <div class={s.toolCard}> 98 + <span class={s.toolCardLabelSoon}>Coming soon</span> 99 + <h3 class={s.toolCardTitle}>Public Kanban</h3> 100 + <p class={s.toolCardDesc}> 101 + A transparent project board your community can follow. Share progress and let people 102 + see what's in the pipeline. 38 103 </p> 39 104 </div> 40 - ) : ( 41 - <div class={ui.stackSm}> 42 - {data.spheres.map((sphere) => ( 43 - <a key={sphere.id} href={`/s/${sphere.handle}`} class={ui.cardLink}> 44 - <div class={ui.row}> 45 - <strong>{sphere.name}</strong> 46 - <span class={ui.badge}>{sphere.visibility}</span> 47 - </div> 48 - {sphere.description && <p class={ui.muted}>{sphere.description}</p>} 49 - </a> 50 - ))} 105 + 106 + <div class={s.toolCard}> 107 + <span class={s.toolCardLabelSoon}>Coming soon</span> 108 + <h3 class={s.toolCardTitle}>Polls</h3> 109 + <p class={s.toolCardDesc}> 110 + Quick pulse checks for your community. Gather opinions and make decisions together. 111 + </p> 51 112 </div> 52 - )} 113 + </div> 114 + </div> 115 + 116 + <div class={ui.section}> 117 + <a href="https://app.exosphere.site/s/exosphere.site" class={s.ctaCard}> 118 + <span class={s.ctaTitle}>Join the Exosphere community</span> 119 + <span class={s.ctaDesc}> 120 + Follow the project's development, request features, and help shape what we build next. 121 + </span> 122 + <span class={s.ctaArrow}>Visit the sphere &rarr;</span> 123 + </a> 53 124 </div> 54 125 </div> 55 126 );
+1 -8
packages/app/src/pages/sphere.tsx
··· 50 50 refreshSphere(); 51 51 }; 52 52 53 - const { sphere: s, modules: enabledModules, memberCount } = data; 53 + const { sphere: s, modules: enabledModules } = data; 54 54 const enabledNames = enabledModules.map((m) => m.name); 55 55 const availableToEnable = modules.data?.available.filter((a) => !enabledNames.includes(a)) ?? []; 56 56 ··· 63 63 </div> 64 64 65 65 {s.description && <p class={ui.description}>{s.description}</p>} 66 - 67 - <div class={ui.metaRow}> 68 - <span class={ui.muted}> 69 - {memberCount} {memberCount === 1 ? "member" : "members"} 70 - </span> 71 - <span class={ui.muted}>Write: {s.writeAccess}</span> 72 - </div> 73 66 </div> 74 67 75 68 {/* Modules section */}
+12 -1
packages/app/src/server.ts
··· 2 2 import { serveStatic } from "hono/bun"; 3 3 import { getCookie } from "hono/cookie"; 4 4 import { getOAuthClient, oauthRoutes } from "@exosphere/core/auth"; 5 - import { createSphereRoutes, getCurrentSphere, sphereContext } from "@exosphere/core/sphere"; 5 + import { 6 + createSphereRoutes, 7 + getCurrentSphere, 8 + getMemberSpheres, 9 + sphereContext, 10 + } from "@exosphere/core/sphere"; 6 11 import { isMultiSphere } from "@exosphere/core/config"; 7 12 import { startJetstream, stopCursorFlushing } from "@exosphere/indexer"; 8 13 import { modules, coreIndexer } from "@exosphere/indexer/modules"; ··· 140 145 141 146 // Prefetch page data by calling our own API routes internally 142 147 const pageData: Record<string, unknown> = {}; 148 + 149 + // Dashboard: prefetch user's spheres for the "My Spheres" section 150 + if (isMultiSphere && !sphereHandleFromUrl && authData.did) { 151 + pageData["my-spheres"] = { spheres: getMemberSpheres(authData.did) }; 152 + } 153 + 143 154 if (sphere) { 144 155 const prefetches = ssrPrefetch(c.req.path, sphereHandleFromUrl); 145 156 for (const prefetch of prefetches) {
+13
packages/app/src/vite-ssr-plugin.ts
··· 115 115 116 116 // Prefetch page data from the API server 117 117 const pageData: Record<string, unknown> = {}; 118 + 119 + // Dashboard: prefetch user's spheres for the "My Spheres" section 120 + if (isMultiSphere && !sphereHandleFromUrl && authData.authenticated) { 121 + try { 122 + const res = await fetch(`${API_SERVER}/api/spheres?member=true`, { 123 + headers: { cookie }, 124 + }); 125 + if (res.ok) pageData["my-spheres"] = await res.json(); 126 + } catch { 127 + /* prefetch failed — client will fetch */ 128 + } 129 + } 130 + 118 131 if (sphereData) { 119 132 const prefetches = ssrPrefetch(url, sphereHandleFromUrl); 120 133 for (const prefetch of prefetches) {
+20 -14
packages/core/src/sphere/api/spheres.ts
··· 11 11 12 12 const SPHERE_COLLECTION = "site.exosphere.sphere"; 13 13 14 + /** Return spheres the given DID is an active member of, including their role. */ 15 + export function getMemberSpheres(did: string) { 16 + const db = getDb(); 17 + const rows = db 18 + .select({ sphere: spheres, role: sphereMembers.role }) 19 + .from(spheres) 20 + .innerJoin( 21 + sphereMembers, 22 + and( 23 + eq(sphereMembers.sphereId, spheres.id), 24 + eq(sphereMembers.did, did), 25 + eq(sphereMembers.status, "active"), 26 + ), 27 + ) 28 + .orderBy(spheres.createdAt) 29 + .all(); 30 + return rows.map((r) => ({ ...r.sphere, role: r.role })); 31 + } 32 + 14 33 /** Load the current sphere with its modules, member count, and caller's role. 15 34 * If `handle` is provided, loads that specific sphere; otherwise loads the first sphere. */ 16 35 export function getCurrentSphere(did: string | null, handle?: string) { ··· 116 135 } 117 136 118 137 if (memberOnly && did) { 119 - const rows = db 120 - .select({ sphere: spheres }) 121 - .from(spheres) 122 - .innerJoin( 123 - sphereMembers, 124 - and( 125 - eq(sphereMembers.sphereId, spheres.id), 126 - eq(sphereMembers.did, did), 127 - eq(sphereMembers.status, "active"), 128 - ), 129 - ) 130 - .orderBy(spheres.createdAt) 131 - .all(); 132 - return c.json({ spheres: rows.map((r) => r.sphere) }); 138 + return c.json({ spheres: getMemberSpheres(did) }); 133 139 } 134 140 135 141 const rows = db.select().from(spheres).orderBy(spheres.createdAt).all();
+1 -1
packages/core/src/sphere/index.ts
··· 1 - export { createSphereRoutes, getCurrentSphere } from "./routes.ts"; 1 + export { createSphereRoutes, getCurrentSphere, getMemberSpheres } from "./routes.ts"; 2 2 export { coreIndexer } from "./indexer.ts"; 3 3 export { 4 4 getActiveMemberRole,
+1 -1
packages/core/src/sphere/routes.ts
··· 4 4 import { createModuleRoutes } from "./api/modules.ts"; 5 5 import { membersApi } from "./api/members.ts"; 6 6 7 - export { getCurrentSphere } from "./api/spheres.ts"; 7 + export { getCurrentSphere, getMemberSpheres } from "./api/spheres.ts"; 8 8 9 9 export function createSphereRoutes(availableModules: string[]) { 10 10 const app = new Hono<AuthEnv>();