feat(friends): add friends section with headers, tabs, and request management
This commit is contained in:
parent
e07036c190
commit
28dc63e0fd
7 changed files with 720 additions and 36 deletions
|
|
@ -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 (
|
||||
<div className="flex flex-col h-full w-full bg-background p-4">
|
||||
{/* Dynamic Header */}
|
||||
<AppHeader />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<h2 className="text-xl font-bold text-white mb-2">Welcome back!</h2>
|
||||
<p className="text-muted max-w-sm">
|
||||
Select a server from the left side or start a chat with your friends.
|
||||
</p>
|
||||
</div>
|
||||
{session?.user?.id ? (
|
||||
<FriendsView
|
||||
currentUserId={session.user.id}
|
||||
initialFriendships={initialFriendships}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AppHeader />
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<h2 className="text-xl font-bold text-white mb-2">Welcome back!</h2>
|
||||
<p className="text-muted max-w-sm">
|
||||
Please sign in to view your friends list or start a conversation.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
98
components/friends/AddFriendTab.tsx
Normal file
98
components/friends/AddFriendTab.tsx
Normal file
|
|
@ -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<void>} 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 (
|
||||
<div className="max-w-xl w-full">
|
||||
<h3 className="font-semibold text-foreground uppercase tracking-wider text-xs mb-1">
|
||||
Add Friend
|
||||
</h3>
|
||||
<p className="text-xs text-muted mb-4">
|
||||
You can add friends with their username.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSendRequest} className="space-y-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-0 relative items-stretch sm:items-center bg-surface rounded-lg p-2 sm:p-3 border border-muted/20 focus-within:border-accent transition-colors">
|
||||
<input
|
||||
type="text"
|
||||
value={addUsername}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || !addUsername.trim()}
|
||||
size="sm"
|
||||
>
|
||||
Send Friend Request
|
||||
</ActionButton>
|
||||
</div>
|
||||
{addError && (
|
||||
<p className="text-xs text-destructive mt-1">{addError}</p>
|
||||
)}
|
||||
{addSuccess && <p className="text-xs text-accent mt-1">{addSuccess}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
components/friends/AllFriendsTab.tsx
Normal file
97
components/friends/AllFriendsTab.tsx
Normal file
|
|
@ -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 (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
|
||||
All Friends — {acceptedFriends.length}
|
||||
</h3>
|
||||
|
||||
{acceptedFriends.length === 0 ? (
|
||||
<p className="text-sm text-muted">You have no friends yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{acceptedFriends.map((f) => {
|
||||
const friend = getFriendUser(f);
|
||||
const isOnline = friend.status && friend.status !== "OFFLINE";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center justify-between p-2.5 rounded-lg hover:bg-surface/50 border-t border-muted/10 group cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 pr-2">
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar user={friend} size="md" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{friend.username}
|
||||
</div>
|
||||
<div className="text-xs text-muted truncate">
|
||||
{isOnline ? "Online" : "Offline"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
title="Message"
|
||||
className="p-2 rounded-full bg-surface hover:bg-muted/30 text-muted hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(f.id)}
|
||||
title="Remove Friend"
|
||||
className="p-2 rounded-full bg-surface hover:bg-destructive text-muted hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
components/friends/FriendsHeader.tsx
Normal file
117
components/friends/FriendsHeader.tsx
Normal file
|
|
@ -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 (
|
||||
<nav
|
||||
aria-label="Friends navigation"
|
||||
className="flex items-center gap-2 h-12"
|
||||
>
|
||||
{/* Title / Icon Indicator */}
|
||||
<div className="flex items-center gap-2.5 pr-3 border-r border-muted/20 text-foreground font-bold tracking-wide">
|
||||
<Users className="w-5 h-5 text-accent" />
|
||||
<span className="hidden sm:inline text-sm">Friends</span>
|
||||
</div>
|
||||
|
||||
{/* Tabs Container */}
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
{/* All Friends Tab */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("all")}
|
||||
className={`px-3 py-1.5 rounded-lg transition-all duration-150 cursor-pointer flex items-center gap-2 active:scale-95 ${
|
||||
activeTab === "all"
|
||||
? "bg-surface text-foreground shadow-sm font-semibold"
|
||||
: "text-muted hover:bg-surface/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>All</span>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded-md ${
|
||||
activeTab === "all"
|
||||
? "bg-muted/20 text-foreground"
|
||||
: "bg-surface text-muted"
|
||||
}`}
|
||||
>
|
||||
{allCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Pending Requests Tab */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("pending")}
|
||||
className={`px-3 py-1.5 rounded-lg transition-all duration-150 flex items-center gap-2 cursor-pointer active:scale-95 ${
|
||||
activeTab === "pending"
|
||||
? "bg-surface text-foreground shadow-sm font-semibold"
|
||||
: "text-muted hover:bg-surface/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-4 h-4 hidden sm:block opacity-70" />
|
||||
<span>Pending</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="bg-destructive text-foreground text-xs font-bold px-1.5 py-0.5 rounded-full animate-pulse">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Add Friend Tab */}
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => setActiveTab("add")}
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline md:hidden">Add Friend</span>
|
||||
<span className="hidden md:inline">Add</span>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
194
components/friends/FriendsView.tsx
Normal file
194
components/friends/FriendsView.tsx
Normal file
|
|
@ -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<TabType>("all");
|
||||
const [friendships, setFriendships] =
|
||||
useState<Friendship[]>(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<void>} 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<void>} 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 (
|
||||
<div className="flex flex-col h-full w-full bg-background text-white">
|
||||
{/* Header */}
|
||||
<AppHeader
|
||||
showFriendsTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
allCount={acceptedFriends.length}
|
||||
pendingCount={pendingRequests.length}
|
||||
/>
|
||||
|
||||
{/* Main Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto pt-4">
|
||||
{activeTab === "add" && <AddFriendTab />}
|
||||
|
||||
{activeTab === "pending" && (
|
||||
<PendingRequestsTab
|
||||
currentUserId={currentUserId}
|
||||
pendingRequests={pendingRequests}
|
||||
getFriendUser={getFriendUser}
|
||||
onAccept={handleAccept}
|
||||
onDeclineOrCancel={handleDeleteOrDecline}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "all" && (
|
||||
<AllFriendsTab
|
||||
acceptedFriends={acceptedFriends}
|
||||
getFriendUser={getFriendUser}
|
||||
onRemove={handleDeleteOrDecline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
components/friends/PendingRequestsTab.tsx
Normal file
107
components/friends/PendingRequestsTab.tsx
Normal file
|
|
@ -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 (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
|
||||
Pending Requests — {pendingRequests.length}
|
||||
</h3>
|
||||
{pendingRequests.length === 0 ? (
|
||||
<p className="text-sm text-muted">No pending friend requests.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{pendingRequests.map((req) => {
|
||||
const isIncoming = req.receiverId === currentUserId;
|
||||
const friend = getFriendUser(req);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={req.id}
|
||||
className="flex items-center justify-between p-2.5 rounded-lg hover:bg-surface/50 border-t border-muted/10 group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 pr-2">
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar user={friend} size="md" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{friend.username}
|
||||
</div>
|
||||
<div className="text-xs text-muted truncate">
|
||||
{isIncoming ? "Incoming Request" : "Outgoing Request"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
{isIncoming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAccept(req.id)}
|
||||
title="Accept"
|
||||
className="p-2 rounded-full bg-surface hover:bg-accent text-muted hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeclineOrCancel(req.id)}
|
||||
title={isIncoming ? "Decline" : "Cancel Request"}
|
||||
className="p-2 rounded-full bg-surface hover:bg-destructive text-muted hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={`flex items-center justify-between bg-background shrink-0 pb-2 ${
|
||||
className={`flex items-center justify-between bg-background shrink-0 pb-3 h-12 ${
|
||||
hasContent ? "border-b border-muted/50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 overflow-hidden">
|
||||
{/* Toggle button for navigation */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleNav}
|
||||
title={isNavOpen ? "Collapse navigation" : "Expand Navigation"}
|
||||
className={`p-1.5 rounded-md text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer ${
|
||||
className={`p-1.5 rounded-md text-muted hover:text-white hover:bg-surface transition-colors cursor-pointer shrink-0 ${
|
||||
isNavOpen ? "md:hidden" : "block"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -67,23 +89,31 @@ export function AppHeader({
|
|||
)}
|
||||
</button>
|
||||
|
||||
{/* Dynamic Title (Channel/Page Name) */}
|
||||
{/* Dynamic Channel Title */}
|
||||
{title && (
|
||||
<div className="flex items-center gap-1.5 ml-1">
|
||||
<Hash className="w-4 h-4 text-muted" />
|
||||
<div className="flex items-center gap-1.5 ml-1 min-w-0">
|
||||
<Hash className="w-4 h-4 text-muted shrink-0" />
|
||||
<h1 className="font-bold text-white text-base truncate">{title}</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friends Header Component */}
|
||||
{showFriendsTabs && setActiveTab && activeTab && (
|
||||
<FriendsHeader
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
allCount={allCount}
|
||||
pendingCount={pendingCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Action Buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Server Settings Menu (contains the Delete Server action) */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{server && (
|
||||
<ServerSettingsMenu serverId={server.id} serverName={server.name} />
|
||||
)}
|
||||
|
||||
{/* Button for the member bar */}
|
||||
{showMembersButton && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
Loading…
Reference in a new issue