diff --git a/components/ProjectCard.tsx b/components/ProjectCard.tsx index 6a2be2aac..26c997445 100644 --- a/components/ProjectCard.tsx +++ b/components/ProjectCard.tsx @@ -1,17 +1,37 @@ import Image from '@/components/Image' import Link from 'next/link' -import { useState, useEffect } from 'react' +import SocialIcon from '@/components/social-icons' +import { getHeartbeatUrl } from '@/utils/heartbeat' + +const FUND_LABELS: Record = { + general: 'General Fund', + nostr: 'Nostr Fund', + ops: 'Operations Budget', +} + +export type FundId = 'general' | 'nostr' | 'ops' export type ProjectCardProps = { - slug - title - summary - coverImage - darkCoverImage? - invertDarkImage? - nym - tags + slug: string + title: string + summary: string + coverImage: string + darkCoverImage?: string + invertDarkImage?: boolean + nym: string + fund?: FundId + git?: string + nostr?: string + heartbeat?: string + zapstore?: string + showAttribution?: boolean + // Accepted for backwards compatibility; the redesigned card no longer + // surfaces tag chips or per-card image overrides. + tags?: string[] customImageStyles?: React.CSSProperties + /** Passed to next/image `sizes` (layout-specific). */ + imageSizes?: string + imageFit?: 'cover' | 'contain' } const ProjectCard: React.FC = ({ @@ -22,65 +42,74 @@ const ProjectCard: React.FC = ({ darkCoverImage, invertDarkImage, nym, - tags, - customImageStyles, + fund, + git, + nostr, + heartbeat, + zapstore, + showAttribution = true, + imageSizes = '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw', + imageFit = 'cover', }) => { - const [isHorizontal, setIsHorizontal] = useState(null) - - useEffect(() => { - const img = document.createElement('img') - img.src = coverImage - - // check if image is horizontal - added additional 10% to height to ensure only true - // horizontals get flagged. - img.onload = () => { - const { naturalWidth, naturalHeight } = img - const isHorizontal = naturalWidth >= naturalHeight * 1.1 - setIsHorizontal(isHorizontal) - } - }, [coverImage]) - - let cardStyle - if (tags.includes('Nostr')) { - cardStyle = - 'h-full space-y-4 rounded-xl border-b-4 border-purple-600 bg-stone-100 dark:border-purple-600 dark:bg-stone-900' - } else if (tags.includes('Lightning')) { - cardStyle = - 'h-full space-y-4 rounded-xl border-b-4 border-yellow-300 bg-stone-100 dark:border-yellow-300 dark:bg-stone-900' - } else if (tags.includes('Bitcoin')) { - cardStyle = - 'h-full space-y-4 rounded-xl border-b-4 border-orange-400 bg-stone-100 dark:border-orange-400 dark:bg-stone-900' - } else { - cardStyle = - 'h-full space-y-4 rounded-xl border-b-4 border-stone-100 bg-stone-100 dark:border-stone-800 dark:bg-stone-900' - } + const heartbeatUrl = heartbeat || getHeartbeatUrl(git) || undefined + const hasFooter = Boolean(git || heartbeatUrl || nostr || zapstore) return ( -
- -
+
+ +
{title}
-
-

{title}

-
by {nym}
-
{summary}
-
+
+

+ + {title} + +

+ {showAttribution && ( +
+ by {nym} + {fund && ( + <> + {' · '} + + via the {FUND_LABELS[fund]} + + + )} +
+ )} +

{summary}

+ {hasFooter && ( +
+ + {nostr && ( + + )} + + +
+ )} +
) } diff --git a/data/projects/zapstore.mdx b/data/projects/zapstore.mdx index 0a681c6ae..e1b23b321 100644 --- a/data/projects/zapstore.mdx +++ b/data/projects/zapstore.mdx @@ -4,6 +4,7 @@ dateAdded: '2025-03-27' summary: 'A nostr-based app store for signed releases, open discovery, and censorship-resistant software distribution.' nym: 'Franzap' website: 'https://zapstore.dev/' +zapstore: 'https://zapstore.dev/apps/dev.zapstore.app' coverImage: '/static/images/projects/zapstore.png' git: 'https://github.com/zapstore/zapstore' tags: ['Nostr', 'Android', 'Mobile'] diff --git a/pages/projects/showcase.tsx b/pages/projects/showcase.tsx index 7476608c7..d48853338 100644 --- a/pages/projects/showcase.tsx +++ b/pages/projects/showcase.tsx @@ -1,56 +1,148 @@ -import type { NextPage } from 'next' -import { useEffect, useState } from 'react' -import ProjectCard from '../../components/ProjectCard' -import { allProjects } from 'contentlayer/generated' -import { Project } from 'contentlayer/generated' -import { isNotOpenSatsProject } from '../funds' +import type { GetStaticProps, NextPage } from 'next' import Link from '@/components/Link' import { PageSEO } from '@/components/SEO' +import StatsSentence from '@/components/StatsSentence' +import ProjectCard from '@/components/ProjectCard' +import type { FundId } from '@/components/ProjectCard' +import { allProjects } from 'contentlayer/generated' +import { buildClusters } from '@/utils/projectClusters' +import { getLifetimeStats, type LifetimeStat } from '@/utils/lifetimeStats' + +type CardData = { + slug: string + title: string + summary: string + coverImage: string + darkCoverImage?: string + invertDarkImage?: boolean + nym: string + fund?: FundId + git?: string + nostr?: string + heartbeat?: string + zapstore?: string +} -const ProjectShowcase: NextPage<{ projects: Project[] }> = ({ projects }) => { - const [sortedProjects, setSortedProjects] = useState() +type RenderCluster = { + id: string + title: string + blurb: string + projects: CardData[] +} - useEffect(() => { - setSortedProjects( - projects.filter(isNotOpenSatsProject).sort(() => 0.5 - Math.random()) - ) - }, [projects]) +type ShowcaseProps = { + clusters: RenderCluster[] + lifetimeStats: LifetimeStat[] | null +} +const KNOWN_FUNDS: FundId[] = ['general', 'nostr', 'ops'] + +function toCardData(project: (typeof allProjects)[number]): CardData { + const fund = (KNOWN_FUNDS as string[]).includes(project.fund || '') + ? (project.fund as FundId) + : undefined + + return { + slug: `/projects/${project.slug}`, + title: project.title, + summary: project.summary, + coverImage: project.coverImage, + darkCoverImage: project.darkCoverImage, + invertDarkImage: project.invertDarkImage, + nym: project.nym, + fund, + git: project.git, + nostr: project.nostr, + heartbeat: project.heartbeat, + zapstore: project.zapstore, + } +} + +const ProjectShowcase: NextPage = ({ + clusters, + lifetimeStats, +}) => { return ( <> -
-
-

Project Showcase

-
-
    - {sortedProjects && - sortedProjects.map((p, i) => ( -
  • - -
  • - ))} -
+ +
+

+ Project Showcase +

+ +

+ This is only a small selection of projects that OpenSats has funded. + For the complete list, see our{' '} + + grant announcements + + . +

-
+ +
+ {clusters.map((cluster) => ( +
+
+

+ {cluster.title} +

+

+ {cluster.blurb} +

+
+
+
    + {cluster.projects.map((p) => ( +
  • + +
  • + ))} +
+
+
+ ))} +
+ +
+ + Apply for funding → + - All Grants → + All grant announcements →
@@ -59,12 +151,19 @@ const ProjectShowcase: NextPage<{ projects: Project[] }> = ({ projects }) => { export default ProjectShowcase -export async function getStaticProps() { - const projects = allProjects +export const getStaticProps: GetStaticProps = async () => { + const clusters: RenderCluster[] = buildClusters(allProjects).map((c) => ({ + id: c.id, + title: c.title, + blurb: c.blurb, + projects: c.projects.map((p) => toCardData(p)), + })) + + const lifetimeStats = await getLifetimeStats() + // Strip `undefined` values so getStaticProps can serialize the payload. return { - props: { - projects, - }, + props: JSON.parse(JSON.stringify({ clusters, lifetimeStats })), + revalidate: 60 * 60 * 12, } } diff --git a/utils/projectClusters.ts b/utils/projectClusters.ts new file mode 100644 index 000000000..c972b7620 --- /dev/null +++ b/utils/projectClusters.ts @@ -0,0 +1,132 @@ +import type { Project } from 'contentlayer/generated' + +export type Cluster = { + id: string + title: string + blurb: string + slugs: string[] +} + +export type ResolvedCluster = Omit & { + projects: Project[] +} + +/** + * Editorial groupings for the project showcase. Order matters — clusters are + * rendered in the order they appear here. Projects within each cluster are + * sorted alphabetically by title when clusters are built. + */ +export const CLUSTERS: Cluster[] = [ + { + id: 'privacy-infra', + title: 'Privacy & Infrastructure', + blurb: 'The plumbing that keeps the rest of the stack honest.', + slugs: ['tor', 'grapheneos', 'pdk', 'wireguard'], + }, + { + id: 'core', + title: 'Protocol Maintenance & Development', + blurb: 'The full node, validation, and the protocol underneath.', + slugs: [ + 'bitcoin-core', + 'libbitcoin', + 'floresta', + 'utreexod', + 'stratumv2', + 'splicing', + 'vls', + 'asmap', + ], + }, + { + id: 'education', + title: 'Education & Research', + blurb: 'People bringing in the next wave of contributors.', + slugs: [ + 'summerofbitcoin', + 'satoshinakamotoinstitute', + 'bitcoindesign', + 'bitshala', + ], + }, + { + id: 'dev-tooling-testing', + title: 'Developer Tooling & Testing', + blurb: + 'Libraries, kits, and workflow tools engineers use to build and ship.', + slugs: [ + 'bdk', + 'rust-bitcoin', + 'cdk', + 'ndk', + 'ngit', + 'bitcoinfuzz', + 'bitcoinresearchkit', + ], + }, + { + id: 'wallets', + title: 'Wallets', + blurb: 'Self-custody software people actually use.', + slugs: ['cove', 'blixt', 'blitz-wallet', 'dana-wallet'], + }, + { + id: 'lightning-payments', + title: 'Merchant Tooling & Payments', + blurb: 'Payments, point-of-sale, and merchant tooling on Lightning.', + slugs: ['btcpayserver', 'lnbits', 'mostro'], + }, + { + id: 'chaumian-ecash', + title: 'Chaumian ecash', + blurb: 'Ecash wallets and mint software on Cashu.', + slugs: ['minibits', 'cashu', 'opencash'], + }, + { + id: 'nostr-clients', + title: 'Nostr Clients', + blurb: 'How people read and post on nostr today.', + slugs: ['damus', 'amethyst', '0xchat', 'coracle', 'flotilla', 'soapbox'], + }, + { + id: 'nostr-infra', + title: 'Nostr Infrastructure', + blurb: 'Relays, libraries, signers, and developer tooling.', + slugs: ['applesauce', 'citrine', 'frostr', 'amber', 'zapstore'], + }, +] + +/** + * Resolve cluster slug lists against the full project set, dropping unknown + * slugs and clusters that end up empty. In development, warns about + * non-OpenSats projects that aren't included in any cluster so the curation + * stays in sync with the data folder. + */ +export function buildClusters(allProjects: Project[]): ResolvedCluster[] { + const bySlug = new Map(allProjects.map((p) => [p.slug, p])) + + const resolved = CLUSTERS.map(({ slugs, ...rest }) => ({ + ...rest, + projects: slugs + .map((slug) => bySlug.get(slug)) + .filter((p): p is Project => Boolean(p)) + .sort((a, b) => + a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }) + ), + })).filter((c) => c.projects.length > 0) + + if (process.env.NODE_ENV !== 'production') { + const clustered = new Set(CLUSTERS.flatMap((c) => c.slugs)) + const orphans = allProjects + .filter((p) => p.nym !== 'OpenSats' && !clustered.has(p.slug)) + .map((p) => p.slug) + if (orphans.length > 0) { + console.warn( + `[projectClusters] ${orphans.length} project(s) not in any cluster:`, + orphans + ) + } + } + + return resolved +} diff --git a/utils/relatedPosts.ts b/utils/relatedPosts.ts index 148b44039..6051cdef8 100644 --- a/utils/relatedPosts.ts +++ b/utils/relatedPosts.ts @@ -79,6 +79,22 @@ export function getRelatedBlogPostsForProject( return getRelatedBlogPostsByTerms([title, slug], blogs) } +export type LatestPost = { slug: string; date: string; title: string } + +/** + * Returns the most recent related blog post for a project as a small DTO, + * or null if there are none. Expects `sortedBlogs` to already be sorted + * newest-first (e.g. via pliny's `sortedBlogPost`). + */ +export function getLatestPostForProject( + project: Project, + sortedBlogs: Blog[] +): LatestPost | null { + const [latest] = getRelatedBlogPostsForProject(project, sortedBlogs) + if (!latest) return null + return { slug: latest.slug, date: latest.date, title: latest.title } +} + /** * Finds blog posts that mention a given topic by its title or any alias. */