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 nav and dashboard

Hugo bdc756be d02fb44c

+1893 -205
+53
packages/app/src/api/sphere-dashboard.ts
··· 1 + import { moduleFetch } from "@exosphere/client/module-api"; 2 + 3 + export type FrStatus = 4 + | "requested" 5 + | "not-planned" 6 + | "approved" 7 + | "in-progress" 8 + | "done" 9 + | "duplicate"; 10 + 11 + export type KanbanStatusType = "backlog" | "planned" | "started" | "completed" | "canceled"; 12 + 13 + export interface InfuseLatestRequest { 14 + id: string; 15 + number: number; 16 + title: string; 17 + status: FrStatus; 18 + voteCount: number; 19 + authorHandle: string | null; 20 + createdAt: string; 21 + } 22 + 23 + export interface InfuseStats { 24 + total: number; 25 + statusCounts: Record<FrStatus, number>; 26 + latestRequests: InfuseLatestRequest[]; 27 + } 28 + 29 + export interface FluxLatestTask { 30 + id: string; 31 + number: number; 32 + title: string; 33 + status: string; 34 + statusType: KanbanStatusType; 35 + statusLabel: string; 36 + authorHandle: string | null; 37 + createdAt: string; 38 + updatedAt: string; 39 + } 40 + 41 + export interface FluxStats { 42 + total: number; 43 + statusTypeCounts: Record<KanbanStatusType, number>; 44 + latestTasks: FluxLatestTask[]; 45 + } 46 + 47 + export function getInfuseStats() { 48 + return moduleFetch<InfuseStats>("/feature-requests/stats"); 49 + } 50 + 51 + export function getFluxStats() { 52 + return moduleFetch<FluxStats>("/kanban/stats"); 53 + }
+131 -15
packages/app/src/app.tsx
··· 1 1 import type { ComponentType } from "preact"; 2 - import { useEffect, useMemo } from "preact/hooks"; 2 + import { useEffect, useMemo, useState } from "preact/hooks"; 3 3 import { auth, logout } from "@exosphere/client/auth"; 4 4 import { useLocation, useRoute } from "@exosphere/client/router"; 5 5 import { spherePath } from "@exosphere/client/router"; 6 6 import { sphereState, sphereHandle, loadSphere } from "@exosphere/client/sphere"; 7 7 import { isMultiSphere } from "@exosphere/client/config"; 8 + import { Link } from "@exosphere/client/link"; 8 9 import * as ui from "@exosphere/client/ui.css"; 9 10 import { ThemeToggle } from "@exosphere/client/components/theme-toggle"; 10 11 import { InvitationBanner } from "@exosphere/client/components/invitation-banner"; ··· 12 13 import { SignIn } from "./pages/sign-in.tsx"; 13 14 import { CreateSphere } from "./pages/create-sphere.tsx"; 14 15 import { SpherePage } from "./pages/sphere.tsx"; 16 + import { SphereSettingsPage } from "./pages/sphere-settings.tsx"; 15 17 import { SphereMembersPage } from "./pages/sphere-members.tsx"; 16 18 import { SpherePermissionsPage } from "./pages/sphere-permissions.tsx"; 17 19 import { SphereLabelsPage } from "./pages/sphere-labels.tsx"; ··· 21 23 import { featureRequestsModule } from "@exosphere/feature-requests/client"; 22 24 import { kanbanModule } from "@exosphere/kanban/client"; 23 25 import { LocationProvider, Router, Route } from "preact-iso"; 26 + import * as h from "./header.css.ts"; 24 27 25 28 const defaultRoutes: ModuleRoute[] = [feedsModule, featureRequestsModule, kanbanModule].flatMap( 26 29 (m) => m.routes ?? [], ··· 143 146 path="/s/:sphereHandle/settings/labels" 144 147 component={withSphereLoader(SphereLabelsPage)} 145 148 /> 149 + <Route 150 + path="/s/:sphereHandle/settings/modules" 151 + component={withSphereLoader(SphereSettingsPage)} 152 + /> 153 + <Route path="/s/:sphereHandle/settings" component={withSphereLoader(SphereSettingsPage)} /> 146 154 <Route path="/s/:sphereHandle" component={withSphereLoader(SpherePage)} /> 147 155 <Route path="/" component={MultiSphereDefaultPage} /> 148 156 <Route default component={NotFoundPage} /> ··· 196 204 <Route path="/settings/members" component={SphereMembersPage} /> 197 205 <Route path="/settings/permissions" component={SpherePermissionsPage} /> 198 206 <Route path="/settings/labels" component={SphereLabelsPage} /> 207 + <Route path="/settings/modules" component={SphereSettingsPage} /> 208 + <Route path="/settings" component={SphereSettingsPage} /> 199 209 {moduleRoutes.map((r) => ( 200 210 <Route key={r.path} path={r.path} component={r.component} /> 201 211 ))} ··· 233 243 ); 234 244 } 235 245 246 + function isModulePathActive(currentPath: string, modulePath: string): boolean { 247 + // currentPath examples: "/s/foo.bar/infuse", "/s/foo.bar/infuse/42", "/infuse" 248 + const stripped = currentPath.replace(/^\/s\/[^/]+/, "") || "/"; 249 + return stripped === modulePath || stripped.startsWith(`${modulePath}/`); 250 + } 251 + 252 + function isSettingsPathActive(currentPath: string): boolean { 253 + const stripped = currentPath.replace(/^\/s\/[^/]+/, "") || "/"; 254 + return stripped.startsWith("/settings"); 255 + } 256 + 257 + function SphereNav({ currentPath }: { currentPath: string }) { 258 + const data = sphereState.value.data; 259 + const enabled = new Set(data?.modules.map((m) => m.name) ?? []); 260 + const hasInfuse = enabled.has("feature-requests"); 261 + const hasFlux = enabled.has("kanban"); 262 + 263 + return ( 264 + <nav class={h.nav} aria-label="Sphere"> 265 + {hasInfuse && ( 266 + <Link 267 + href={spherePath("/infuse")} 268 + class={`${h.navLink} ${isModulePathActive(currentPath, "/infuse") ? h.navLinkActive : ""}`} 269 + > 270 + Infuse 271 + </Link> 272 + )} 273 + {hasFlux && ( 274 + <Link 275 + href={spherePath("/flux")} 276 + class={`${h.navLink} ${isModulePathActive(currentPath, "/flux") ? h.navLinkActive : ""}`} 277 + > 278 + Flux 279 + </Link> 280 + )} 281 + </nav> 282 + ); 283 + } 284 + 236 285 function Header() { 237 - const { route } = useLocation(); 286 + const { route, path } = useLocation(); 238 287 const { loading, authenticated, did, handle } = auth.value; 239 288 const sphere = sphereState.value.data?.sphere; 289 + const [drawerOpen, setDrawerOpen] = useState(false); 240 290 241 291 const homeHref = isMultiSphere && sphere ? spherePath("/") : "/"; 292 + const showSphereNav = Boolean(sphere); 293 + const settingsActive = isSettingsPathActive(path); 294 + 295 + const closeDrawer = () => setDrawerOpen(false); 242 296 243 297 return ( 244 - <header class={ui.header}> 245 - <div class={ui.headerInner}> 246 - <a href={homeHref} class={ui.headerTitle}> 298 + <header class={h.appHeader}> 299 + <div class={h.headerInner}> 300 + <a href={homeHref} class={h.brand} aria-label="Exosphere"> 247 301 {sphere?.name ?? "Exosphere"} 302 + {showSphereNav && sphere && <span class={h.handlePill}>{sphere.handle}</span>} 248 303 </a> 249 - <nav class={ui.headerNav}> 250 - {authenticated && ( 251 - <span class={`${ui.muted} ${ui.hiddenMobile}`} title={did ?? undefined}> 252 - {handle && `@${handle}`} 304 + 305 + {showSphereNav && <SphereNav currentPath={path} />} 306 + 307 + <span class={h.spacer} /> 308 + 309 + <div class={h.right}> 310 + {authenticated && handle && ( 311 + <span class={h.handleText} title={did ?? undefined}> 312 + @{handle} 253 313 </span> 254 314 )} 255 315 <ThemeToggle icons={{ light: Sun, dark: Moon, system: Monitor }} /> 316 + {showSphereNav && ( 317 + <Link 318 + href={spherePath("/settings")} 319 + class={`${h.headerAction} ${settingsActive ? h.headerActionActive : ""}`} 320 + > 321 + Settings 322 + </Link> 323 + )} 256 324 {loading ? ( 257 - // optimistically show a dummy "signout button" 258 - // avoid layout flickering 259 - <button class={ui.buttonSecondary} disabled> 325 + <button class={h.headerAction} disabled> 260 326 Sign out 261 327 </button> 262 328 ) : authenticated ? ( 263 - <button class={ui.buttonSecondary} onClick={() => logout().then(() => route("/"))}> 329 + <button class={h.headerAction} onClick={() => logout().then(() => route("/"))}> 264 330 Sign out 265 331 </button> 266 332 ) : ( 267 - <a href="/sign-in" class={ui.button}> 333 + <a href="/sign-in" class={h.headerAction}> 268 334 Sign in 269 335 </a> 270 336 )} 271 - </nav> 337 + </div> 338 + 339 + <button 340 + type="button" 341 + class={h.hamburger} 342 + aria-label="Menu" 343 + aria-expanded={drawerOpen} 344 + onClick={() => setDrawerOpen((v) => !v)} 345 + > 346 + <svg 347 + viewBox="0 0 24 24" 348 + fill="none" 349 + stroke="currentColor" 350 + stroke-width="2" 351 + width="18" 352 + height="18" 353 + > 354 + <path d="M3 6h18M3 12h18M3 18h18" /> 355 + </svg> 356 + </button> 272 357 </div> 358 + 359 + {drawerOpen && ( 360 + <div class={`${h.mobileDrawer} ${h.mobileDrawerOpen}`} onClick={closeDrawer}> 361 + <div class={h.drawerRow}> 362 + {authenticated && handle && <span class={h.handleText}>@{handle}</span>} 363 + <ThemeToggle icons={{ light: Sun, dark: Moon, system: Monitor }} /> 364 + </div> 365 + <div class={h.drawerActionRow}> 366 + {showSphereNav && ( 367 + <Link 368 + href={spherePath("/settings")} 369 + class={`${ui.buttonSecondary} ${h.drawerAction}`} 370 + > 371 + Settings 372 + </Link> 373 + )} 374 + {authenticated ? ( 375 + <button 376 + class={`${ui.buttonSecondary} ${h.drawerAction}`} 377 + onClick={() => logout().then(() => route("/"))} 378 + > 379 + Sign out 380 + </button> 381 + ) : ( 382 + <a href="/sign-in" class={`${ui.button} ${h.drawerAction}`}> 383 + Sign in 384 + </a> 385 + )} 386 + </div> 387 + </div> 388 + )} 273 389 </header> 274 390 ); 275 391 }
+222
packages/app/src/header.css.ts
··· 1 + import { style, globalStyle } from "@vanilla-extract/css"; 2 + import { vars } from "@exosphere/client/theme.css"; 3 + 4 + const bp = { 5 + tablet: "screen and (max-width: 959px)", 6 + mobile: "screen and (max-width: 639px)", 7 + desktop: "screen and (min-width: 640px)", 8 + }; 9 + 10 + export const appHeader = style({ 11 + borderBlockEnd: `1px solid ${vars.color.border}`, 12 + backgroundColor: vars.color.surface, 13 + boxShadow: `0 1px 4px ${vars.color.shadow}`, 14 + position: "sticky", 15 + insetBlockStart: 0, 16 + zIndex: 5, 17 + }); 18 + 19 + export const headerInner = style({ 20 + maxInlineSize: "1120px", 21 + marginInline: "auto", 22 + paddingInline: vars.space.lg, 23 + display: "flex", 24 + alignItems: "stretch", 25 + gap: vars.space.md, 26 + "@media": { 27 + [bp.tablet]: { 28 + flexWrap: "wrap", 29 + paddingInline: vars.space.md, 30 + }, 31 + [bp.mobile]: { 32 + paddingInline: vars.space.md, 33 + gap: "8px", 34 + }, 35 + }, 36 + }); 37 + 38 + export const brand = style({ 39 + display: "inline-flex", 40 + alignItems: "center", 41 + gap: "10px", 42 + paddingBlock: "12px", 43 + fontFamily: vars.font.heading, 44 + fontWeight: 700, 45 + fontSize: "1rem", 46 + color: vars.color.text, 47 + letterSpacing: "-0.02em", 48 + whiteSpace: "nowrap", 49 + borderInlineEnd: `1px solid ${vars.color.border}`, 50 + paddingInlineEnd: vars.space.md, 51 + textDecoration: "none", 52 + ":hover": { textDecoration: "none" }, 53 + "@media": { 54 + [bp.tablet]: { borderInlineEnd: 0, paddingInlineEnd: 0 }, 55 + [bp.mobile]: { fontSize: "0.9375rem" }, 56 + }, 57 + }); 58 + 59 + export const handlePill = style({ 60 + fontFamily: vars.font.body, 61 + fontSize: "0.6875rem", 62 + color: vars.color.textMuted, 63 + fontWeight: 500, 64 + paddingBlock: "2px", 65 + paddingInline: "6px", 66 + backgroundColor: vars.color.bg, 67 + border: `1px solid ${vars.color.border}`, 68 + borderRadius: "4px", 69 + "@media": { 70 + [bp.tablet]: { display: "none" }, 71 + }, 72 + }); 73 + 74 + export const nav = style({ 75 + display: "flex", 76 + alignItems: "stretch", 77 + gap: "2px", 78 + overflowX: "auto", 79 + scrollbarWidth: "none", 80 + "@media": { 81 + [bp.tablet]: { 82 + order: 3, 83 + flexBasis: "100%", 84 + borderBlockStart: `1px solid ${vars.color.border}`, 85 + marginInline: `calc(-1 * ${vars.space.md})`, 86 + paddingInline: vars.space.md, 87 + }, 88 + }, 89 + }); 90 + 91 + globalStyle(`${nav}::-webkit-scrollbar`, { display: "none" }); 92 + 93 + export const navLink = style({ 94 + display: "inline-flex", 95 + alignItems: "center", 96 + gap: "6px", 97 + paddingInline: "12px", 98 + fontSize: "0.875rem", 99 + fontWeight: 500, 100 + color: vars.color.textMuted, 101 + borderBlockEnd: "2px solid transparent", 102 + textDecoration: "none", 103 + whiteSpace: "nowrap", 104 + transition: "color 0.15s, border-color 0.15s", 105 + marginBlockEnd: "-1px", 106 + ":hover": { color: vars.color.text, textDecoration: "none" }, 107 + "@media": { 108 + [bp.tablet]: { paddingBlock: "10px", paddingInline: "10px" }, 109 + [bp.mobile]: { fontSize: "0.8125rem" }, 110 + }, 111 + }); 112 + 113 + export const navLinkActive = style({ 114 + color: vars.color.text, 115 + borderBlockEndColor: vars.color.primary, 116 + }); 117 + 118 + export const spacer = style({ flex: 1 }); 119 + 120 + export const right = style({ 121 + display: "flex", 122 + alignItems: "center", 123 + gap: vars.space.sm, 124 + paddingBlock: "8px", 125 + "@media": { 126 + [bp.mobile]: { display: "none" }, 127 + }, 128 + }); 129 + 130 + export const handleText = style({ 131 + color: vars.color.textMuted, 132 + fontSize: "0.8125rem", 133 + whiteSpace: "nowrap", 134 + "@media": { 135 + [bp.tablet]: { display: "none" }, 136 + }, 137 + }); 138 + 139 + export const headerAction = style({ 140 + display: "inline-flex", 141 + alignItems: "center", 142 + justifyContent: "center", 143 + inlineSize: "90px", 144 + blockSize: "32px", 145 + paddingInline: "10px", 146 + border: `1px solid ${vars.color.border}`, 147 + borderRadius: vars.radius.sm, 148 + backgroundColor: vars.color.surface, 149 + color: vars.color.text, 150 + fontSize: "0.8125rem", 151 + fontWeight: 500, 152 + textDecoration: "none", 153 + cursor: "pointer", 154 + fontFamily: vars.font.body, 155 + transition: "background-color 0.15s, border-color 0.15s", 156 + ":hover": { 157 + borderColor: vars.color.primary, 158 + backgroundColor: vars.color.surfaceHover, 159 + textDecoration: "none", 160 + }, 161 + ":disabled": { 162 + opacity: 0.5, 163 + cursor: "not-allowed", 164 + }, 165 + }); 166 + 167 + export const headerActionActive = style({ 168 + backgroundColor: vars.color.primaryLight, 169 + borderColor: vars.color.primary, 170 + color: vars.color.primary, 171 + }); 172 + 173 + export const hamburger = style({ 174 + display: "none", 175 + inlineSize: "36px", 176 + blockSize: "36px", 177 + alignItems: "center", 178 + justifyContent: "center", 179 + border: `1px solid ${vars.color.border}`, 180 + borderRadius: vars.radius.sm, 181 + backgroundColor: vars.color.surface, 182 + color: vars.color.text, 183 + marginInlineStart: "auto", 184 + alignSelf: "center", 185 + cursor: "pointer", 186 + "@media": { 187 + [bp.mobile]: { display: "inline-flex" }, 188 + }, 189 + }); 190 + 191 + export const mobileDrawer = style({ 192 + display: "none", 193 + borderBlockStart: `1px solid ${vars.color.border}`, 194 + backgroundColor: vars.color.surface, 195 + paddingBlock: vars.space.md, 196 + paddingInline: vars.space.lg, 197 + }); 198 + 199 + export const mobileDrawerOpen = style({ 200 + display: "flex", 201 + flexDirection: "column", 202 + gap: vars.space.md, 203 + }); 204 + 205 + export const drawerRow = style({ 206 + display: "flex", 207 + alignItems: "center", 208 + justifyContent: "space-between", 209 + gap: vars.space.sm, 210 + flexWrap: "wrap", 211 + }); 212 + 213 + export const drawerActionRow = style({ 214 + display: "flex", 215 + alignItems: "center", 216 + gap: "8px", 217 + flexWrap: "wrap", 218 + }); 219 + 220 + export const drawerAction = style({ 221 + flex: 1, 222 + });
+80
packages/app/src/pages/settings-layout.tsx
··· 1 + import type { ComponentChildren } from "preact"; 2 + import { useLocation } from "@exosphere/client/router"; 3 + import { spherePath } from "@exosphere/client/router"; 4 + import { sphereState } from "@exosphere/client/sphere"; 5 + import { canDo } from "@exosphere/client/permissions"; 6 + import { Link } from "@exosphere/client/link"; 7 + import * as s from "./sphere.css.ts"; 8 + import * as ss from "./sphere-settings.css.ts"; 9 + 10 + // "members" and "permissions" stay in the union because their pages still pass 11 + // them as `active`, even though the rail doesn't render links to them anymore. 12 + // The pages remain reachable by direct URL. 13 + export type SettingsSection = "general" | "modules" | "labels" | "members" | "permissions"; 14 + 15 + interface Props { 16 + active: SettingsSection; 17 + children: ComponentChildren; 18 + } 19 + 20 + function RailLink({ 21 + href, 22 + active, 23 + label, 24 + danger, 25 + }: { 26 + href: string; 27 + active: boolean; 28 + label: string; 29 + danger?: boolean; 30 + }) { 31 + const classes = [ss.railLink, active ? ss.railLinkActive : "", danger ? ss.railLinkDanger : ""] 32 + .filter(Boolean) 33 + .join(" "); 34 + return ( 35 + <Link href={href} class={classes}> 36 + {label} 37 + </Link> 38 + ); 39 + } 40 + 41 + export function SettingsLayout({ active, children }: Props) { 42 + // `useLocation` makes this re-render on nav so the active state updates. 43 + useLocation(); 44 + 45 + const data = sphereState.value.data; 46 + const canManageLabels = canDo("sphere", "manageLabels"); 47 + 48 + return ( 49 + <div class={s.spherePage}> 50 + <section class={ss.settingsHero}> 51 + <h1 class={ss.settingsTitle}>Settings</h1> 52 + <p class={ss.settingsSub}> 53 + Configure how {data?.sphere.name ?? "this sphere"} works. Changes affect everyone in the 54 + sphere. 55 + </p> 56 + </section> 57 + 58 + <div class={ss.layout}> 59 + <aside class={ss.rail} aria-label="Settings sections"> 60 + <div class={ss.railGroup}>Sphere</div> 61 + <RailLink href={spherePath("/settings")} active={active === "general"} label="General" /> 62 + <RailLink 63 + href={spherePath("/settings/modules")} 64 + active={active === "modules"} 65 + label="Modules" 66 + /> 67 + {canManageLabels && ( 68 + <RailLink 69 + href={spherePath("/settings/labels")} 70 + active={active === "labels"} 71 + label="Labels" 72 + /> 73 + )} 74 + </aside> 75 + 76 + <div class={ss.content}>{children}</div> 77 + </div> 78 + </div> 79 + ); 80 + }
+9 -10
packages/app/src/pages/sphere-labels.tsx
··· 3 3 import { sphereState, sphereHandle } from "@exosphere/client/sphere"; 4 4 import { canDo } from "@exosphere/client/permissions"; 5 5 import { useQuery } from "@exosphere/client/hooks"; 6 - import { spherePath } from "@exosphere/client/router"; 7 6 import { LabelBadge } from "@exosphere/client/components/label-badge"; 8 7 import { GripVertical } from "lucide-preact"; 9 8 import * as ui from "@exosphere/client/ui.css"; 10 9 import * as cpUi from "./color-picker.css.ts"; 10 + import { SettingsLayout } from "./settings-layout.tsx"; 11 11 import { 12 12 getSphereLabels, 13 13 createSphereLabel, ··· 35 35 if (!data || !handle) return null; 36 36 if (!canDo("sphere", "manageLabels")) return null; 37 37 38 - return <LabelsContent handle={handle} />; 38 + return ( 39 + <SettingsLayout active="labels"> 40 + <LabelsContent handle={handle} /> 41 + </SettingsLayout> 42 + ); 39 43 } 40 44 41 45 function LabelsContent({ handle }: { handle: string }) { ··· 197 201 }; 198 202 199 203 return ( 200 - <div class={ui.container}> 204 + <> 201 205 <div class={ui.section}> 202 - <div> 203 - <a href={spherePath("/")} class={ui.muted}> 204 - &larr; Sphere 205 - </a> 206 - </div> 207 - <h1 class={ui.pageTitle}>Labels</h1> 206 + <h2 class={ui.sectionTitle}>Labels</h2> 208 207 <p class={ui.description}>Create and manage labels for feature requests and tasks.</p> 209 208 </div> 210 209 ··· 363 362 </div> 364 363 )} 365 364 </div> 366 - </div> 365 + </> 367 366 ); 368 367 } 369 368
+10 -9
packages/app/src/pages/sphere-members.tsx
··· 2 2 import { sphereState, sphereHandle } from "@exosphere/client/sphere"; 3 3 import { canDo } from "@exosphere/client/permissions"; 4 4 import { useQuery } from "@exosphere/client/hooks"; 5 - import { spherePath } from "@exosphere/client/router"; 6 5 import * as ui from "@exosphere/client/ui.css"; 6 + import { SettingsLayout } from "./settings-layout.tsx"; 7 7 import { 8 8 getSphereMembers, 9 9 inviteMember, ··· 36 36 canDo("sphere", "updateMemberRole"); 37 37 if (!canManageMembers) return null; 38 38 39 - return <MembersContent handle={handle} sphereName={data.sphere.name} />; 39 + return ( 40 + <SettingsLayout active="members"> 41 + <MembersContent handle={handle} /> 42 + </SettingsLayout> 43 + ); 40 44 } 41 45 42 - function MembersContent({ handle, sphereName }: { handle: string; sphereName: string }) { 46 + function MembersContent({ handle }: { handle: string }) { 43 47 const members = useQuery(() => getSphereMembers(handle), [handle]); 44 48 const inviteIdentifier = useSignal(""); 45 49 const inviteRole = useSignal<"admin" | "member">("member"); ··· 88 92 }; 89 93 90 94 return ( 91 - <div class={ui.container}> 95 + <> 92 96 <div class={ui.section}> 93 - <a href={spherePath("/")} class={ui.muted}> 94 - &larr; {sphereName} 95 - </a> 96 - <h1 class={ui.pageTitle}>Members</h1> 97 + <h2 class={ui.sectionTitle}>Members</h2> 97 98 </div> 98 99 99 100 {members.loading && <p class={ui.muted}>Loading members...</p>} ··· 201 202 </form> 202 203 </div> 203 204 )} 204 - </div> 205 + </> 205 206 ); 206 207 }
+10 -9
packages/app/src/pages/sphere-permissions.tsx
··· 2 2 import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; 3 3 import { canDo } from "@exosphere/client/permissions"; 4 4 import { useQuery } from "@exosphere/client/hooks"; 5 - import { spherePath } from "@exosphere/client/router"; 6 5 import * as ui from "@exosphere/client/ui.css"; 6 + import { SettingsLayout } from "./settings-layout.tsx"; 7 7 import { getSpherePermissions, updateSpherePermissions } from "../api/spheres.ts"; 8 8 9 9 const roleLabels: Record<string, string> = { ··· 28 28 if (!data || !handle) return null; 29 29 if (!canDo("sphere", "updatePermissions")) return null; 30 30 31 - return <PermissionsContent handle={handle} sphereName={data.sphere.name} />; 31 + return ( 32 + <SettingsLayout active="permissions"> 33 + <PermissionsContent handle={handle} /> 34 + </SettingsLayout> 35 + ); 32 36 } 33 37 34 - function PermissionsContent({ handle, sphereName }: { handle: string; sphereName: string }) { 38 + function PermissionsContent({ handle }: { handle: string }) { 35 39 const perms = useQuery(() => getSpherePermissions(handle), [handle]); 36 40 const saving = useSignal(false); 37 41 const saveError = useSignal(""); ··· 60 64 const hasChanges = Object.keys(pendingChanges.value).length > 0; 61 65 62 66 return ( 63 - <div class={ui.container}> 67 + <> 64 68 <div class={ui.section}> 65 - <a href={spherePath("/")} class={ui.muted}> 66 - &larr; {sphereName} 67 - </a> 68 - <h1 class={ui.pageTitle}>Permissions</h1> 69 + <h2 class={ui.sectionTitle}>Permissions</h2> 69 70 </div> 70 71 71 72 {perms.loading && <p class={ui.muted}>Loading permissions...</p>} ··· 122 123 )} 123 124 </div> 124 125 )} 125 - </div> 126 + </> 126 127 ); 127 128 }
+197
packages/app/src/pages/sphere-settings.css.ts
··· 1 + import { style, globalStyle } from "@vanilla-extract/css"; 2 + import { vars } from "@exosphere/client/theme.css"; 3 + 4 + const bp = { 5 + mobile: "screen and (max-width: 860px)", 6 + }; 7 + 8 + export const settingsHero = style({ 9 + marginBlockEnd: vars.space.lg, 10 + }); 11 + 12 + export const settingsTitle = style({ 13 + fontFamily: vars.font.heading, 14 + fontWeight: 800, 15 + fontSize: "1.625rem", 16 + letterSpacing: "-0.02em", 17 + }); 18 + 19 + export const settingsSub = style({ 20 + color: vars.color.textMuted, 21 + fontSize: "0.9375rem", 22 + marginBlockStart: "6px", 23 + }); 24 + 25 + export const layout = style({ 26 + display: "grid", 27 + gridTemplateColumns: "220px 1fr", 28 + gap: vars.space.lg, 29 + "@media": { 30 + [bp.mobile]: { gridTemplateColumns: "1fr" }, 31 + }, 32 + }); 33 + 34 + export const rail = style({ 35 + display: "flex", 36 + flexDirection: "column", 37 + gap: "2px", 38 + backgroundColor: vars.color.surface, 39 + border: `1px solid ${vars.color.border}`, 40 + borderRadius: vars.radius.lg, 41 + padding: "8px", 42 + blockSize: "max-content", 43 + position: "sticky", 44 + insetBlockStart: "60px", 45 + "@media": { 46 + [bp.mobile]: { position: "static" }, 47 + }, 48 + }); 49 + 50 + export const railLink = style({ 51 + display: "flex", 52 + alignItems: "center", 53 + paddingBlock: "9px", 54 + paddingInline: "12px", 55 + borderRadius: "6px", 56 + color: vars.color.textMuted, 57 + fontSize: "0.875rem", 58 + fontWeight: 500, 59 + textDecoration: "none", 60 + transition: "color 0.15s, background-color 0.15s", 61 + ":hover": { 62 + backgroundColor: vars.color.surfaceHover, 63 + color: vars.color.text, 64 + textDecoration: "none", 65 + }, 66 + }); 67 + 68 + export const railLinkActive = style({ 69 + backgroundColor: vars.color.primaryLight, 70 + color: vars.color.primary, 71 + }); 72 + 73 + export const railLinkDanger = style({ 74 + color: vars.color.danger, 75 + }); 76 + 77 + export const railGroup = style({ 78 + paddingBlock: "10px 4px", 79 + paddingInline: "12px", 80 + fontSize: "0.6875rem", 81 + textTransform: "uppercase", 82 + letterSpacing: "0.07em", 83 + color: vars.color.textMuted, 84 + fontWeight: 600, 85 + }); 86 + 87 + export const content = style({ 88 + display: "flex", 89 + flexDirection: "column", 90 + gap: vars.space.md, 91 + minInlineSize: 0, 92 + }); 93 + 94 + export const card = style({ 95 + backgroundColor: vars.color.surface, 96 + border: `1px solid ${vars.color.border}`, 97 + borderRadius: vars.radius.lg, 98 + padding: vars.space.lg, 99 + display: "flex", 100 + flexDirection: "column", 101 + gap: vars.space.md, 102 + }); 103 + 104 + export const cardTitle = style({ 105 + fontFamily: vars.font.heading, 106 + fontSize: "1.125rem", 107 + fontWeight: 700, 108 + letterSpacing: "-0.01em", 109 + }); 110 + 111 + export const cardDesc = style({ 112 + color: vars.color.textMuted, 113 + fontSize: "0.8125rem", 114 + marginBlockStart: "2px", 115 + }); 116 + 117 + export const row = style({ 118 + display: "flex", 119 + alignItems: "flex-start", 120 + justifyContent: "space-between", 121 + gap: vars.space.md, 122 + paddingBlock: vars.space.md, 123 + borderBlockStart: `1px solid ${vars.color.border}`, 124 + selectors: { 125 + "&:first-child": { borderBlockStart: 0, paddingBlockStart: 0 }, 126 + "&:last-child": { paddingBlockEnd: 0 }, 127 + }, 128 + "@media": { 129 + ["screen and (max-width: 480px)"]: { 130 + flexDirection: "column", 131 + alignItems: "stretch", 132 + }, 133 + }, 134 + }); 135 + 136 + export const rowMeta = style({ 137 + flex: 1, 138 + minInlineSize: 0, 139 + }); 140 + 141 + export const rowName = style({ 142 + fontWeight: 600, 143 + fontSize: "0.9375rem", 144 + }); 145 + 146 + export const rowDesc = style({ 147 + fontSize: "0.8125rem", 148 + color: vars.color.textMuted, 149 + marginBlockStart: "4px", 150 + maxInlineSize: "440px", 151 + }); 152 + 153 + export const toggle = style({ 154 + position: "relative", 155 + inlineSize: "40px", 156 + blockSize: "22px", 157 + borderRadius: "999px", 158 + backgroundColor: vars.color.border, 159 + flex: "0 0 auto", 160 + border: "none", 161 + padding: 0, 162 + cursor: "pointer", 163 + transition: "background-color 0.2s", 164 + }); 165 + 166 + globalStyle(`${toggle}::after`, { 167 + content: "", 168 + position: "absolute", 169 + insetBlockStart: "2px", 170 + insetInlineStart: "2px", 171 + inlineSize: "18px", 172 + blockSize: "18px", 173 + borderRadius: "50%", 174 + backgroundColor: vars.color.text, 175 + transition: "transform 0.2s", 176 + }); 177 + 178 + globalStyle(`${toggle}[aria-checked="true"]`, { 179 + backgroundColor: vars.color.primary, 180 + }); 181 + 182 + globalStyle(`${toggle}[aria-checked="true"]::after`, { 183 + transform: "translateX(18px)", 184 + backgroundColor: "#fff", 185 + }); 186 + 187 + globalStyle(`${toggle}:disabled`, { 188 + opacity: 0.4, 189 + cursor: "not-allowed", 190 + }); 191 + 192 + export const comingSoon = style({ 193 + color: vars.color.textMuted, 194 + marginInlineStart: "6px", 195 + fontSize: "0.75rem", 196 + fontWeight: 500, 197 + });
+173
packages/app/src/pages/sphere-settings.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; 3 + import { canDo } from "@exosphere/client/permissions"; 4 + import { useLocation } from "@exosphere/client/router"; 5 + import { useQuery } from "@exosphere/client/hooks"; 6 + import * as ui from "@exosphere/client/ui.css"; 7 + import * as ss from "./sphere-settings.css.ts"; 8 + import { SettingsLayout } from "./settings-layout.tsx"; 9 + import { 10 + getSphereModules, 11 + enableModule as apiEnableModule, 12 + disableModule as apiDisableModule, 13 + } from "../api/spheres.ts"; 14 + 15 + const moduleDisplay: Record<string, { label: string; description: string; comingSoon?: boolean }> = 16 + { 17 + "feature-requests": { 18 + label: "Infuse", 19 + description: "Submit, vote on, and track feature requests from your community.", 20 + }, 21 + kanban: { 22 + label: "Flux", 23 + description: "Track tasks across columns with a visual project board.", 24 + }, 25 + feeds: { 26 + label: "Feeds & Discussions", 27 + description: "Announcements, Q&A, and open-ended threads in one place.", 28 + comingSoon: true, 29 + }, 30 + }; 31 + 32 + function GeneralSection() { 33 + const sphere = sphereState.value.data?.sphere; 34 + if (!sphere) return null; 35 + 36 + return ( 37 + <div class={ss.card}> 38 + <div> 39 + <h2 class={ss.cardTitle}>General</h2> 40 + <p class={ss.cardDesc}>Basic info shown to members and visitors.</p> 41 + </div> 42 + 43 + <div class={ss.row}> 44 + <div class={ss.rowMeta}> 45 + <div class={ss.rowName}>Name</div> 46 + <div class={ss.rowDesc}>The display name of this sphere.</div> 47 + </div> 48 + <span class={ui.muted}>{sphere.name}</span> 49 + </div> 50 + 51 + <div class={ss.row}> 52 + <div class={ss.rowMeta}> 53 + <div class={ss.rowName}>Handle</div> 54 + <div class={ss.rowDesc}> 55 + Used in URLs:{" "} 56 + <span style={{ fontFamily: "ui-monospace, Menlo, monospace" }}> 57 + /s/<b>{sphere.handle}</b> 58 + </span> 59 + </div> 60 + </div> 61 + <span class={ui.muted} style={{ fontFamily: "ui-monospace, Menlo, monospace" }}> 62 + {sphere.handle} 63 + </span> 64 + </div> 65 + 66 + {sphere.description && ( 67 + <div class={ss.row}> 68 + <div class={ss.rowMeta}> 69 + <div class={ss.rowName}>Description</div> 70 + <div class={ss.rowDesc}>A short summary of what this sphere is about.</div> 71 + </div> 72 + <span class={ui.muted}>{sphere.description}</span> 73 + </div> 74 + )} 75 + </div> 76 + ); 77 + } 78 + 79 + function ModulesSection({ handle }: { handle: string }) { 80 + const modules = useQuery(() => getSphereModules(handle), [handle]); 81 + const canEnable = canDo("sphere", "enableModule"); 82 + const canDisable = canDo("sphere", "disableModule"); 83 + const toggleError = useSignal(""); 84 + 85 + const enabledNames = new Set(modules.data?.modules.map((m) => m.name) ?? []); 86 + const allKnown = ["feature-requests", "kanban", "feeds"]; 87 + const available = modules.data?.available ?? []; 88 + 89 + // Show enabled + available + any known (coming soon) that aren't available yet. 90 + const rows = Array.from( 91 + new Set([...enabledNames, ...available, ...allKnown.filter((n) => !enabledNames.has(n))]), 92 + ); 93 + 94 + const toggle = async (name: string, nextEnabled: boolean) => { 95 + toggleError.value = ""; 96 + try { 97 + if (nextEnabled) { 98 + await apiEnableModule(handle, name); 99 + } else { 100 + await apiDisableModule(handle, name); 101 + } 102 + modules.refetch(); 103 + refreshSphere(); 104 + } catch (err) { 105 + toggleError.value = err instanceof Error ? err.message : "Failed to update module."; 106 + modules.refetch(); 107 + } 108 + }; 109 + 110 + return ( 111 + <div class={ss.card}> 112 + <div> 113 + <h2 class={ss.cardTitle}>Modules</h2> 114 + <p class={ss.cardDesc}> 115 + Add or remove feature modules. Enabled modules appear in the sphere nav. 116 + </p> 117 + </div> 118 + 119 + {toggleError.value && <p class={ui.errorText}>{toggleError.value}</p>} 120 + 121 + {rows.map((name) => { 122 + const display = moduleDisplay[name] ?? { label: name, description: "" }; 123 + const isEnabled = enabledNames.has(name); 124 + const isComingSoon = display.comingSoon === true; 125 + const canToggle = 126 + !isComingSoon && (isEnabled ? canDisable : canEnable) && available.includes(name); 127 + return ( 128 + <div class={ss.row} key={name}> 129 + <div class={ss.rowMeta}> 130 + <div class={ss.rowName}> 131 + {display.label} 132 + {isComingSoon && <span class={ss.comingSoon}>Coming soon</span>} 133 + </div> 134 + <div class={ss.rowDesc}>{display.description}</div> 135 + </div> 136 + <button 137 + type="button" 138 + class={ss.toggle} 139 + role="switch" 140 + aria-checked={isEnabled} 141 + aria-label={`Toggle ${display.label}`} 142 + disabled={!canToggle} 143 + onClick={() => canToggle && toggle(name, !isEnabled)} 144 + /> 145 + </div> 146 + ); 147 + })} 148 + </div> 149 + ); 150 + } 151 + 152 + function resolveSection(path: string): "general" | "modules" { 153 + const stripped = path.replace(/^\/s\/[^/]+/, "") || "/"; 154 + if (stripped === "/settings/modules") return "modules"; 155 + return "general"; 156 + } 157 + 158 + export function SphereSettingsPage() { 159 + const { path } = useLocation(); 160 + const { data } = sphereState.value; 161 + const handle = sphereHandle.value; 162 + 163 + if (!data || !handle) return null; 164 + 165 + const section = resolveSection(path ?? ""); 166 + 167 + return ( 168 + <SettingsLayout active={section}> 169 + {section === "general" && <GeneralSection />} 170 + {section === "modules" && <ModulesSection handle={handle} />} 171 + </SettingsLayout> 172 + ); 173 + }
+397
packages/app/src/pages/sphere.css.ts
··· 1 + import { style, styleVariants, globalStyle } from "@vanilla-extract/css"; 2 + import { vars } from "@exosphere/client/theme.css"; 3 + 4 + // ---- Sphere hero ---- 5 + 6 + export const hero = style({ 7 + display: "flex", 8 + alignItems: "flex-start", 9 + justifyContent: "space-between", 10 + gap: vars.space.lg, 11 + marginBlockEnd: vars.space.xl, 12 + flexWrap: "wrap", 13 + }); 14 + 15 + export const heroLeft = style({ 16 + minInlineSize: 0, 17 + maxInlineSize: "640px", 18 + }); 19 + 20 + export const heroSub = style({ 21 + color: vars.color.textMuted, 22 + fontSize: "0.9375rem", 23 + }); 24 + 25 + // ---- Widget grid ---- 26 + 27 + export const widgetGrid = style({ 28 + display: "flex", 29 + flexDirection: "column", 30 + gap: vars.space.xl, 31 + }); 32 + 33 + export const widget = style({ 34 + backgroundColor: vars.color.surface, 35 + border: `1px solid ${vars.color.border}`, 36 + borderRadius: vars.radius.lg, 37 + padding: vars.space.lg, 38 + display: "flex", 39 + flexDirection: "column", 40 + gap: vars.space.md, 41 + minInlineSize: 0, 42 + transition: "border-color 0.2s, box-shadow 0.2s", 43 + }); 44 + 45 + export const widgetHead = style({ 46 + display: "flex", 47 + alignItems: "center", 48 + justifyContent: "space-between", 49 + gap: vars.space.sm, 50 + }); 51 + 52 + export const widgetTitle = style({ 53 + fontFamily: vars.font.heading, 54 + fontSize: "1rem", 55 + fontWeight: 700, 56 + letterSpacing: "-0.01em", 57 + }); 58 + 59 + export const widgetSub = style({ 60 + color: vars.color.textMuted, 61 + fontSize: "0.8125rem", 62 + marginBlockStart: "2px", 63 + }); 64 + 65 + export const widgetLink = style({ 66 + fontSize: "0.8125rem", 67 + color: vars.color.textMuted, 68 + textDecoration: "none", 69 + ":hover": { color: vars.color.primary, textDecoration: "none" }, 70 + }); 71 + 72 + export const widgetActions = style({ 73 + display: "inline-flex", 74 + alignItems: "center", 75 + gap: vars.space.sm, 76 + }); 77 + 78 + export const widgetNewBtn = style({ 79 + display: "inline-flex", 80 + alignItems: "center", 81 + paddingBlock: "4px", 82 + paddingInline: "10px", 83 + border: `1px solid ${vars.color.border}`, 84 + borderRadius: vars.radius.sm, 85 + fontSize: "0.75rem", 86 + fontWeight: 500, 87 + color: vars.color.text, 88 + backgroundColor: vars.color.surface, 89 + textDecoration: "none", 90 + ":hover": { 91 + borderColor: vars.color.primary, 92 + backgroundColor: vars.color.surfaceHover, 93 + textDecoration: "none", 94 + }, 95 + }); 96 + 97 + // ---- KPI strip (joined cards with 1px hairline gap) ---- 98 + 99 + export const kpis = style({ 100 + display: "grid", 101 + gap: "1px", 102 + gridTemplateColumns: "repeat(4, 1fr)", 103 + backgroundColor: vars.color.border, 104 + border: `1px solid ${vars.color.border}`, 105 + borderRadius: vars.radius.md, 106 + overflow: "hidden", 107 + }); 108 + 109 + export const kpi = style({ 110 + backgroundColor: vars.color.bg, 111 + paddingBlock: "12px", 112 + paddingInline: "14px", 113 + display: "flex", 114 + flexDirection: "column", 115 + gap: "2px", 116 + }); 117 + 118 + const kpiNumBase = { 119 + fontFamily: vars.font.heading, 120 + fontWeight: 900, 121 + fontSize: "1.5rem", 122 + letterSpacing: "-0.02em", 123 + fontVariantNumeric: "tabular-nums" as const, 124 + }; 125 + 126 + export const kpiNum = styleVariants({ 127 + default: kpiNumBase, 128 + primary: { ...kpiNumBase, color: vars.color.primary }, 129 + warning: { ...kpiNumBase, color: vars.color.warning }, 130 + success: { ...kpiNumBase, color: vars.color.success }, 131 + danger: { ...kpiNumBase, color: vars.color.danger }, 132 + }); 133 + 134 + export const kpiLabel = style({ 135 + fontSize: "0.6875rem", 136 + color: vars.color.textMuted, 137 + textTransform: "uppercase", 138 + letterSpacing: "0.06em", 139 + fontWeight: 600, 140 + }); 141 + 142 + // ---- Status bar chart ---- 143 + 144 + export const statusBar = style({ 145 + display: "flex", 146 + blockSize: "8px", 147 + borderRadius: "999px", 148 + overflow: "hidden", 149 + backgroundColor: vars.color.border, 150 + }); 151 + 152 + export const statusBarSeg = style({ 153 + display: "block", 154 + blockSize: "100%", 155 + minInlineSize: 0, 156 + }); 157 + 158 + globalStyle(`${statusBarSeg}[data-s="requested"]`, { backgroundColor: vars.color.primary }); 159 + globalStyle(`${statusBarSeg}[data-s="approved"]`, { backgroundColor: "#3b82f6" }); 160 + globalStyle(`${statusBarSeg}[data-s="in-progress"]`, { backgroundColor: vars.color.warning }); 161 + globalStyle(`${statusBarSeg}[data-s="done"]`, { backgroundColor: vars.color.success }); 162 + globalStyle(`${statusBarSeg}[data-s="not-planned"]`, { backgroundColor: vars.color.danger }); 163 + 164 + export const legend = style({ 165 + display: "flex", 166 + flexWrap: "wrap", 167 + gap: "10px 14px", 168 + fontSize: "0.75rem", 169 + color: vars.color.textMuted, 170 + marginBlockStart: "10px", 171 + }); 172 + 173 + export const legendItem = style({ 174 + display: "inline-flex", 175 + alignItems: "center", 176 + gap: "6px", 177 + }); 178 + 179 + export const legendDot = style({ 180 + inlineSize: "8px", 181 + blockSize: "8px", 182 + borderRadius: "2px", 183 + display: "inline-block", 184 + }); 185 + 186 + globalStyle(`${legendDot}[data-s="requested"]`, { backgroundColor: vars.color.primary }); 187 + globalStyle(`${legendDot}[data-s="approved"]`, { backgroundColor: "#3b82f6" }); 188 + globalStyle(`${legendDot}[data-s="in-progress"]`, { backgroundColor: vars.color.warning }); 189 + globalStyle(`${legendDot}[data-s="done"]`, { backgroundColor: vars.color.success }); 190 + globalStyle(`${legendDot}[data-s="not-planned"]`, { backgroundColor: vars.color.danger }); 191 + 192 + // ---- Flux columns (joined strip, like KPIs) ---- 193 + 194 + export const fluxColumns = style({ 195 + display: "grid", 196 + gap: "1px", 197 + gridTemplateColumns: "repeat(4, 1fr)", 198 + backgroundColor: vars.color.border, 199 + border: `1px solid ${vars.color.border}`, 200 + borderRadius: vars.radius.md, 201 + overflow: "hidden", 202 + "@media": { 203 + ["screen and (max-width: 480px)"]: { 204 + gridTemplateColumns: "repeat(2, 1fr)", 205 + }, 206 + }, 207 + }); 208 + 209 + export const fluxCol = style({ 210 + backgroundColor: vars.color.bg, 211 + paddingBlock: "12px", 212 + paddingInline: "14px", 213 + display: "flex", 214 + flexDirection: "column", 215 + gap: "2px", 216 + }); 217 + 218 + export const fluxColName = style({ 219 + fontSize: "0.6875rem", 220 + textTransform: "uppercase", 221 + letterSpacing: "0.06em", 222 + color: vars.color.textMuted, 223 + fontWeight: 600, 224 + }); 225 + 226 + export const fluxColCount = style({ 227 + fontFamily: vars.font.heading, 228 + fontWeight: 900, 229 + fontSize: "1.5rem", 230 + letterSpacing: "-0.02em", 231 + fontVariantNumeric: "tabular-nums", 232 + }); 233 + 234 + // ---- List (latest requests / tasks) ---- 235 + 236 + export const listHeading = style({ 237 + fontSize: "0.75rem", 238 + textTransform: "uppercase", 239 + letterSpacing: "0.06em", 240 + color: vars.color.textMuted, 241 + fontWeight: 600, 242 + marginBlockEnd: "6px", 243 + }); 244 + 245 + export const list = style({ 246 + display: "flex", 247 + flexDirection: "column", 248 + listStyle: "none", 249 + padding: 0, 250 + margin: 0, 251 + }); 252 + 253 + export const listItem = style({ 254 + display: "flex", 255 + alignItems: "center", 256 + gap: vars.space.sm, 257 + paddingBlock: "10px", 258 + borderBlockStart: `1px solid ${vars.color.border}`, 259 + selectors: { 260 + "&:first-child": { borderBlockStart: 0 }, 261 + }, 262 + }); 263 + 264 + export const listTitleCell = style({ 265 + flex: 1, 266 + minInlineSize: 0, 267 + }); 268 + 269 + export const listTitle = style({ 270 + fontSize: "0.875rem", 271 + fontWeight: 500, 272 + color: vars.color.text, 273 + display: "block", 274 + overflow: "hidden", 275 + textOverflow: "ellipsis", 276 + whiteSpace: "nowrap", 277 + textDecoration: "none", 278 + ":hover": { color: vars.color.primary, textDecoration: "none" }, 279 + }); 280 + 281 + export const listSub = style({ 282 + fontSize: "0.75rem", 283 + color: vars.color.textMuted, 284 + marginBlockStart: "2px", 285 + }); 286 + 287 + export const listRightMeta = style({ 288 + display: "flex", 289 + alignItems: "center", 290 + gap: vars.space.sm, 291 + whiteSpace: "nowrap", 292 + }); 293 + 294 + // ---- Status pill ---- 295 + 296 + const statusPillBase = { 297 + display: "inline-block" as const, 298 + paddingBlock: "2px", 299 + paddingInline: "7px", 300 + borderRadius: "4px", 301 + fontSize: "0.6875rem", 302 + fontWeight: 600, 303 + letterSpacing: "0.02em", 304 + lineHeight: 1, 305 + textTransform: "uppercase" as const, 306 + }; 307 + 308 + export const statusPillRequested = style({ 309 + ...statusPillBase, 310 + backgroundColor: vars.color.primaryLight, 311 + color: vars.color.primary, 312 + }); 313 + export const statusPillApproved = style({ 314 + ...statusPillBase, 315 + backgroundColor: vars.color.primaryLight, 316 + color: vars.color.primary, 317 + }); 318 + export const statusPillInProgress = style({ 319 + ...statusPillBase, 320 + backgroundColor: vars.color.warningLight, 321 + color: vars.color.warning, 322 + }); 323 + export const statusPillDone = style({ 324 + ...statusPillBase, 325 + backgroundColor: vars.color.successLight, 326 + color: vars.color.success, 327 + }); 328 + export const statusPillNotPlanned = style({ 329 + ...statusPillBase, 330 + backgroundColor: vars.color.dangerLight, 331 + color: vars.color.danger, 332 + }); 333 + export const statusPillNeutral = style({ 334 + ...statusPillBase, 335 + backgroundColor: vars.color.surfaceHover, 336 + color: vars.color.textMuted, 337 + }); 338 + 339 + // ---- Vote chip ---- 340 + 341 + export const voteChip = style({ 342 + display: "inline-flex", 343 + alignItems: "center", 344 + justifyContent: "center", 345 + paddingBlock: "2px", 346 + paddingInline: "4px", 347 + borderRadius: "4px", 348 + backgroundColor: vars.color.bg, 349 + border: `1px solid ${vars.color.border}`, 350 + fontSize: "0.6875rem", 351 + fontWeight: 600, 352 + color: vars.color.textMuted, 353 + fontVariantNumeric: "tabular-nums", 354 + minInlineSize: "26px", 355 + blockSize: "22px", 356 + }); 357 + 358 + // ---- Empty / not-enabled state ---- 359 + 360 + export const modulesEmpty = style({ 361 + padding: vars.space.xl, 362 + textAlign: "center", 363 + border: `1px dashed ${vars.color.border}`, 364 + borderRadius: vars.radius.lg, 365 + backgroundColor: vars.color.surface, 366 + gridColumn: "1 / -1", 367 + }); 368 + 369 + export const modulesEmptyTitle = style({ 370 + fontFamily: vars.font.heading, 371 + fontSize: "1.125rem", 372 + fontWeight: 700, 373 + marginBlockEnd: "6px", 374 + }); 375 + 376 + export const modulesEmptyDesc = style({ 377 + color: vars.color.textMuted, 378 + fontSize: "0.8125rem", 379 + maxInlineSize: "420px", 380 + marginInline: "auto", 381 + marginBlockEnd: vars.space.md, 382 + }); 383 + 384 + // ---- Sphere-aware container (full-width dashboard layout) ---- 385 + 386 + export const spherePage = style({ 387 + maxInlineSize: "1120px", 388 + marginInline: "auto", 389 + paddingInline: vars.space.lg, 390 + paddingBlockStart: vars.space.xl, 391 + paddingBlockEnd: vars.space.xxl, 392 + "@media": { 393 + "screen and (max-width: 640px)": { 394 + paddingInline: vars.space.md, 395 + }, 396 + }, 397 + });
+321 -150
packages/app/src/pages/sphere.tsx
··· 1 - import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; 2 - import { canDo } from "@exosphere/client/permissions"; 1 + import { useEffect } from "preact/hooks"; 2 + import { sphereState, sphereHandle } from "@exosphere/client/sphere"; 3 3 import { useQuery } from "@exosphere/client/hooks"; 4 4 import { spherePath } from "@exosphere/client/router"; 5 5 import { Link } from "@exosphere/client/link"; 6 + import { ssrPageData } from "@exosphere/client/ssr-data"; 6 7 import * as ui from "@exosphere/client/ui.css"; 8 + import * as s from "./sphere.css.ts"; 7 9 import { 8 - getSphereModules, 9 - enableModule as apiEnableModule, 10 - disableModule as apiDisableModule, 11 - } from "../api/spheres.ts"; 10 + getInfuseStats, 11 + getFluxStats, 12 + type InfuseStats, 13 + type FluxStats, 14 + type KanbanStatusType, 15 + type FrStatus, 16 + } from "../api/sphere-dashboard.ts"; 12 17 13 - /** Map internal module names to user-facing labels (used for display and URL paths). */ 14 - const moduleLabels: Record<string, { label: string; path: string; description: string }> = { 15 - // feeds: { 16 - // label: "Feeds", 17 - // path: "feeds", 18 - // description: "Discuss topics and share updates with the community.", 19 - // }, 20 - "feature-requests": { 21 - label: "Infuse", 22 - path: "infuse", 23 - description: "Submit, vote on, and track feature requests.", 24 - }, 25 - kanban: { 26 - label: "Flux", 27 - path: "flux", 28 - description: "Track tasks across columns with a visual project board.", 29 - }, 18 + const FR_STATUS_SEGMENTS: { key: FrStatus; label: string }[] = [ 19 + { key: "requested", label: "Requested" }, 20 + { key: "approved", label: "Approved" }, 21 + { key: "in-progress", label: "In progress" }, 22 + { key: "done", label: "Done" }, 23 + { key: "not-planned", label: "Not planned" }, 24 + ]; 25 + 26 + const FR_PILL_BY_STATUS: Record<FrStatus, string> = { 27 + requested: s.statusPillRequested, 28 + approved: s.statusPillApproved, 29 + "in-progress": s.statusPillInProgress, 30 + done: s.statusPillDone, 31 + "not-planned": s.statusPillNotPlanned, 32 + duplicate: s.statusPillNeutral, 30 33 }; 31 34 32 - export function SpherePage() { 33 - const { data, loading } = sphereState.value; 34 - const handle = sphereHandle.value; 35 + const FR_STATUS_LABEL: Record<FrStatus, string> = { 36 + requested: "Requested", 37 + approved: "Approved", 38 + "in-progress": "In progress", 39 + done: "Done", 40 + "not-planned": "Not planned", 41 + duplicate: "Duplicate", 42 + }; 35 43 36 - if (!handle) return null; 44 + // StatusType → dashboard column label (matches the design wording). 45 + const FLUX_COLUMNS: { type: KanbanStatusType; label: string }[] = [ 46 + { type: "backlog", label: "Backlog" }, 47 + { type: "planned", label: "To do" }, 48 + { type: "started", label: "Started" }, 49 + { type: "completed", label: "Done" }, 50 + ]; 37 51 38 - if (!data) { 39 - if (!loading) return null; 40 - return ( 41 - <div class={ui.container}> 42 - <div class={ui.section}> 43 - <div class={ui.row}> 44 - <div class={ui.skeletonLine} style={{ inlineSize: "200px", blockSize: "1.625rem" }} /> 45 - <div class={ui.skeletonLine} style={{ inlineSize: "60px", blockSize: "1.25rem" }} /> 46 - </div> 47 - <div class={ui.skeletonLine} style={{ inlineSize: "80%" }} /> 48 - </div> 49 - <div class={ui.section}> 50 - <div class={ui.skeletonLine} style={{ inlineSize: "100px", blockSize: "1.125rem" }} /> 51 - <div class={ui.stackSm}> 52 - {[0, 1].map((i) => ( 53 - <div key={i} class={ui.card} style={{ pointerEvents: "none" }}> 54 - <div class={ui.skeletonLine} style={{ inlineSize: "40%" }} /> 55 - <div 56 - class={ui.skeletonLine} 57 - style={{ inlineSize: "70%", marginBlockStart: "8px" }} 58 - /> 59 - </div> 60 - ))} 61 - </div> 62 - </div> 63 - </div> 64 - ); 65 - } 52 + // StatusType → pill variant used for latest tasks. 53 + const FLUX_STATUS_PILL: Record<KanbanStatusType, string> = { 54 + backlog: s.statusPillNeutral, 55 + planned: s.statusPillApproved, 56 + started: s.statusPillInProgress, 57 + completed: s.statusPillDone, 58 + canceled: s.statusPillNotPlanned, 59 + }; 66 60 67 - const modules = useQuery(() => getSphereModules(handle), [handle]); 61 + function InfuseWidget({ handle }: { handle: string }) { 62 + const ssr = ssrPageData.peek()?.["infuse-stats"] as InfuseStats | undefined; 63 + // Consume the SSR key after first render so subsequent re-navigations refetch 64 + useEffect(() => { 65 + const pd = ssrPageData.peek(); 66 + if (pd && "infuse-stats" in pd) delete pd["infuse-stats"]; 67 + }, []); 68 + const { data } = useQuery(() => getInfuseStats(), [handle], { 69 + initialData: ssr, 70 + cacheKey: `infuse-stats:${handle}`, 71 + }); 68 72 69 - const canManageMembers = 70 - canDo("sphere", "inviteMember") || 71 - canDo("sphere", "revokeMember") || 72 - canDo("sphere", "updateMemberRole"); 73 + return ( 74 + <section class={s.widget} aria-label="Infuse"> 75 + <header class={s.widgetHead}> 76 + <div> 77 + <div class={s.widgetTitle}>Infuse</div> 78 + <div class={s.widgetSub}>Feature requests</div> 79 + </div> 80 + <div class={s.widgetActions}> 81 + <Link href={spherePath("/infuse?new=1")} class={s.widgetNewBtn}> 82 + + New 83 + </Link> 84 + <Link href={spherePath("/infuse")} class={s.widgetLink}> 85 + Open &rarr; 86 + </Link> 87 + </div> 88 + </header> 73 89 74 - const enableModule = async (moduleName: string) => { 75 - await apiEnableModule(handle, moduleName); 76 - modules.refetch(); 77 - refreshSphere(); 78 - }; 90 + {data ? ( 91 + <> 92 + <div class={s.kpis}> 93 + <div class={s.kpi}> 94 + <span class={s.kpiLabel}>Total</span> 95 + <span class={s.kpiNum.default}>{data.total}</span> 96 + </div> 97 + <div class={s.kpi}> 98 + <span class={s.kpiLabel}>Requested</span> 99 + <span class={s.kpiNum.primary}>{data.statusCounts.requested}</span> 100 + </div> 101 + <div class={s.kpi}> 102 + <span class={s.kpiLabel}>In progress</span> 103 + <span class={s.kpiNum.warning}>{data.statusCounts["in-progress"]}</span> 104 + </div> 105 + <div class={s.kpi}> 106 + <span class={s.kpiLabel}>Done</span> 107 + <span class={s.kpiNum.success}>{data.statusCounts.done}</span> 108 + </div> 109 + </div> 79 110 80 - const disableModule = async (moduleName: string) => { 81 - await apiDisableModule(handle, moduleName); 82 - modules.refetch(); 83 - refreshSphere(); 84 - }; 111 + <StatusBar counts={data.statusCounts} /> 85 112 86 - const { sphere: s, modules: enabledModules } = data; 87 - const enabledNames = enabledModules.map((m) => m.name); 88 - const availableToEnable = modules.data?.available.filter((a) => !enabledNames.includes(a)) ?? []; 113 + <div> 114 + <h3 class={s.listHeading}>Latest requests</h3> 115 + {data.latestRequests.length === 0 ? ( 116 + <p class={ui.muted}>No feature requests yet.</p> 117 + ) : ( 118 + <ul class={s.list}> 119 + {data.latestRequests.map((req) => ( 120 + <li key={req.id} class={s.listItem}> 121 + <span class={s.voteChip}>{req.voteCount}</span> 122 + <div class={s.listTitleCell}> 123 + <Link href={spherePath(`/infuse/${req.number}`)} class={s.listTitle}> 124 + {req.title} 125 + </Link> 126 + {req.authorHandle && <span class={s.listSub}>by @{req.authorHandle}</span>} 127 + </div> 128 + <div class={s.listRightMeta}> 129 + <span class={FR_PILL_BY_STATUS[req.status] ?? s.statusPillNeutral}> 130 + {FR_STATUS_LABEL[req.status] ?? req.status} 131 + </span> 132 + </div> 133 + </li> 134 + ))} 135 + </ul> 136 + )} 137 + </div> 138 + </> 139 + ) : ( 140 + <InfuseSkeleton /> 141 + )} 142 + </section> 143 + ); 144 + } 89 145 146 + function StatusBar({ counts }: { counts: Record<FrStatus, number> }) { 147 + const total = FR_STATUS_SEGMENTS.reduce((sum, seg) => sum + (counts[seg.key] ?? 0), 0); 90 148 return ( 91 - <div class={ui.container}> 92 - <div class={ui.section}> 93 - <div class={ui.row}> 94 - <h1 class={ui.pageTitle}>{s.name}</h1> 95 - <span class={ui.badge}>{s.visibility}</span> 149 + <div> 150 + <div class={s.statusBar} aria-label="Request status breakdown"> 151 + {FR_STATUS_SEGMENTS.map((seg) => { 152 + const n = counts[seg.key] ?? 0; 153 + if (n === 0) return null; 154 + return <span key={seg.key} class={s.statusBarSeg} data-s={seg.key} style={{ flex: n }} />; 155 + })} 156 + </div> 157 + {total > 0 && ( 158 + <div class={s.legend}> 159 + {FR_STATUS_SEGMENTS.map((seg) => ( 160 + <span key={seg.key} class={s.legendItem}> 161 + <span class={s.legendDot} data-s={seg.key} /> 162 + {seg.label} {counts[seg.key] ?? 0} 163 + </span> 164 + ))} 96 165 </div> 166 + )} 167 + </div> 168 + ); 169 + } 97 170 98 - {s.description && <p class={ui.description}>{s.description}</p>} 171 + function InfuseSkeleton() { 172 + return ( 173 + <> 174 + <div class={s.kpis}> 175 + {[0, 1, 2, 3].map((i) => ( 176 + <div key={i} class={s.kpi}> 177 + <span class={s.kpiLabel}>&nbsp;</span> 178 + <span class={s.kpiNum.default}>&nbsp;</span> 179 + </div> 180 + ))} 99 181 </div> 182 + <div class={ui.skeletonLine} style={{ blockSize: "8px", borderRadius: "999px" }} /> 183 + <div> 184 + <h3 class={s.listHeading}>Latest requests</h3> 185 + <ul class={s.list}> 186 + {[0, 1, 2, 3].map((i) => ( 187 + <li key={i} class={s.listItem}> 188 + <span class={s.voteChip}>&nbsp;</span> 189 + <div class={s.listTitleCell}> 190 + <span class={s.listTitle}>&nbsp;</span> 191 + <span class={s.listSub}>&nbsp;</span> 192 + </div> 193 + </li> 194 + ))} 195 + </ul> 196 + </div> 197 + </> 198 + ); 199 + } 100 200 101 - {/* Modules section */} 102 - <div class={ui.section}> 103 - <h2 class={ui.sectionTitle}>Modules</h2> 201 + function FluxWidget({ handle }: { handle: string }) { 202 + const ssr = ssrPageData.peek()?.["flux-stats"] as FluxStats | undefined; 203 + useEffect(() => { 204 + const pd = ssrPageData.peek(); 205 + if (pd && "flux-stats" in pd) delete pd["flux-stats"]; 206 + }, []); 207 + const { data } = useQuery(() => getFluxStats(), [handle], { 208 + initialData: ssr, 209 + cacheKey: `flux-stats:${handle}`, 210 + }); 104 211 105 - {enabledModules.length === 0 && availableToEnable.length === 0 && ( 106 - <p class={ui.emptyState}>No modules available.</p> 107 - )} 212 + return ( 213 + <section class={s.widget} aria-label="Flux"> 214 + <header class={s.widgetHead}> 215 + <div> 216 + <div class={s.widgetTitle}>Flux</div> 217 + <div class={s.widgetSub}>Project board</div> 218 + </div> 219 + <div class={s.widgetActions}> 220 + <Link href={spherePath("/flux?new=1")} class={s.widgetNewBtn}> 221 + + New 222 + </Link> 223 + <Link href={spherePath("/flux")} class={s.widgetLink}> 224 + Open &rarr; 225 + </Link> 226 + </div> 227 + </header> 108 228 109 - {enabledModules.length > 0 && ( 110 - <div class={ui.stackSm}> 111 - {enabledModules.map((mod) => ( 112 - <div class={ui.card} key={mod.name}> 113 - <div class={ui.row}> 114 - <div> 115 - <Link href={spherePath(`/${moduleLabels[mod.name]?.path ?? mod.name}`)}> 116 - <strong>{moduleLabels[mod.name]?.label ?? mod.name}</strong> 117 - </Link> 118 - <span class={`${ui.muted} ${ui.inlineTag}`}>enabled</span> 119 - </div> 120 - {canDo("sphere", "disableModule") && ( 121 - <button class={ui.buttonDanger} onClick={() => disableModule(mod.name)}> 122 - Disable 123 - </button> 124 - )} 125 - </div> 126 - {moduleLabels[mod.name]?.description && ( 127 - <p class={ui.muted}>{moduleLabels[mod.name].description}</p> 128 - )} 229 + {data ? ( 230 + <> 231 + <div class={s.fluxColumns}> 232 + {FLUX_COLUMNS.map((col) => ( 233 + <div key={col.type} class={s.fluxCol}> 234 + <div class={s.fluxColName}>{col.label}</div> 235 + <div class={s.fluxColCount}>{data.statusTypeCounts[col.type] ?? 0}</div> 129 236 </div> 130 237 ))} 131 238 </div> 132 - )} 239 + 240 + <div> 241 + <h3 class={s.listHeading}>Latest tasks</h3> 242 + {data.latestTasks.length === 0 ? ( 243 + <p class={ui.muted}>No tasks yet.</p> 244 + ) : ( 245 + <ul class={s.list}> 246 + {data.latestTasks.map((task) => ( 247 + <li key={task.id} class={s.listItem}> 248 + <div class={s.listTitleCell}> 249 + <Link href={spherePath(`/flux/${task.number}`)} class={s.listTitle}> 250 + {task.title} 251 + </Link> 252 + {task.authorHandle && <span class={s.listSub}>@{task.authorHandle}</span>} 253 + </div> 254 + <div class={s.listRightMeta}> 255 + <span class={FLUX_STATUS_PILL[task.statusType] ?? s.statusPillNeutral}> 256 + {task.statusLabel} 257 + </span> 258 + </div> 259 + </li> 260 + ))} 261 + </ul> 262 + )} 263 + </div> 264 + </> 265 + ) : ( 266 + <FluxSkeleton /> 267 + )} 268 + </section> 269 + ); 270 + } 133 271 134 - {canDo("sphere", "enableModule") && availableToEnable.length > 0 && ( 135 - <div class={ui.stackSm}> 136 - <h3 class={ui.subsectionTitle}>Available modules</h3> 137 - <div class={ui.stackSm}> 138 - {availableToEnable.map((name) => ( 139 - <div class={ui.card} key={name}> 140 - <div class={ui.row}> 141 - <strong>{moduleLabels[name]?.label ?? name}</strong> 142 - <button class={ui.buttonSecondary} onClick={() => enableModule(name)}> 143 - Enable 144 - </button> 145 - </div> 146 - {moduleLabels[name]?.description && ( 147 - <p class={ui.muted}>{moduleLabels[name].description}</p> 148 - )} 149 - </div> 150 - ))} 151 - </div> 272 + function FluxSkeleton() { 273 + return ( 274 + <> 275 + <div class={s.fluxColumns}> 276 + {FLUX_COLUMNS.map((col) => ( 277 + <div key={col.type} class={s.fluxCol}> 278 + <div class={s.fluxColName}>{col.label}</div> 279 + <div class={s.fluxColCount}>&nbsp;</div> 152 280 </div> 153 - )} 281 + ))} 282 + </div> 283 + <div> 284 + <h3 class={s.listHeading}>Latest tasks</h3> 285 + <ul class={s.list}> 286 + {[0, 1, 2, 3].map((i) => ( 287 + <li key={i} class={s.listItem}> 288 + <div class={s.listTitleCell}> 289 + <span class={s.listTitle}>&nbsp;</span> 290 + <span class={s.listSub}>&nbsp;</span> 291 + </div> 292 + </li> 293 + ))} 294 + </ul> 154 295 </div> 296 + </> 297 + ); 298 + } 299 + 300 + function EnabledModulesEmpty() { 301 + return ( 302 + <div class={s.modulesEmpty}> 303 + <div class={s.modulesEmptyTitle}>No modules enabled yet</div> 304 + <p class={s.modulesEmptyDesc}> 305 + Add modules to turn your sphere into a place for feedback or planning. You can enable or 306 + disable them anytime. 307 + </p> 308 + <Link href={spherePath("/settings/modules")} class={ui.button}> 309 + Go to Settings, Modules 310 + </Link> 311 + </div> 312 + ); 313 + } 314 + 315 + export function SpherePage() { 316 + const { data, loading } = sphereState.value; 317 + const handle = sphereHandle.value; 155 318 156 - {/* Settings — visible to users with relevant permissions */} 157 - {(canManageMembers || 158 - canDo("sphere", "updatePermissions") || 159 - canDo("sphere", "manageLabels")) && ( 160 - <div class={ui.section}> 161 - <h2 class={ui.sectionTitle}>Settings</h2> 162 - <div class={ui.stackSm}> 163 - {canManageMembers && ( 164 - <a href={spherePath("/settings/members")} class={ui.cardLink}> 165 - <strong>Members</strong> 166 - <p class={ui.muted}>Invite, manage roles, and revoke members.</p> 167 - </a> 168 - )} 169 - {canDo("sphere", "updatePermissions") && ( 170 - <a href={spherePath("/settings/permissions")} class={ui.cardLink}> 171 - <strong>Permissions</strong> 172 - <p class={ui.muted}>Configure who can perform actions in this sphere.</p> 173 - </a> 174 - )} 175 - {canDo("sphere", "manageLabels") && ( 176 - <a href={spherePath("/settings/labels")} class={ui.cardLink}> 177 - <strong>Labels</strong> 178 - <p class={ui.muted}>Create and manage labels for feature requests and tasks.</p> 179 - </a> 180 - )} 319 + if (!handle) return null; 320 + 321 + if (!data) { 322 + if (!loading) return null; 323 + return ( 324 + <div class={s.spherePage}> 325 + <div class={s.hero}> 326 + <div class={s.heroLeft}> 327 + <div class={ui.skeletonLine} style={{ inlineSize: "320px" }} /> 181 328 </div> 182 329 </div> 330 + </div> 331 + ); 332 + } 333 + 334 + const enabledNames = new Set(data.modules.map((m) => m.name)); 335 + const hasInfuse = enabledNames.has("feature-requests"); 336 + const hasFlux = enabledNames.has("kanban"); 337 + const hasAnyModule = hasInfuse || hasFlux; 338 + const sphere = data.sphere; 339 + 340 + return ( 341 + <div class={s.spherePage}> 342 + {sphere.description && ( 343 + <section class={s.hero}> 344 + <div class={s.heroLeft}> 345 + <p class={s.heroSub}>{sphere.description}</p> 346 + </div> 347 + </section> 183 348 )} 349 + 350 + <div class={s.widgetGrid}> 351 + {!hasAnyModule && <EnabledModulesEmpty />} 352 + {hasInfuse && <InfuseWidget handle={handle} />} 353 + {hasFlux && <FluxWidget handle={handle} />} 354 + </div> 184 355 </div> 185 356 ); 186 357 }
+11 -2
packages/app/src/vite-ssr-plugin.ts
··· 16 16 server.middlewares.use(async (req, res, next) => { 17 17 const url = req.url ?? "/"; 18 18 19 - // Only handle page navigations — let Vite handle everything else 19 + // Only handle page navigations — let Vite handle everything else. 20 + // Note: we can't skip on any "." in the URL because sphere handles 21 + // contain dots (e.g. `/s/alice.pds.dev`). Match known asset extensions 22 + // and explicitly exclude /s/ paths so any TLD is safe. 20 23 const accept = req.headers.accept ?? ""; 24 + const urlPath = url.split("?")[0].split("#")[0]; 25 + const isStaticAsset = 26 + !urlPath.startsWith("/s/") && 27 + /\.(js|mjs|ts|tsx|jsx|css|map|json|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf|eot|wasm|txt|xml|mp4|webm|ogg|mp3|wav|pdf)$/i.test( 28 + urlPath, 29 + ); 21 30 if ( 22 31 !accept.includes("text/html") || 23 32 url.startsWith("/api") || ··· 27 36 url.startsWith("/xrpc") || 28 37 url.startsWith("/node_modules") || 29 38 url.startsWith("/src") || 30 - url.includes(".") 39 + isStaticAsset 31 40 ) { 32 41 return next(); 33 42 }
+11
packages/client/src/router.tsx
··· 15 15 // Avoid trailing slash for root path: spherePath("/") → "/s/handle" not "/s/handle/" 16 16 return path === "/" ? `/s/${handle}` : `/s/${handle}${path}`; 17 17 } 18 + 19 + /** Strip a single query param from the current URL without triggering a navigation. 20 + * Safe to call during render — no-ops on the server. */ 21 + export function clearQueryParam(key: string): void { 22 + if (typeof window === "undefined") return; 23 + const url = new URL(window.location.href); 24 + if (!url.searchParams.has(key)) return; 25 + url.searchParams.delete(key); 26 + const qs = url.searchParams.toString(); 27 + history.replaceState(null, "", url.pathname + (qs ? `?${qs}` : "")); 28 + }
+12 -1
packages/client/src/ssr-prefetch.ts
··· 6 6 ): { key: string; apiUrl: string }[] { 7 7 const apiBase = sphereHandle ? `/api/s/${sphereHandle}` : "/api"; 8 8 9 + // Strip query string / hash — dev SSR passes `req.url` which includes them, 10 + // while prod passes the Hono-cleaned path. Normalize here so routing matches. 11 + const cleanPath = path.split("?")[0].split("#")[0]; 12 + 9 13 // In multi-sphere mode, strip the /s/:handle prefix to match module paths 10 - const modulePath = sphereHandle ? path.replace(/^\/s\/[^/]+/, "") : path; 14 + const modulePath = sphereHandle ? cleanPath.replace(/^\/s\/[^/]+/, "") : cleanPath; 15 + 16 + // Sphere dashboard — fetch per-module stats in parallel 17 + if (modulePath === "" || modulePath === "/") 18 + return [ 19 + { key: "infuse-stats", apiUrl: `${apiBase}/feature-requests/stats` }, 20 + { key: "flux-stats", apiUrl: `${apiBase}/kanban/stats` }, 21 + ]; 11 22 12 23 if (modulePath === "/infuse") 13 24 return [
+2
packages/feature-requests/src/api/routes.ts
··· 5 5 import { statusesApi } from "./statuses.ts"; 6 6 import { votesApi } from "./votes.ts"; 7 7 import { commentsApi } from "./comments.ts"; 8 + import { frStatsApi } from "./stats.ts"; 8 9 9 10 const app = new Hono<AuthEnv & SphereEnv>(); 10 11 12 + app.route("/", frStatsApi); 11 13 app.route("/", requestsApi); 12 14 app.route("/", statusesApi); 13 15 app.route("/", votesApi);
+89
packages/feature-requests/src/api/stats.ts
··· 1 + import { Hono } from "hono"; 2 + import { getDb } from "@exosphere/core/db"; 3 + import { eq, and, sql, count, desc } from "@exosphere/core/db/drizzle"; 4 + import { resolveDidHandles } from "@exosphere/core/identity"; 5 + import { tidToDate } from "@exosphere/core/pds"; 6 + import type { AuthEnv } from "@exosphere/core/auth"; 7 + import type { SphereEnv } from "@exosphere/core/types"; 8 + import { featureRequests, featureRequestVotes } from "../db/schema.ts"; 9 + import { statuses, type Status } from "../schemas/feature-request.ts"; 10 + 11 + const LATEST_LIMIT = 4; 12 + const STATUS_SET: ReadonlySet<Status> = new Set(statuses); 13 + 14 + function isStatus(value: string): value is Status { 15 + return STATUS_SET.has(value as Status); 16 + } 17 + 18 + const app = new Hono<AuthEnv & SphereEnv>(); 19 + 20 + // Dashboard stats: counts grouped by status + latest requests with vote counts. 21 + app.get("/stats", async (c) => { 22 + const db = getDb(); 23 + const sphereId = c.var.sphereId; 24 + 25 + const countRows = db 26 + .select({ 27 + status: featureRequests.status, 28 + count: sql<number>`count(*)`.as("count"), 29 + }) 30 + .from(featureRequests) 31 + .where(and(eq(featureRequests.sphereId, sphereId), sql`${featureRequests.hiddenAt} is null`)) 32 + .groupBy(featureRequests.status) 33 + .all(); 34 + 35 + const statusCounts: Record<Status, number> = { 36 + requested: 0, 37 + "not-planned": 0, 38 + approved: 0, 39 + "in-progress": 0, 40 + done: 0, 41 + duplicate: 0, 42 + }; 43 + let total = 0; 44 + for (const row of countRows) { 45 + if (isStatus(row.status)) { 46 + statusCounts[row.status] = row.count; 47 + } 48 + // Duplicates are not counted toward the dashboard total. 49 + if (row.status !== "duplicate") total += row.count; 50 + } 51 + 52 + const voteCountCol = count(featureRequestVotes.authorDid); 53 + const latestRows = db 54 + .select({ 55 + id: featureRequests.id, 56 + number: featureRequests.number, 57 + authorDid: featureRequests.authorDid, 58 + title: featureRequests.title, 59 + status: featureRequests.status, 60 + voteCount: voteCountCol, 61 + }) 62 + .from(featureRequests) 63 + .leftJoin(featureRequestVotes, eq(featureRequestVotes.requestId, featureRequests.id)) 64 + .where(and(eq(featureRequests.sphereId, sphereId), sql`${featureRequests.hiddenAt} is null`)) 65 + .groupBy(featureRequests.id) 66 + .orderBy(desc(featureRequests.id)) 67 + .limit(LATEST_LIMIT) 68 + .all(); 69 + 70 + const handleMap = await resolveDidHandles(latestRows.map((r) => r.authorDid)); 71 + 72 + const latestRequests = latestRows.map((r) => ({ 73 + id: r.id, 74 + number: r.number, 75 + title: r.title, 76 + status: r.status, 77 + voteCount: r.voteCount, 78 + authorHandle: handleMap.get(r.authorDid) ?? null, 79 + createdAt: tidToDate(r.id), 80 + })); 81 + 82 + return c.json({ 83 + total, 84 + statusCounts, 85 + latestRequests, 86 + }); 87 + }); 88 + 89 + export { app as frStatsApi };
+13 -2
packages/feature-requests/src/ui/pages/feature-requests.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 - import { spherePath, useLocation } from "@exosphere/client/router"; 3 + import { spherePath, useLocation, clearQueryParam } from "@exosphere/client/router"; 4 4 import { Link } from "@exosphere/client/link"; 5 5 import { useQuery } from "@exosphere/client/hooks"; 6 6 import * as ui from "@exosphere/client/ui.css"; ··· 148 148 149 149 export function FeatureRequestsListPage() { 150 150 const { activeTab, statuses } = useActiveTab(); 151 - const showForm = useSignal(false); 151 + const { query } = useLocation(); 152 + const showForm = useSignal(query?.new === "1"); 152 153 const { sortBy, sortOrder } = useSortParams(); 154 + 155 + // Sync with query param changes (e.g. clicking "New" on the dashboard after arriving here). 156 + useEffect(() => { 157 + if (query?.new === "1") showForm.value = true; 158 + }, [query?.new]); 159 + 160 + // When the modal closes, clear ?new= from the URL so it doesn't re-open on back/refresh. 161 + useEffect(() => { 162 + if (!showForm.value) clearQueryParam("new"); 163 + }, [showForm.value]); 153 164 const prefetchKey = 154 165 activeTab === "requests" ? "feature-requests" : `feature-requests-${statuses.join(",")}`; 155 166 const prefetched = ssrPageData.peek()?.[prefetchKey] as
+2
packages/kanban/src/api/routes.ts
··· 4 4 import { tasksApi } from "./tasks.ts"; 5 5 import { commentsApi } from "./comments.ts"; 6 6 import { columnsApi } from "./columns.ts"; 7 + import { statsApi } from "./stats.ts"; 7 8 8 9 const app = new Hono<AuthEnv & SphereEnv>(); 9 10 11 + app.route("/", statsApi); 10 12 app.route("/", columnsApi); 11 13 app.route("/", tasksApi); 12 14 app.route("/", commentsApi);
+112
packages/kanban/src/api/stats.ts
··· 1 + import { Hono } from "hono"; 2 + import { getDb } from "@exosphere/core/db"; 3 + import { eq, and, sql, count, desc } from "@exosphere/core/db/drizzle"; 4 + import { resolveDidHandles } from "@exosphere/core/identity"; 5 + import { tidToDate } from "@exosphere/core/pds"; 6 + import type { AuthEnv } from "@exosphere/core/auth"; 7 + import type { SphereEnv } from "@exosphere/core/types"; 8 + import { kanbanTasks, kanbanColumns } from "../db/schema.ts"; 9 + import { STATUS_TYPES, type StatusType } from "../schemas/status-type.ts"; 10 + 11 + const LATEST_TASKS_LIMIT = 4; 12 + const STATUS_TYPE_SET: ReadonlySet<StatusType> = new Set(STATUS_TYPES); 13 + 14 + function isStatusType(value: string): value is StatusType { 15 + return STATUS_TYPE_SET.has(value as StatusType); 16 + } 17 + 18 + const app = new Hono<AuthEnv & SphereEnv>(); 19 + 20 + // Dashboard stats: task counts grouped by StatusType + latest tasks. 21 + // Counts are computed by joining tasks with their column so we group by 22 + // the column's statusType, not by the column slug. One JOIN + GROUP BY. 23 + app.get("/stats", async (c) => { 24 + const db = getDb(); 25 + const sphereId = c.var.sphereId; 26 + 27 + const countRows = db 28 + .select({ 29 + statusType: kanbanColumns.statusType, 30 + count: sql<number>`count(${kanbanTasks.id})`.as("count"), 31 + }) 32 + .from(kanbanTasks) 33 + .innerJoin( 34 + kanbanColumns, 35 + and( 36 + eq(kanbanColumns.sphereId, kanbanTasks.sphereId), 37 + eq(kanbanColumns.slug, kanbanTasks.status), 38 + ), 39 + ) 40 + .where(and(eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`)) 41 + .groupBy(kanbanColumns.statusType) 42 + .all(); 43 + 44 + // Count all tasks (including any orphans whose status slug no longer maps to a column) 45 + // so the total matches reality even if the join-based counts drop some rows. 46 + const totalRow = db 47 + .select({ total: count() }) 48 + .from(kanbanTasks) 49 + .where(and(eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`)) 50 + .get(); 51 + const total = totalRow?.total ?? 0; 52 + 53 + const statusTypeCounts: Record<StatusType, number> = { 54 + backlog: 0, 55 + planned: 0, 56 + started: 0, 57 + completed: 0, 58 + canceled: 0, 59 + }; 60 + for (const row of countRows) { 61 + if (isStatusType(row.statusType)) { 62 + statusTypeCounts[row.statusType] = row.count; 63 + } 64 + } 65 + 66 + const latestRows = db 67 + .select({ 68 + id: kanbanTasks.id, 69 + number: kanbanTasks.number, 70 + authorDid: kanbanTasks.authorDid, 71 + title: kanbanTasks.title, 72 + status: kanbanTasks.status, 73 + statusType: kanbanColumns.statusType, 74 + statusLabel: kanbanColumns.label, 75 + updatedAt: kanbanTasks.updatedAt, 76 + }) 77 + .from(kanbanTasks) 78 + .innerJoin( 79 + kanbanColumns, 80 + and( 81 + eq(kanbanColumns.sphereId, kanbanTasks.sphereId), 82 + eq(kanbanColumns.slug, kanbanTasks.status), 83 + ), 84 + ) 85 + .where(and(eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`)) 86 + // TID-based IDs sort by creation time, matching feature-requests "latest" semantics. 87 + .orderBy(desc(kanbanTasks.id)) 88 + .limit(LATEST_TASKS_LIMIT) 89 + .all(); 90 + 91 + const handleMap = await resolveDidHandles(latestRows.map((r) => r.authorDid)); 92 + 93 + const latestTasks = latestRows.map((r) => ({ 94 + id: r.id, 95 + number: r.number, 96 + title: r.title, 97 + status: r.status, 98 + statusType: r.statusType as StatusType, 99 + statusLabel: r.statusLabel, 100 + authorHandle: handleMap.get(r.authorDid) ?? null, 101 + createdAt: tidToDate(r.id), 102 + updatedAt: r.updatedAt, 103 + })); 104 + 105 + return c.json({ 106 + total, 107 + statusTypeCounts, 108 + latestTasks, 109 + }); 110 + }); 111 + 112 + export { app as statsApi };
+16 -2
packages/kanban/src/ui/pages/board.tsx
··· 4 4 import { canDo } from "@exosphere/client/permissions"; 5 5 import { useQuery } from "@exosphere/client/hooks"; 6 6 import { ssrPageData } from "@exosphere/client/ssr-data"; 7 - import { spherePath } from "@exosphere/client/router"; 7 + import { spherePath, useLocation, clearQueryParam } from "@exosphere/client/router"; 8 8 import { Link } from "@exosphere/client/link"; 9 9 import { Settings } from "lucide-preact"; 10 10 import * as ui from "@exosphere/client/ui.css"; ··· 17 17 import { useBoardDnd } from "../hooks/use-board-dnd.ts"; 18 18 19 19 export function BoardPage() { 20 - const showForm = useSignal(false); 20 + const { query } = useLocation(); 21 + const showForm = useSignal(query?.new === "1"); 21 22 const formInitialStatus = useSignal<string | undefined>(undefined); 23 + 24 + // Sync with query param changes (e.g. clicking "+ New" on the dashboard after arriving here). 25 + useEffect(() => { 26 + if (query?.new === "1") { 27 + formInitialStatus.value = undefined; 28 + showForm.value = true; 29 + } 30 + }, [query?.new]); 31 + 32 + // When the modal closes, clear ?new= from the URL so it doesn't re-open on back/refresh. 33 + useEffect(() => { 34 + if (!showForm.value) clearQueryParam("new"); 35 + }, [showForm.value]); 22 36 const prefetched = ssrPageData.peek()?.["kanban-tasks"] as 23 37 | Awaited<ReturnType<typeof getTasks>> 24 38 | undefined;
+22 -5
packages/kanban/src/ui/ui.css.ts
··· 8 8 flexDirection: "column", 9 9 minBlockSize: "calc(100vh - 220px)", 10 10 paddingInline: vars.space.xl, 11 + paddingBlockStart: vars.space.md, 12 + paddingBlockEnd: vars.space.xl, 11 13 "@media": { 12 14 "(min-width: 900px)": { 13 15 paddingInline: vars.space.xxl, ··· 288 290 }); 289 291 290 292 export const statusTypeBadge = styleVariants({ 291 - backlog: [statusTypeBadgeBase, { color: vars.color.textMuted, border: `1px solid ${vars.color.border}` }], 292 - planned: [statusTypeBadgeBase, { color: vars.color.text, border: `1px solid ${vars.color.border}` }], 293 - started: [statusTypeBadgeBase, { color: vars.color.warning, border: `1px solid ${vars.color.warning}` }], 294 - completed: [statusTypeBadgeBase, { color: vars.color.success, border: `1px solid ${vars.color.success}` }], 295 - canceled: [statusTypeBadgeBase, { color: vars.color.danger, border: `1px solid ${vars.color.danger}` }], 293 + backlog: [ 294 + statusTypeBadgeBase, 295 + { color: vars.color.textMuted, border: `1px solid ${vars.color.border}` }, 296 + ], 297 + planned: [ 298 + statusTypeBadgeBase, 299 + { color: vars.color.text, border: `1px solid ${vars.color.border}` }, 300 + ], 301 + started: [ 302 + statusTypeBadgeBase, 303 + { color: vars.color.warning, border: `1px solid ${vars.color.warning}` }, 304 + ], 305 + completed: [ 306 + statusTypeBadgeBase, 307 + { color: vars.color.success, border: `1px solid ${vars.color.success}` }, 308 + ], 309 + canceled: [ 310 + statusTypeBadgeBase, 311 + { color: vars.color.danger, border: `1px solid ${vars.color.danger}` }, 312 + ], 296 313 }); 297 314 298 315 export const dragHandle = style({