diff --git a/src/app/api/indexer/route.ts b/src/app/api/indexer/route.ts index 3962ff50..d011ebfc 100644 --- a/src/app/api/indexer/route.ts +++ b/src/app/api/indexer/route.ts @@ -330,19 +330,23 @@ ${ACTIVITY_NODE_SELECTION} } `, - // Every endorsement-typed badge award across the network — backs the - // /endorsement-graph page. Returns the directed edge - // (issuer `did` → `subject` DID) plus the issuer's denormalised actor + // Every badge award of one `badgeType` across the network — backs the + // /endorsement-graph page, which scans once per type ("endorsement" and + // "award"; the proxy allowlists the value). Returns the directed edge + // (issuer `did` → `subject`) plus the issuer's denormalised actor // profile (same `issuer { ... }` join as ReceivedEndorsements, drops a - // per-issuer resolve fan-out). Subjects that never issued an award have - // no inline profile here, so the client resolves those DIDs via + // per-issuer resolve fan-out). The subject union carries the DID for + // account-targeted awards and the strong-ref `uri` for record-targeted + // ones (award-typed badges usually point at records; the at:// authority + // identifies the owning account). Subjects that never issued an award + // have no inline profile here, so the client resolves those DIDs via // NetworkActorsByDids. `excludeAuthorLabels` hides awards authored by // likely-test accounts, mirroring AwardCount so the graph matches the // public counters. Paginated; the client unions pages up to a cap. AllEndorsements: ` - query AllEndorsements($first: Int!, $after: String) { + query AllEndorsements($badgeType: String!, $first: Int!, $after: String) { appCertifiedBadgeAward( - where: { badgeType: { eq: "endorsement" } } + where: { badgeType: { eq: $badgeType } } excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])} first: $first after: $after @@ -357,6 +361,7 @@ ${ACTIVITY_NODE_SELECTION} subject { __typename ... on AppCertifiedDefsDid { did } + ... on ComAtprotoRepoStrongRef { uri } } issuer { did handle displayName avatarCid pds } response { state } @@ -1605,9 +1610,15 @@ function buildVariables( return { viewer, degree: rawDegree } } case "AllEndorsements": { - // Zero required vars — paginated network-wide scan. Same clamp - // shape as the other 100-per-page reads (ReceivedEndorsements). + // Paginated network-wide scan, one badge type per pass. Strict + // allowlist on `badgeType` (defaults to "endorsement" for older + // clients) — anything else 400s here rather than fanning an + // arbitrary string out to the indexer. Same clamp shape as the + // other 100-per-page reads (ReceivedEndorsements). + const badgeType = vars.badgeType === undefined ? "endorsement" : vars.badgeType + if (badgeType !== "endorsement" && badgeType !== "award") return null return { + badgeType, first: clampFirst(vars.first, MAX_FIRST, 100), after: readString(vars.after, MAX_AFTER_LEN), } diff --git a/src/app/help/page.tsx b/src/app/help/page.tsx index a3e9afc0..a0b0df98 100644 --- a/src/app/help/page.tsx +++ b/src/app/help/page.tsx @@ -47,6 +47,21 @@ export default function HelpPage() {

+
+

+ What is certified.app? +

+

+ certified.app is a view into the whole Certified network. It brings together every + account on the network, the activities and impact data they publish, and the + connections between them — who endorses whom, who follows whom, and who works + together in which group. The data itself doesn't live in this app: each account + keeps its own records on AT Protocol, and certified.app reads across all of them + to show the network in one place. Other compatible apps can present the same network + in their own way. +

+
+

Take the walk-through diff --git a/src/app/styles/visualization.css b/src/app/styles/visualization.css index 636a6050..fec88954 100644 --- a/src/app/styles/visualization.css +++ b/src/app/styles/visualization.css @@ -145,6 +145,17 @@ min-width: 200px; } +/* Endorsement/award checkboxes float over the canvas, so give the row the + same card chrome as the legend for readability. */ +.viz__kind-toggles { + gap: var(--space-3, 12px); + padding: var(--space-2, 8px) var(--space-3, 12px); + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); +} + /* Layout + scope switchers fill the control panel so their segments read as one clear toggle rather than floating short. */ .viz__layout-toggle, @@ -263,6 +274,10 @@ border-top-width: 3px; } +.viz__legend-swatch--award { + border-top-color: var(--color-warning); +} + /* ---- Hover / selected node info panel ---- */ .viz__panel { position: absolute; @@ -446,6 +461,13 @@ gap: var(--space-3, 12px); } +/* With an odd cell count (5 since the Awards stat) the last cell spans the + 2-column grid instead of orphaning at half width. The tablet range + overrides this — there the five cells share one row. */ +.viz__stats-grid > .viz__stat:last-child:nth-child(odd) { + grid-column: 1 / -1; +} + .viz__stat { display: flex; flex-direction: column; @@ -560,7 +582,11 @@ } .viz__stats-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .viz__stats-grid > .viz__stat:last-child:nth-child(odd) { + grid-column: auto; } } @@ -573,6 +599,12 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } + /* Back to 2 columns here, so the odd-count span applies again (the + tablet block above also matches this range and would otherwise win). */ + .viz__stats-grid > .viz__stat:last-child:nth-child(odd) { + grid-column: 1 / -1; + } + .viz__panel { width: 220px; } diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 63a8c00e..14c4a185 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -6,6 +6,7 @@ import { useFeedback } from "@/lib/feedback-context"; import { useOrg } from "@/lib/groups/org-context"; import { isRouteVisibleToActor } from "@/lib/groups/personal-only"; import { useLayoutBreakpoints } from "@/hooks/use-layout-breakpoints"; +import { isBottomNavVisible } from "@/lib/layout/bottom-nav-visibility"; import Tooltip from "@/components/ui/tooltip"; export default function BottomNav() { @@ -15,22 +16,11 @@ export default function BottomNav() { const { activeOrg } = useOrg(); const { isDesktop, isStandalone } = useLayoutBreakpoints(); - // Unmount at ≥800px — the left rail is the primary nav on desktop and - // bottom-nav's focusable buttons would compete for tab order. - if (isDesktop) return null; - - // Only show in installed / standalone mode (PWA or A2HS web clip). - // In a regular browser tab the address bar and OS gestures provide - // navigation; the bar also occludes content on /welcome for guests. - if (!isStandalone) return null; - - // Embeds render bare (board-only) inside a third-party iframe. - if (pathname.startsWith("/embed")) return null; - - // /welcome uses the minimal landing chrome (wordmark + sign-in) at every - // width — no bottom nav. Matches the mobile navbar + desktop-top-bar, - // which both return null on /welcome. - if (pathname === "/welcome") return null; + // Visibility is centralized in isBottomNavVisible so the floating + // can hide itself under the exact same conditions + // (the bar already carries a Feedback entry). See the helper for the + // full per-condition rationale. + if (!isBottomNavVisible({ pathname, isDesktop, isStandalone })) return null; const isHome = pathname === "/home" || pathname.startsWith("/home/"); diff --git a/src/components/layout/feedback-trigger.tsx b/src/components/layout/feedback-trigger.tsx index c0abb446..4bc289ba 100644 --- a/src/components/layout/feedback-trigger.tsx +++ b/src/components/layout/feedback-trigger.tsx @@ -1,8 +1,11 @@ "use client" import { useCallback, useEffect, useState } from "react" +import { usePathname } from "next/navigation" import { MessageSquare } from "lucide-react" import { useFeedback } from "@/lib/feedback-context" +import { useLayoutBreakpoints } from "@/hooks/use-layout-breakpoints" +import { isBottomNavVisible } from "@/lib/layout/bottom-nav-visibility" /** * Floating "Share Feedback" button, pinned bottom-right. It's the app's @@ -15,6 +18,8 @@ import { useFeedback } from "@/lib/feedback-context" */ export default function FeedbackTrigger() { const { isOpen, openFeedback } = useFeedback() + const pathname = usePathname() + const { isDesktop, isStandalone } = useLayoutBreakpoints() const [bottomOffset, setBottomOffset] = useState(20) const updatePosition = useCallback(() => { @@ -62,7 +67,16 @@ export default function FeedbackTrigger() { } }, [updatePosition]) - if (isOpen) return null + // Hidden on the marketing landing page (/welcome has its own contact + // CTA), and wherever the mobile bottom nav is on screen — that bar + // already carries a Feedback entry, so the floating button would + // duplicate it and overlap the bar. + if ( + isOpen || + pathname === "/welcome" || + isBottomNavVisible({ pathname, isDesktop, isStandalone }) + ) + return null return ( + {/* Badge-kind toggles. The last checked box is disabled so at + least one kind is always visible. */} +
+ setShowEndorsements(e.target.checked)} + /> + setShowAwards(e.target.checked)} + /> +
@@ -953,40 +1037,32 @@ export default function EndorsementGraph({ nodes, links, focusReq, truncated = f - {neighbourList.endorsed.length > 0 && ( -
-
Endorsed ({neighbourList.endorsed.length})
-
- {neighbourList.endorsed.slice(0, 12).map((n) => ( - - - - ))} -
-
- )} - - {neighbourList.endorsedBy.length > 0 && ( -
-
- Endorsed by ({neighbourList.endorsedBy.length}) -
-
- {neighbourList.endorsedBy.slice(0, 12).map((n) => ( - - - - ))} + {( + [ + ["Endorsed", neighbourList.endorsed], + ["Endorsed by", neighbourList.endorsedBy], + ["Awarded", neighbourList.awarded], + ["Awarded by", neighbourList.awardedBy], + ] as const + ).map(([title, list]) => + list.length > 0 ? ( +
+
+ {title} ({list.length}) +
+
+ {list.slice(0, 12).map((n) => ( + + + + ))} +
-
+ ) : null, )} { const participants = nodes.length @@ -96,6 +96,10 @@ export default function EndorsementStats({ graph, onFocus }: EndorsementStatsPro {totalEndorsements.toLocaleString()} Endorsements
+
+ {totalAwards.toLocaleString()} + Awards +
{stats.participants.toLocaleString()} People diff --git a/src/hooks/use-endorsement-graph.ts b/src/hooks/use-endorsement-graph.ts index 67f75e1d..e76f8da2 100644 Binary files a/src/hooks/use-endorsement-graph.ts and b/src/hooks/use-endorsement-graph.ts differ diff --git a/src/lib/layout/bottom-nav-visibility.ts b/src/lib/layout/bottom-nav-visibility.ts new file mode 100644 index 00000000..008f9830 --- /dev/null +++ b/src/lib/layout/bottom-nav-visibility.ts @@ -0,0 +1,34 @@ +/** + * Single source of truth for whether the mobile bottom navigation bar + * () is on screen for the current environment. + * + * Shared by , which renders the bar, and , + * which hides its floating "Feedback" button whenever the bar is present: + * the bar already carries a Feedback entry, so the floating button would + * be a redundant second door and would visually overlap the bar. + * + * The bar is shown only when ALL of these hold: + * - not desktop (<800px): at >=800px the left rail / top bar own + * navigation, and the bar's focusable buttons would compete for tab + * order, so it unmounts. + * - standalone (installed PWA / A2HS web clip): in a regular browser tab + * the address bar and OS gestures provide navigation, and the bar + * would occlude content (e.g. /welcome for guests). + * - not an /embed route: embeds render bare (board-only) inside a + * third-party iframe. + * - not /welcome: the minimal landing chrome (wordmark + sign-in) has no + * bottom nav at any width. + */ +export function isBottomNavVisible(params: { + pathname: string; + isDesktop: boolean; + isStandalone: boolean; +}): boolean { + const { pathname, isDesktop, isStandalone } = params; + return ( + !isDesktop && + isStandalone && + !pathname.startsWith("/embed") && + pathname !== "/welcome" + ); +}