A locally focused bluesky appview

profile page link, replies tab

Changed files
+93 -8
frontend
+19 -1
frontend/src/App.tsx
··· 1 - import React, { useState } from 'react'; 1 + import React, { useState, useEffect } from 'react'; 2 2 import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'; 3 3 import { FollowingFeed } from './components/FollowingFeed'; 4 4 import { ProfilePage } from './components/ProfilePage'; 5 5 import { PostView } from './components/PostView'; 6 6 import { ThreadView } from './components/ThreadView'; 7 7 import { PostComposer } from './components/PostComposer'; 8 + import { ApiClient } from './api'; 8 9 import './App.css'; 9 10 10 11 function Navigation() { 11 12 const location = useLocation(); 13 + const [myHandle, setMyHandle] = useState<string | null>(null); 14 + 15 + useEffect(() => { 16 + ApiClient.getMe().then(data => { 17 + setMyHandle(data.handle); 18 + }).catch(err => { 19 + console.error('Failed to fetch current user:', err); 20 + }); 21 + }, []); 12 22 13 23 return ( 14 24 <nav className="app-nav"> ··· 23 33 > 24 34 Following 25 35 </Link> 36 + {myHandle && ( 37 + <Link 38 + to={`/profile/${myHandle}`} 39 + className={`nav-link ${location.pathname.includes('/profile/') ? 'active' : ''}`} 40 + > 41 + Profile 42 + </Link> 43 + )} 26 44 </div> 27 45 </div> 28 46 </nav>
+8
frontend/src/api.ts
··· 3 3 const API_BASE_URL = 'http://localhost:4444/api'; 4 4 5 5 export class ApiClient { 6 + static async getMe(): Promise<{did: string, handle: string}> { 7 + const response = await fetch(`${API_BASE_URL}/me`); 8 + if (!response.ok) { 9 + throw new Error(`Failed to fetch current user: ${response.statusText}`); 10 + } 11 + return response.json(); 12 + } 13 + 6 14 static async getFollowingFeed(cursor?: string): Promise<FeedResponse> { 7 15 const url = cursor 8 16 ? `${API_BASE_URL}/followingfeed?cursor=${encodeURIComponent(cursor)}`
+28
frontend/src/components/ProfilePage.css
··· 147 147 padding: 0 20px; 148 148 } 149 149 150 + .profile-tabs { 151 + display: flex; 152 + border-bottom: 1px solid #e1e8ed; 153 + margin-bottom: 16px; 154 + } 155 + 156 + .profile-tab { 157 + flex: 1; 158 + padding: 16px; 159 + background: none; 160 + border: none; 161 + border-bottom: 2px solid transparent; 162 + font-size: 15px; 163 + font-weight: 600; 164 + color: #536471; 165 + cursor: pointer; 166 + transition: all 0.2s; 167 + } 168 + 169 + .profile-tab:hover { 170 + background-color: #f7f9fa; 171 + } 172 + 173 + .profile-tab--active { 174 + color: #1da1f2; 175 + border-bottom-color: #1da1f2; 176 + } 177 + 150 178 .posts-header { 151 179 padding: 16px 0; 152 180 border-bottom: 1px solid #e1e8ed;
+21 -7
frontend/src/components/ProfilePage.tsx
··· 16 16 const [userDid, setUserDid] = useState<string | null>(null); 17 17 const [cursor, setCursor] = useState<string | null>(null); 18 18 const [hasMore, setHasMore] = useState(true); 19 + const [activeTab, setActiveTab] = useState<'posts' | 'replies'>('posts'); 19 20 const observerTarget = useRef<HTMLDivElement>(null); 20 21 21 22 useEffect(() => { ··· 189 190 </div> 190 191 191 192 <div className="profile-content"> 192 - <div className="posts-header"> 193 - <h2>Posts ({posts.length})</h2> 193 + <div className="profile-tabs"> 194 + <button 195 + className={`profile-tab ${activeTab === 'posts' ? 'profile-tab--active' : ''}`} 196 + onClick={() => setActiveTab('posts')} 197 + > 198 + Posts 199 + </button> 200 + <button 201 + className={`profile-tab ${activeTab === 'replies' ? 'profile-tab--active' : ''}`} 202 + onClick={() => setActiveTab('replies')} 203 + > 204 + Replies 205 + </button> 194 206 </div> 195 207 196 208 <div className="posts-list"> 197 - {posts.map((post, index) => ( 198 - <PostCard key={post.uri || index} postResponse={post} /> 199 - ))} 200 - {posts.length === 0 && !loading && ( 209 + {posts 210 + .filter(post => activeTab === 'posts' ? !post.replyTo : !!post.replyTo) 211 + .map((post, index) => ( 212 + <PostCard key={post.uri || index} postResponse={post} /> 213 + ))} 214 + {posts.filter(post => activeTab === 'posts' ? !post.replyTo : !!post.replyTo).length === 0 && !loading && ( 201 215 <div className="empty-posts"> 202 - <p>No posts yet</p> 216 + <p>{activeTab === 'posts' ? 'No posts yet' : 'No replies yet'}</p> 203 217 </div> 204 218 )} 205 219 {hasMore && <div ref={observerTarget} style={{ height: '20px' }} />}
+17
handlers.go
··· 25 25 e.GET("/debug", s.handleGetDebugInfo) 26 26 27 27 views := e.Group("/api") 28 + views.GET("/me", s.handleGetMe) 28 29 views.GET("/profile/:account/post/:rkey", s.handleGetPost) 29 30 views.GET("/profile/:account", s.handleGetProfileView) 30 31 views.GET("/profile/:account/posts", s.handleGetProfilePosts) ··· 45 46 46 47 return e.JSON(200, map[string]any{ 47 48 "seq": seq, 49 + }) 50 + } 51 + 52 + func (s *Server) handleGetMe(e echo.Context) error { 53 + ctx := e.Request().Context() 54 + 55 + resp, err := s.dir.LookupDID(ctx, syntax.DID(s.mydid)) 56 + if err != nil { 57 + return e.JSON(500, map[string]any{ 58 + "error": "failed to lookup handle", 59 + }) 60 + } 61 + 62 + return e.JSON(200, map[string]any{ 63 + "did": s.mydid, 64 + "handle": resp.Handle.String(), 48 65 }) 49 66 } 50 67