Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 87 additions & 58 deletions components/ProjectCard.tsx
Original file line number Diff line number Diff line change
@@ -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<FundId, string> = {
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<ProjectCardProps> = ({
Expand All @@ -22,65 +42,74 @@ const ProjectCard: React.FC<ProjectCardProps> = ({
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<boolean | null>(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 (
<figure className={cardStyle}>
<Link href={`${slug}`} passHref>
<div className="flex h-36 w-full sm:h-52">
<figure className="flex h-full flex-col overflow-hidden rounded-xl bg-stone-100 dark:bg-stone-900">
<Link href={slug} className="block">
<div className="relative aspect-[4/3] w-full bg-white dark:bg-black">
<Image
alt={title}
src={coverImage}
darkSrc={darkCoverImage}
width={1200}
height={1200}
style={{
objectFit: isHorizontal ? 'fill' : 'cover',
...customImageStyles,
}}
priority={true}
className={`cursor-pointer rounded-t-xl bg-white dark:bg-black ${
invertDarkImage ? 'dark:invert' : ''
}`}
fill
sizes={imageSizes}
className={`cursor-pointer ${
imageFit === 'contain'
? 'object-contain p-4 sm:p-5'
: 'object-cover'
} ${invertDarkImage ? 'dark:invert' : ''}`}
/>
</div>
<figcaption className="p-2">
<h2 className="font-bold">{title}</h2>
<div className="mb-4 text-sm">by {nym}</div>
<div className="mb-2 line-clamp-3">{summary}</div>
</figcaption>
</Link>
<figcaption className="flex flex-1 flex-col gap-2 p-4">
<h2 className="font-bold leading-tight">
<Link href={slug} className="hover:text-orange-500">
{title}
</Link>
</h2>
{showAttribution && (
<div className="text-sm text-stone-500 dark:text-stone-400">
by {nym}
{fund && (
<>
{' · '}
<Link
href={`/funds/${fund}`}
className="underline-offset-2 hover:text-orange-500 hover:underline"
>
via the {FUND_LABELS[fund]}
</Link>
</>
)}
</div>
)}
<p className="line-clamp-3 text-sm">{summary}</p>
{hasFooter && (
<div className="mt-auto flex items-center justify-end gap-2 pt-2 text-xs text-stone-500 dark:text-stone-400 [&>a]:opacity-70 [&>a]:transition-opacity [&>a]:hover:opacity-100">
<SocialIcon kind="github" href={git} size={4} />
{nostr && (
<SocialIcon
kind="nostr"
href={`https://njump.to/${nostr}`}
size={4}
/>
)}
<SocialIcon kind="zapstore" href={zapstore} size={4} />
<SocialIcon kind="heartbeat" href={heartbeatUrl} size={4} />
</div>
)}
</figcaption>
</figure>
)
}
Expand Down
1 change: 1 addition & 0 deletions data/projects/zapstore.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
183 changes: 141 additions & 42 deletions pages/projects/showcase.tsx
Original file line number Diff line number Diff line change
@@ -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<Project[]>()
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<ShowcaseProps> = ({
clusters,
lifetimeStats,
}) => {
return (
<>
<PageSEO
title="Project Showcase - OpenSats"
description="A showcase of free and open-source Bitcoin and Nostr projects supported by OpenSats."
/>
<section className="flex flex-col p-4 md:p-8">
<div className="flex w-full items-center justify-between pb-8">
<h1 id="funds">Project Showcase</h1>
</div>
<ul className="grid max-w-5xl grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
{sortedProjects &&
sortedProjects.map((p, i) => (
<li key={i} className="">
<ProjectCard
slug={`/projects/${p.slug}`}
title={p.title}
summary={p.summary}
coverImage={p.coverImage}
darkCoverImage={p.darkCoverImage}
invertDarkImage={p.invertDarkImage}
nym={p.nym}
tags={p.tags}
/>
</li>
))}
</ul>

<section className="pt-4 md:pb-8">
<h1 className="py-2 text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 max-[375px]:text-2xl sm:text-3xl sm:leading-10 md:py-4 md:text-5xl md:leading-14 lg:text-6xl">
Project Showcase
</h1>
<StatsSentence
initialStats={lifetimeStats}
className="text-lg leading-7 text-gray-500 dark:text-gray-400"
/>
<p className="pt-3 text-lg leading-7 text-gray-500 dark:text-gray-400">
This is only a small selection of projects that OpenSats has funded.
For the complete list, see our{' '}
<Link
href="/tags/grants"
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
>
grant announcements
</Link>
.
</p>
</section>
<div className="flex justify-end pt-4 text-base font-medium leading-6">

<div className="divide-y divide-gray-200 dark:divide-gray-700">
{clusters.map((cluster) => (
<section key={cluster.id} className="py-10 first:pt-6">
<div className="pb-6">
<h2
id={`showcase-cluster-${cluster.id}`}
className="text-2xl font-bold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-3xl"
>
{cluster.title}
</h2>
<p className="pt-1 text-base text-gray-500 dark:text-gray-400">
{cluster.blurb}
</p>
</div>
<div
className="-mx-4 overflow-x-auto overflow-y-hidden overscroll-x-contain pb-2 pt-1 sm:mx-0 md:snap-x md:snap-mandatory"
aria-labelledby={`showcase-cluster-${cluster.id}`}
role="region"
>
<ul className="flex w-max max-w-none flex-nowrap gap-4 px-4 sm:px-0">
{cluster.projects.map((p) => (
<li
key={p.slug}
className="w-[min(22rem,calc(100vw-2.5rem))] shrink-0 md:w-[min(22rem,calc((100vw-6rem)/3))] md:snap-start"
>
<ProjectCard
{...p}
showAttribution={false}
imageFit="contain"
imageSizes="(max-width: 768px) min(22rem, 90vw), (max-width: 1024px) 30vw, 22rem"
/>
</li>
))}
</ul>
</div>
</section>
))}
</div>

<div className="flex flex-wrap justify-end gap-6 pt-4 text-base font-medium leading-6">
<Link
href="/apply"
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label="Apply for funding"
>
Apply for funding &rarr;
</Link>
<Link
href="/tags/grants"
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
aria-label="All Grants"
aria-label="All grant announcements"
>
All Grants &rarr;
All grant announcements &rarr;
</Link>
</div>
</>
Expand All @@ -59,12 +151,19 @@ const ProjectShowcase: NextPage<{ projects: Project[] }> = ({ projects }) => {

export default ProjectShowcase

export async function getStaticProps() {
const projects = allProjects
export const getStaticProps: GetStaticProps<ShowcaseProps> = 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,
}
}
Loading
Loading