-
{t("userId")}
-
{user.id}
+
+ {user.image ? (
+
+
-
-
{t("username")}
-
- {user.username || {t("notSet")}}
-
-
-
-
{t("displayName")}
-
- {user.displayUsername || {t("notSet")}}
-
-
-
-
{t("memberSince")}
-
- {new Date(user.createdAt).toLocaleDateString()}
-
-
-
-
-
- {/* Folders Section */}
-
-
{t("folders.title")}
- {folders.length === 0 ? (
-
{t("folders.noFolders")}
) : (
-
-
-
-
- |
- {t("folders.folderName")}
- |
-
- {t("folders.totalPairs")}
- |
-
- {t("folders.createdAt")}
- |
-
- {t("folders.actions")}
- |
-
-
-
- {folders.map((folder) => (
-
- |
- {folder.name}
- ID: {folder.id}
- |
-
- {folder.total}
- |
-
- {new Date(folder.createdAt).toLocaleDateString()}
- |
-
-
-
- {t("folders.view")}
-
-
- |
-
- ))}
-
-
+
+
+ {(user.displayUsername || user.username || user.email)[0].toUpperCase()}
+
)}
+
+
+
+ {user.displayUsername || user.username || t("anonymous")}
+
+ {user.username && (
+
+ @{user.username}
+
+ )}
+ {user.bio && (
+
+ {user.bio}
+
+ )}
+
+
+ {t("joined")}: {new Date(user.createdAt).toLocaleDateString()}
+
+ {user.emailVerified && (
+
+
+ {t("verified")}
+
+ )}
+
+
+
+
+
+
+
+
+
{t("accountInfo")}
+
+
+
- {t("userId")}
+ - {user.id}
+
+
+
- {t("username")}
+ -
+ {user.username || {t("notSet")}}
+
+
+
+
- {t("displayName")}
+ -
+ {user.displayUsername || {t("notSet")}}
+
+
+
+
- {t("memberSince")}
+ -
+ {new Date(user.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+
{t("folders.title")}
+ {folders.length === 0 ? (
+
{t("folders.noFolders")}
+ ) : (
+
+
+
+
+ |
+ {t("folders.folderName")}
+ |
+
+ {t("folders.totalPairs")}
+ |
+
+ {t("folders.createdAt")}
+ |
+
+ {t("folders.actions")}
+ |
+
+
+
+ {folders.map((folder) => (
+
+ |
+ {folder.name}
+ ID: {folder.id}
+ |
+
+ {folder.total}
+ |
+
+ {new Date(folder.createdAt).toLocaleDateString()}
+ |
+
+
+
+ {t("folders.view")}
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
);
}
diff --git a/src/components/follow/FollowButton.tsx b/src/components/follow/FollowButton.tsx
new file mode 100644
index 0000000..19b7cb2
--- /dev/null
+++ b/src/components/follow/FollowButton.tsx
@@ -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 (
+
+ {isPending ? "..." : "Following"}
+
+ );
+ }
+
+ return (
+
+ {isPending ? "..." : "Follow"}
+
+ );
+}
diff --git a/src/components/follow/FollowStats.tsx b/src/components/follow/FollowStats.tsx
new file mode 100644
index 0000000..b882d95
--- /dev/null
+++ b/src/components/follow/FollowStats.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/follow/UserList.tsx b/src/components/follow/UserList.tsx
new file mode 100644
index 0000000..2bbf315
--- /dev/null
+++ b/src/components/follow/UserList.tsx
@@ -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 (
+
+ {emptyMessage}
+
+ );
+ }
+
+ return (
+
+ {users.map((user) => (
+
+ {user.image ? (
+
+
+
+ ) : (
+
+
+ {(user.displayUsername || user.username || "U")[0].toUpperCase()}
+
+
+ )}
+
+
+ {user.displayUsername || user.username || "Anonymous"}
+
+ {user.username && (
+
@{user.username}
+ )}
+ {user.bio && (
+
+ {user.bio}
+
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/src/modules/auth/auth-action-dto.ts b/src/modules/auth/auth-action-dto.ts
index 81b16fd..26dd025 100644
--- a/src/modules/auth/auth-action-dto.ts
+++ b/src/modules/auth/auth-action-dto.ts
@@ -56,6 +56,7 @@ export type ActionOutputUserProfile = {
username: string | null;
displayUsername: string | null;
image: string | null;
+ bio: string | null;
createdAt: Date;
updatedAt: Date;
};
diff --git a/src/modules/auth/auth-repository-dto.ts b/src/modules/auth/auth-repository-dto.ts
index c413e5e..37bdb99 100644
--- a/src/modules/auth/auth-repository-dto.ts
+++ b/src/modules/auth/auth-repository-dto.ts
@@ -8,6 +8,7 @@ export type RepoOutputUserProfile = {
username: string | null;
displayUsername: string | null;
image: string | null;
+ bio: string | null;
createdAt: Date;
updatedAt: Date;
} | null;
diff --git a/src/modules/auth/auth-repository.ts b/src/modules/auth/auth-repository.ts
index cd644c0..3f9031e 100644
--- a/src/modules/auth/auth-repository.ts
+++ b/src/modules/auth/auth-repository.ts
@@ -6,9 +6,6 @@ import {
RepoOutputUserProfile
} from "./auth-repository-dto";
-/**
- * Find user by username
- */
export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise
{
const user = await prisma.user.findUnique({
where: { username: dto.username },
@@ -19,6 +16,7 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername):
username: true,
displayUsername: true,
image: true,
+ bio: true,
createdAt: true,
updatedAt: true,
}
@@ -27,9 +25,6 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername):
return user;
}
-/**
- * Find user by ID
- */
export async function repoFindUserById(dto: RepoInputFindUserById): Promise {
const user = await prisma.user.findUnique({
where: { id: dto.id },
@@ -40,6 +35,7 @@ export async function repoFindUserById(dto: RepoInputFindUserById): Promise {
const user = await prisma.user.findUnique({
where: { email: dto.email },
@@ -61,6 +54,7 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis
username: true,
displayUsername: true,
image: true,
+ bio: true,
createdAt: true,
updatedAt: true,
}
diff --git a/src/modules/auth/auth-service-dto.ts b/src/modules/auth/auth-service-dto.ts
index f13f5ba..6159b94 100644
--- a/src/modules/auth/auth-service-dto.ts
+++ b/src/modules/auth/auth-service-dto.ts
@@ -34,6 +34,7 @@ export type ServiceOutputUserProfile = {
username: string | null;
displayUsername: string | null;
image: string | null;
+ bio: string | null;
createdAt: Date;
updatedAt: Date;
} | null;
diff --git a/src/modules/follow/follow-action-dto.ts b/src/modules/follow/follow-action-dto.ts
new file mode 100644
index 0000000..4937620
--- /dev/null
+++ b/src/modules/follow/follow-action-dto.ts
@@ -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;
+export type ActionInputGetFollowers = z.infer;
+export type ActionInputGetFollowing = z.infer;
+export type ActionInputGetFollowStatus = z.infer;
+
+export {
+ schemaActionInputGetFollowers,
+ schemaActionInputGetFollowing,
+ schemaActionInputGetFollowStatus,
+ schemaActionInputToggleFollow,
+};
diff --git a/src/modules/follow/follow-action.ts b/src/modules/follow/follow-action.ts
new file mode 100644
index 0000000..f651301
--- /dev/null
+++ b/src/modules/follow/follow-action.ts
@@ -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 };
+ }
+}
diff --git a/src/modules/follow/follow-repository-dto.ts b/src/modules/follow/follow-repository-dto.ts
new file mode 100644
index 0000000..aec62b0
--- /dev/null
+++ b/src/modules/follow/follow-repository-dto.ts
@@ -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;
diff --git a/src/modules/follow/follow-repository.ts b/src/modules/follow/follow-repository.ts
new file mode 100644
index 0000000..41589b4
--- /dev/null
+++ b/src/modules/follow/follow-repository.ts
@@ -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 {
+ 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 {
+ 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 {
+ const follow = await prisma.follow.findUnique({
+ where: {
+ followerId_followingId: {
+ followerId: dto.followerId,
+ followingId: dto.followingId,
+ },
+ },
+ });
+
+ return !!follow;
+}
+
+export async function repoGetFollowersCount(userId: string): Promise {
+ return prisma.follow.count({
+ where: { followingId: userId },
+ });
+}
+
+export async function repoGetFollowingCount(userId: string): Promise {
+ return prisma.follow.count({
+ where: { followerId: userId },
+ });
+}
+
+export async function repoGetFollowers(
+ dto: RepoInputGetFollowers
+): Promise {
+ 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 {
+ 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,
+ }));
+}
diff --git a/src/modules/follow/follow-service-dto.ts b/src/modules/follow/follow-service-dto.ts
new file mode 100644
index 0000000..cb54976
--- /dev/null
+++ b/src/modules/follow/follow-service-dto.ts
@@ -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;
+};
diff --git a/src/modules/follow/follow-service.ts b/src/modules/follow/follow-service.ts
new file mode 100644
index 0000000..da51d36
--- /dev/null
+++ b/src/modules/follow/follow-service.ts
@@ -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 {
+ 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 {
+ 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 };
+}