diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index a325ec9..81f2126 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,28 +1,69 @@ /** * @file app/(app)/page.tsx - * @description Main application layout page featuring a dynamic header and default welcome screen. + * @description Main application layout page featuring the FriendsView. */ +import { auth } from "@/auth"; +import { db } from "@/db"; +import { friendships } from "@/db/schema"; +import { or, eq } from "drizzle-orm"; import { AppHeader } from "@/components/layout/AppHeader"; +import { FriendsView, Friendship } from "@/components/friends/FriendsView"; + +export default async function AppPage() { + const session = await auth(); + + let initialFriendships: Friendship[] = []; + + if (session?.user?.id) { + const currentUserId = session.user.id; + + const rawFriendships = await db.query.friendships.findMany({ + where: or( + eq(friendships.senderId, currentUserId), + eq(friendships.receiverId, currentUserId), + ), + with: { + sender: { + columns: { + id: true, + username: true, + color: true, + status: true, + }, + }, + receiver: { + columns: { + id: true, + username: true, + color: true, + status: true, + }, + }, + }, + }); + + initialFriendships = rawFriendships as Friendship[]; + } -/** - * Renders the default application page with the header and central welcome message. - * - * @returns {JSX.Element} The rendered application page view. - */ -export default function AppPage() { return (
- {/* Dynamic Header */} - - - {/* Main Content */} -
-

Welcome back!

-

- Select a server from the left side or start a chat with your friends. -

-
+ {session?.user?.id ? ( + + ) : ( + <> + +
+

Welcome back!

+

+ Please sign in to view your friends list or start a conversation. +

+
+ + )}
); } diff --git a/components/friends/AddFriendTab.tsx b/components/friends/AddFriendTab.tsx new file mode 100644 index 0000000..0d15536 --- /dev/null +++ b/components/friends/AddFriendTab.tsx @@ -0,0 +1,98 @@ +/** + * @file components/friends/AddFriendTab.tsx + * @description Tab component providing a form to send friend requests via username. + */ + +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "../ui/ActionButton"; + +/** + * Renders a form to add new friends by entering their username. + * + * @returns {JSX.Element} The rendered add friend tab component. + */ +export function AddFriendTab() { + const router = useRouter(); + const [addUsername, setAddUsername] = useState(""); + const [addError, setAddError] = useState(""); + const [addSuccess, setAddSuccess] = useState(""); + const [loading, setLoading] = useState(false); + + /** + * Handles the form submission to send a friend request asynchronously. + * + * @async + * @function handleSendRequest + * @param {React.FormEvent} e - The form submission event. + * @returns {Promise} Resolves when the friend request process completes. + */ + const handleSendRequest = async (e: React.FormEvent) => { + e.preventDefault(); + setAddError(""); + setAddSuccess(""); + if (!addUsername.trim()) return; + + setLoading(true); + try { + const res = await fetch("/api/friends", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: addUsername }), + }); + + const data = await res.json(); + + if (!res.ok) { + setAddError(data.error || "Failed to send request"); + } else { + setAddSuccess( + `Success! Your friend request to ${addUsername} was sent.`, + ); + setAddUsername(""); + router.refresh(); + } + } catch { + setAddError("Internal server error"); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ Add Friend +

+

+ You can add friends with their username. +

+ +
+
+ setAddUsername(e.target.value)} + placeholder="You can add friends with their username" + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted/60 focus:outline-none py-1 sm:py-0 sm:pr-36" + /> + + Send Friend Request + +
+ {addError && ( +

{addError}

+ )} + {addSuccess &&

{addSuccess}

} +
+
+ ); +} diff --git a/components/friends/AllFriendsTab.tsx b/components/friends/AllFriendsTab.tsx new file mode 100644 index 0000000..6b3b0da --- /dev/null +++ b/components/friends/AllFriendsTab.tsx @@ -0,0 +1,97 @@ +/** + * @file components/friends/AllFriendsTab.tsx + * @description Component rendering the list of all accepted friends along with status indicators, messaging options, and remove actions. + */ + +"use client"; + +import { MessageSquare, X } from "lucide-react"; +import { UserAvatar } from "../ui/UserAvatar"; +import { Friendship, FriendUser } from "./FriendsView"; + +/** + * Properties for the AllFriendsTab component. + * + * @interface AllFriendsTabProps + * @property {Friendship[]} acceptedFriends - Array of accepted friendship objects. + * @property {(f: Friendship) => FriendUser} getFriendUser - Helper function to retrieve user details from a friendship relationship. + * @property {(id: string) => void} onRemove - Callback function to remove a friend by their friendship ID. + */ +interface AllFriendsTabProps { + acceptedFriends: Friendship[]; + getFriendUser: (f: Friendship) => FriendUser; + onRemove: (id: string) => void; +} + +/** + * Renders the list of all accepted friends along with status, message, and remove actions. + * + * @param {AllFriendsTabProps} props - The component props. + * @param {Friendship[]} props.acceptedFriends - Array of accepted friendship objects. + * @param {(f: Friendship) => FriendUser} props.getFriendUser - Helper function to extract user details. + * @param {(id: string) => void} props.onRemove - Callback function to remove a friend. + * @returns {JSX.Element} The rendered list of all accepted friends. + */ +export function AllFriendsTab({ + acceptedFriends, + getFriendUser, + onRemove, +}: AllFriendsTabProps) { + return ( +
+

+ All Friends — {acceptedFriends.length} +

+ + {acceptedFriends.length === 0 ? ( +

You have no friends yet.

+ ) : ( +
+ {acceptedFriends.map((f) => { + const friend = getFriendUser(f); + const isOnline = friend.status && friend.status !== "OFFLINE"; + + return ( +
+
+
+ +
+
+
+ {friend.username} +
+
+ {isOnline ? "Online" : "Offline"} +
+
+
+ +
+ + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/components/friends/FriendsHeader.tsx b/components/friends/FriendsHeader.tsx new file mode 100644 index 0000000..246ff8e --- /dev/null +++ b/components/friends/FriendsHeader.tsx @@ -0,0 +1,117 @@ +/** + * @file components/friends/FriendsHeader.tsx + * @description Renders a responsive sub-header with tabs for navigating between all friends, pending requests, and adding a friend. + */ + +"use client"; + +import { Users, Clock } from "lucide-react"; +import { ActionButton } from "../ui/ActionButton"; + +/** + * Available tab types for the friends navigation view. + * + * @type {TabType} + */ +export type TabType = "all" | "pending" | "add"; + +/** + * Properties for the FriendsHeader component. + * + * @interface FriendsHeaderProps + * @property {TabType} activeTab - The currently active friends tab identifier. + * @property {(tab: TabType) => void} setActiveTab - Callback function to update the active friends tab. + * @property {number} [allCount=0] - The total number of friends. + * @property {number} [pendingCount=0] - The number of pending friend requests. + */ +interface FriendsHeaderProps { + activeTab: TabType; + setActiveTab: (tab: TabType) => void; + allCount?: number; + pendingCount?: number; +} + +/** + * Renders a responsive sub-header with tabs for navigating between all friends, pending requests, and adding a friend. + * + * @param {FriendsHeaderProps} props - The component props. + * @param {TabType} props.activeTab - The currently active tab. + * @param {(tab: TabType) => void} props.setActiveTab - Function to change the active tab. + * @param {number} [props.allCount=0] - Total count of all friends. + * @param {number} [props.pendingCount=0] - Count of pending friend requests. + * @returns {JSX.Element} The rendered friends navigation header component. + */ +export function FriendsHeader({ + activeTab, + setActiveTab, + allCount = 0, + pendingCount = 0, +}: FriendsHeaderProps) { + return ( + + ); +} diff --git a/components/friends/FriendsView.tsx b/components/friends/FriendsView.tsx new file mode 100644 index 0000000..a70f1c4 --- /dev/null +++ b/components/friends/FriendsView.tsx @@ -0,0 +1,194 @@ +/** + * @file components/friends/FriendsView.tsx + * @description Interactive Discord-style friends list view component orchestrating tabs. + */ + +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { AppHeader } from "@/components/layout/AppHeader"; +import { TabType } from "@/components/friends/FriendsHeader"; +import { AddFriendTab } from "./AddFriendTab"; +import { PendingRequestsTab } from "./PendingRequestsTab"; +import { AllFriendsTab } from "./AllFriendsTab"; +import { UserStatus } from "@/db/schema"; + +/** + * Status types for a friendship relation. + * + * @type {FriendshipStatus} + */ +export type FriendshipStatus = "PENDING" | "ACCEPTED" | "BLOCKED"; + +/** + * Represents a user within a friendship context. + * + * @interface FriendUser + * @property {string} id - The unique identifier of the user. + * @property {string} username - The display username of the user. + * @property {string} color - The associated profile color of the user. + * @property {UserStatus} status - The current online/activity status of the user. + */ +export interface FriendUser { + id: string; + username: string; + color: string; + status: UserStatus; +} + +/** + * Represents a friendship entry between two users. + * + * @interface Friendship + * @property {string} id - The unique identifier of the friendship record. + * @property {string} senderId - The user ID of the friend request sender. + * @property {string} receiverId - The user ID of the friend request receiver. + * @property {FriendshipStatus} status - The current status of the friendship. + * @property {FriendUser} sender - The profile details of the sender user. + * @property {FriendUser} receiver - The profile details of the receiver user. + */ +export interface Friendship { + id: string; + senderId: string; + receiverId: string; + status: FriendshipStatus; + sender: FriendUser; + receiver: FriendUser; +} + +/** + * Properties for the FriendsView component. + * + * @interface FriendsViewProps + * @property {string} currentUserId - The unique identifier of the currently logged-in user. + * @property {Friendship[]} initialFriendships - Initial list of friendships fetched from the server. + */ +interface FriendsViewProps { + currentUserId: string; + initialFriendships: Friendship[]; +} + +/** + * Renders the friends view container, managing navigation tabs, state synchronization, and friendship mutation actions. + * + * @param {FriendsViewProps} props - The component props. + * @param {string} props.currentUserId - The unique identifier of the currently logged-in user. + * @param {Friendship[]} props.initialFriendships - Initial list of friendships. + * @returns {JSX.Element} The rendered friends view interface. + */ +export function FriendsView({ + currentUserId, + initialFriendships, +}: FriendsViewProps) { + const router = useRouter(); + const [activeTab, setActiveTab] = useState("all"); + const [friendships, setFriendships] = + useState(initialFriendships); + + useEffect(() => { + setFriendships(initialFriendships); + }, [initialFriendships]); + + /** + * Retrieves the counter-party user object from a given friendship record relative to the current user. + * + * @function getFriendUser + * @param {Friendship} f - The friendship object to evaluate. + * @returns {FriendUser} The other user involved in the friendship. + */ + const getFriendUser = (f: Friendship): FriendUser => { + return f.senderId === currentUserId ? f.receiver : f.sender; + }; + + const pendingRequests = friendships.filter((f) => f.status === "PENDING"); + const acceptedFriends = friendships.filter((f) => f.status === "ACCEPTED"); + + /** + * Handles accepting a pending friend request via API. + * + * @async + * @function handleAccept + * @param {string} friendshipId - The unique identifier of the friendship request to accept. + * @returns {Promise} Resolves when the request is processed. + */ + const handleAccept = async (friendshipId: string) => { + try { + const res = await fetch(`/api/friends/${friendshipId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "ACCEPTED" }), + }); + if (res.ok) { + setFriendships((prev) => + prev.map((item) => + item.id === friendshipId ? { ...item, status: "ACCEPTED" } : item, + ), + ); + router.refresh(); + } + } catch (err) { + console.error("Failed to accept request:", err); + } + }; + + /** + * Handles deleting or declining a friendship or pending request via API. + * + * @async + * @function handleDeleteOrDecline + * @param {string} friendshipId - The unique identifier of the friendship to delete. + * @returns {Promise} Resolves when the deletion is processed. + */ + const handleDeleteOrDecline = async (friendshipId: string) => { + try { + const res = await fetch(`/api/friends/${friendshipId}`, { + method: "DELETE", + }); + if (res.ok) { + setFriendships((prev) => + prev.filter((item) => item.id !== friendshipId), + ); + router.refresh(); + } + } catch (err) { + console.error("Failed to delete friendship/request:", err); + } + }; + + return ( +
+ {/* Header */} + + + {/* Main Tab Content */} +
+ {activeTab === "add" && } + + {activeTab === "pending" && ( + + )} + + {activeTab === "all" && ( + + )} +
+
+ ); +} diff --git a/components/friends/PendingRequestsTab.tsx b/components/friends/PendingRequestsTab.tsx new file mode 100644 index 0000000..5fbd171 --- /dev/null +++ b/components/friends/PendingRequestsTab.tsx @@ -0,0 +1,107 @@ +/** + * @file components/friends/PendingRequestsTab.tsx + * @description Tab component that renders a list of pending friend requests with options to accept, decline, or cancel them. + */ + +"use client"; + +import { Check, X } from "lucide-react"; +import { UserAvatar } from "../ui/UserAvatar"; +import { Friendship, FriendUser } from "./FriendsView"; + +/** + * Properties for the PendingRequestsTab component. + * + * @interface PendingRequestsTabProps + * @property {string} currentUserId - The unique identifier of the currently logged-in user. + * @property {Friendship[]} pendingRequests - Array of pending friendship objects. + * @property {(f: Friendship) => FriendUser} getFriendUser - Helper function to extract the friend user from a friendship record. + * @property {(id: string) => void} onAccept - Callback function triggered to accept an incoming friend request. + * @property {(id: string) => void} onDeclineOrCancel - Callback function triggered to decline an incoming request or cancel an outgoing one. + */ +interface PendingRequestsTabProps { + currentUserId: string; + pendingRequests: Friendship[]; + getFriendUser: (f: Friendship) => FriendUser; + onAccept: (id: string) => void; + onDeclineOrCancel: (id: string) => void; +} + +/** + * Renders a list of pending friend requests with options to accept, decline, or cancel them. + * + * @param {PendingRequestsTabProps} props - The component props. + * @param {string} props.currentUserId - The unique identifier of the currently logged-in user. + * @param {Friendship[]} props.pendingRequests - Array of pending friendship objects. + * @param {(f: Friendship) => FriendUser} props.getFriendUser - Helper function to extract the friend user. + * @param {(id: string) => void} props.onAccept - Callback to accept a request. + * @param {(id: string) => void} props.onDeclineOrCancel - Callback to decline or cancel a request. + * @returns {JSX.Element} The rendered pending requests tab. + */ +export function PendingRequestsTab({ + currentUserId, + pendingRequests, + getFriendUser, + onAccept, + onDeclineOrCancel, +}: PendingRequestsTabProps) { + return ( +
+

+ Pending Requests — {pendingRequests.length} +

+ {pendingRequests.length === 0 ? ( +

No pending friend requests.

+ ) : ( +
+ {pendingRequests.map((req) => { + const isIncoming = req.receiverId === currentUserId; + const friend = getFriendUser(req); + + return ( +
+
+
+ +
+
+
+ {friend.username} +
+
+ {isIncoming ? "Incoming Request" : "Outgoing Request"} +
+
+
+ +
+ {isIncoming && ( + + )} + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/components/layout/AppHeader.tsx b/components/layout/AppHeader.tsx index 045e039..0f3d999 100644 --- a/components/layout/AppHeader.tsx +++ b/components/layout/AppHeader.tsx @@ -1,6 +1,6 @@ /** * @file components/layout/AppHeader.tsx - * @description Unified application header supporting general views, chat channels, and DM views. + * @description Application top header component providing navigation controls, dynamic channel or friend tab titles, and optional server settings or member list toggles. */ "use client"; @@ -8,14 +8,20 @@ import { useSidebarStore } from "@/lib/stores/useSidebarStore"; import { PanelLeftOpen, PanelLeftClose, Users, Hash } from "lucide-react"; import { ServerSettingsMenu } from "./ServerSettingsMenu"; +import { FriendsHeader, TabType } from "@/components/friends/FriendsHeader"; /** * Properties for the AppHeader component. * * @interface AppHeaderProps - * @property {string} [title] - Optional channel or page title to display in the header. - * @property {boolean} [showMembersButton=false] - Flag indicating whether to display the member list toggle button. - * @property {{ id: string; name: string }} [server] - Optional server details to enable server settings & delete functionality. + * @property {string} [title] - The title of the current channel or view. + * @property {boolean} [showMembersButton=false] - Whether to show the button that toggles the members sidebar. + * @property {{ id: string; name: string }} [server] - Optional server configuration object containing its unique identifier and name. + * @property {boolean} [showFriendsTabs=false] - Whether to display the friends navigation tabs in the header. + * @property {TabType} [activeTab] - The currently active friends tab identifier. + * @property {(tab: TabType) => void} [setActiveTab] - Callback function to update the active friends tab. + * @property {number} [allCount=0] - The total number of friends. + * @property {number} [pendingCount=0] - The number of pending friend requests. */ interface AppHeaderProps { title?: string; @@ -24,39 +30,55 @@ interface AppHeaderProps { id: string; name: string; }; + showFriendsTabs?: boolean; + activeTab?: TabType; + setActiveTab?: (tab: TabType) => void; + allCount?: number; + pendingCount?: number; } /** - * Renders the application header bar with navigation controls, dynamic page titles, server settings, and member list toggle capability. + * Renders the application header with navigation controls, dynamic titles, tabs, and action buttons. * * @param {AppHeaderProps} props - The component props. - * @param {string} [props.title] - Optional channel or page title to display. - * @param {boolean} [props.showMembersButton=false] - Whether to show the button toggling the right sidebar/member panel. - * @param {{ id: string; name: string }} [props.server] - Optional server object containing id and name for settings menu. - * @returns {JSX.Element} The header component visual structure. + * @param {string} [props.title] - The title of the current channel or view. + * @param {boolean} [props.showMembersButton=false] - Whether to show the members list toggle button. + * @param {{ id: string; name: string }} [props.server] - Optional server details object. + * @param {boolean} [props.showFriendsTabs=false] - Whether to display the friends tabs. + * @param {TabType} [props.activeTab] - The active friends tab. + * @param {(tab: TabType) => void} [props.setActiveTab] - Function to change the active friends tab. + * @param {number} [props.allCount=0] - Count of all friends. + * @param {number} [props.pendingCount=0] - Count of pending friend requests. + * @returns {JSX.Element} The rendered application header container. */ export function AppHeader({ title, showMembersButton = false, server, + showFriendsTabs = false, + activeTab, + setActiveTab, + allCount = 0, + pendingCount = 0, }: AppHeaderProps) { const { isNavOpen, toggleNav, toggleMembers } = useSidebarStore(); - const hasContent = !isNavOpen || !!title || showMembersButton || !!server; + const hasContent = + !isNavOpen || !!title || showMembersButton || !!server || showFriendsTabs; return (
-
+
{/* Toggle button for navigation */} - {/* Dynamic Title (Channel/Page Name) */} + {/* Dynamic Channel Title */} {title && ( -
- +
+

{title}

)} + + {/* Friends Header Component */} + {showFriendsTabs && setActiveTab && activeTab && ( + + )}
{/* Right Action Buttons */} -
- {/* Server Settings Menu (contains the Delete Server action) */} +
{server && ( )} - {/* Button for the member bar */} {showMembersButton && (