bluesky-posts.tsx

1'use client'
2
3import React, { useEffect, useState } from 'react'
4import { motion } from 'framer-motion'
5import Image from 'next/image'
6import { formatDistanceToNow } from 'date-fns'
7import { ImageLightbox } from './image-lightbox'
8
9interface Post {
10	uri: string
11	cid: string
12	author: {
13		handle: string
14		displayName: string
15		avatar: string
16	}
17	record: {
18		text: string
19		createdAt: string
20		embed?: {
21			images?: {
22				alt: string
23				image: {
24					ref: { $link: string }
25					mimeType: string
26				}
27			}[]
28		}
29	}
30	embed?: {
31		images?: {
32			fullsize: string
33			thumb: string
34			alt: string
35		}[]
36	}
37}
38
39export function BlueskyPosts() {
40	const [posts, setPosts] = useState<Post[]>([])
41	const [loading, setLoading] = useState(true)
42	const [error, setError] = useState<string | null>(null)
43	const [selectedImage, setSelectedImage] = useState<{
44		src: string
45		alt: string
46	} | null>(null)
47
48	useEffect(() => {
49		const fetchPosts = async () => {
50			try {
51				const response = await fetch('/api/posts', {
52					next: {
53						revalidate: 300, // 5 minutes
54					},
55				})
56
57				if (!response.ok) {
58					const error = await response.json()
59					throw new Error(error.error || 'Failed to fetch posts')
60				}
61
62				const data = await response.json()
63				setPosts(data)
64			} catch (error) {
65				console.error('Error fetching posts:', error)
66				setError(
67					error instanceof Error ? error.message : 'Failed to fetch posts',
68				)
69			} finally {
70				setLoading(false)
71			}
72		}
73
74		fetchPosts()
75	}, [])
76
77	const fadeInUp = {
78		initial: { opacity: 0, y: 20 },
79		animate: { opacity: 1, y: 0 },
80		transition: { duration: 0.6 },
81	}
82
83	if (loading) {
84		return (
85			<div className="flex min-h-screen items-center justify-center">
86				<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
87			</div>
88		)
89	}
90
91	if (error) {
92		return (
93			<div className="flex min-h-screen items-center justify-center">
94				<div className="text-center">
95					<h2 className="mb-4 text-2xl font-bold text-red-500">Error</h2>
96					<p className="text-gray-400">{error}</p>
97				</div>
98			</div>
99		)
100	}
101
102	return (
103		<section className="min-h-screen py-20">
104			{selectedImage && (
105				<ImageLightbox
106					src={selectedImage.src}
107					alt={selectedImage.alt}
108					onClose={() => setSelectedImage(null)}
109				/>
110			)}
111			<motion.div
112				className="container mx-auto px-4"
113				initial="initial"
114				animate="animate"
115				variants={{
116					initial: { opacity: 0 },
117					animate: { opacity: 1, transition: { staggerChildren: 0.1 } },
118				}}
119			>
120				<motion.h2
121					className="mb-12 text-center text-4xl font-bold"
122					variants={fadeInUp}
123				>
124					Latest Posts
125				</motion.h2>
126				<div className="mx-auto max-w-2xl space-y-6">
127					{posts.map((post) => (
128						<motion.div
129							key={post.cid}
130							className="rounded-xl border border-gray-700 bg-gray-800/50 p-6 backdrop-blur-sm"
131							variants={fadeInUp}
132						>
133							<div className="mb-4 flex items-center gap-3">
134								<Image
135									src={post.author.avatar}
136									alt={post.author.displayName}
137									width={40}
138									height={40}
139									className="rounded-full"
140								/>
141								<div>
142									<h3 className="font-semibold">{post.author.displayName}</h3>
143									<p className="text-sm text-gray-400">@{post.author.handle}</p>
144								</div>
145								<span className="ml-auto text-sm text-gray-400">
146									{formatDistanceToNow(new Date(post.record.createdAt), {
147										addSuffix: true,
148									})}
149								</span>
150							</div>
151							<p className="mb-4 whitespace-pre-wrap text-gray-200">
152								{post.record.text}
153							</p>
154							{post.embed?.images && (
155								<div className="grid gap-2">
156									{post.embed.images.map((image, index) => (
157										<div
158											key={index}
159											className="relative aspect-video cursor-pointer overflow-hidden rounded-lg"
160											onClick={() =>
161												setSelectedImage({
162													src: image.fullsize,
163													alt: image.alt || 'Post image',
164												})
165											}
166										>
167											<Image
168												src={image.fullsize}
169												alt={image.alt || 'Post image'}
170												fill
171												className="object-cover transition-transform duration-200 hover:scale-105"
172											/>
173										</div>
174									))}
175								</div>
176							)}
177							<div className="mt-4 flex items-center gap-4 text-gray-400">
178								<a
179									href={`https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`}
180									target="_blank"
181									rel="noopener noreferrer"
182									className="text-sm hover:text-blue-400"
183								>
184									View on Bluesky ā†’
185								</a>
186							</div>
187						</motion.div>
188					))}
189				</div>
190			</motion.div>
191		</section>
192	)
193}
194