feat(favorites): migrate to backend API
This commit is contained in:
@@ -2,14 +2,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import {
|
import { getUserById, listPostsByAuthor } from "@/lib/api"
|
||||||
getUserById,
|
import { MessageSquare, ThumbsUp } from "lucide-react"
|
||||||
listFavoritesByUser,
|
|
||||||
listPlayers,
|
|
||||||
listPostsByAuthor,
|
|
||||||
listShops,
|
|
||||||
} from "@/lib/api"
|
|
||||||
import { MessageSquare, Star, ThumbsUp } from "lucide-react"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
@@ -21,20 +15,8 @@ export default async function UserProfilePage({ params }: { params: Promise<{ id
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const [userPosts, userFavorites, players, shops] = await Promise.all([
|
const userPosts = await listPostsByAuthor(id)
|
||||||
listPostsByAuthor(id),
|
const favoriteCountText = "—"
|
||||||
listFavoritesByUser(user.id),
|
|
||||||
listPlayers(),
|
|
||||||
listShops(),
|
|
||||||
])
|
|
||||||
const favoritePlayers = userFavorites
|
|
||||||
.filter((f) => f.targetType === "player")
|
|
||||||
.map((f) => players.find((p) => p.id === f.targetId))
|
|
||||||
.filter((p): p is NonNullable<typeof p> => p != null)
|
|
||||||
const favoriteShops = userFavorites
|
|
||||||
.filter((f) => f.targetType === "shop")
|
|
||||||
.map((f) => shops.find((s) => s.id === f.targetId))
|
|
||||||
.filter((s): s is NonNullable<typeof s> => s != null)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl px-4 py-8 space-y-6">
|
<div className="container mx-auto max-w-4xl px-4 py-8 space-y-6">
|
||||||
@@ -48,7 +30,7 @@ export default async function UserProfilePage({ params }: { params: Promise<{ id
|
|||||||
<p className="text-sm text-muted-foreground">{user.bio || "这个人很懒,什么都没写~"}</p>
|
<p className="text-sm text-muted-foreground">{user.bio || "这个人很懒,什么都没写~"}</p>
|
||||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||||
<span>{userPosts.length} 帖子</span>
|
<span>{userPosts.length} 帖子</span>
|
||||||
<span>{userFavorites.length} 收藏</span>
|
<span>{favoriteCountText} 收藏</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,73 +77,7 @@ export default async function UserProfilePage({ params }: { params: Promise<{ id
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="favorites" className="mt-4 space-y-6">
|
<TabsContent value="favorites" className="mt-4 space-y-6">
|
||||||
{favoritePlayers.length > 0 && (
|
<div className="text-center py-12 text-muted-foreground">暂不支持查看用户收藏</div>
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">收藏的打手</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{favoritePlayers.map((player) => (
|
|
||||||
<Link key={player.id} href={`/player/${player.id}`}>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
|
||||||
<Avatar>
|
|
||||||
<AvatarImage src={player.user.avatar} />
|
|
||||||
<AvatarFallback>{player.user.nickname[0]}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium truncate">{player.user.nickname}</p>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
|
|
||||||
<span>{player.rating}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{player.totalOrders}单</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={player.status === "available" ? "default" : "secondary"}
|
|
||||||
className="text-xs shrink-0"
|
|
||||||
>
|
|
||||||
{player.status === "available" ? "可接单" : "忙碌"}
|
|
||||||
</Badge>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{favoriteShops.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">收藏的店铺</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{favoriteShops.map((shop) => (
|
|
||||||
<Link key={shop.id} href={`/shop/${shop.id}`}>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
|
||||||
<Avatar>
|
|
||||||
<AvatarImage src={shop.owner.avatar} />
|
|
||||||
<AvatarFallback>{shop.name[0]}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium truncate">{shop.name}</p>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
|
|
||||||
<span>{shop.rating}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{shop.totalOrders}单</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{favoritePlayers.length === 0 && favoriteShops.length === 0 && (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">暂无收藏</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { addFavorite, isFavorited, listFavorites, removeFavorite } from "@/lib/api/favorites"
|
||||||
|
import { toApiError } from "@/lib/errors"
|
||||||
|
import { notifyInfo } from "@/lib/toast"
|
||||||
import { useRequireAuth } from "@/lib/use-require-auth"
|
import { useRequireAuth } from "@/lib/use-require-auth"
|
||||||
import { useAuthStore } from "@/store/auth"
|
import { useAuthStore } from "@/store/auth"
|
||||||
import { useFavoriteStore } from "@/store/favorites"
|
|
||||||
import { Heart } from "lucide-react"
|
import { Heart } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
interface FavoriteButtonProps {
|
interface FavoriteButtonProps {
|
||||||
initialFavorited: boolean
|
initialFavorited: boolean
|
||||||
@@ -15,21 +18,76 @@ interface FavoriteButtonProps {
|
|||||||
export function FavoriteButton({ initialFavorited, targetType, targetId }: FavoriteButtonProps) {
|
export function FavoriteButton({ initialFavorited, targetType, targetId }: FavoriteButtonProps) {
|
||||||
const { requireAuth } = useRequireAuth()
|
const { requireAuth } = useRequireAuth()
|
||||||
const userId = useAuthStore((s) => s.user?.id)
|
const userId = useAuthStore((s) => s.user?.id)
|
||||||
const favoritedInStore = useFavoriteStore((state) =>
|
const [favorited, setFavorited] = useState(initialFavorited)
|
||||||
userId ? state.isFavorited(userId, targetType, targetId) : false,
|
const [pending, setPending] = useState(false)
|
||||||
)
|
|
||||||
const toggleFavorite = useFavoriteStore((state) => state.toggleFavorite)
|
useEffect(() => {
|
||||||
const favorited = userId ? favoritedInStore : initialFavorited
|
let cancelled = false
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setFavorited(initialFavorited)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isFavorited(userId, targetType, targetId)
|
||||||
|
.then((value) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setFavorited(value)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (err instanceof Error && err.message === "UNAUTHORIZED") return
|
||||||
|
notifyInfo(toApiError(err).msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [initialFavorited, targetId, targetType, userId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant={favorited ? "default" : "outline"}
|
variant={favorited ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
|
disabled={pending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requireAuth(() => {
|
requireAuth(() => {
|
||||||
|
if (pending) return
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
toggleFavorite(userId, targetType, targetId)
|
|
||||||
|
const prevFavorited = favorited
|
||||||
|
const nextFavorited = !prevFavorited
|
||||||
|
setFavorited(nextFavorited)
|
||||||
|
setPending(true)
|
||||||
|
|
||||||
|
const action = prevFavorited
|
||||||
|
? listFavorites().then((favorites) => {
|
||||||
|
const match = favorites.find(
|
||||||
|
(f) => f.targetType === targetType && f.targetId === targetId,
|
||||||
|
)
|
||||||
|
if (!match) return
|
||||||
|
return removeFavorite(match.id)
|
||||||
|
})
|
||||||
|
: addFavorite({ targetType, targetId }).then(() => {})
|
||||||
|
|
||||||
|
action
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setFavorited(prevFavorited)
|
||||||
|
if (err instanceof Error && err.message === "UNAUTHORIZED") {
|
||||||
|
notifyInfo("请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifyInfo(toApiError(err).msg)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setPending(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
+48
-13
@@ -1,20 +1,55 @@
|
|||||||
import { useFavoriteStore } from "@/store/favorites"
|
import type { Favorite } from "@/lib/types"
|
||||||
|
|
||||||
export function listFavorites() {
|
import { httpJson } from "./http"
|
||||||
return useFavoriteStore.getState().favorites
|
|
||||||
|
type Paginated<T> = {
|
||||||
|
items: T[]
|
||||||
|
meta: {
|
||||||
|
total: number
|
||||||
|
offset: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFavoritesByUser(userId: string) {
|
export async function listFavorites(): Promise<Favorite[]> {
|
||||||
return useFavoriteStore.getState().favorites.filter((favorite) => favorite.userId === userId)
|
const res = await httpJson<Paginated<Favorite> | Favorite[]>("/api/v1/favorites", {
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
return Array.isArray(res) ? res : res.items
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isFavorited(userId: string, targetType: "player" | "shop", targetId: string) {
|
export async function isFavorited(
|
||||||
return useFavoriteStore
|
userId: string,
|
||||||
.getState()
|
targetType: "player" | "shop",
|
||||||
.favorites.some(
|
targetId: string,
|
||||||
(favorite) =>
|
): Promise<boolean> {
|
||||||
favorite.userId === userId &&
|
const searchParams = new URLSearchParams({
|
||||||
favorite.targetType === targetType &&
|
targetType,
|
||||||
favorite.targetId === targetId,
|
targetId,
|
||||||
|
})
|
||||||
|
const res = await httpJson<{ favorited: boolean }>(
|
||||||
|
`/api/v1/users/${encodeURIComponent(userId)}/favorites/check?${searchParams.toString()}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
return res.favorited
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFavorite(input: {
|
||||||
|
targetType: "player" | "shop"
|
||||||
|
targetId: string
|
||||||
|
}): Promise<Favorite> {
|
||||||
|
return await httpJson<Favorite>("/api/v1/favorites", {
|
||||||
|
method: "POST",
|
||||||
|
json: input,
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFavorite(favoriteId: string): Promise<void> {
|
||||||
|
await httpJson<unknown>(`/api/v1/favorites/${encodeURIComponent(favoriteId)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user