diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index 2350a22..fa6e9b8 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -75,6 +75,12 @@ pub struct NotificationResponse { pub created_at: DateTime, } +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TopFriendsResponse { + pub top_friends: Vec, +} + #[derive(Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ErrorResponse { diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs index b3bc334..7fbb1bd 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social.rs @@ -4,6 +4,8 @@ use crate::{ state::AppState, }; use api_types::requests::SetTopFriendsRequest; +use api_types::responses::TopFriendsResponse; +use crate::handlers::auth::to_user_response; use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends}; use application::use_cases::social::*; use axum::{ @@ -155,15 +157,17 @@ pub async fn put_top_friends( set_top_friends(&*d.top_friends, &uid, ids).await?; Ok(StatusCode::NO_CONTENT) } -#[utoipa::path(get, path = "/users/{username}/top-friends", params(("username" = String, Path, description = "Username")), responses((status = 200, description = "Top friends list")))] +#[utoipa::path(get, path = "/users/{username}/top-friends", + params(("username" = String, Path, description = "Username")), + responses((status = 200, description = "Top friends list", body = TopFriendsResponse)))] pub async fn get_top_friends_handler( Deps(d): Deps, Path(username): Path, -) -> Result, ApiError> { +) -> Result, ApiError> { let user = get_user_by_username(&*d.users, &username).await?; let friends = get_top_friends(&*d.top_friends, &user.id).await?; - let usernames: Vec<&str> = friends.iter().map(|(_, u)| u.username.as_str()).collect(); - Ok(Json(serde_json::json!({ "topFriends": usernames }))) + let top_friends = friends.iter().map(|(_, u)| to_user_response(u)).collect(); + Ok(Json(TopFriendsResponse { top_friends })) } #[cfg(test)] diff --git a/thoughts-frontend/app/loading.tsx b/thoughts-frontend/app/loading.tsx new file mode 100644 index 0000000..55549f4 --- /dev/null +++ b/thoughts-frontend/app/loading.tsx @@ -0,0 +1,20 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function FeedLoading() { + return ( +
+
+
+
+ ); +} diff --git a/thoughts-frontend/app/page.tsx b/thoughts-frontend/app/page.tsx index ebac652..a4dc23a 100644 --- a/thoughts-frontend/app/page.tsx +++ b/thoughts-frontend/app/page.tsx @@ -1,12 +1,6 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; -import { - getFeed, - getFriends, - getMe, - getTopFriends, - Me, -} from "@/lib/api"; +import { getFeed, getMe, Me } from "@/lib/api"; import { ThoughtForm } from "@/components/thought-form"; import { EmptyState } from "@/components/empty-state"; import { Button } from "@/components/ui/button"; @@ -16,9 +10,10 @@ import { ThoughtThread } from "@/components/thought-thread"; import { buildThoughtThreads } from "@/lib/utils"; import { TopFriends } from "@/components/top-friends"; import { UsersCount } from "@/components/users-count"; - import { PaginationNav } from "@/components/pagination-nav"; import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton"; export const metadata: Metadata = { title: "Home", @@ -61,18 +56,26 @@ async function FeedPage({ const { items: allThoughts, totalPages } = feedData!; const thoughtThreads = buildThoughtThreads(allThoughts); - const friends = (await getFriends(token)).users.map((user) => user.username); - const topFriendsData = me - ? await getTopFriends(me.username, token).catch(() => ({ topFriends: [] })) - : { topFriends: [] }; - const shouldDisplayTopFriends = topFriendsData.topFriends.length > 0; + const sidebar = ( + <> + }> + + + }> + + + }> + + + + ); return (
@@ -84,14 +87,7 @@ async function FeedPage({
- - {shouldDisplayTopFriends && ( - - )} - {!shouldDisplayTopFriends && token && friends.length > 0 && ( - - )} - + {sidebar}
@@ -115,14 +111,7 @@ async function FeedPage({
diff --git a/thoughts-frontend/app/search/loading.tsx b/thoughts-frontend/app/search/loading.tsx new file mode 100644 index 0000000..7b9887b --- /dev/null +++ b/thoughts-frontend/app/search/loading.tsx @@ -0,0 +1,12 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function SearchLoading() { + return ( +
+
+ + + +
+ ); +} diff --git a/thoughts-frontend/app/tags/[tagName]/loading.tsx b/thoughts-frontend/app/tags/[tagName]/loading.tsx new file mode 100644 index 0000000..30a7a62 --- /dev/null +++ b/thoughts-frontend/app/tags/[tagName]/loading.tsx @@ -0,0 +1,12 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function TagLoading() { + return ( +
+
+ + + +
+ ); +} diff --git a/thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx b/thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx new file mode 100644 index 0000000..4fbc58f --- /dev/null +++ b/thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx @@ -0,0 +1,13 @@ +import { ThoughtSkeleton } from "@/components/loading-skeleton"; + +export default function ThoughtLoading() { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/thoughts-frontend/app/users/[username]/page.tsx b/thoughts-frontend/app/users/[username]/page.tsx index bbb4ef1..8e8c30c 100644 --- a/thoughts-frontend/app/users/[username]/page.tsx +++ b/thoughts-frontend/app/users/[username]/page.tsx @@ -5,7 +5,6 @@ import { getMe, getRemoteFollowers, getRemoteFollowing, - getTopFriends, getUserProfile, getUserThoughts, Me, @@ -52,6 +51,8 @@ import { notFound } from "next/navigation"; import { cookies } from "next/headers"; import { FollowButton } from "@/components/follow-button"; import { TopFriends } from "@/components/top-friends"; +import { Suspense } from "react"; +import { ProfileSkeleton } from "@/components/loading-skeleton"; import { buildThoughtThreads } from "@/lib/utils"; import { ThoughtThread } from "@/components/thought-thread"; import { Button } from "@/components/ui/button"; @@ -127,15 +128,6 @@ export default async function ProfilePage({ params }: ProfilePageProps) { const fediverseHandle = user.local && apiDomain ? `@${user.username}@${apiDomain}` : null; - // Show who the profile owner follows (uses the already-fetched followingResult). - const friends = - followingResult.status === "fulfilled" - ? followingResult.value.items.map((u) => u.username) - : []; - - const topFriendsData = await getTopFriends(username, token).catch(() => ({ topFriends: [] })); - const shouldDisplayTopFriends = topFriendsData.topFriends.length > 0; - return (
{user.customCss && ( @@ -252,10 +244,9 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
- {shouldDisplayTopFriends && ( - - )} - {token && } + }> + +
diff --git a/thoughts-frontend/components/loading-skeleton.tsx b/thoughts-frontend/components/loading-skeleton.tsx index 8b7cbbe..8e6a729 100644 --- a/thoughts-frontend/components/loading-skeleton.tsx +++ b/thoughts-frontend/components/loading-skeleton.tsx @@ -32,3 +32,26 @@ export function ProfileSkeleton() { ) } + +export function TagsSkeleton() { + return ( + + + + {[...Array(5)].map((_, i) => ( + + ))} + + + ) +} + +export function CountSkeleton() { + return ( + + + + + + ) +} diff --git a/thoughts-frontend/components/top-friends.tsx b/thoughts-frontend/components/top-friends.tsx index b7aed43..9d4f23a 100644 --- a/thoughts-frontend/components/top-friends.tsx +++ b/thoughts-frontend/components/top-friends.tsx @@ -1,51 +1,25 @@ import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { UserAvatar } from "./user-avatar"; -import { getUserProfile, User } from "@/lib/api"; +import { getTopFriends } from "@/lib/api"; import { cookies } from "next/headers"; interface TopFriendsProps { - mode: "friends" | "top-friends"; - usernames: string[]; + username: string; } -export async function TopFriends({ - mode = "top-friends", - usernames, -}: TopFriendsProps) { +export async function TopFriends({ username }: TopFriendsProps) { const token = (await cookies()).get("auth_token")?.value ?? null; + const data = await getTopFriends(username, token).catch(() => ({ topFriends: [] })); + const friends = data.topFriends; - if (usernames.length === 0) { - return ( - - - Top Friends - - -

- No top friends to display. -

-
-
- ); - } - - const friendsResults = await Promise.allSettled( - usernames.map((username) => getUserProfile(username, token)) - ); - - const friends = friendsResults - .filter( - (result): result is PromiseFulfilledResult => - result.status === "fulfilled" - ) - .map((result) => result.value); + if (friends.length === 0) return null; return ( - {mode === "top-friends" ? "Top Friends" : "Friends"} + Top Friends @@ -59,7 +33,7 @@ export async function TopFriends({ {friend.displayName || friend.username} diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts index d88ff8f..1b8cc59 100644 --- a/thoughts-frontend/lib/api.ts +++ b/thoughts-frontend/lib/api.ts @@ -232,7 +232,7 @@ export const getTopFriends = (username: string, token: string | null) => apiFetch( `/users/${username}/top-friends`, { next: { tags: [`profile:${username}`] } }, - z.object({ topFriends: z.array(z.string()) }), + z.object({ topFriends: z.array(UserSchema) }), token );