An API you can curl, or open in a browser, to receive Bluesky data as markdown!
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add OG image card and first-visit follow prompt

- Add og.png (1200×630) and wire up openGraph + twitter metadata in layout
- Add FollowPrompt component: fixed bottom-right card, fetches creator
profile from Bluesky, dismisses with localStorage so it never shows again

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

jack bb834fec fbb8eb26

+291 -1
+169
app/components/FollowPrompt.module.css
··· 1 + @keyframes slideUp { 2 + from { opacity: 0; transform: translateY(16px); } 3 + to { opacity: 1; transform: translateY(0); } 4 + } 5 + 6 + @keyframes slideDown { 7 + from { opacity: 1; transform: translateY(0); } 8 + to { opacity: 0; transform: translateY(16px); } 9 + } 10 + 11 + .card { 12 + position: fixed; 13 + bottom: 24px; 14 + right: 24px; 15 + width: 290px; 16 + background: var(--bg); 17 + border: 1px solid var(--border); 18 + border-radius: 16px; 19 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08); 20 + z-index: 100; 21 + overflow: hidden; 22 + animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); 23 + } 24 + 25 + @media (prefers-color-scheme: dark) { 26 + .card { 27 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); 28 + } 29 + } 30 + 31 + .cardLeaving { 32 + animation: slideDown 0.25s ease forwards; 33 + } 34 + 35 + @media (max-width: 400px) { 36 + .card { 37 + left: 16px; 38 + right: 16px; 39 + width: auto; 40 + bottom: 16px; 41 + } 42 + } 43 + 44 + /* Top strip – subtle blue gradient */ 45 + .strip { 46 + height: 3px; 47 + background: linear-gradient(90deg, #0085ff, #4da6ff); 48 + } 49 + 50 + .body { 51 + padding: 16px 16px 14px; 52 + } 53 + 54 + .header { 55 + display: flex; 56 + align-items: flex-start; 57 + gap: 12px; 58 + margin-bottom: 12px; 59 + } 60 + 61 + .avatar { 62 + width: 46px; 63 + height: 46px; 64 + border-radius: 50%; 65 + object-fit: cover; 66 + flex-shrink: 0; 67 + border: 2px solid var(--border); 68 + background: var(--bg-2); 69 + } 70 + 71 + .avatarFallback { 72 + width: 46px; 73 + height: 46px; 74 + border-radius: 50%; 75 + background: linear-gradient(135deg, #0085ff, #4da6ff); 76 + display: flex; 77 + align-items: center; 78 + justify-content: center; 79 + font-size: 1.4rem; 80 + flex-shrink: 0; 81 + } 82 + 83 + .info { 84 + flex: 1; 85 + min-width: 0; 86 + } 87 + 88 + .name { 89 + font-size: 0.9rem; 90 + font-weight: 700; 91 + color: var(--text); 92 + white-space: nowrap; 93 + overflow: hidden; 94 + text-overflow: ellipsis; 95 + line-height: 1.3; 96 + } 97 + 98 + .handle { 99 + font-size: 0.78rem; 100 + color: var(--text-2); 101 + white-space: nowrap; 102 + overflow: hidden; 103 + text-overflow: ellipsis; 104 + } 105 + 106 + .dismiss { 107 + width: 26px; 108 + height: 26px; 109 + border-radius: 50%; 110 + border: 1px solid var(--border); 111 + background: transparent; 112 + cursor: pointer; 113 + color: var(--text-3); 114 + font-size: 0.85rem; 115 + display: flex; 116 + align-items: center; 117 + justify-content: center; 118 + flex-shrink: 0; 119 + transition: background 0.12s, color 0.12s; 120 + line-height: 1; 121 + padding: 0; 122 + } 123 + 124 + .dismiss:hover { 125 + background: var(--bg-2); 126 + color: var(--text); 127 + } 128 + 129 + .label { 130 + font-size: 0.78rem; 131 + color: var(--text-2); 132 + margin-bottom: 10px; 133 + line-height: 1.45; 134 + } 135 + 136 + .followBtn { 137 + display: flex; 138 + align-items: center; 139 + justify-content: center; 140 + gap: 7px; 141 + width: 100%; 142 + padding: 10px 0; 143 + background: #0085ff; 144 + color: #fff; 145 + border: none; 146 + border-radius: 10px; 147 + font-size: 0.88rem; 148 + font-weight: 600; 149 + cursor: pointer; 150 + text-decoration: none; 151 + transition: background 0.15s, transform 0.1s, box-shadow 0.15s; 152 + } 153 + 154 + .followBtn:hover { 155 + background: #006ad4; 156 + box-shadow: 0 4px 14px rgba(0, 133, 255, 0.3); 157 + text-decoration: none; 158 + color: #fff; 159 + } 160 + 161 + .followBtn:active { 162 + transform: scale(0.98); 163 + } 164 + 165 + .bskyIcon { 166 + width: 16px; 167 + height: 16px; 168 + flex-shrink: 0; 169 + }
+93
app/components/FollowPrompt.tsx
··· 1 + 'use client' 2 + 3 + import { useEffect, useState } from 'react' 4 + import s from './FollowPrompt.module.css' 5 + 6 + const STORAGE_KEY = 'bsky-md-follow-dismissed' 7 + const CREATOR_DID = 'did:plc:4hawmtgzjx3vclfyphbhfn7v' 8 + const FOLLOW_URL = `https://bsky.app/profile/${CREATOR_DID}` 9 + 10 + interface Profile { 11 + displayName?: string 12 + handle?: string 13 + avatar?: string 14 + followersCount?: number 15 + } 16 + 17 + export default function FollowPrompt() { 18 + const [visible, setVisible] = useState(false) 19 + const [leaving, setLeaving] = useState(false) 20 + const [profile, setProfile] = useState<Profile | null>(null) 21 + 22 + useEffect(() => { 23 + try { 24 + if (localStorage.getItem(STORAGE_KEY)) return 25 + } catch { 26 + return 27 + } 28 + // Delay so the page loads first 29 + const t = setTimeout(() => setVisible(true), 1800) 30 + return () => clearTimeout(t) 31 + }, []) 32 + 33 + useEffect(() => { 34 + if (!visible) return 35 + fetch( 36 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${CREATOR_DID}`, 37 + ) 38 + .then((r) => r.json()) 39 + .then((d: Profile) => setProfile(d)) 40 + .catch(() => {}) 41 + }, [visible]) 42 + 43 + const dismiss = () => { 44 + setLeaving(true) 45 + try { localStorage.setItem(STORAGE_KEY, '1') } catch {} 46 + setTimeout(() => setVisible(false), 280) 47 + } 48 + 49 + if (!visible) return null 50 + 51 + const displayName = profile?.displayName || 'j4ck.xyz' 52 + const handle = profile?.handle || 'j4ck.xyz' 53 + 54 + return ( 55 + <div className={`${s.card} ${leaving ? s.cardLeaving : ''}`} role="complementary" aria-label="Follow the creator"> 56 + <div className={s.strip} /> 57 + <div className={s.body}> 58 + <div className={s.header}> 59 + {profile?.avatar ? ( 60 + <img src={profile.avatar} alt={displayName} className={s.avatar} /> 61 + ) : ( 62 + <div className={s.avatarFallback}>🦋</div> 63 + )} 64 + <div className={s.info}> 65 + <div className={s.name}>{displayName}</div> 66 + <div className={s.handle}>@{handle}</div> 67 + </div> 68 + <button className={s.dismiss} onClick={dismiss} aria-label="Dismiss"> 69 + 70 + </button> 71 + </div> 72 + 73 + <p className={s.label}> 74 + Built by j4ck — follow to hear about updates and new tools. 75 + </p> 76 + 77 + <a 78 + href={FOLLOW_URL} 79 + target="_blank" 80 + rel="noopener noreferrer" 81 + className={s.followBtn} 82 + onClick={dismiss} 83 + > 84 + {/* Bluesky butterfly SVG */} 85 + <svg className={s.bskyIcon} viewBox="0 0 360 320" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> 86 + <path d="M180 142C180 142 107.5 41.5 54.5 13.5C1.5 -14.5 -11 37.5 9 75C21.5 99 45.5 115.5 45.5 115.5C45.5 115.5 2.5 107.5 0.5 144.5C-1.5 181.5 35 194 75 181C75 181 55 255.5 101.5 280C148 304.5 180 242 180 242C180 242 212 304.5 258.5 280C305 255.5 285 181 285 181C325 194 361.5 181.5 359.5 144.5C357.5 107.5 314.5 115.5 314.5 115.5C314.5 115.5 338.5 99 351 75C371 37.5 358.5 -14.5 305.5 13.5C252.5 41.5 180 142 180 142Z" fill="white"/> 87 + </svg> 88 + Follow on Bluesky 89 + </a> 90 + </div> 91 + </div> 92 + ) 93 + }
+29 -1
app/layout.tsx
··· 1 1 import type { Metadata } from 'next' 2 2 import './globals.css' 3 + import FollowPrompt from './components/FollowPrompt' 4 + 5 + const BASE = 'https://bsky-md.vercel.app' 3 6 4 7 export const metadata: Metadata = { 5 8 title: 'bsky.md — Bluesky as Markdown', 6 9 description: 7 10 'Fetch any public Bluesky profile, post, feed, or search as clean Markdown. No auth, no API key.', 11 + metadataBase: new URL(BASE), 8 12 icons: { 9 13 icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🦋</text></svg>", 10 14 }, 15 + openGraph: { 16 + title: 'bsky.md — Bluesky as Markdown', 17 + description: 'Fetch any public Bluesky content as clean, portable Markdown. No auth, no API key.', 18 + url: BASE, 19 + siteName: 'bsky.md', 20 + images: [ 21 + { 22 + url: `${BASE}/og.png`, 23 + width: 1200, 24 + height: 630, 25 + alt: 'bsky.md — Bluesky as Markdown', 26 + }, 27 + ], 28 + type: 'website', 29 + }, 30 + twitter: { 31 + card: 'summary_large_image', 32 + title: 'bsky.md — Bluesky as Markdown', 33 + description: 'Fetch any public Bluesky content as clean, portable Markdown. No auth, no API key.', 34 + images: [`${BASE}/og.png`], 35 + }, 11 36 } 12 37 13 38 export default function RootLayout({ children }: { children: React.ReactNode }) { 14 39 return ( 15 40 <html lang="en"> 16 - <body>{children}</body> 41 + <body> 42 + {children} 43 + <FollowPrompt /> 44 + </body> 17 45 </html> 18 46 ) 19 47 }
public/og.png

This is a binary file and will not be displayed.