A project tracker for decentralized social media platforms, clients, and tools

feat: add independent infrastructure indicator

Add star indicator for projects running fully independent infrastructure:
- Add hasIndependentInfrastructure boolean field to Project type
- Implement blue star icon in ProjectCard component with tooltip
- Mark Bluesky, Blacksky, and Tangled as having independent infrastructure
- Add separate state management for star and warning tooltips

Projects with independent infrastructure run their own PDS, relay,
and AppView services rather than relying on Bluesky's infrastructure.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+76 -35
public
src
components
types
+6 -3
public/data/projects.json
···
{ "kind": "social", "url": "https://bsky.app/profile/bsky.app" }
],
"stars": 7200,
-
"updatedAt": "2024-12-15"
+
"updatedAt": "2024-12-15",
+
"hasIndependentInfrastructure": true
},
{
"id": "atproto",
···
{ "kind": "social", "url": "https://bsky.app/profile/tangled.sh" }
],
"stars": 360,
-
"updatedAt": "2024-12-16"
+
"updatedAt": "2024-12-16",
+
"hasIndependentInfrastructure": true
},
{
"id": "anisota",
···
{ "kind": "social", "url": "https://bsky.app/profile/blacksky.app" }
],
"stars": 480,
-
"updatedAt": "2025-09-13"
+
"updatedAt": "2025-09-13",
+
"hasIndependentInfrastructure": true
},
{
"id": "bridgy-fed",
+69 -32
src/components/ProjectCard.tsx
···
export default function ProjectCard({ project }: ProjectCardProps) {
const [showWarning, setShowWarning] = useState(false);
+
const [showInfrastructureInfo, setShowInfrastructureInfo] = useState(false);
const getLinkIcon = (kind: string, url?: string) => {
// Special case: Show Bluesky icon for social links on bsky.app
if (kind === 'social' && url?.includes('bsky.app')) {
···
)}
</div>
</a>
-
{project.type === 'semi-platform' && (
-
<div className="relative group">
-
<button
-
onClick={(e) => {
-
e.stopPropagation();
-
setShowWarning(!showWarning);
-
}}
-
className="p-1 -m-1 rounded hover:bg-gray-700 transition-colors sm:pointer-events-none"
-
aria-label="Warning information"
-
>
-
<svg
-
className="w-5 h-5 text-yellow-500"
-
fill="currentColor"
-
viewBox="0 0 20 20"
+
<div className="flex items-center gap-2">
+
{project.hasIndependentInfrastructure && (
+
<div className="relative group">
+
<button
+
onClick={(e) => {
+
e.stopPropagation();
+
setShowInfrastructureInfo(!showInfrastructureInfo);
+
}}
+
className="p-1 -m-1 rounded hover:bg-gray-700 transition-colors sm:pointer-events-none"
+
aria-label="Independent infrastructure"
>
-
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
-
</svg>
-
</button>
-
{showWarning && (
-
<>
-
<div
-
className="fixed inset-0 z-20 sm:hidden"
-
onClick={() => setShowWarning(false)}
-
/>
-
<div className="absolute right-0 top-8 w-48 p-2 bg-gray-900 text-gray-200 text-xs rounded-lg z-30 border border-gray-700 sm:hidden">
-
Has not implemented platform-based AT Protocol lexicon
-
</div>
-
</>
-
)}
-
<div className="hidden sm:block absolute right-0 top-6 w-48 p-2 bg-gray-900 text-gray-200 text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 border border-gray-700">
-
Has not implemented platform-based AT Protocol lexicon
+
<svg
+
className="w-5 h-5 text-blue-400"
+
fill="currentColor"
+
viewBox="0 0 20 20"
+
>
+
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
+
</svg>
+
</button>
+
{showInfrastructureInfo && (
+
<>
+
<div
+
className="fixed inset-0 z-20 sm:hidden"
+
onClick={() => setShowInfrastructureInfo(false)}
+
/>
+
<div className="absolute right-0 top-8 w-48 p-2 bg-gray-900 text-gray-200 text-xs rounded-lg z-30 border border-gray-700 sm:hidden">
+
Runs fully independent infrastructure
+
</div>
+
</>
+
)}
+
<div className="hidden sm:block absolute right-0 top-6 w-48 p-2 bg-gray-900 text-gray-200 text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 border border-gray-700">
+
Runs fully independent infrastructure
+
</div>
</div>
-
</div>
-
)}
+
)}
+
{project.type === 'semi-platform' && (
+
<div className="relative group">
+
<button
+
onClick={(e) => {
+
e.stopPropagation();
+
setShowWarning(!showWarning);
+
}}
+
className="p-1 -m-1 rounded hover:bg-gray-700 transition-colors sm:pointer-events-none"
+
aria-label="Warning information"
+
>
+
<svg
+
className="w-5 h-5 text-yellow-500"
+
fill="currentColor"
+
viewBox="0 0 20 20"
+
>
+
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
+
</svg>
+
</button>
+
{showWarning && (
+
<>
+
<div
+
className="fixed inset-0 z-20 sm:hidden"
+
onClick={() => setShowWarning(false)}
+
/>
+
<div className="absolute right-0 top-8 w-48 p-2 bg-gray-900 text-gray-200 text-xs rounded-lg z-30 border border-gray-700 sm:hidden">
+
Has not implemented platform-based AT Protocol lexicon
+
</div>
+
</>
+
)}
+
<div className="hidden sm:block absolute right-0 top-6 w-48 p-2 bg-gray-900 text-gray-200 text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 border border-gray-700">
+
Has not implemented platform-based AT Protocol lexicon
+
</div>
+
</div>
+
)}
+
</div>
</div>
{project.bannerUrl && (
+1
src/types/project.ts
···
links?: ProjectLink[];
stars?: number;
updatedAt?: string;
+
hasIndependentInfrastructure?: boolean;
};
export type FilterState = {