feat(user-profile): integrate user lists and subscriptions in profile page

- Added functionality to fetch and display user-created lists alongside subscriptions.
- Introduced new components for rendering lists and subscriptions with loading states.
- Updated metadata fetching to include lists data for user profiles.
- Refactored user subscriptions query for better data handling.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-09 21:20:22 +08:00
parent 6dcf03894a
commit a92bdb89b3
5 changed files with 277 additions and 134 deletions
@@ -2,6 +2,8 @@ import { MainContainer } from "@client/components/layout/main"
import { FeedIcon } from "@client/components/ui/feed-icon"
import { openInFollowApp } from "@client/lib/helper"
import { UrlBuilder } from "@client/lib/url-builder"
import { useListsByUserId } from "@client/query/list"
import type { SubscriptionResult, User } from "@client/query/users"
import { useUserQuery, useUserSubscriptionsQuery } from "@client/query/users"
import { FollowIcon } from "@follow/components/icons/follow.jsx"
import { Avatar, AvatarFallback, AvatarImage } from "@follow/components/ui/avatar/index.jsx"
@@ -13,14 +15,17 @@ import { Fragment, memo, useState } from "react"
import { useParams } from "react-router"
interface FeedCardProps {
subscription: any
subscription: SubscriptionResult[number]
feedId: string
view: number
}
const FeedCard = memo<FeedCardProps>(({ subscription, feedId, view }) => {
if (!("feeds" in subscription)) {
return null
}
return (
<div className="border-border/40 bg-card hover:border-border group/card relative overflow-hidden rounded-xl border p-5 transition-all duration-200 hover:shadow-sm hover:shadow-black/5 dark:hover:shadow-white/5">
<div className="border-border/40 hover:border-border group/card relative overflow-hidden rounded-xl border p-5 transition-all duration-200 hover:shadow-sm hover:shadow-black/5 dark:hover:shadow-white/5">
{/* Feed Content */}
<a
className="block"
@@ -45,12 +50,11 @@ const FeedCard = memo<FeedCardProps>(({ subscription, feedId, view }) => {
</div>
</a>
{/* Follow Button */}
<div className="mt-4 flex justify-end">
{/* Follow Button - positioned absolutely to avoid layout shift */}
<div className="absolute bottom-4 right-4 translate-y-2 opacity-0 transition-all duration-200 ease-out group-hover/card:translate-y-0 group-hover/card:opacity-100">
<Button
size="sm"
variant="primary"
buttonClassName="opacity-0 transition-opacity group-hover/card:opacity-100"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
@@ -75,10 +79,171 @@ export const Component = () => {
const user = useUserQuery(params.id)
const subscriptions = useUserSubscriptionsQuery(user.data?.id)
useTitle(user.data?.name)
return (
<MainContainer className="bg-background min-h-screen">
{user.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
) : (
<Fragment>
<UserHero user={user.data!} />
<Lists userId={user.data?.id} />
{/* Subscriptions Section */}
<Subscriptions userId={user.data?.id} />
</Fragment>
)}
</MainContainer>
)
}
const UserHero = ({ user }: { user: User }) => {
const subscriptions = useUserSubscriptionsQuery(user.id)
const totalFeeds = Object.values(subscriptions.data || {}).reduce(
(total, category) => total + category.length,
0,
)
return (
<div className="mx-auto max-w-4xl px-6 py-16 text-center sm:px-8 sm:py-20">
{/* Avatar */}
<div className="mb-6">
<Avatar className="border-border mx-auto size-20 border">
<AvatarImage className="animate-in fade-in-0 duration-300" src={user.image!} />
<AvatarFallback className="bg-zinc-100 text-xl font-medium text-zinc-600 dark:bg-neutral-800 dark:text-neutral-400">
{user.name?.slice(0, 2)}
</AvatarFallback>
</Avatar>
</div>
{/* User Info */}
<div className="space-y-3">
<h1 className="text-3xl font-semibold text-zinc-900 sm:text-4xl dark:text-zinc-100">
{user.name}
</h1>
{user.handle && (
<p className="text-base text-zinc-500 dark:text-zinc-400">@{user.handle}</p>
)}
{/* Stats */}
<div className="!mt-8 flex justify-center">
<div className="divide-material-ultra-thick flex items-center divide-x">
<div className="px-4 text-center">
<div className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{totalFeeds}
</div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">Subscriptions</div>
</div>
<div className="px-4 text-center">
<div className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{Object.keys(subscriptions.data || {}).length}
</div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">Categories</div>
</div>
</div>
</div>
</div>
</div>
)
}
const Lists = ({ userId }: { userId: string }) => {
const lists = useListsByUserId(userId)
if (lists.isLoading) {
return (
<div className="mx-auto max-w-6xl px-6 pb-16 sm:px-8">
<div className="flex h-64 items-center justify-center">
<LoadingCircle size="large" />
</div>
</div>
)
}
if (!lists.data || lists.data.length === 0) {
return null
}
return (
<div className="mx-auto w-full max-w-6xl px-6 pb-16 sm:px-8">
<div className="border-border/40 mb-6 border-b pb-3">
<h2 className="text-xl font-medium text-zinc-900 dark:text-zinc-100">Created Lists</h2>
</div>
<div data-testid="profile-lists" className="flex flex-col space-y-4">
{lists.data?.map((list) => (
<a
key={list.id}
href={UrlBuilder.shareList(list.id)}
target="_blank"
rel="noopener noreferrer"
className="group/card border-border/40 relative flex items-start space-x-4 overflow-hidden border-b pb-4 last:border-0"
>
<FeedIcon
fallback
feed={list}
className="mask-squircle mask border-border border"
size={80}
noMargin
/>
<div className="flex min-w-0 flex-1 flex-col justify-between self-stretch">
<div>
<h3 className="group-hover/card:text-accent truncate font-medium text-zinc-900 transition-colors dark:text-zinc-100">
{list.title}
</h3>
{list.description && (
<p className="mt-1 line-clamp-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
{list.description}
</p>
)}
</div>
<div className="mt-2 flex items-center space-x-4 text-sm text-zinc-500 dark:text-zinc-400">
{typeof list.subscriptionCount === "number" && (
<div className="flex items-center space-x-1">
<i className="i-mingcute-group-2-line" />
<span>
{list.subscriptionCount}
<span className="hidden sm:inline">
{list.subscriptionCount > 1 ? " Subscriptions" : " Subscription"}
</span>
</span>
</div>
)}
{typeof list.fee === "number" && list.fee > 0 && (
<div className="flex items-center space-x-1">
<i className="i-mingcute-copper-coin-line" />
<span>{list.fee}</span>
</div>
)}
</div>
</div>
<div className="absolute bottom-4 right-4">
<Button
size="sm"
variant="primary"
buttonClassName="opacity-0 transition-opacity group-hover/card:opacity-100"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openInFollowApp({
deeplink: `list?id=${list.id}`,
fallbackUrl: `/list/${list.id}`,
})
}}
>
<FollowIcon className="mr-1.5 size-3" />
Open in {APP_NAME}
</Button>
</div>
</a>
))}
</div>
</div>
)
}
const Subscriptions = ({ userId }: { userId: string }) => {
const subscriptions = useUserSubscriptionsQuery(userId)
const [expandedCategories, setExpandedCategories] = useState<Record<string, boolean>>({})
const toggleCategory = (category: string) => {
@@ -87,128 +252,67 @@ export const Component = () => {
[category]: !prev[category],
}))
}
const totalFeeds = Object.values(subscriptions.data || {}).reduce(
(total, category) => total + category.length,
0,
)
return (
<MainContainer className="bg-background min-h-screen">
{user.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
<div className="mx-auto max-w-6xl px-6 pb-16 sm:px-8">
{subscriptions.isLoading ? (
<div className="flex h-64 items-center justify-center">
<LoadingCircle size="large" />
</div>
) : (
<Fragment>
{/* Hero Section */}
<div>
<div className="mx-auto max-w-4xl px-6 py-16 text-center sm:px-8 sm:py-20">
{/* Avatar */}
<div className="mb-6">
<Avatar className="border-border mx-auto size-20 border">
<AvatarImage
className="animate-in fade-in-0 duration-300"
src={user.data?.image}
/>
<AvatarFallback className="bg-zinc-100 text-xl font-medium text-zinc-600 dark:bg-neutral-800 dark:text-neutral-400">
{user.data?.name?.slice(0, 2)}
</AvatarFallback>
</Avatar>
</div>
<div data-testid="profile-subscriptions" className="space-y-8">
{Object.keys(subscriptions.data || {}).map((category) => {
const isExpanded = expandedCategories[category] ?? true
{/* User Info */}
<div className="space-y-3">
<h1 className="text-3xl font-semibold text-zinc-900 sm:text-4xl dark:text-zinc-100">
{user.data?.name}
</h1>
{user.data?.handle && (
<p className="text-base text-zinc-500 dark:text-zinc-400">@{user.data.handle}</p>
)}
return (
<div key={category} className="group">
{/* Category Header */}
<button
type="button"
className="border-border/40 hover:border-border/60 mb-6 flex w-full items-center justify-between border-b pb-3 text-left transition-colors"
onClick={() => toggleCategory(category)}
>
<h2 className="text-xl font-medium text-zinc-900 dark:text-zinc-100">
{category}
</h2>
<div className="flex items-center space-x-3">
<span className="rounded-full bg-zinc-100 px-3 py-1 text-sm font-medium text-zinc-600 dark:bg-neutral-800 dark:text-neutral-400">
{subscriptions.data?.[category]?.length || 0}
</span>
<i
className={cn(
"i-mingcute-down-line text-zinc-400 transition-transform duration-200",
isExpanded && "rotate-180",
)}
/>
</div>
</button>
{/* Stats */}
<div className="!mt-8 flex justify-center">
<div className="divide-material-ultra-thick flex items-center divide-x">
<div className="px-4 text-center">
<div className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{totalFeeds}
</div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">Subscriptions</div>
</div>
<div className="px-4 text-center">
<div className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{Object.keys(subscriptions.data || {}).length}
</div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">Categories</div>
</div>
{/* Feeds Grid with collapse animation */}
<div
className={cn(
"overflow-hidden transition-all duration-300 ease-out",
isExpanded ? "max-h-[5000px] opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{subscriptions.data?.[category]!.map(
(subscription) =>
"feeds" in subscription && (
<FeedCard
key={subscription.feedId}
subscription={subscription}
feedId={subscription.feedId}
view={subscription.view}
/>
),
)}
</div>
</div>
</div>
</div>
</div>
{/* Subscriptions Section */}
<div className="mx-auto max-w-6xl px-6 pb-16 sm:px-8">
{subscriptions.isLoading ? (
<div className="flex h-64 items-center justify-center">
<LoadingCircle size="large" />
</div>
) : (
<div data-testid="profile-subscriptions" className="space-y-8">
{Object.keys(subscriptions.data || {}).map((category) => {
const isExpanded = expandedCategories[category] ?? true
return (
<div key={category} className="group">
{/* Category Header */}
<button
type="button"
className="border-border/40 hover:border-border/60 mb-6 flex w-full items-center justify-between border-b pb-3 text-left transition-colors"
onClick={() => toggleCategory(category)}
>
<h2 className="text-xl font-medium text-zinc-900 dark:text-zinc-100">
{category}
</h2>
<div className="flex items-center space-x-3">
<span className="rounded-full bg-zinc-100 px-3 py-1 text-sm font-medium text-zinc-600 dark:bg-neutral-800 dark:text-neutral-400">
{subscriptions.data?.[category]?.length || 0}
</span>
<i
className={cn(
"i-mingcute-down-line text-zinc-400 transition-transform duration-200",
isExpanded && "rotate-180",
)}
/>
</div>
</button>
{/* Feeds Grid with collapse animation */}
<div
className={cn(
"overflow-hidden transition-all duration-300 ease-out",
isExpanded ? "max-h-[5000px] opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{subscriptions.data?.[category]!.map(
(subscription) =>
"feeds" in subscription && (
<FeedCard
key={subscription.feedId}
subscription={subscription}
feedId={subscription.feedId}
view={subscription.view}
/>
),
)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</Fragment>
)
})}
</div>
)}
</MainContainer>
</div>
)
}
@@ -23,16 +23,21 @@ export default defineMetadata(async ({ params, apiClient, origin }): Promise<Met
.catch((e) => callNotFound(e.message))
const realUserId = profileRes.data.id
const [subscriptionsRes] = await Promise.allSettled([
const [subscriptionsRes, listsRes] = await Promise.allSettled([
profileRes.data.id
? apiClient.subscriptions.$get({
query: { userId: realUserId },
})
: Promise.reject(),
profileRes.data.id
? apiClient.lists.list.$get({
query: { userId: realUserId },
})
: Promise.reject(),
])
const isSubscriptionsResolved = subscriptionsRes.status === "fulfilled"
const isListsResolved = listsRes.status === "fulfilled"
const { name } = profileRes.data
const subscriptions = isSubscriptionsResolved ? subscriptionsRes.value.data : []
@@ -67,5 +72,11 @@ export default defineMetadata(async ({ params, apiClient, origin }): Promise<Met
path: apiClient.subscriptions.$url({ query: { userId: realUserId } }).pathname,
key: `subscriptions.$get,query:userId=${realUserId}`,
},
isListsResolved && {
type: "hydrate",
data: listsRes.value.data,
path: apiClient.lists.list.$url({ query: { userId: realUserId } }).pathname,
key: `lists.list.$get,query:userId=${realUserId}`,
},
].filter((v) => !!v) as MetaTag[]
})
+15
View File
@@ -15,3 +15,18 @@ export const useList = ({ id }: { id?: string }) =>
ReturnType<typeof fetchListById>
>,
})
const fetchListsByUserId = async (userId: string) => {
const res = await apiClient.lists.list.$get({ query: { userId } })
return res.data
}
export const useListsByUserId = (userId: string) =>
useQuery({
queryKey: ["lists", userId],
queryFn: () => fetchListsByUserId(userId),
enabled: !!userId,
initialData: getHydrateData(`lists.list.$get,query:userId=${userId}`) as any as Awaited<
ReturnType<typeof fetchListsByUserId>
>,
})
+18 -9
View File
@@ -6,8 +6,10 @@ import type { ExtractBizResponse } from "@follow/models"
import { capitalizeFirstLetter, isBizId, parseUrl } from "@follow/utils/utils"
import { useQuery } from "@tanstack/react-query"
const groupSubscriptions = (subscriptions: SubscriptionResult) => {
const groupFolder = {} as Record<string, typeof subscriptions>
const groupSubscriptions = (
subscriptions: SubscriptionResult,
): Record<string, SubscriptionResult> => {
const groupFolder = {} as Record<string, SubscriptionResult>
for (const subscription of subscriptions.filter((s) => !s.isPrivate) || []) {
if (!subscription.category && "feeds" in subscription) {
const { siteUrl } = subscription.feeds
@@ -25,19 +27,25 @@ const groupSubscriptions = (subscriptions: SubscriptionResult) => {
return groupFolder
}
type SubscriptionResult = ExtractBizResponse<typeof apiClient.subscriptions.$get>["data"]
export type SubscriptionResult = ExtractBizResponse<typeof apiClient.subscriptions.$get>["data"]
const fetchUserSubscriptions = async (userId: string | undefined) => {
const res = await apiClient.subscriptions.$get({
query: { userId },
})
return res.data
}
export const useUserSubscriptionsQuery = (userId: string | undefined) => {
return useQuery({
queryKey: ["subscriptions", "group", userId],
queryFn: async () => {
const res = await apiClient.subscriptions.$get({
query: { userId },
})
return res.data
return fetchUserSubscriptions(userId)
},
select: groupSubscriptions,
enabled: !!userId,
initialData: getHydrateData(`subscriptions.$get,query:userId=${userId}`),
initialData: getHydrateData(`subscriptions.$get,query:userId=${userId}`) as any as Awaited<
ReturnType<typeof fetchUserSubscriptions>
>,
})
}
@@ -57,12 +65,13 @@ export const fetchUser = async (handleOrId: string | undefined) => {
return res.data
}
export type User = Awaited<ReturnType<typeof fetchUser>>
export const useUserQuery = (handleOrId: string | undefined) => {
return useQuery({
queryKey: ["profiles", handleOrId],
queryFn: () => fetchUser(handleOrId),
enabled: !!handleOrId,
initialData: getHydrateData(`profiles.$get,query:id=${handleOrId}`),
initialData: getHydrateData(`profiles.$get,query:id=${handleOrId}`) as any as User,
})
}
export interface AuthProvider {
+5 -1
View File
@@ -19910,7 +19910,11 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
} & {
"/list": {
$get: {
input: {};
input: {
query: {
userId?: string | undefined;
};
};
output: {
code: 0;
data: {