diff --git a/packages/app/src/api/sphere-dashboard.ts b/packages/app/src/api/sphere-dashboard.ts new file mode 100644 index 0000000..b343d88 --- /dev/null +++ b/packages/app/src/api/sphere-dashboard.ts @@ -0,0 +1,53 @@ +import { moduleFetch } from "@exosphere/client/module-api"; + +export type FrStatus = + | "requested" + | "not-planned" + | "approved" + | "in-progress" + | "done" + | "duplicate"; + +export type KanbanStatusType = "backlog" | "planned" | "started" | "completed" | "canceled"; + +export interface InfuseLatestRequest { + id: string; + number: number; + title: string; + status: FrStatus; + voteCount: number; + authorHandle: string | null; + createdAt: string; +} + +export interface InfuseStats { + total: number; + statusCounts: Record; + latestRequests: InfuseLatestRequest[]; +} + +export interface FluxLatestTask { + id: string; + number: number; + title: string; + status: string; + statusType: KanbanStatusType; + statusLabel: string; + authorHandle: string | null; + createdAt: string; + updatedAt: string; +} + +export interface FluxStats { + total: number; + statusTypeCounts: Record; + latestTasks: FluxLatestTask[]; +} + +export function getInfuseStats() { + return moduleFetch("/feature-requests/stats"); +} + +export function getFluxStats() { + return moduleFetch("/kanban/stats"); +} diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 098f850..c1127a6 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,10 +1,11 @@ import type { ComponentType } from "preact"; -import { useEffect, useMemo } from "preact/hooks"; +import { useEffect, useMemo, useState } from "preact/hooks"; import { auth, logout } from "@exosphere/client/auth"; import { useLocation, useRoute } from "@exosphere/client/router"; import { spherePath } from "@exosphere/client/router"; import { sphereState, sphereHandle, loadSphere } from "@exosphere/client/sphere"; import { isMultiSphere } from "@exosphere/client/config"; +import { Link } from "@exosphere/client/link"; import * as ui from "@exosphere/client/ui.css"; import { ThemeToggle } from "@exosphere/client/components/theme-toggle"; import { InvitationBanner } from "@exosphere/client/components/invitation-banner"; @@ -12,6 +13,7 @@ import { Sun, Moon, Monitor } from "lucide-preact"; import { SignIn } from "./pages/sign-in.tsx"; import { CreateSphere } from "./pages/create-sphere.tsx"; import { SpherePage } from "./pages/sphere.tsx"; +import { SphereSettingsPage } from "./pages/sphere-settings.tsx"; import { SphereMembersPage } from "./pages/sphere-members.tsx"; import { SpherePermissionsPage } from "./pages/sphere-permissions.tsx"; import { SphereLabelsPage } from "./pages/sphere-labels.tsx"; @@ -21,6 +23,7 @@ import { feedsModule } from "@exosphere/feeds/client"; import { featureRequestsModule } from "@exosphere/feature-requests/client"; import { kanbanModule } from "@exosphere/kanban/client"; import { LocationProvider, Router, Route } from "preact-iso"; +import * as h from "./header.css.ts"; const defaultRoutes: ModuleRoute[] = [feedsModule, featureRequestsModule, kanbanModule].flatMap( (m) => m.routes ?? [], @@ -143,6 +146,11 @@ function MainContent({ moduleRoutes }: { moduleRoutes: ModuleRoute[] }) { path="/s/:sphereHandle/settings/labels" component={withSphereLoader(SphereLabelsPage)} /> + + @@ -196,6 +204,8 @@ function MainContent({ moduleRoutes }: { moduleRoutes: ModuleRoute[] }) { + + {moduleRoutes.map((r) => ( ))} @@ -233,43 +243,149 @@ function Footer() { ); } +function isModulePathActive(currentPath: string, modulePath: string): boolean { + // currentPath examples: "/s/foo.bar/infuse", "/s/foo.bar/infuse/42", "/infuse" + const stripped = currentPath.replace(/^\/s\/[^/]+/, "") || "/"; + return stripped === modulePath || stripped.startsWith(`${modulePath}/`); +} + +function isSettingsPathActive(currentPath: string): boolean { + const stripped = currentPath.replace(/^\/s\/[^/]+/, "") || "/"; + return stripped.startsWith("/settings"); +} + +function SphereNav({ currentPath }: { currentPath: string }) { + const data = sphereState.value.data; + const enabled = new Set(data?.modules.map((m) => m.name) ?? []); + const hasInfuse = enabled.has("feature-requests"); + const hasFlux = enabled.has("kanban"); + + return ( + + ); +} + function Header() { - const { route } = useLocation(); + const { route, path } = useLocation(); const { loading, authenticated, did, handle } = auth.value; const sphere = sphereState.value.data?.sphere; + const [drawerOpen, setDrawerOpen] = useState(false); const homeHref = isMultiSphere && sphere ? spherePath("/") : "/"; + const showSphereNav = Boolean(sphere); + const settingsActive = isSettingsPathActive(path); + + const closeDrawer = () => setDrawerOpen(false); return ( -
-
- +
+
+ {sphere?.name ?? "Exosphere"} + {showSphereNav && sphere && {sphere.handle}} - +
+ +
+ + {drawerOpen && ( +
+
+ {authenticated && handle && @{handle}} + +
+
+ {showSphereNav && ( + + Settings + + )} + {authenticated ? ( + + ) : ( + + Sign in + + )} +
+
+ )}
); } diff --git a/packages/app/src/header.css.ts b/packages/app/src/header.css.ts new file mode 100644 index 0000000..7765eea --- /dev/null +++ b/packages/app/src/header.css.ts @@ -0,0 +1,222 @@ +import { style, globalStyle } from "@vanilla-extract/css"; +import { vars } from "@exosphere/client/theme.css"; + +const bp = { + tablet: "screen and (max-width: 959px)", + mobile: "screen and (max-width: 639px)", + desktop: "screen and (min-width: 640px)", +}; + +export const appHeader = style({ + borderBlockEnd: `1px solid ${vars.color.border}`, + backgroundColor: vars.color.surface, + boxShadow: `0 1px 4px ${vars.color.shadow}`, + position: "sticky", + insetBlockStart: 0, + zIndex: 5, +}); + +export const headerInner = style({ + maxInlineSize: "1120px", + marginInline: "auto", + paddingInline: vars.space.lg, + display: "flex", + alignItems: "stretch", + gap: vars.space.md, + "@media": { + [bp.tablet]: { + flexWrap: "wrap", + paddingInline: vars.space.md, + }, + [bp.mobile]: { + paddingInline: vars.space.md, + gap: "8px", + }, + }, +}); + +export const brand = style({ + display: "inline-flex", + alignItems: "center", + gap: "10px", + paddingBlock: "12px", + fontFamily: vars.font.heading, + fontWeight: 700, + fontSize: "1rem", + color: vars.color.text, + letterSpacing: "-0.02em", + whiteSpace: "nowrap", + borderInlineEnd: `1px solid ${vars.color.border}`, + paddingInlineEnd: vars.space.md, + textDecoration: "none", + ":hover": { textDecoration: "none" }, + "@media": { + [bp.tablet]: { borderInlineEnd: 0, paddingInlineEnd: 0 }, + [bp.mobile]: { fontSize: "0.9375rem" }, + }, +}); + +export const handlePill = style({ + fontFamily: vars.font.body, + fontSize: "0.6875rem", + color: vars.color.textMuted, + fontWeight: 500, + paddingBlock: "2px", + paddingInline: "6px", + backgroundColor: vars.color.bg, + border: `1px solid ${vars.color.border}`, + borderRadius: "4px", + "@media": { + [bp.tablet]: { display: "none" }, + }, +}); + +export const nav = style({ + display: "flex", + alignItems: "stretch", + gap: "2px", + overflowX: "auto", + scrollbarWidth: "none", + "@media": { + [bp.tablet]: { + order: 3, + flexBasis: "100%", + borderBlockStart: `1px solid ${vars.color.border}`, + marginInline: `calc(-1 * ${vars.space.md})`, + paddingInline: vars.space.md, + }, + }, +}); + +globalStyle(`${nav}::-webkit-scrollbar`, { display: "none" }); + +export const navLink = style({ + display: "inline-flex", + alignItems: "center", + gap: "6px", + paddingInline: "12px", + fontSize: "0.875rem", + fontWeight: 500, + color: vars.color.textMuted, + borderBlockEnd: "2px solid transparent", + textDecoration: "none", + whiteSpace: "nowrap", + transition: "color 0.15s, border-color 0.15s", + marginBlockEnd: "-1px", + ":hover": { color: vars.color.text, textDecoration: "none" }, + "@media": { + [bp.tablet]: { paddingBlock: "10px", paddingInline: "10px" }, + [bp.mobile]: { fontSize: "0.8125rem" }, + }, +}); + +export const navLinkActive = style({ + color: vars.color.text, + borderBlockEndColor: vars.color.primary, +}); + +export const spacer = style({ flex: 1 }); + +export const right = style({ + display: "flex", + alignItems: "center", + gap: vars.space.sm, + paddingBlock: "8px", + "@media": { + [bp.mobile]: { display: "none" }, + }, +}); + +export const handleText = style({ + color: vars.color.textMuted, + fontSize: "0.8125rem", + whiteSpace: "nowrap", + "@media": { + [bp.tablet]: { display: "none" }, + }, +}); + +export const headerAction = style({ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + inlineSize: "90px", + blockSize: "32px", + paddingInline: "10px", + border: `1px solid ${vars.color.border}`, + borderRadius: vars.radius.sm, + backgroundColor: vars.color.surface, + color: vars.color.text, + fontSize: "0.8125rem", + fontWeight: 500, + textDecoration: "none", + cursor: "pointer", + fontFamily: vars.font.body, + transition: "background-color 0.15s, border-color 0.15s", + ":hover": { + borderColor: vars.color.primary, + backgroundColor: vars.color.surfaceHover, + textDecoration: "none", + }, + ":disabled": { + opacity: 0.5, + cursor: "not-allowed", + }, +}); + +export const headerActionActive = style({ + backgroundColor: vars.color.primaryLight, + borderColor: vars.color.primary, + color: vars.color.primary, +}); + +export const hamburger = style({ + display: "none", + inlineSize: "36px", + blockSize: "36px", + alignItems: "center", + justifyContent: "center", + border: `1px solid ${vars.color.border}`, + borderRadius: vars.radius.sm, + backgroundColor: vars.color.surface, + color: vars.color.text, + marginInlineStart: "auto", + alignSelf: "center", + cursor: "pointer", + "@media": { + [bp.mobile]: { display: "inline-flex" }, + }, +}); + +export const mobileDrawer = style({ + display: "none", + borderBlockStart: `1px solid ${vars.color.border}`, + backgroundColor: vars.color.surface, + paddingBlock: vars.space.md, + paddingInline: vars.space.lg, +}); + +export const mobileDrawerOpen = style({ + display: "flex", + flexDirection: "column", + gap: vars.space.md, +}); + +export const drawerRow = style({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: vars.space.sm, + flexWrap: "wrap", +}); + +export const drawerActionRow = style({ + display: "flex", + alignItems: "center", + gap: "8px", + flexWrap: "wrap", +}); + +export const drawerAction = style({ + flex: 1, +}); diff --git a/packages/app/src/pages/settings-layout.tsx b/packages/app/src/pages/settings-layout.tsx new file mode 100644 index 0000000..6b2a169 --- /dev/null +++ b/packages/app/src/pages/settings-layout.tsx @@ -0,0 +1,80 @@ +import type { ComponentChildren } from "preact"; +import { useLocation } from "@exosphere/client/router"; +import { spherePath } from "@exosphere/client/router"; +import { sphereState } from "@exosphere/client/sphere"; +import { canDo } from "@exosphere/client/permissions"; +import { Link } from "@exosphere/client/link"; +import * as s from "./sphere.css.ts"; +import * as ss from "./sphere-settings.css.ts"; + +// "members" and "permissions" stay in the union because their pages still pass +// them as `active`, even though the rail doesn't render links to them anymore. +// The pages remain reachable by direct URL. +export type SettingsSection = "general" | "modules" | "labels" | "members" | "permissions"; + +interface Props { + active: SettingsSection; + children: ComponentChildren; +} + +function RailLink({ + href, + active, + label, + danger, +}: { + href: string; + active: boolean; + label: string; + danger?: boolean; +}) { + const classes = [ss.railLink, active ? ss.railLinkActive : "", danger ? ss.railLinkDanger : ""] + .filter(Boolean) + .join(" "); + return ( + + {label} + + ); +} + +export function SettingsLayout({ active, children }: Props) { + // `useLocation` makes this re-render on nav so the active state updates. + useLocation(); + + const data = sphereState.value.data; + const canManageLabels = canDo("sphere", "manageLabels"); + + return ( +
+
+

Settings

+

+ Configure how {data?.sphere.name ?? "this sphere"} works. Changes affect everyone in the + sphere. +

+
+ +
+ + +
{children}
+
+
+ ); +} diff --git a/packages/app/src/pages/sphere-labels.tsx b/packages/app/src/pages/sphere-labels.tsx index d5f8fe4..171a493 100644 --- a/packages/app/src/pages/sphere-labels.tsx +++ b/packages/app/src/pages/sphere-labels.tsx @@ -3,11 +3,11 @@ import { useSignal } from "@preact/signals"; import { sphereState, sphereHandle } from "@exosphere/client/sphere"; import { canDo } from "@exosphere/client/permissions"; import { useQuery } from "@exosphere/client/hooks"; -import { spherePath } from "@exosphere/client/router"; import { LabelBadge } from "@exosphere/client/components/label-badge"; import { GripVertical } from "lucide-preact"; import * as ui from "@exosphere/client/ui.css"; import * as cpUi from "./color-picker.css.ts"; +import { SettingsLayout } from "./settings-layout.tsx"; import { getSphereLabels, createSphereLabel, @@ -35,7 +35,11 @@ export function SphereLabelsPage() { if (!data || !handle) return null; if (!canDo("sphere", "manageLabels")) return null; - return ; + return ( + + + + ); } function LabelsContent({ handle }: { handle: string }) { @@ -197,14 +201,9 @@ function LabelsContent({ handle }: { handle: string }) { }; return ( -
+ <>
- -

Labels

+

Labels

Create and manage labels for feature requests and tasks.

@@ -363,7 +362,7 @@ function LabelsContent({ handle }: { handle: string }) {
)} - + ); } diff --git a/packages/app/src/pages/sphere-members.tsx b/packages/app/src/pages/sphere-members.tsx index 27275da..e2ceb7f 100644 --- a/packages/app/src/pages/sphere-members.tsx +++ b/packages/app/src/pages/sphere-members.tsx @@ -2,8 +2,8 @@ import { useSignal } from "@preact/signals"; import { sphereState, sphereHandle } from "@exosphere/client/sphere"; import { canDo } from "@exosphere/client/permissions"; import { useQuery } from "@exosphere/client/hooks"; -import { spherePath } from "@exosphere/client/router"; import * as ui from "@exosphere/client/ui.css"; +import { SettingsLayout } from "./settings-layout.tsx"; import { getSphereMembers, inviteMember, @@ -36,10 +36,14 @@ export function SphereMembersPage() { canDo("sphere", "updateMemberRole"); if (!canManageMembers) return null; - return ; + return ( + + + + ); } -function MembersContent({ handle, sphereName }: { handle: string; sphereName: string }) { +function MembersContent({ handle }: { handle: string }) { const members = useQuery(() => getSphereMembers(handle), [handle]); const inviteIdentifier = useSignal(""); const inviteRole = useSignal<"admin" | "member">("member"); @@ -88,12 +92,9 @@ function MembersContent({ handle, sphereName }: { handle: string; sphereName: st }; return ( -
+ <>
- - ← {sphereName} - -

Members

+

Members

{members.loading &&

Loading members...

} @@ -201,6 +202,6 @@ function MembersContent({ handle, sphereName }: { handle: string; sphereName: st
)} - + ); } diff --git a/packages/app/src/pages/sphere-permissions.tsx b/packages/app/src/pages/sphere-permissions.tsx index bf7361b..b23a573 100644 --- a/packages/app/src/pages/sphere-permissions.tsx +++ b/packages/app/src/pages/sphere-permissions.tsx @@ -2,8 +2,8 @@ import { useSignal } from "@preact/signals"; import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; import { canDo } from "@exosphere/client/permissions"; import { useQuery } from "@exosphere/client/hooks"; -import { spherePath } from "@exosphere/client/router"; import * as ui from "@exosphere/client/ui.css"; +import { SettingsLayout } from "./settings-layout.tsx"; import { getSpherePermissions, updateSpherePermissions } from "../api/spheres.ts"; const roleLabels: Record = { @@ -28,10 +28,14 @@ export function SpherePermissionsPage() { if (!data || !handle) return null; if (!canDo("sphere", "updatePermissions")) return null; - return ; + return ( + + + + ); } -function PermissionsContent({ handle, sphereName }: { handle: string; sphereName: string }) { +function PermissionsContent({ handle }: { handle: string }) { const perms = useQuery(() => getSpherePermissions(handle), [handle]); const saving = useSignal(false); const saveError = useSignal(""); @@ -60,12 +64,9 @@ function PermissionsContent({ handle, sphereName }: { handle: string; sphereName const hasChanges = Object.keys(pendingChanges.value).length > 0; return ( -
+ <>
- - ← {sphereName} - -

Permissions

+

Permissions

{perms.loading &&

Loading permissions...

} @@ -122,6 +123,6 @@ function PermissionsContent({ handle, sphereName }: { handle: string; sphereName )}
)} - + ); } diff --git a/packages/app/src/pages/sphere-settings.css.ts b/packages/app/src/pages/sphere-settings.css.ts new file mode 100644 index 0000000..3a04324 --- /dev/null +++ b/packages/app/src/pages/sphere-settings.css.ts @@ -0,0 +1,197 @@ +import { style, globalStyle } from "@vanilla-extract/css"; +import { vars } from "@exosphere/client/theme.css"; + +const bp = { + mobile: "screen and (max-width: 860px)", +}; + +export const settingsHero = style({ + marginBlockEnd: vars.space.lg, +}); + +export const settingsTitle = style({ + fontFamily: vars.font.heading, + fontWeight: 800, + fontSize: "1.625rem", + letterSpacing: "-0.02em", +}); + +export const settingsSub = style({ + color: vars.color.textMuted, + fontSize: "0.9375rem", + marginBlockStart: "6px", +}); + +export const layout = style({ + display: "grid", + gridTemplateColumns: "220px 1fr", + gap: vars.space.lg, + "@media": { + [bp.mobile]: { gridTemplateColumns: "1fr" }, + }, +}); + +export const rail = style({ + display: "flex", + flexDirection: "column", + gap: "2px", + backgroundColor: vars.color.surface, + border: `1px solid ${vars.color.border}`, + borderRadius: vars.radius.lg, + padding: "8px", + blockSize: "max-content", + position: "sticky", + insetBlockStart: "60px", + "@media": { + [bp.mobile]: { position: "static" }, + }, +}); + +export const railLink = style({ + display: "flex", + alignItems: "center", + paddingBlock: "9px", + paddingInline: "12px", + borderRadius: "6px", + color: vars.color.textMuted, + fontSize: "0.875rem", + fontWeight: 500, + textDecoration: "none", + transition: "color 0.15s, background-color 0.15s", + ":hover": { + backgroundColor: vars.color.surfaceHover, + color: vars.color.text, + textDecoration: "none", + }, +}); + +export const railLinkActive = style({ + backgroundColor: vars.color.primaryLight, + color: vars.color.primary, +}); + +export const railLinkDanger = style({ + color: vars.color.danger, +}); + +export const railGroup = style({ + paddingBlock: "10px 4px", + paddingInline: "12px", + fontSize: "0.6875rem", + textTransform: "uppercase", + letterSpacing: "0.07em", + color: vars.color.textMuted, + fontWeight: 600, +}); + +export const content = style({ + display: "flex", + flexDirection: "column", + gap: vars.space.md, + minInlineSize: 0, +}); + +export const card = style({ + backgroundColor: vars.color.surface, + border: `1px solid ${vars.color.border}`, + borderRadius: vars.radius.lg, + padding: vars.space.lg, + display: "flex", + flexDirection: "column", + gap: vars.space.md, +}); + +export const cardTitle = style({ + fontFamily: vars.font.heading, + fontSize: "1.125rem", + fontWeight: 700, + letterSpacing: "-0.01em", +}); + +export const cardDesc = style({ + color: vars.color.textMuted, + fontSize: "0.8125rem", + marginBlockStart: "2px", +}); + +export const row = style({ + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: vars.space.md, + paddingBlock: vars.space.md, + borderBlockStart: `1px solid ${vars.color.border}`, + selectors: { + "&:first-child": { borderBlockStart: 0, paddingBlockStart: 0 }, + "&:last-child": { paddingBlockEnd: 0 }, + }, + "@media": { + ["screen and (max-width: 480px)"]: { + flexDirection: "column", + alignItems: "stretch", + }, + }, +}); + +export const rowMeta = style({ + flex: 1, + minInlineSize: 0, +}); + +export const rowName = style({ + fontWeight: 600, + fontSize: "0.9375rem", +}); + +export const rowDesc = style({ + fontSize: "0.8125rem", + color: vars.color.textMuted, + marginBlockStart: "4px", + maxInlineSize: "440px", +}); + +export const toggle = style({ + position: "relative", + inlineSize: "40px", + blockSize: "22px", + borderRadius: "999px", + backgroundColor: vars.color.border, + flex: "0 0 auto", + border: "none", + padding: 0, + cursor: "pointer", + transition: "background-color 0.2s", +}); + +globalStyle(`${toggle}::after`, { + content: "", + position: "absolute", + insetBlockStart: "2px", + insetInlineStart: "2px", + inlineSize: "18px", + blockSize: "18px", + borderRadius: "50%", + backgroundColor: vars.color.text, + transition: "transform 0.2s", +}); + +globalStyle(`${toggle}[aria-checked="true"]`, { + backgroundColor: vars.color.primary, +}); + +globalStyle(`${toggle}[aria-checked="true"]::after`, { + transform: "translateX(18px)", + backgroundColor: "#fff", +}); + +globalStyle(`${toggle}:disabled`, { + opacity: 0.4, + cursor: "not-allowed", +}); + +export const comingSoon = style({ + color: vars.color.textMuted, + marginInlineStart: "6px", + fontSize: "0.75rem", + fontWeight: 500, +}); diff --git a/packages/app/src/pages/sphere-settings.tsx b/packages/app/src/pages/sphere-settings.tsx new file mode 100644 index 0000000..dcc8088 --- /dev/null +++ b/packages/app/src/pages/sphere-settings.tsx @@ -0,0 +1,173 @@ +import { useSignal } from "@preact/signals"; +import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; +import { canDo } from "@exosphere/client/permissions"; +import { useLocation } from "@exosphere/client/router"; +import { useQuery } from "@exosphere/client/hooks"; +import * as ui from "@exosphere/client/ui.css"; +import * as ss from "./sphere-settings.css.ts"; +import { SettingsLayout } from "./settings-layout.tsx"; +import { + getSphereModules, + enableModule as apiEnableModule, + disableModule as apiDisableModule, +} from "../api/spheres.ts"; + +const moduleDisplay: Record = + { + "feature-requests": { + label: "Infuse", + description: "Submit, vote on, and track feature requests from your community.", + }, + kanban: { + label: "Flux", + description: "Track tasks across columns with a visual project board.", + }, + feeds: { + label: "Feeds & Discussions", + description: "Announcements, Q&A, and open-ended threads in one place.", + comingSoon: true, + }, + }; + +function GeneralSection() { + const sphere = sphereState.value.data?.sphere; + if (!sphere) return null; + + return ( +
+
+

General

+

Basic info shown to members and visitors.

+
+ +
+
+
Name
+
The display name of this sphere.
+
+ {sphere.name} +
+ +
+
+
Handle
+
+ Used in URLs:{" "} + + /s/{sphere.handle} + +
+
+ + {sphere.handle} + +
+ + {sphere.description && ( +
+
+
Description
+
A short summary of what this sphere is about.
+
+ {sphere.description} +
+ )} +
+ ); +} + +function ModulesSection({ handle }: { handle: string }) { + const modules = useQuery(() => getSphereModules(handle), [handle]); + const canEnable = canDo("sphere", "enableModule"); + const canDisable = canDo("sphere", "disableModule"); + const toggleError = useSignal(""); + + const enabledNames = new Set(modules.data?.modules.map((m) => m.name) ?? []); + const allKnown = ["feature-requests", "kanban", "feeds"]; + const available = modules.data?.available ?? []; + + // Show enabled + available + any known (coming soon) that aren't available yet. + const rows = Array.from( + new Set([...enabledNames, ...available, ...allKnown.filter((n) => !enabledNames.has(n))]), + ); + + const toggle = async (name: string, nextEnabled: boolean) => { + toggleError.value = ""; + try { + if (nextEnabled) { + await apiEnableModule(handle, name); + } else { + await apiDisableModule(handle, name); + } + modules.refetch(); + refreshSphere(); + } catch (err) { + toggleError.value = err instanceof Error ? err.message : "Failed to update module."; + modules.refetch(); + } + }; + + return ( +
+
+

Modules

+

+ Add or remove feature modules. Enabled modules appear in the sphere nav. +

+
+ + {toggleError.value &&

{toggleError.value}

} + + {rows.map((name) => { + const display = moduleDisplay[name] ?? { label: name, description: "" }; + const isEnabled = enabledNames.has(name); + const isComingSoon = display.comingSoon === true; + const canToggle = + !isComingSoon && (isEnabled ? canDisable : canEnable) && available.includes(name); + return ( +
+
+
+ {display.label} + {isComingSoon && Coming soon} +
+
{display.description}
+
+
+ ); + })} +
+ ); +} + +function resolveSection(path: string): "general" | "modules" { + const stripped = path.replace(/^\/s\/[^/]+/, "") || "/"; + if (stripped === "/settings/modules") return "modules"; + return "general"; +} + +export function SphereSettingsPage() { + const { path } = useLocation(); + const { data } = sphereState.value; + const handle = sphereHandle.value; + + if (!data || !handle) return null; + + const section = resolveSection(path ?? ""); + + return ( + + {section === "general" && } + {section === "modules" && } + + ); +} diff --git a/packages/app/src/pages/sphere.css.ts b/packages/app/src/pages/sphere.css.ts new file mode 100644 index 0000000..a3b19a9 --- /dev/null +++ b/packages/app/src/pages/sphere.css.ts @@ -0,0 +1,397 @@ +import { style, styleVariants, globalStyle } from "@vanilla-extract/css"; +import { vars } from "@exosphere/client/theme.css"; + +// ---- Sphere hero ---- + +export const hero = style({ + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: vars.space.lg, + marginBlockEnd: vars.space.xl, + flexWrap: "wrap", +}); + +export const heroLeft = style({ + minInlineSize: 0, + maxInlineSize: "640px", +}); + +export const heroSub = style({ + color: vars.color.textMuted, + fontSize: "0.9375rem", +}); + +// ---- Widget grid ---- + +export const widgetGrid = style({ + display: "flex", + flexDirection: "column", + gap: vars.space.xl, +}); + +export const widget = style({ + backgroundColor: vars.color.surface, + border: `1px solid ${vars.color.border}`, + borderRadius: vars.radius.lg, + padding: vars.space.lg, + display: "flex", + flexDirection: "column", + gap: vars.space.md, + minInlineSize: 0, + transition: "border-color 0.2s, box-shadow 0.2s", +}); + +export const widgetHead = style({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: vars.space.sm, +}); + +export const widgetTitle = style({ + fontFamily: vars.font.heading, + fontSize: "1rem", + fontWeight: 700, + letterSpacing: "-0.01em", +}); + +export const widgetSub = style({ + color: vars.color.textMuted, + fontSize: "0.8125rem", + marginBlockStart: "2px", +}); + +export const widgetLink = style({ + fontSize: "0.8125rem", + color: vars.color.textMuted, + textDecoration: "none", + ":hover": { color: vars.color.primary, textDecoration: "none" }, +}); + +export const widgetActions = style({ + display: "inline-flex", + alignItems: "center", + gap: vars.space.sm, +}); + +export const widgetNewBtn = style({ + display: "inline-flex", + alignItems: "center", + paddingBlock: "4px", + paddingInline: "10px", + border: `1px solid ${vars.color.border}`, + borderRadius: vars.radius.sm, + fontSize: "0.75rem", + fontWeight: 500, + color: vars.color.text, + backgroundColor: vars.color.surface, + textDecoration: "none", + ":hover": { + borderColor: vars.color.primary, + backgroundColor: vars.color.surfaceHover, + textDecoration: "none", + }, +}); + +// ---- KPI strip (joined cards with 1px hairline gap) ---- + +export const kpis = style({ + display: "grid", + gap: "1px", + gridTemplateColumns: "repeat(4, 1fr)", + backgroundColor: vars.color.border, + border: `1px solid ${vars.color.border}`, + borderRadius: vars.radius.md, + overflow: "hidden", +}); + +export const kpi = style({ + backgroundColor: vars.color.bg, + paddingBlock: "12px", + paddingInline: "14px", + display: "flex", + flexDirection: "column", + gap: "2px", +}); + +const kpiNumBase = { + fontFamily: vars.font.heading, + fontWeight: 900, + fontSize: "1.5rem", + letterSpacing: "-0.02em", + fontVariantNumeric: "tabular-nums" as const, +}; + +export const kpiNum = styleVariants({ + default: kpiNumBase, + primary: { ...kpiNumBase, color: vars.color.primary }, + warning: { ...kpiNumBase, color: vars.color.warning }, + success: { ...kpiNumBase, color: vars.color.success }, + danger: { ...kpiNumBase, color: vars.color.danger }, +}); + +export const kpiLabel = style({ + fontSize: "0.6875rem", + color: vars.color.textMuted, + textTransform: "uppercase", + letterSpacing: "0.06em", + fontWeight: 600, +}); + +// ---- Status bar chart ---- + +export const statusBar = style({ + display: "flex", + blockSize: "8px", + borderRadius: "999px", + overflow: "hidden", + backgroundColor: vars.color.border, +}); + +export const statusBarSeg = style({ + display: "block", + blockSize: "100%", + minInlineSize: 0, +}); + +globalStyle(`${statusBarSeg}[data-s="requested"]`, { backgroundColor: vars.color.primary }); +globalStyle(`${statusBarSeg}[data-s="approved"]`, { backgroundColor: "#3b82f6" }); +globalStyle(`${statusBarSeg}[data-s="in-progress"]`, { backgroundColor: vars.color.warning }); +globalStyle(`${statusBarSeg}[data-s="done"]`, { backgroundColor: vars.color.success }); +globalStyle(`${statusBarSeg}[data-s="not-planned"]`, { backgroundColor: vars.color.danger }); + +export const legend = style({ + display: "flex", + flexWrap: "wrap", + gap: "10px 14px", + fontSize: "0.75rem", + color: vars.color.textMuted, + marginBlockStart: "10px", +}); + +export const legendItem = style({ + display: "inline-flex", + alignItems: "center", + gap: "6px", +}); + +export const legendDot = style({ + inlineSize: "8px", + blockSize: "8px", + borderRadius: "2px", + display: "inline-block", +}); + +globalStyle(`${legendDot}[data-s="requested"]`, { backgroundColor: vars.color.primary }); +globalStyle(`${legendDot}[data-s="approved"]`, { backgroundColor: "#3b82f6" }); +globalStyle(`${legendDot}[data-s="in-progress"]`, { backgroundColor: vars.color.warning }); +globalStyle(`${legendDot}[data-s="done"]`, { backgroundColor: vars.color.success }); +globalStyle(`${legendDot}[data-s="not-planned"]`, { backgroundColor: vars.color.danger }); + +// ---- Flux columns (joined strip, like KPIs) ---- + +export const fluxColumns = style({ + display: "grid", + gap: "1px", + gridTemplateColumns: "repeat(4, 1fr)", + backgroundColor: vars.color.border, + border: `1px solid ${vars.color.border}`, + borderRadius: vars.radius.md, + overflow: "hidden", + "@media": { + ["screen and (max-width: 480px)"]: { + gridTemplateColumns: "repeat(2, 1fr)", + }, + }, +}); + +export const fluxCol = style({ + backgroundColor: vars.color.bg, + paddingBlock: "12px", + paddingInline: "14px", + display: "flex", + flexDirection: "column", + gap: "2px", +}); + +export const fluxColName = style({ + fontSize: "0.6875rem", + textTransform: "uppercase", + letterSpacing: "0.06em", + color: vars.color.textMuted, + fontWeight: 600, +}); + +export const fluxColCount = style({ + fontFamily: vars.font.heading, + fontWeight: 900, + fontSize: "1.5rem", + letterSpacing: "-0.02em", + fontVariantNumeric: "tabular-nums", +}); + +// ---- List (latest requests / tasks) ---- + +export const listHeading = style({ + fontSize: "0.75rem", + textTransform: "uppercase", + letterSpacing: "0.06em", + color: vars.color.textMuted, + fontWeight: 600, + marginBlockEnd: "6px", +}); + +export const list = style({ + display: "flex", + flexDirection: "column", + listStyle: "none", + padding: 0, + margin: 0, +}); + +export const listItem = style({ + display: "flex", + alignItems: "center", + gap: vars.space.sm, + paddingBlock: "10px", + borderBlockStart: `1px solid ${vars.color.border}`, + selectors: { + "&:first-child": { borderBlockStart: 0 }, + }, +}); + +export const listTitleCell = style({ + flex: 1, + minInlineSize: 0, +}); + +export const listTitle = style({ + fontSize: "0.875rem", + fontWeight: 500, + color: vars.color.text, + display: "block", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + textDecoration: "none", + ":hover": { color: vars.color.primary, textDecoration: "none" }, +}); + +export const listSub = style({ + fontSize: "0.75rem", + color: vars.color.textMuted, + marginBlockStart: "2px", +}); + +export const listRightMeta = style({ + display: "flex", + alignItems: "center", + gap: vars.space.sm, + whiteSpace: "nowrap", +}); + +// ---- Status pill ---- + +const statusPillBase = { + display: "inline-block" as const, + paddingBlock: "2px", + paddingInline: "7px", + borderRadius: "4px", + fontSize: "0.6875rem", + fontWeight: 600, + letterSpacing: "0.02em", + lineHeight: 1, + textTransform: "uppercase" as const, +}; + +export const statusPillRequested = style({ + ...statusPillBase, + backgroundColor: vars.color.primaryLight, + color: vars.color.primary, +}); +export const statusPillApproved = style({ + ...statusPillBase, + backgroundColor: vars.color.primaryLight, + color: vars.color.primary, +}); +export const statusPillInProgress = style({ + ...statusPillBase, + backgroundColor: vars.color.warningLight, + color: vars.color.warning, +}); +export const statusPillDone = style({ + ...statusPillBase, + backgroundColor: vars.color.successLight, + color: vars.color.success, +}); +export const statusPillNotPlanned = style({ + ...statusPillBase, + backgroundColor: vars.color.dangerLight, + color: vars.color.danger, +}); +export const statusPillNeutral = style({ + ...statusPillBase, + backgroundColor: vars.color.surfaceHover, + color: vars.color.textMuted, +}); + +// ---- Vote chip ---- + +export const voteChip = style({ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + paddingBlock: "2px", + paddingInline: "4px", + borderRadius: "4px", + backgroundColor: vars.color.bg, + border: `1px solid ${vars.color.border}`, + fontSize: "0.6875rem", + fontWeight: 600, + color: vars.color.textMuted, + fontVariantNumeric: "tabular-nums", + minInlineSize: "26px", + blockSize: "22px", +}); + +// ---- Empty / not-enabled state ---- + +export const modulesEmpty = style({ + padding: vars.space.xl, + textAlign: "center", + border: `1px dashed ${vars.color.border}`, + borderRadius: vars.radius.lg, + backgroundColor: vars.color.surface, + gridColumn: "1 / -1", +}); + +export const modulesEmptyTitle = style({ + fontFamily: vars.font.heading, + fontSize: "1.125rem", + fontWeight: 700, + marginBlockEnd: "6px", +}); + +export const modulesEmptyDesc = style({ + color: vars.color.textMuted, + fontSize: "0.8125rem", + maxInlineSize: "420px", + marginInline: "auto", + marginBlockEnd: vars.space.md, +}); + +// ---- Sphere-aware container (full-width dashboard layout) ---- + +export const spherePage = style({ + maxInlineSize: "1120px", + marginInline: "auto", + paddingInline: vars.space.lg, + paddingBlockStart: vars.space.xl, + paddingBlockEnd: vars.space.xxl, + "@media": { + "screen and (max-width: 640px)": { + paddingInline: vars.space.md, + }, + }, +}); diff --git a/packages/app/src/pages/sphere.tsx b/packages/app/src/pages/sphere.tsx index fd29369..79eb822 100644 --- a/packages/app/src/pages/sphere.tsx +++ b/packages/app/src/pages/sphere.tsx @@ -1,186 +1,357 @@ -import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; -import { canDo } from "@exosphere/client/permissions"; +import { useEffect } from "preact/hooks"; +import { sphereState, sphereHandle } from "@exosphere/client/sphere"; import { useQuery } from "@exosphere/client/hooks"; import { spherePath } from "@exosphere/client/router"; import { Link } from "@exosphere/client/link"; +import { ssrPageData } from "@exosphere/client/ssr-data"; import * as ui from "@exosphere/client/ui.css"; +import * as s from "./sphere.css.ts"; import { - getSphereModules, - enableModule as apiEnableModule, - disableModule as apiDisableModule, -} from "../api/spheres.ts"; - -/** Map internal module names to user-facing labels (used for display and URL paths). */ -const moduleLabels: Record = { - // feeds: { - // label: "Feeds", - // path: "feeds", - // description: "Discuss topics and share updates with the community.", - // }, - "feature-requests": { - label: "Infuse", - path: "infuse", - description: "Submit, vote on, and track feature requests.", - }, - kanban: { - label: "Flux", - path: "flux", - description: "Track tasks across columns with a visual project board.", - }, + getInfuseStats, + getFluxStats, + type InfuseStats, + type FluxStats, + type KanbanStatusType, + type FrStatus, +} from "../api/sphere-dashboard.ts"; + +const FR_STATUS_SEGMENTS: { key: FrStatus; label: string }[] = [ + { key: "requested", label: "Requested" }, + { key: "approved", label: "Approved" }, + { key: "in-progress", label: "In progress" }, + { key: "done", label: "Done" }, + { key: "not-planned", label: "Not planned" }, +]; + +const FR_PILL_BY_STATUS: Record = { + requested: s.statusPillRequested, + approved: s.statusPillApproved, + "in-progress": s.statusPillInProgress, + done: s.statusPillDone, + "not-planned": s.statusPillNotPlanned, + duplicate: s.statusPillNeutral, }; -export function SpherePage() { - const { data, loading } = sphereState.value; - const handle = sphereHandle.value; +const FR_STATUS_LABEL: Record = { + requested: "Requested", + approved: "Approved", + "in-progress": "In progress", + done: "Done", + "not-planned": "Not planned", + duplicate: "Duplicate", +}; - if (!handle) return null; +// StatusType → dashboard column label (matches the design wording). +const FLUX_COLUMNS: { type: KanbanStatusType; label: string }[] = [ + { type: "backlog", label: "Backlog" }, + { type: "planned", label: "To do" }, + { type: "started", label: "Started" }, + { type: "completed", label: "Done" }, +]; - if (!data) { - if (!loading) return null; - return ( -
-
-
-
-
-
-
-
-
-
-
- {[0, 1].map((i) => ( -
-
-
-
- ))} -
-
-
- ); - } +// StatusType → pill variant used for latest tasks. +const FLUX_STATUS_PILL: Record = { + backlog: s.statusPillNeutral, + planned: s.statusPillApproved, + started: s.statusPillInProgress, + completed: s.statusPillDone, + canceled: s.statusPillNotPlanned, +}; - const modules = useQuery(() => getSphereModules(handle), [handle]); +function InfuseWidget({ handle }: { handle: string }) { + const ssr = ssrPageData.peek()?.["infuse-stats"] as InfuseStats | undefined; + // Consume the SSR key after first render so subsequent re-navigations refetch + useEffect(() => { + const pd = ssrPageData.peek(); + if (pd && "infuse-stats" in pd) delete pd["infuse-stats"]; + }, []); + const { data } = useQuery(() => getInfuseStats(), [handle], { + initialData: ssr, + cacheKey: `infuse-stats:${handle}`, + }); - const canManageMembers = - canDo("sphere", "inviteMember") || - canDo("sphere", "revokeMember") || - canDo("sphere", "updateMemberRole"); + return ( +
+
+
+
Infuse
+
Feature requests
+
+
+ + + New + + + Open → + +
+
- const enableModule = async (moduleName: string) => { - await apiEnableModule(handle, moduleName); - modules.refetch(); - refreshSphere(); - }; + {data ? ( + <> +
+
+ Total + {data.total} +
+
+ Requested + {data.statusCounts.requested} +
+
+ In progress + {data.statusCounts["in-progress"]} +
+
+ Done + {data.statusCounts.done} +
+
- const disableModule = async (moduleName: string) => { - await apiDisableModule(handle, moduleName); - modules.refetch(); - refreshSphere(); - }; + - const { sphere: s, modules: enabledModules } = data; - const enabledNames = enabledModules.map((m) => m.name); - const availableToEnable = modules.data?.available.filter((a) => !enabledNames.includes(a)) ?? []; +
+

Latest requests

+ {data.latestRequests.length === 0 ? ( +

No feature requests yet.

+ ) : ( +
    + {data.latestRequests.map((req) => ( +
  • + {req.voteCount} +
    + + {req.title} + + {req.authorHandle && by @{req.authorHandle}} +
    +
    + + {FR_STATUS_LABEL[req.status] ?? req.status} + +
    +
  • + ))} +
+ )} +
+ + ) : ( + + )} +
+ ); +} +function StatusBar({ counts }: { counts: Record }) { + const total = FR_STATUS_SEGMENTS.reduce((sum, seg) => sum + (counts[seg.key] ?? 0), 0); return ( -
-
-
-

{s.name}

- {s.visibility} +
+
+ {FR_STATUS_SEGMENTS.map((seg) => { + const n = counts[seg.key] ?? 0; + if (n === 0) return null; + return ; + })} +
+ {total > 0 && ( +
+ {FR_STATUS_SEGMENTS.map((seg) => ( + + + {seg.label} {counts[seg.key] ?? 0} + + ))}
+ )} +
+ ); +} - {s.description &&

{s.description}

} +function InfuseSkeleton() { + return ( + <> +
+ {[0, 1, 2, 3].map((i) => ( +
+   +   +
+ ))} +
+
+
+

Latest requests

+
    + {[0, 1, 2, 3].map((i) => ( +
  • +   +
    +   +   +
    +
  • + ))} +
+ + ); +} - {/* Modules section */} -
-

Modules

- - {enabledModules.length === 0 && availableToEnable.length === 0 && ( -

No modules available.

- )} - - {enabledModules.length > 0 && ( -
- {enabledModules.map((mod) => ( -
-
-
- - {moduleLabels[mod.name]?.label ?? mod.name} - - enabled -
- {canDo("sphere", "disableModule") && ( - - )} -
- {moduleLabels[mod.name]?.description && ( -

{moduleLabels[mod.name].description}

- )} +function FluxWidget({ handle }: { handle: string }) { + const ssr = ssrPageData.peek()?.["flux-stats"] as FluxStats | undefined; + useEffect(() => { + const pd = ssrPageData.peek(); + if (pd && "flux-stats" in pd) delete pd["flux-stats"]; + }, []); + const { data } = useQuery(() => getFluxStats(), [handle], { + initialData: ssr, + cacheKey: `flux-stats:${handle}`, + }); + + return ( +
+
+
+
Flux
+
Project board
+
+
+ + + New + + + Open → + +
+
+ + {data ? ( + <> +
+ {FLUX_COLUMNS.map((col) => ( +
+
{col.label}
+
{data.statusTypeCounts[col.type] ?? 0}
))}
- )} - - {canDo("sphere", "enableModule") && availableToEnable.length > 0 && ( -
-

Available modules

-
- {availableToEnable.map((name) => ( -
-
- {moduleLabels[name]?.label ?? name} - -
- {moduleLabels[name]?.description && ( -

{moduleLabels[name].description}

- )} -
- ))} -
+ +
+

Latest tasks

+ {data.latestTasks.length === 0 ? ( +

No tasks yet.

+ ) : ( +
    + {data.latestTasks.map((task) => ( +
  • +
    + + {task.title} + + {task.authorHandle && @{task.authorHandle}} +
    +
    + + {task.statusLabel} + +
    +
  • + ))} +
+ )} +
+ + ) : ( + + )} +
+ ); +} + +function FluxSkeleton() { + return ( + <> +
+ {FLUX_COLUMNS.map((col) => ( +
+
{col.label}
+
 
- )} + ))}
+
+

Latest tasks

+
    + {[0, 1, 2, 3].map((i) => ( +
  • +
    +   +   +
    +
  • + ))} +
+
+ + ); +} - {/* Settings — visible to users with relevant permissions */} - {(canManageMembers || - canDo("sphere", "updatePermissions") || - canDo("sphere", "manageLabels")) && ( -
-

Settings

-
- {canManageMembers && ( - - Members -

Invite, manage roles, and revoke members.

-
- )} - {canDo("sphere", "updatePermissions") && ( - - Permissions -

Configure who can perform actions in this sphere.

-
- )} - {canDo("sphere", "manageLabels") && ( - - Labels -

Create and manage labels for feature requests and tasks.

-
- )} +function EnabledModulesEmpty() { + return ( +
+
No modules enabled yet
+

+ Add modules to turn your sphere into a place for feedback or planning. You can enable or + disable them anytime. +

+ + Go to Settings, Modules + +
+ ); +} + +export function SpherePage() { + const { data, loading } = sphereState.value; + const handle = sphereHandle.value; + + if (!handle) return null; + + if (!data) { + if (!loading) return null; + return ( +
+
+
+
+
+ ); + } + + const enabledNames = new Set(data.modules.map((m) => m.name)); + const hasInfuse = enabledNames.has("feature-requests"); + const hasFlux = enabledNames.has("kanban"); + const hasAnyModule = hasInfuse || hasFlux; + const sphere = data.sphere; + + return ( +
+ {sphere.description && ( +
+
+

{sphere.description}

+
+
)} + +
+ {!hasAnyModule && } + {hasInfuse && } + {hasFlux && } +
); } diff --git a/packages/app/src/vite-ssr-plugin.ts b/packages/app/src/vite-ssr-plugin.ts index c2f91c4..31ec9c4 100644 --- a/packages/app/src/vite-ssr-plugin.ts +++ b/packages/app/src/vite-ssr-plugin.ts @@ -16,8 +16,17 @@ export function ssrDevPlugin({ isMultiSphere = false } = {}): Plugin { server.middlewares.use(async (req, res, next) => { const url = req.url ?? "/"; - // Only handle page navigations — let Vite handle everything else + // Only handle page navigations — let Vite handle everything else. + // Note: we can't skip on any "." in the URL because sphere handles + // contain dots (e.g. `/s/alice.pds.dev`). Match known asset extensions + // and explicitly exclude /s/ paths so any TLD is safe. const accept = req.headers.accept ?? ""; + const urlPath = url.split("?")[0].split("#")[0]; + const isStaticAsset = + !urlPath.startsWith("/s/") && + /\.(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( + urlPath, + ); if ( !accept.includes("text/html") || url.startsWith("/api") || @@ -27,7 +36,7 @@ export function ssrDevPlugin({ isMultiSphere = false } = {}): Plugin { url.startsWith("/xrpc") || url.startsWith("/node_modules") || url.startsWith("/src") || - url.includes(".") + isStaticAsset ) { return next(); } diff --git a/packages/client/src/router.tsx b/packages/client/src/router.tsx index 70729f9..e7a609b 100644 --- a/packages/client/src/router.tsx +++ b/packages/client/src/router.tsx @@ -15,3 +15,14 @@ export function spherePath(path: string): string { // Avoid trailing slash for root path: spherePath("/") → "/s/handle" not "/s/handle/" return path === "/" ? `/s/${handle}` : `/s/${handle}${path}`; } + +/** Strip a single query param from the current URL without triggering a navigation. + * Safe to call during render — no-ops on the server. */ +export function clearQueryParam(key: string): void { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + if (!url.searchParams.has(key)) return; + url.searchParams.delete(key); + const qs = url.searchParams.toString(); + history.replaceState(null, "", url.pathname + (qs ? `?${qs}` : "")); +} diff --git a/packages/client/src/ssr-prefetch.ts b/packages/client/src/ssr-prefetch.ts index a846860..6d1456e 100644 --- a/packages/client/src/ssr-prefetch.ts +++ b/packages/client/src/ssr-prefetch.ts @@ -6,8 +6,19 @@ export function ssrPrefetch( ): { key: string; apiUrl: string }[] { const apiBase = sphereHandle ? `/api/s/${sphereHandle}` : "/api"; + // Strip query string / hash — dev SSR passes `req.url` which includes them, + // while prod passes the Hono-cleaned path. Normalize here so routing matches. + const cleanPath = path.split("?")[0].split("#")[0]; + // In multi-sphere mode, strip the /s/:handle prefix to match module paths - const modulePath = sphereHandle ? path.replace(/^\/s\/[^/]+/, "") : path; + const modulePath = sphereHandle ? cleanPath.replace(/^\/s\/[^/]+/, "") : cleanPath; + + // Sphere dashboard — fetch per-module stats in parallel + if (modulePath === "" || modulePath === "/") + return [ + { key: "infuse-stats", apiUrl: `${apiBase}/feature-requests/stats` }, + { key: "flux-stats", apiUrl: `${apiBase}/kanban/stats` }, + ]; if (modulePath === "/infuse") return [ diff --git a/packages/feature-requests/src/api/routes.ts b/packages/feature-requests/src/api/routes.ts index 247d0b9..697d859 100644 --- a/packages/feature-requests/src/api/routes.ts +++ b/packages/feature-requests/src/api/routes.ts @@ -5,9 +5,11 @@ import { requestsApi } from "./requests.ts"; import { statusesApi } from "./statuses.ts"; import { votesApi } from "./votes.ts"; import { commentsApi } from "./comments.ts"; +import { frStatsApi } from "./stats.ts"; const app = new Hono(); +app.route("/", frStatsApi); app.route("/", requestsApi); app.route("/", statusesApi); app.route("/", votesApi); diff --git a/packages/feature-requests/src/api/stats.ts b/packages/feature-requests/src/api/stats.ts new file mode 100644 index 0000000..d7684c7 --- /dev/null +++ b/packages/feature-requests/src/api/stats.ts @@ -0,0 +1,89 @@ +import { Hono } from "hono"; +import { getDb } from "@exosphere/core/db"; +import { eq, and, sql, count, desc } from "@exosphere/core/db/drizzle"; +import { resolveDidHandles } from "@exosphere/core/identity"; +import { tidToDate } from "@exosphere/core/pds"; +import type { AuthEnv } from "@exosphere/core/auth"; +import type { SphereEnv } from "@exosphere/core/types"; +import { featureRequests, featureRequestVotes } from "../db/schema.ts"; +import { statuses, type Status } from "../schemas/feature-request.ts"; + +const LATEST_LIMIT = 4; +const STATUS_SET: ReadonlySet = new Set(statuses); + +function isStatus(value: string): value is Status { + return STATUS_SET.has(value as Status); +} + +const app = new Hono(); + +// Dashboard stats: counts grouped by status + latest requests with vote counts. +app.get("/stats", async (c) => { + const db = getDb(); + const sphereId = c.var.sphereId; + + const countRows = db + .select({ + status: featureRequests.status, + count: sql`count(*)`.as("count"), + }) + .from(featureRequests) + .where(and(eq(featureRequests.sphereId, sphereId), sql`${featureRequests.hiddenAt} is null`)) + .groupBy(featureRequests.status) + .all(); + + const statusCounts: Record = { + requested: 0, + "not-planned": 0, + approved: 0, + "in-progress": 0, + done: 0, + duplicate: 0, + }; + let total = 0; + for (const row of countRows) { + if (isStatus(row.status)) { + statusCounts[row.status] = row.count; + } + // Duplicates are not counted toward the dashboard total. + if (row.status !== "duplicate") total += row.count; + } + + const voteCountCol = count(featureRequestVotes.authorDid); + const latestRows = db + .select({ + id: featureRequests.id, + number: featureRequests.number, + authorDid: featureRequests.authorDid, + title: featureRequests.title, + status: featureRequests.status, + voteCount: voteCountCol, + }) + .from(featureRequests) + .leftJoin(featureRequestVotes, eq(featureRequestVotes.requestId, featureRequests.id)) + .where(and(eq(featureRequests.sphereId, sphereId), sql`${featureRequests.hiddenAt} is null`)) + .groupBy(featureRequests.id) + .orderBy(desc(featureRequests.id)) + .limit(LATEST_LIMIT) + .all(); + + const handleMap = await resolveDidHandles(latestRows.map((r) => r.authorDid)); + + const latestRequests = latestRows.map((r) => ({ + id: r.id, + number: r.number, + title: r.title, + status: r.status, + voteCount: r.voteCount, + authorHandle: handleMap.get(r.authorDid) ?? null, + createdAt: tidToDate(r.id), + })); + + return c.json({ + total, + statusCounts, + latestRequests, + }); +}); + +export { app as frStatsApi }; diff --git a/packages/feature-requests/src/ui/pages/feature-requests.tsx b/packages/feature-requests/src/ui/pages/feature-requests.tsx index 534b97f..ccfa015 100644 --- a/packages/feature-requests/src/ui/pages/feature-requests.tsx +++ b/packages/feature-requests/src/ui/pages/feature-requests.tsx @@ -1,6 +1,6 @@ import { useSignal } from "@preact/signals"; import { auth } from "@exosphere/client/auth"; -import { spherePath, useLocation } from "@exosphere/client/router"; +import { spherePath, useLocation, clearQueryParam } from "@exosphere/client/router"; import { Link } from "@exosphere/client/link"; import { useQuery } from "@exosphere/client/hooks"; import * as ui from "@exosphere/client/ui.css"; @@ -148,8 +148,19 @@ function useActiveTab(): { activeTab: ActiveTab; statuses: string[] } { export function FeatureRequestsListPage() { const { activeTab, statuses } = useActiveTab(); - const showForm = useSignal(false); + const { query } = useLocation(); + const showForm = useSignal(query?.new === "1"); const { sortBy, sortOrder } = useSortParams(); + + // Sync with query param changes (e.g. clicking "New" on the dashboard after arriving here). + useEffect(() => { + if (query?.new === "1") showForm.value = true; + }, [query?.new]); + + // When the modal closes, clear ?new= from the URL so it doesn't re-open on back/refresh. + useEffect(() => { + if (!showForm.value) clearQueryParam("new"); + }, [showForm.value]); const prefetchKey = activeTab === "requests" ? "feature-requests" : `feature-requests-${statuses.join(",")}`; const prefetched = ssrPageData.peek()?.[prefetchKey] as diff --git a/packages/kanban/src/api/routes.ts b/packages/kanban/src/api/routes.ts index 11b29ba..ed66b1b 100644 --- a/packages/kanban/src/api/routes.ts +++ b/packages/kanban/src/api/routes.ts @@ -4,9 +4,11 @@ import type { SphereEnv } from "@exosphere/core/types"; import { tasksApi } from "./tasks.ts"; import { commentsApi } from "./comments.ts"; import { columnsApi } from "./columns.ts"; +import { statsApi } from "./stats.ts"; const app = new Hono(); +app.route("/", statsApi); app.route("/", columnsApi); app.route("/", tasksApi); app.route("/", commentsApi); diff --git a/packages/kanban/src/api/stats.ts b/packages/kanban/src/api/stats.ts new file mode 100644 index 0000000..bbfae53 --- /dev/null +++ b/packages/kanban/src/api/stats.ts @@ -0,0 +1,112 @@ +import { Hono } from "hono"; +import { getDb } from "@exosphere/core/db"; +import { eq, and, sql, count, desc } from "@exosphere/core/db/drizzle"; +import { resolveDidHandles } from "@exosphere/core/identity"; +import { tidToDate } from "@exosphere/core/pds"; +import type { AuthEnv } from "@exosphere/core/auth"; +import type { SphereEnv } from "@exosphere/core/types"; +import { kanbanTasks, kanbanColumns } from "../db/schema.ts"; +import { STATUS_TYPES, type StatusType } from "../schemas/status-type.ts"; + +const LATEST_TASKS_LIMIT = 4; +const STATUS_TYPE_SET: ReadonlySet = new Set(STATUS_TYPES); + +function isStatusType(value: string): value is StatusType { + return STATUS_TYPE_SET.has(value as StatusType); +} + +const app = new Hono(); + +// Dashboard stats: task counts grouped by StatusType + latest tasks. +// Counts are computed by joining tasks with their column so we group by +// the column's statusType, not by the column slug. One JOIN + GROUP BY. +app.get("/stats", async (c) => { + const db = getDb(); + const sphereId = c.var.sphereId; + + const countRows = db + .select({ + statusType: kanbanColumns.statusType, + count: sql`count(${kanbanTasks.id})`.as("count"), + }) + .from(kanbanTasks) + .innerJoin( + kanbanColumns, + and( + eq(kanbanColumns.sphereId, kanbanTasks.sphereId), + eq(kanbanColumns.slug, kanbanTasks.status), + ), + ) + .where(and(eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`)) + .groupBy(kanbanColumns.statusType) + .all(); + + // Count all tasks (including any orphans whose status slug no longer maps to a column) + // so the total matches reality even if the join-based counts drop some rows. + const totalRow = db + .select({ total: count() }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`)) + .get(); + const total = totalRow?.total ?? 0; + + const statusTypeCounts: Record = { + backlog: 0, + planned: 0, + started: 0, + completed: 0, + canceled: 0, + }; + for (const row of countRows) { + if (isStatusType(row.statusType)) { + statusTypeCounts[row.statusType] = row.count; + } + } + + const latestRows = db + .select({ + id: kanbanTasks.id, + number: kanbanTasks.number, + authorDid: kanbanTasks.authorDid, + title: kanbanTasks.title, + status: kanbanTasks.status, + statusType: kanbanColumns.statusType, + statusLabel: kanbanColumns.label, + updatedAt: kanbanTasks.updatedAt, + }) + .from(kanbanTasks) + .innerJoin( + kanbanColumns, + and( + eq(kanbanColumns.sphereId, kanbanTasks.sphereId), + eq(kanbanColumns.slug, kanbanTasks.status), + ), + ) + .where(and(eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`)) + // TID-based IDs sort by creation time, matching feature-requests "latest" semantics. + .orderBy(desc(kanbanTasks.id)) + .limit(LATEST_TASKS_LIMIT) + .all(); + + const handleMap = await resolveDidHandles(latestRows.map((r) => r.authorDid)); + + const latestTasks = latestRows.map((r) => ({ + id: r.id, + number: r.number, + title: r.title, + status: r.status, + statusType: r.statusType as StatusType, + statusLabel: r.statusLabel, + authorHandle: handleMap.get(r.authorDid) ?? null, + createdAt: tidToDate(r.id), + updatedAt: r.updatedAt, + })); + + return c.json({ + total, + statusTypeCounts, + latestTasks, + }); +}); + +export { app as statsApi }; diff --git a/packages/kanban/src/ui/pages/board.tsx b/packages/kanban/src/ui/pages/board.tsx index 0ab6277..54a5af3 100644 --- a/packages/kanban/src/ui/pages/board.tsx +++ b/packages/kanban/src/ui/pages/board.tsx @@ -4,7 +4,7 @@ import { auth } from "@exosphere/client/auth"; import { canDo } from "@exosphere/client/permissions"; import { useQuery } from "@exosphere/client/hooks"; import { ssrPageData } from "@exosphere/client/ssr-data"; -import { spherePath } from "@exosphere/client/router"; +import { spherePath, useLocation, clearQueryParam } from "@exosphere/client/router"; import { Link } from "@exosphere/client/link"; import { Settings } from "lucide-preact"; import * as ui from "@exosphere/client/ui.css"; @@ -17,8 +17,22 @@ import { TaskForm } from "../components/task-form.tsx"; import { useBoardDnd } from "../hooks/use-board-dnd.ts"; export function BoardPage() { - const showForm = useSignal(false); + const { query } = useLocation(); + const showForm = useSignal(query?.new === "1"); const formInitialStatus = useSignal(undefined); + + // Sync with query param changes (e.g. clicking "+ New" on the dashboard after arriving here). + useEffect(() => { + if (query?.new === "1") { + formInitialStatus.value = undefined; + showForm.value = true; + } + }, [query?.new]); + + // When the modal closes, clear ?new= from the URL so it doesn't re-open on back/refresh. + useEffect(() => { + if (!showForm.value) clearQueryParam("new"); + }, [showForm.value]); const prefetched = ssrPageData.peek()?.["kanban-tasks"] as | Awaited> | undefined; diff --git a/packages/kanban/src/ui/ui.css.ts b/packages/kanban/src/ui/ui.css.ts index ecb3b21..e555888 100644 --- a/packages/kanban/src/ui/ui.css.ts +++ b/packages/kanban/src/ui/ui.css.ts @@ -8,6 +8,8 @@ export const boardSection = style({ flexDirection: "column", minBlockSize: "calc(100vh - 220px)", paddingInline: vars.space.xl, + paddingBlockStart: vars.space.md, + paddingBlockEnd: vars.space.xl, "@media": { "(min-width: 900px)": { paddingInline: vars.space.xxl, @@ -288,11 +290,26 @@ const statusTypeBadgeBase = style({ }); export const statusTypeBadge = styleVariants({ - backlog: [statusTypeBadgeBase, { color: vars.color.textMuted, border: `1px solid ${vars.color.border}` }], - planned: [statusTypeBadgeBase, { color: vars.color.text, border: `1px solid ${vars.color.border}` }], - started: [statusTypeBadgeBase, { color: vars.color.warning, border: `1px solid ${vars.color.warning}` }], - completed: [statusTypeBadgeBase, { color: vars.color.success, border: `1px solid ${vars.color.success}` }], - canceled: [statusTypeBadgeBase, { color: vars.color.danger, border: `1px solid ${vars.color.danger}` }], + backlog: [ + statusTypeBadgeBase, + { color: vars.color.textMuted, border: `1px solid ${vars.color.border}` }, + ], + planned: [ + statusTypeBadgeBase, + { color: vars.color.text, border: `1px solid ${vars.color.border}` }, + ], + started: [ + statusTypeBadgeBase, + { color: vars.color.warning, border: `1px solid ${vars.color.warning}` }, + ], + completed: [ + statusTypeBadgeBase, + { color: vars.color.success, border: `1px solid ${vars.color.success}` }, + ], + canceled: [ + statusTypeBadgeBase, + { color: vars.color.danger, border: `1px solid ${vars.color.danger}` }, + ], }); export const dragHandle = style({