feat: 添加用户关注功能
- 新增 Follow 表和 User.bio 字段 (Prisma schema)
- 创建 follow 模块 (action-service-repository)
- 新增 FollowButton/FollowStats/UserList 组件
- 用户页面显示 bio、粉丝/关注数、关注按钮
- 新增 /users/[username]/followers 和 following 页面
- 添加 en-US/zh-CN i18n 翻译
⚠️ 需要运行: prisma migrate dev --name add_follow_and_bio
This commit is contained in:
@@ -354,6 +354,7 @@
|
|||||||
"displayName": "Display Name",
|
"displayName": "Display Name",
|
||||||
"notSet": "Not Set",
|
"notSet": "Not Set",
|
||||||
"memberSince": "Member Since",
|
"memberSince": "Member Since",
|
||||||
|
"joined": "Joined",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "Folders",
|
"title": "Folders",
|
||||||
@@ -364,5 +365,14 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"view": "View"
|
"view": "View"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
"follow": "Follow",
|
||||||
|
"following": "Following",
|
||||||
|
"followers": "Followers",
|
||||||
|
"followersOf": "{username}'s Followers",
|
||||||
|
"followingOf": "{username}'s Following",
|
||||||
|
"noFollowers": "No followers yet",
|
||||||
|
"noFollowing": "Not following anyone yet"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,6 +354,7 @@
|
|||||||
"displayName": "显示名称",
|
"displayName": "显示名称",
|
||||||
"notSet": "未设置",
|
"notSet": "未设置",
|
||||||
"memberSince": "注册时间",
|
"memberSince": "注册时间",
|
||||||
|
"joined": "加入于",
|
||||||
"logout": "登出",
|
"logout": "登出",
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "文件夹",
|
"title": "文件夹",
|
||||||
@@ -364,5 +365,14 @@
|
|||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"view": "查看"
|
"view": "查看"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
"follow": "关注",
|
||||||
|
"following": "已关注",
|
||||||
|
"followers": "粉丝",
|
||||||
|
"followersOf": "{username} 的粉丝",
|
||||||
|
"followingOf": "{username} 的关注",
|
||||||
|
"noFollowers": "还没有粉丝",
|
||||||
|
"noFollowing": "还没有关注任何人"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
displayUsername String?
|
displayUsername String?
|
||||||
username String @unique
|
username String @unique
|
||||||
|
bio String?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
dictionaryLookUps DictionaryLookUp[]
|
dictionaryLookUps DictionaryLookUp[]
|
||||||
folders Folder[]
|
folders Folder[]
|
||||||
folderFavorites FolderFavorite[]
|
folderFavorites FolderFavorite[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
translationHistories TranslationHistory[]
|
translationHistories TranslationHistory[]
|
||||||
|
followers Follow[] @relation("UserFollowers")
|
||||||
|
following Follow[] @relation("UserFollowing")
|
||||||
|
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
@@ -198,3 +201,18 @@ model TranslationHistory {
|
|||||||
@@index([translatedText, sourceLanguage, targetLanguage])
|
@@index([translatedText, sourceLanguage, targetLanguage])
|
||||||
@@map("translation_history")
|
@@map("translation_history")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Follow {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
followerId String @map("follower_id")
|
||||||
|
followingId String @map("following_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
follower User @relation("UserFollowers", fields: [followerId], references: [id], onDelete: Cascade)
|
||||||
|
following User @relation("UserFollowing", fields: [followingId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([followerId, followingId])
|
||||||
|
@@index([followerId])
|
||||||
|
@@index([followingId])
|
||||||
|
@@map("follows")
|
||||||
|
}
|
||||||
|
|||||||
44
src/app/(auth)/users/[username]/followers/page.tsx
Normal file
44
src/app/(auth)/users/[username]/followers/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { UserList } from "@/components/follow/UserList";
|
||||||
|
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||||
|
import { actionGetFollowers } from "@/modules/follow/follow-action";
|
||||||
|
|
||||||
|
interface FollowersPageProps {
|
||||||
|
params: Promise<{ username: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FollowersPage({ params }: FollowersPageProps) {
|
||||||
|
const { username } = await params;
|
||||||
|
const t = await getTranslations("follow");
|
||||||
|
|
||||||
|
const userResult = await actionGetUserProfileByUsername({ username });
|
||||||
|
|
||||||
|
if (!userResult.success || !userResult.data) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.data;
|
||||||
|
|
||||||
|
const followersResult = await actionGetFollowers({
|
||||||
|
userId: user.id,
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const followers = followersResult.success && followersResult.data
|
||||||
|
? followersResult.data.followers.map((f) => f.user)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
|
{t("followersOf", { username: user.displayUsername || user.username || "User" })}
|
||||||
|
</h1>
|
||||||
|
<UserList users={followers} emptyMessage={t("noFollowers")} />
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/app/(auth)/users/[username]/following/page.tsx
Normal file
44
src/app/(auth)/users/[username]/following/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { UserList } from "@/components/follow/UserList";
|
||||||
|
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||||
|
import { actionGetFollowing } from "@/modules/follow/follow-action";
|
||||||
|
|
||||||
|
interface FollowingPageProps {
|
||||||
|
params: Promise<{ username: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FollowingPage({ params }: FollowingPageProps) {
|
||||||
|
const { username } = await params;
|
||||||
|
const t = await getTranslations("follow");
|
||||||
|
|
||||||
|
const userResult = await actionGetUserProfileByUsername({ username });
|
||||||
|
|
||||||
|
if (!userResult.success || !userResult.data) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.data;
|
||||||
|
|
||||||
|
const followingResult = await actionGetFollowing({
|
||||||
|
userId: user.id,
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const following = followingResult.success && followingResult.data
|
||||||
|
? followingResult.data.following.map((f) => f.user)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
|
{t("followingOf", { username: user.displayUsername || user.username || "User" })}
|
||||||
|
</h1>
|
||||||
|
<UserList users={following} emptyMessage={t("noFollowing")} />
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { LightButton, LinkButton } from "@/design-system/base/button";
|
import { LinkButton } from "@/design-system/base/button";
|
||||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||||
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
|
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
|
||||||
|
import { actionGetFollowStatus } from "@/modules/follow/follow-action";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
// import { LogoutButton } from "./LogoutButton";
|
import { FollowStats } from "@/components/follow/FollowStats";
|
||||||
|
|
||||||
interface UserPageProps {
|
interface UserPageProps {
|
||||||
params: Promise<{ username: string; }>;
|
params: Promise<{ username: string; }>;
|
||||||
@@ -18,10 +19,8 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
const { username } = await params;
|
const { username } = await params;
|
||||||
const t = await getTranslations("user_profile");
|
const t = await getTranslations("user_profile");
|
||||||
|
|
||||||
// Get current session
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
// Get user profile
|
|
||||||
const result = await actionGetUserProfileByUsername({ username });
|
const result = await actionGetUserProfileByUsername({ username });
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
@@ -30,24 +29,27 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
|
|
||||||
const user = result.data;
|
const user = result.data;
|
||||||
|
|
||||||
// Get user's folders
|
const [folders, followStatus] = await Promise.all([
|
||||||
const folders = await repoGetFoldersWithTotalPairsByUserId(user.id);
|
repoGetFoldersWithTotalPairsByUserId(user.id),
|
||||||
|
actionGetFollowStatus({ targetUserId: user.id }),
|
||||||
|
]);
|
||||||
|
|
||||||
// Check if viewing own profile
|
|
||||||
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
|
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
|
||||||
|
|
||||||
|
const followersCount = followStatus.success && followStatus.data ? followStatus.data.followersCount : 0;
|
||||||
|
const followingCount = followStatus.success && followStatus.data ? followStatus.data.followingCount : 0;
|
||||||
|
const isFollowing = followStatus.success && followStatus.data ? followStatus.data.isFollowing : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div></div>
|
<div></div>
|
||||||
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
|
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center space-y-4 sm:space-y-0 sm:space-x-6">
|
||||||
{/* Avatar */}
|
|
||||||
{user.image ? (
|
{user.image ? (
|
||||||
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden">
|
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src={user.image}
|
src={user.image}
|
||||||
alt={user.displayUsername || user.username || user.email}
|
alt={user.displayUsername || user.username || user.email}
|
||||||
@@ -57,14 +59,13 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center">
|
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center flex-shrink-0">
|
||||||
<span className="text-3xl font-bold text-white">
|
<span className="text-3xl font-bold text-white">
|
||||||
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Info */}
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||||
{user.displayUsername || user.username || t("anonymous")}
|
{user.displayUsername || user.username || t("anonymous")}
|
||||||
@@ -74,27 +75,39 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
@{user.username}
|
@{user.username}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-gray-600 text-sm mb-1">
|
{user.bio && (
|
||||||
{user.email}
|
<p className="text-gray-700 mt-2 mb-2">
|
||||||
|
{user.bio}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-4 text-sm">
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm mt-3">
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
Joined: {new Date(user.createdAt).toLocaleDateString()}
|
{t("joined")}: {new Date(user.createdAt).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
{user.emailVerified && (
|
{user.emailVerified && (
|
||||||
<span className="flex items-center text-green-600">
|
<span className="flex items-center text-green-600">
|
||||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
Verified
|
{t("verified")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<FollowStats
|
||||||
|
userId={user.id}
|
||||||
|
initialFollowersCount={followersCount}
|
||||||
|
initialFollowingCount={followingCount}
|
||||||
|
initialIsFollowing={isFollowing}
|
||||||
|
currentUserId={session?.user?.id}
|
||||||
|
isOwnProfile={isOwnProfile}
|
||||||
|
username={user.username || user.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account Info */}
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
|
||||||
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
@@ -123,7 +136,6 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Folders Section */}
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
|
||||||
{folders.length === 0 ? (
|
{folders.length === 0 ? (
|
||||||
|
|||||||
47
src/components/follow/FollowButton.tsx
Normal file
47
src/components/follow/FollowButton.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { PrimaryButton, LightButton } from "@/design-system/base/button";
|
||||||
|
import { actionToggleFollow } from "@/modules/follow/follow-action";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface FollowButtonProps {
|
||||||
|
targetUserId: string;
|
||||||
|
initialIsFollowing: boolean;
|
||||||
|
onFollowChange?: (isFollowing: boolean, followersCount: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowButton({
|
||||||
|
targetUserId,
|
||||||
|
initialIsFollowing,
|
||||||
|
onFollowChange,
|
||||||
|
}: FollowButtonProps) {
|
||||||
|
const [isFollowing, setIsFollowing] = useState(initialIsFollowing);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleToggleFollow = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await actionToggleFollow({ targetUserId });
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setIsFollowing(result.data.isFollowing);
|
||||||
|
onFollowChange?.(result.data.isFollowing, result.data.followersCount);
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to update follow status");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFollowing) {
|
||||||
|
return (
|
||||||
|
<LightButton onClick={handleToggleFollow} disabled={isPending}>
|
||||||
|
{isPending ? "..." : "Following"}
|
||||||
|
</LightButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimaryButton onClick={handleToggleFollow} disabled={isPending}>
|
||||||
|
{isPending ? "..." : "Follow"}
|
||||||
|
</PrimaryButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/follow/FollowStats.tsx
Normal file
54
src/components/follow/FollowStats.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FollowButton } from "./FollowButton";
|
||||||
|
|
||||||
|
interface FollowStatsProps {
|
||||||
|
userId: string;
|
||||||
|
initialFollowersCount: number;
|
||||||
|
initialFollowingCount: number;
|
||||||
|
initialIsFollowing: boolean;
|
||||||
|
currentUserId?: string;
|
||||||
|
isOwnProfile: boolean;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowStats({
|
||||||
|
userId,
|
||||||
|
initialFollowersCount,
|
||||||
|
initialFollowingCount,
|
||||||
|
initialIsFollowing,
|
||||||
|
currentUserId,
|
||||||
|
isOwnProfile,
|
||||||
|
username,
|
||||||
|
}: FollowStatsProps) {
|
||||||
|
const [followersCount, setFollowersCount] = useState(initialFollowersCount);
|
||||||
|
|
||||||
|
const handleFollowChange = (isFollowing: boolean, count: number) => {
|
||||||
|
setFollowersCount(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href={`/users/${username}/followers`}
|
||||||
|
className="text-sm text-gray-600 hover:text-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-gray-900">{followersCount}</span> followers
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/users/${username}/following`}
|
||||||
|
className="text-sm text-gray-600 hover:text-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-gray-900">{initialFollowingCount}</span> following
|
||||||
|
</a>
|
||||||
|
{currentUserId && !isOwnProfile && (
|
||||||
|
<FollowButton
|
||||||
|
targetUserId={userId}
|
||||||
|
initialIsFollowing={initialIsFollowing}
|
||||||
|
onFollowChange={handleFollowChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/components/follow/UserList.tsx
Normal file
68
src/components/follow/UserList.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface UserItem {
|
||||||
|
id: string;
|
||||||
|
username: string | null;
|
||||||
|
displayUsername: string | null;
|
||||||
|
image: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserListProps {
|
||||||
|
users: UserItem[];
|
||||||
|
emptyMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserList({ users, emptyMessage }: UserListProps) {
|
||||||
|
if (users.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{users.map((user) => (
|
||||||
|
<Link
|
||||||
|
key={user.id}
|
||||||
|
href={`/users/${user.username || user.id}`}
|
||||||
|
className="flex items-center gap-4 p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
{user.image ? (
|
||||||
|
<div className="relative w-12 h-12 rounded-full overflow-hidden flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={user.image}
|
||||||
|
alt={user.displayUsername || user.username || "User"}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary-500 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-lg font-bold text-white">
|
||||||
|
{(user.displayUsername || user.username || "U")[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold text-gray-900 truncate">
|
||||||
|
{user.displayUsername || user.username || "Anonymous"}
|
||||||
|
</div>
|
||||||
|
{user.username && (
|
||||||
|
<div className="text-sm text-gray-500">@{user.username}</div>
|
||||||
|
)}
|
||||||
|
{user.bio && (
|
||||||
|
<div className="text-sm text-gray-600 truncate mt-1">
|
||||||
|
{user.bio}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ export type ActionOutputUserProfile = {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
displayUsername: string | null;
|
displayUsername: string | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
|
bio: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type RepoOutputUserProfile = {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
displayUsername: string | null;
|
displayUsername: string | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
|
bio: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
} | null;
|
} | null;
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
RepoOutputUserProfile
|
RepoOutputUserProfile
|
||||||
} from "./auth-repository-dto";
|
} from "./auth-repository-dto";
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by username
|
|
||||||
*/
|
|
||||||
export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise<RepoOutputUserProfile> {
|
export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise<RepoOutputUserProfile> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { username: dto.username },
|
where: { username: dto.username },
|
||||||
@@ -19,6 +16,7 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername):
|
|||||||
username: true,
|
username: true,
|
||||||
displayUsername: true,
|
displayUsername: true,
|
||||||
image: true,
|
image: true,
|
||||||
|
bio: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
}
|
}
|
||||||
@@ -27,9 +25,6 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername):
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by ID
|
|
||||||
*/
|
|
||||||
export async function repoFindUserById(dto: RepoInputFindUserById): Promise<RepoOutputUserProfile> {
|
export async function repoFindUserById(dto: RepoInputFindUserById): Promise<RepoOutputUserProfile> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: dto.id },
|
where: { id: dto.id },
|
||||||
@@ -40,6 +35,7 @@ export async function repoFindUserById(dto: RepoInputFindUserById): Promise<Repo
|
|||||||
username: true,
|
username: true,
|
||||||
displayUsername: true,
|
displayUsername: true,
|
||||||
image: true,
|
image: true,
|
||||||
|
bio: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
}
|
}
|
||||||
@@ -48,9 +44,6 @@ export async function repoFindUserById(dto: RepoInputFindUserById): Promise<Repo
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by email
|
|
||||||
*/
|
|
||||||
export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise<RepoOutputUserProfile> {
|
export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise<RepoOutputUserProfile> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email: dto.email },
|
where: { email: dto.email },
|
||||||
@@ -61,6 +54,7 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis
|
|||||||
username: true,
|
username: true,
|
||||||
displayUsername: true,
|
displayUsername: true,
|
||||||
image: true,
|
image: true,
|
||||||
|
bio: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type ServiceOutputUserProfile = {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
displayUsername: string | null;
|
displayUsername: string | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
|
bio: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
} | null;
|
} | null;
|
||||||
|
|||||||
33
src/modules/follow/follow-action-dto.ts
Normal file
33
src/modules/follow/follow-action-dto.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schemaActionInputToggleFollow = z.object({
|
||||||
|
targetUserId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaActionInputGetFollowers = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
page: z.number().int().min(1).optional().default(1),
|
||||||
|
limit: z.number().int().min(1).max(100).optional().default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaActionInputGetFollowing = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
page: z.number().int().min(1).optional().default(1),
|
||||||
|
limit: z.number().int().min(1).max(100).optional().default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaActionInputGetFollowStatus = z.object({
|
||||||
|
targetUserId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ActionInputToggleFollow = z.infer<typeof schemaActionInputToggleFollow>;
|
||||||
|
export type ActionInputGetFollowers = z.infer<typeof schemaActionInputGetFollowers>;
|
||||||
|
export type ActionInputGetFollowing = z.infer<typeof schemaActionInputGetFollowing>;
|
||||||
|
export type ActionInputGetFollowStatus = z.infer<typeof schemaActionInputGetFollowStatus>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
schemaActionInputGetFollowers,
|
||||||
|
schemaActionInputGetFollowing,
|
||||||
|
schemaActionInputGetFollowStatus,
|
||||||
|
schemaActionInputToggleFollow,
|
||||||
|
};
|
||||||
84
src/modules/follow/follow-action.ts
Normal file
84
src/modules/follow/follow-action.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { validate } from "@/utils/validate";
|
||||||
|
import { serviceGetFollowers, serviceGetFollowing, serviceGetFollowStatus, serviceToggleFollow } from "./follow-service";
|
||||||
|
import {
|
||||||
|
schemaActionInputGetFollowers,
|
||||||
|
schemaActionInputGetFollowing,
|
||||||
|
schemaActionInputGetFollowStatus,
|
||||||
|
schemaActionInputToggleFollow,
|
||||||
|
} from "./follow-action-dto";
|
||||||
|
import { createLogger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = createLogger("follow-action");
|
||||||
|
|
||||||
|
export async function actionToggleFollow(input: unknown) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dto = validate(input, schemaActionInputToggleFollow);
|
||||||
|
const result = await serviceToggleFollow({
|
||||||
|
currentUserId: session.user.id,
|
||||||
|
targetUserId: dto.targetUserId,
|
||||||
|
});
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to toggle follow";
|
||||||
|
log.error("Failed to toggle follow", { error });
|
||||||
|
return { success: false, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetFollowStatus(input: unknown) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dto = validate(input, schemaActionInputGetFollowStatus);
|
||||||
|
const result = await serviceGetFollowStatus({
|
||||||
|
currentUserId: session?.user?.id || "",
|
||||||
|
targetUserId: dto.targetUserId,
|
||||||
|
});
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to get follow status";
|
||||||
|
log.error("Failed to get follow status", { error });
|
||||||
|
return { success: false, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetFollowers(input: unknown) {
|
||||||
|
try {
|
||||||
|
const dto = validate(input, schemaActionInputGetFollowers);
|
||||||
|
const result = await serviceGetFollowers({
|
||||||
|
userId: dto.userId,
|
||||||
|
page: dto.page,
|
||||||
|
limit: dto.limit,
|
||||||
|
});
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to get followers";
|
||||||
|
log.error("Failed to get followers", { error });
|
||||||
|
return { success: false, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetFollowing(input: unknown) {
|
||||||
|
try {
|
||||||
|
const dto = validate(input, schemaActionInputGetFollowing);
|
||||||
|
const result = await serviceGetFollowing({
|
||||||
|
userId: dto.userId,
|
||||||
|
page: dto.page,
|
||||||
|
limit: dto.limit,
|
||||||
|
});
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to get following";
|
||||||
|
log.error("Failed to get following", { error });
|
||||||
|
return { success: false, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/modules/follow/follow-repository-dto.ts
Normal file
51
src/modules/follow/follow-repository-dto.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export type RepoInputCreateFollow = {
|
||||||
|
followerId: string;
|
||||||
|
followingId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoInputDeleteFollow = {
|
||||||
|
followerId: string;
|
||||||
|
followingId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoInputCheckFollow = {
|
||||||
|
followerId: string;
|
||||||
|
followingId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoInputGetFollowers = {
|
||||||
|
userId: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoInputGetFollowing = {
|
||||||
|
userId: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoOutputFollowUser = {
|
||||||
|
id: string;
|
||||||
|
followerId: string;
|
||||||
|
followingId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoOutputFollowWithUser = {
|
||||||
|
id: string;
|
||||||
|
followerId: string;
|
||||||
|
followingId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string | null;
|
||||||
|
displayUsername: string | null;
|
||||||
|
image: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoOutputFollowerCount = number;
|
||||||
|
export type RepoOutputFollowingCount = number;
|
||||||
|
export type RepoOutputIsFollowing = boolean;
|
||||||
142
src/modules/follow/follow-repository.ts
Normal file
142
src/modules/follow/follow-repository.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { createLogger } from "@/lib/logger";
|
||||||
|
import type {
|
||||||
|
RepoInputCheckFollow,
|
||||||
|
RepoInputCreateFollow,
|
||||||
|
RepoInputDeleteFollow,
|
||||||
|
RepoInputGetFollowers,
|
||||||
|
RepoInputGetFollowing,
|
||||||
|
RepoOutputFollowerCount,
|
||||||
|
RepoOutputFollowUser,
|
||||||
|
RepoOutputFollowWithUser,
|
||||||
|
RepoOutputFollowingCount,
|
||||||
|
RepoOutputIsFollowing,
|
||||||
|
} from "./follow-repository-dto";
|
||||||
|
|
||||||
|
const log = createLogger("follow-repository");
|
||||||
|
|
||||||
|
export async function repoCreateFollow(
|
||||||
|
dto: RepoInputCreateFollow
|
||||||
|
): Promise<RepoOutputFollowUser> {
|
||||||
|
log.debug("Creating follow", { followerId: dto.followerId, followingId: dto.followingId });
|
||||||
|
|
||||||
|
const follow = await prisma.follow.create({
|
||||||
|
data: {
|
||||||
|
followerId: dto.followerId,
|
||||||
|
followingId: dto.followingId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("Follow created", { followId: follow.id });
|
||||||
|
return follow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoDeleteFollow(
|
||||||
|
dto: RepoInputDeleteFollow
|
||||||
|
): Promise<void> {
|
||||||
|
log.debug("Deleting follow", { followerId: dto.followerId, followingId: dto.followingId });
|
||||||
|
|
||||||
|
await prisma.follow.delete({
|
||||||
|
where: {
|
||||||
|
followerId_followingId: {
|
||||||
|
followerId: dto.followerId,
|
||||||
|
followingId: dto.followingId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("Follow deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoCheckFollow(
|
||||||
|
dto: RepoInputCheckFollow
|
||||||
|
): Promise<RepoOutputIsFollowing> {
|
||||||
|
const follow = await prisma.follow.findUnique({
|
||||||
|
where: {
|
||||||
|
followerId_followingId: {
|
||||||
|
followerId: dto.followerId,
|
||||||
|
followingId: dto.followingId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!follow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetFollowersCount(userId: string): Promise<RepoOutputFollowerCount> {
|
||||||
|
return prisma.follow.count({
|
||||||
|
where: { followingId: userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetFollowingCount(userId: string): Promise<RepoOutputFollowingCount> {
|
||||||
|
return prisma.follow.count({
|
||||||
|
where: { followerId: userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetFollowers(
|
||||||
|
dto: RepoInputGetFollowers
|
||||||
|
): Promise<RepoOutputFollowWithUser[]> {
|
||||||
|
const { userId, page = 1, limit = 20 } = dto;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const follows = await prisma.follow.findMany({
|
||||||
|
where: { followingId: userId },
|
||||||
|
include: {
|
||||||
|
follower: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayUsername: true,
|
||||||
|
image: true,
|
||||||
|
bio: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return follows.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
followerId: f.followerId,
|
||||||
|
followingId: f.followingId,
|
||||||
|
createdAt: f.createdAt,
|
||||||
|
user: f.follower,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetFollowing(
|
||||||
|
dto: RepoInputGetFollowing
|
||||||
|
): Promise<RepoOutputFollowWithUser[]> {
|
||||||
|
const { userId, page = 1, limit = 20 } = dto;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const follows = await prisma.follow.findMany({
|
||||||
|
where: { followerId: userId },
|
||||||
|
include: {
|
||||||
|
following: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayUsername: true,
|
||||||
|
image: true,
|
||||||
|
bio: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return follows.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
followerId: f.followerId,
|
||||||
|
followingId: f.followingId,
|
||||||
|
createdAt: f.createdAt,
|
||||||
|
user: f.following,
|
||||||
|
}));
|
||||||
|
}
|
||||||
51
src/modules/follow/follow-service-dto.ts
Normal file
51
src/modules/follow/follow-service-dto.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export type ServiceInputToggleFollow = {
|
||||||
|
currentUserId: string;
|
||||||
|
targetUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceInputGetFollowers = {
|
||||||
|
userId: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceInputGetFollowing = {
|
||||||
|
userId: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceInputCheckFollow = {
|
||||||
|
currentUserId: string;
|
||||||
|
targetUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputFollowUser = {
|
||||||
|
id: string;
|
||||||
|
followerId: string;
|
||||||
|
followingId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputFollowWithUser = {
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string | null;
|
||||||
|
displayUsername: string | null;
|
||||||
|
image: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputFollowStatus = {
|
||||||
|
isFollowing: boolean;
|
||||||
|
followersCount: number;
|
||||||
|
followingCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputToggleFollow = {
|
||||||
|
isFollowing: boolean;
|
||||||
|
followersCount: number;
|
||||||
|
};
|
||||||
103
src/modules/follow/follow-service.ts
Normal file
103
src/modules/follow/follow-service.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { createLogger } from "@/lib/logger";
|
||||||
|
import {
|
||||||
|
repoCheckFollow,
|
||||||
|
repoCreateFollow,
|
||||||
|
repoDeleteFollow,
|
||||||
|
repoGetFollowers,
|
||||||
|
repoGetFollowersCount,
|
||||||
|
repoGetFollowing,
|
||||||
|
repoGetFollowingCount,
|
||||||
|
} from "./follow-repository";
|
||||||
|
import type {
|
||||||
|
ServiceInputCheckFollow,
|
||||||
|
ServiceInputGetFollowers,
|
||||||
|
ServiceInputGetFollowing,
|
||||||
|
ServiceInputToggleFollow,
|
||||||
|
ServiceOutputFollowStatus,
|
||||||
|
ServiceOutputFollowWithUser,
|
||||||
|
ServiceOutputToggleFollow,
|
||||||
|
} from "./follow-service-dto";
|
||||||
|
|
||||||
|
const log = createLogger("follow-service");
|
||||||
|
|
||||||
|
export async function serviceToggleFollow(
|
||||||
|
dto: ServiceInputToggleFollow
|
||||||
|
): Promise<ServiceOutputToggleFollow> {
|
||||||
|
const { currentUserId, targetUserId } = dto;
|
||||||
|
|
||||||
|
if (currentUserId === targetUserId) {
|
||||||
|
throw new Error("Cannot follow yourself");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFollowing = await repoCheckFollow({
|
||||||
|
followerId: currentUserId,
|
||||||
|
followingId: targetUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isFollowing) {
|
||||||
|
await repoDeleteFollow({
|
||||||
|
followerId: currentUserId,
|
||||||
|
followingId: targetUserId,
|
||||||
|
});
|
||||||
|
log.info("Unfollowed user", { currentUserId, targetUserId });
|
||||||
|
} else {
|
||||||
|
await repoCreateFollow({
|
||||||
|
followerId: currentUserId,
|
||||||
|
followingId: targetUserId,
|
||||||
|
});
|
||||||
|
log.info("Followed user", { currentUserId, targetUserId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const followersCount = await repoGetFollowersCount(targetUserId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFollowing: !isFollowing,
|
||||||
|
followersCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetFollowStatus(
|
||||||
|
dto: ServiceInputCheckFollow
|
||||||
|
): Promise<ServiceOutputFollowStatus> {
|
||||||
|
const { currentUserId, targetUserId } = dto;
|
||||||
|
|
||||||
|
const [isFollowing, followersCount, followingCount] = await Promise.all([
|
||||||
|
currentUserId
|
||||||
|
? repoCheckFollow({ followerId: currentUserId, followingId: targetUserId })
|
||||||
|
: false,
|
||||||
|
repoGetFollowersCount(targetUserId),
|
||||||
|
repoGetFollowingCount(targetUserId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFollowing,
|
||||||
|
followersCount,
|
||||||
|
followingCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetFollowers(
|
||||||
|
dto: ServiceInputGetFollowers
|
||||||
|
): Promise<{ followers: ServiceOutputFollowWithUser[]; total: number }> {
|
||||||
|
const { userId, page, limit } = dto;
|
||||||
|
|
||||||
|
const [followers, total] = await Promise.all([
|
||||||
|
repoGetFollowers({ userId, page, limit }),
|
||||||
|
repoGetFollowersCount(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { followers, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetFollowing(
|
||||||
|
dto: ServiceInputGetFollowing
|
||||||
|
): Promise<{ following: ServiceOutputFollowWithUser[]; total: number }> {
|
||||||
|
const { userId, page, limit } = dto;
|
||||||
|
|
||||||
|
const [following, total] = await Promise.all([
|
||||||
|
repoGetFollowing({ userId, page, limit }),
|
||||||
|
repoGetFollowingCount(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { following, total };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user