feat: added mobile style and date handling
- Integrated `moment` library for improved date formatting in direct messages. - Refactored timestamp display logic to show relative time for today's messages and full date for older messages. - Made various UI adjustments for better responsiveness and consistency across components for the mobile version. - Updated dependencies in `package.json` and `bun.lock` to include `moment`.
This commit is contained in:
parent
f04b76dadc
commit
af7142d3d0
10 changed files with 420 additions and 170 deletions
3
bun.lock
3
bun.lock
|
|
@ -36,6 +36,7 @@
|
||||||
"dexie-react-hooks": "^4.2.0",
|
"dexie-react-hooks": "^4.2.0",
|
||||||
"framer-motion": "^12.23.27",
|
"framer-motion": "^12.23.27",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"moment": "^2.30.1",
|
||||||
"nanostores": "^1.1.0",
|
"nanostores": "^1.1.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|
@ -520,6 +521,8 @@
|
||||||
|
|
||||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
|
||||||
|
|
||||||
"motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="],
|
"motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="],
|
||||||
|
|
||||||
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
|
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
"dexie-react-hooks": "^4.2.0",
|
"dexie-react-hooks": "^4.2.0",
|
||||||
"framer-motion": "^12.23.27",
|
"framer-motion": "^12.23.27",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"moment": "^2.30.1",
|
||||||
"nanostores": "^1.1.0",
|
"nanostores": "^1.1.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarProvider,
|
SidebarProvider
|
||||||
SidebarTrigger
|
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { CompassIcon, HouseIcon } from "@phosphor-icons/react";
|
import { CompassIcon, HouseIcon } from "@phosphor-icons/react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
@ -85,9 +84,6 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 h-svh min-h-0 overflow-hidden">
|
<div className="flex flex-col flex-1 h-svh min-h-0 overflow-hidden">
|
||||||
<header className="flex items-center justify-between md:justify-center gap-2 px-4 py-0.5 md:border-none border-b border-border backdrop-blur sticky top-0 z-10">
|
<header className="flex items-center justify-between md:justify-center gap-2 px-4 py-0.5 md:border-none border-b border-border backdrop-blur sticky top-0 z-10">
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
|
||||||
<SidebarTrigger className="size-9" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 justify-end w-full select-none">
|
<div className="flex items-center gap-2 justify-end w-full select-none">
|
||||||
<div className="flex items-center justify-center gap-2 text-sm font-semibold w-full text-center">
|
<div className="flex items-center justify-center gap-2 text-sm font-semibold w-full text-center">
|
||||||
{
|
{
|
||||||
|
|
@ -122,7 +118,6 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
|
||||||
{/* Socket connection status */}
|
{/* Socket connection status */}
|
||||||
<ConnectionStatusIndicator socketStatus={socketStatus} socketInfo={socketInfo} disconnectSocket={disconnectSocket} connectSocket={connectSocket} />
|
<ConnectionStatusIndicator socketStatus={socketStatus} socketInfo={socketInfo} disconnectSocket={disconnectSocket} connectSocket={connectSocket} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-9 md:hidden" /> {/* Spacer for centering on mobile */}
|
|
||||||
</header>
|
</header>
|
||||||
<SidebarInset className="mr-0 mb-0 border-none flex-1 min-h-0 overflow-hidden rounded-l-lg">
|
<SidebarInset className="mr-0 mb-0 border-none flex-1 min-h-0 overflow-hidden rounded-l-lg">
|
||||||
<div className="w-full h-full bg-background border-border border rounded-l-lg rounded-bl-none overflow-hidden min-h-0">
|
<div className="w-full h-full bg-background border-border border rounded-l-lg rounded-bl-none overflow-hidden min-h-0">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useSocketContext } from "@/contexts/socket-context";
|
||||||
import { clearUnread, db, sendMessage } from "@/lib/db";
|
import { clearUnread, db, sendMessage } from "@/lib/db";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { KeyRound } from "lucide-react";
|
import { KeyRound } from "lucide-react";
|
||||||
|
import moment from "moment";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
|
||||||
|
|
@ -222,10 +223,13 @@ export default function DMChannelContent(
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="h-full overflow-y-auto"
|
className="h-full overflow-y-auto flex flex-col"
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<div className="pt-4">
|
{/* Spacer to push messages to the bottom when there are few messages */}
|
||||||
|
<div className="flex-1 min-h-0" />
|
||||||
|
|
||||||
|
<div className="pt-2 md:pt-4">
|
||||||
{/* Load more indicator */}
|
{/* Load more indicator */}
|
||||||
{hasMoreMessages && (
|
{hasMoreMessages && (
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
|
|
@ -244,7 +248,7 @@ export default function DMChannelContent(
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
}, 100);
|
}, 100);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-muted/50"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-muted/50 active:bg-muted/70"
|
||||||
>
|
>
|
||||||
Load more messages
|
Load more messages
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -256,7 +260,8 @@ export default function DMChannelContent(
|
||||||
const selfDetail = participantDetails.find((p) => p.id === userId);
|
const selfDetail = participantDetails.find((p) => p.id === userId);
|
||||||
const isSelf = msg.fromUserId === userId;
|
const isSelf = msg.fromUserId === userId;
|
||||||
const displayName = isSelf ? selfDetail?.displayUsername ?? selfDetail?.username ?? selfDetail?.name ?? "You" : (sender?.displayUsername ?? sender?.username ?? sender?.name ?? "Unknown");
|
const displayName = isSelf ? selfDetail?.displayUsername ?? selfDetail?.username ?? selfDetail?.name ?? "You" : (sender?.displayUsername ?? sender?.username ?? sender?.name ?? "Unknown");
|
||||||
const timeLabel = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
|
const timestamp = moment(msg.timestamp);
|
||||||
|
const timeLabel = timestamp.isSame(moment(), "day") ? timestamp.format("h:mm A") : timestamp.format("MMM D, YYYY h:mm A");
|
||||||
|
|
||||||
// Check if this message is from the same user as the previous one within 5 minutes
|
// Check if this message is from the same user as the previous one within 5 minutes
|
||||||
const prevMsg = index > 0 ? messages[index - 1] : null;
|
const prevMsg = index > 0 ? messages[index - 1] : null;
|
||||||
|
|
@ -268,12 +273,12 @@ export default function DMChannelContent(
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
className="group relative px-4 py-0.5 hover:bg-muted/50 transition-colors duration-100"
|
className="group relative px-2 md:px-4 py-0.5 hover:bg-muted/50 active:bg-muted/50 transition-colors duration-100"
|
||||||
>
|
>
|
||||||
{!isGrouped ? (
|
{!isGrouped ? (
|
||||||
// Full message with avatar and header
|
// Full message with avatar and header
|
||||||
<div className="flex gap-4 mt-[17px]">
|
<div className="flex gap-2 md:gap-4 mt-3 md:mt-[17px]">
|
||||||
<Avatar className="w-10 h-10 shrink-0 mt-0.5">
|
<Avatar className="w-8 h-8 md:w-10 md:h-10 shrink-0 mt-0.5">
|
||||||
<AvatarImage src={sender?.image ?? undefined} alt={displayName} />
|
<AvatarImage src={sender?.image ?? undefined} alt={displayName} />
|
||||||
<AvatarFallback className="text-xs">
|
<AvatarFallback className="text-xs">
|
||||||
{displayName.slice(0, 2).toUpperCase()}
|
{displayName.slice(0, 2).toUpperCase()}
|
||||||
|
|
@ -281,28 +286,30 @@ export default function DMChannelContent(
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 pt-0.5">
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
<div className="flex items-baseline gap-2 leading-snug">
|
<div className="flex items-baseline gap-2 leading-snug flex-wrap">
|
||||||
<span className="font-semibold text-[15px] text-foreground hover:underline cursor-pointer">
|
<span className="font-semibold text-sm md:text-[15px] text-foreground hover:underline cursor-pointer">
|
||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-muted-foreground font-medium">
|
<span className="text-[10px] md:text-[11px] text-muted-foreground font-medium">
|
||||||
{timeLabel}
|
{timeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[15px] leading-[1.375rem] text-foreground mt-0.5 wrap-break-word">
|
<div className="text-sm md:text-[15px] leading-[1.375rem] text-foreground mt-0.5 wrap-break-word">
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Compact message without avatar (grouped)
|
// Compact message without avatar (grouped)
|
||||||
<div className="flex gap-4 leading-[1.375rem]">
|
<div className="flex gap-2 md:gap-4 leading-[1.375rem]">
|
||||||
<div className="w-10 shrink-0 flex items-start justify-end pt-0.5">
|
<div className="w-8 md:w-10 shrink-0 flex items-start justify-end pt-0.5">
|
||||||
<span className="text-[10px] text-transparent group-hover:text-muted-foreground transition-colors duration-100 font-medium">
|
<span className="text-[10px] text-transparent group-hover:text-muted-foreground transition-colors duration-100 font-medium">
|
||||||
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
{
|
||||||
|
timeLabel
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 text-[15px] leading-[1.375rem] text-foreground wrap-break-word">
|
<div className="flex-1 min-w-0 text-sm md:text-[15px] leading-[1.375rem] text-foreground wrap-break-word">
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -317,12 +324,17 @@ export default function DMChannelContent(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message input */}
|
{/* Message input */}
|
||||||
<div className="shrink-0 px-4 pb-6 pt-2">
|
<div className="shrink-0 px-2 md:px-4 pb-4 md:pb-6 pt-2">
|
||||||
<Input
|
<Input
|
||||||
className="h-11 rounded-lg bg-muted border-0 focus-visible:ring-0 focus-visible:ring-offset-0 px-4 text-[15px]"
|
className="h-10 md:h-11 rounded-lg bg-muted border-0 focus-visible:ring-0 focus-visible:ring-offset-0 px-3 md:px-4 text-sm md:text-[15px]"
|
||||||
placeholder={`Message @${otherUser.username ?? otherUser.name}`}
|
placeholder={
|
||||||
|
otherUser.status === "offline" ?
|
||||||
|
"As of now, you cannot message offline users." :
|
||||||
|
`Message @${otherUser.username ?? otherUser.name}`
|
||||||
|
}
|
||||||
value={messageInput}
|
value={messageInput}
|
||||||
onChange={(e) => setMessageInput(e.target.value)}
|
onChange={(e) => setMessageInput(e.target.value)}
|
||||||
|
disabled={otherUser.status === "offline"}
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey && messageInput.trim() && password) {
|
if (e.key === 'Enter' && !e.shiftKey && messageInput.trim() && password) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export function FriendListItem({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-row items-center justify-between w-full p-3 rounded-md hover:bg-accent/50 transition-colors group border border-transparent hover:border-border/40 hover:cursor-pointer"
|
className="flex flex-row items-center justify-between w-full p-2 md:p-3 rounded-md hover:bg-accent/50 active:bg-accent/60 transition-colors group border border-transparent hover:border-border/40 hover:cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Call the db to create or get the dm channel
|
// Call the db to create or get the dm channel
|
||||||
getOrCreateDmChannel(userId, {
|
getOrCreateDmChannel(userId, {
|
||||||
|
|
@ -62,7 +62,7 @@ export function FriendListItem({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left side: Avatar + Info */}
|
{/* Left side: Avatar + Info */}
|
||||||
<div className="flex flex-row items-center gap-3 flex-1 min-w-0">
|
<div className="flex flex-row items-center gap-2 md:gap-3 flex-1 min-w-0">
|
||||||
<UserCard
|
<UserCard
|
||||||
userName={displayName ?? ""}
|
userName={displayName ?? ""}
|
||||||
image={friend.image ?? undefined}
|
image={friend.image ?? undefined}
|
||||||
|
|
@ -79,13 +79,16 @@ export function FriendListItem({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side: Actions Menu */}
|
{/* Right side: Actions Menu - always visible on mobile via opacity, hover on desktop */}
|
||||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
<div className="flex items-center gap-1 md:gap-2 md:opacity-0 md:group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="size-8 hover:bg-background/80"
|
className="size-9 md:size-8 hover:bg-background/80"
|
||||||
onClick={() => onMessage?.(friend._id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onMessage?.(friend._id)
|
||||||
|
}}
|
||||||
title="Message"
|
title="Message"
|
||||||
>
|
>
|
||||||
<MessageCircleIcon className="size-4" />
|
<MessageCircleIcon className="size-4" />
|
||||||
|
|
|
||||||
|
|
@ -77,19 +77,19 @@ export function FriendsPage({
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
{/* Search Input - Sticky at top */}
|
{/* Search Input - Sticky at top */}
|
||||||
<div className="flex flex-col p-4 pb-2 bg-background border-b border-border/40">
|
<div className="flex flex-col p-2 md:p-4 pb-2 bg-background border-b border-border/40">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search for a friend..."
|
placeholder="Search for a friend..."
|
||||||
value={friendsSearch}
|
value={friendsSearch}
|
||||||
onChange={(e) => setFriendsSearch(e.target.value)}
|
onChange={(e) => setFriendsSearch(e.target.value)}
|
||||||
className="w-full"
|
className="w-full h-10 md:h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Friends List */}
|
{/* Scrollable Friends List */}
|
||||||
<div className="flex flex-col flex-1 overflow-y-auto p-4">
|
<div className="flex flex-col flex-1 overflow-y-auto p-2 md:p-4">
|
||||||
<div className="flex flex-col items-start w-full gap-2">
|
<div className="flex flex-col items-start w-full gap-1 md:gap-2">
|
||||||
<span className="text-sm text-start font-medium">
|
<span className="text-xs md:text-sm text-start font-medium text-muted-foreground mb-1">
|
||||||
{friendsPage === "all"
|
{friendsPage === "all"
|
||||||
? `All Friends • ${filteredFriends.length} of ${friends?.length || 0}`
|
? `All Friends • ${filteredFriends.length} of ${friends?.length || 0}`
|
||||||
: `Available Friends • ${filteredFriends.length} of ${friends?.filter((f: FriendData) => f && f.status?.status !== "offline").length || 0}`
|
: `Available Friends • ${filteredFriends.length} of ${friends?.filter((f: FriendData) => f && f.status?.status !== "offline").length || 0}`
|
||||||
|
|
@ -110,8 +110,8 @@ export function FriendsPage({
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center w-full py-12">
|
<div className="flex flex-col items-center justify-center w-full py-8 md:py-12">
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
<span className="text-sm font-medium text-muted-foreground text-center px-4">
|
||||||
{friendsSearch ? `No friends found matching "${friendsSearch}"` : emptyMessage}
|
{friendsSearch ? `No friends found matching "${friendsSearch}"` : emptyMessage}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"
|
||||||
import { clearUnread, db } from "@/lib/db"
|
import { clearUnread, db } from "@/lib/db"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { useLiveQuery } from "dexie-react-hooks"
|
import { useLiveQuery } from "dexie-react-hooks"
|
||||||
import { PlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react"
|
import { MessageSquarePlusIcon, SettingsIcon, Sparkles, UsersIcon, XIcon } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import UserCard from "../user/user-card"
|
import UserCard from "../user/user-card"
|
||||||
|
|
||||||
|
|
@ -23,8 +23,11 @@ export interface ChannelListProps {
|
||||||
displayUsername: string
|
displayUsername: string
|
||||||
image: string
|
image: string
|
||||||
status: "online" | "busy" | "offline" | "away"
|
status: "online" | "busy" | "offline" | "away"
|
||||||
|
isCurrentUser: boolean
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
onChannelSelect?: () => void
|
||||||
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelList({
|
export function ChannelList({
|
||||||
|
|
@ -34,6 +37,8 @@ export function ChannelList({
|
||||||
onPageChange,
|
onPageChange,
|
||||||
emptyMessage = "No messages yet",
|
emptyMessage = "No messages yet",
|
||||||
dmChannel,
|
dmChannel,
|
||||||
|
onChannelSelect,
|
||||||
|
isMobile,
|
||||||
}: ChannelListProps) {
|
}: ChannelListProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
@ -42,141 +47,209 @@ export function ChannelList({
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleNavigation = (path: string) => {
|
||||||
|
router.push(path)
|
||||||
|
onChannelSelect?.()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col shrink-0 max-w-72 min-w-72 border-r border-border/40">
|
<div className={`flex flex-col shrink-0 border-border/40 ${isMobile ? 'w-full h-full bg-transparent' : 'max-w-72 min-w-72 border-r bg-linear-to-b from-background to-muted/20'}`}>
|
||||||
{/* Channel List Header */}
|
{/* Channel List Header - Navigation Items (Desktop only) */}
|
||||||
<div className="flex justify-center items-center min-h-10 max-h-50 bg-background">
|
{!isMobile && (
|
||||||
<div className="flex flex-col justify-start items-start p-1 gap-2 w-full">
|
<>
|
||||||
|
<div className="flex flex-col p-2 gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`w-full justify-start gap-3 h-11 px-3 rounded-lg transition-all ${page === "friends"
|
||||||
|
? "bg-primary/10 text-primary hover:bg-primary/15 ring-1 ring-primary/20"
|
||||||
|
: "hover:bg-accent/60"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
onPageChange("friends")
|
||||||
|
handleNavigation("/")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${page === "friends"
|
||||||
|
? "bg-primary/20"
|
||||||
|
: "bg-muted/50"
|
||||||
|
}`}>
|
||||||
|
<UsersIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold">Friends</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`w-full justify-start gap-3 h-11 px-3 rounded-lg transition-all ${page === "support"
|
||||||
|
? "bg-primary/10 text-primary hover:bg-primary/15 ring-1 ring-primary/20"
|
||||||
|
: "hover:bg-accent/60"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
onPageChange("support")
|
||||||
|
onChannelSelect?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${page === "support"
|
||||||
|
? "bg-primary/20"
|
||||||
|
: "bg-muted/50"
|
||||||
|
}`}>
|
||||||
|
<SettingsIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold">Settings</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider with label */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<div className="h-px flex-1 bg-linear-to-r from-border/60 to-transparent" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Navigation Buttons */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="flex gap-2 px-2 py-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant={page === "friends" ? "default" : "outline"}
|
||||||
className="w-full h-full hover:cursor-pointer justify-start"
|
size="sm"
|
||||||
|
className="flex-1 h-9 text-xs font-semibold"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPageChange("friends")
|
onPageChange("friends")
|
||||||
router.push("/")
|
handleNavigation("/")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UsersIcon className="size-4" />
|
<UsersIcon className="size-3.5 mr-1.5" />
|
||||||
<span className="text-sm font-medium">Friends</span>
|
Friends
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant={page === "support" ? "default" : "outline"}
|
||||||
className="w-full h-full hover:cursor-pointer justify-start"
|
size="sm"
|
||||||
onClick={() => onPageChange("support")}
|
className="flex-1 h-9 text-xs font-semibold"
|
||||||
|
onClick={() => {
|
||||||
|
onPageChange("support")
|
||||||
|
onChannelSelect?.()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="size-4" />
|
<SettingsIcon className="size-3.5 mr-1.5" />
|
||||||
<span className="text-sm font-medium">Settings</span>
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="w-[calc(100%-0.8rem)] h-px bg-border/40 mx-2" />
|
|
||||||
|
|
||||||
{/* Channel List */}
|
{/* Channel List */}
|
||||||
<div className="flex flex-col flex-1 overflow-y-auto">
|
<div className={`flex flex-col flex-1 overflow-y-auto ${isMobile ? 'px-2' : 'px-2'}`}>
|
||||||
{page === "friends" || !currentChannel ? (
|
{page === "friends" || !currentChannel ? (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
<div className="flex items-center w-full justify-between p-2 select-none">
|
{/* DM Header */}
|
||||||
<span className="text-xs font-semibold text-muted-foreground">
|
<div className="flex items-center justify-between px-1 py-2 select-none">
|
||||||
|
<span className={`font-bold uppercase tracking-wider text-muted-foreground/70 ${isMobile ? 'text-[10px]' : 'text-[11px]'}`}>
|
||||||
Direct Messages
|
Direct Messages
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="hover:cursor-pointer hover:bg-transparent!"
|
className="size-6 hover:bg-accent rounded-md"
|
||||||
|
title="New Message"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />
|
<MessageSquarePlusIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{openDmChannels.length > 0 ? (
|
{openDmChannels.length > 0 ? (
|
||||||
openDmChannels.map((channel) => {
|
<div className="flex flex-col gap-0.5">
|
||||||
const isActive = dmChannel?.id === channel.id
|
{openDmChannels.map((channel) => {
|
||||||
const lastMessage = channel.times?.lastMessage
|
const participantDetails = dmChannel?.participantDetails.find((p) => p.id === channel.participants[0])
|
||||||
const lastMessageTime = channel.times?.lastMessageAt
|
const isActive = dmChannel?.id === channel.id
|
||||||
const channelUnreadCount = unreadCount?.find((unread) => unread.channelId === channel.id)?.count ?? 0
|
const lastMessage = channel.times?.lastMessage
|
||||||
if (!channel.isOpen) return null;
|
const lastMessageTime = channel.times?.lastMessageAt
|
||||||
|
const channelUnreadCount = unreadCount?.find((unread) => unread.channelId === channel.id)?.count ?? 0
|
||||||
|
if (!channel.isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
className={`flex flex-row items-center gap-3 px-2 py-1.5 mx-2 mb-0.5 rounded-md transition-all cursor-pointer group ${isActive
|
className={`flex flex-row items-center gap-3 px-2 py-2.5 rounded-lg transition-all cursor-pointer group ${isActive
|
||||||
? "bg-accent/60"
|
? "bg-accent/80 shadow-sm ring-1 ring-accent"
|
||||||
: "hover:bg-accent/40"
|
: "hover:bg-accent/40 active:bg-accent/60"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearUnread(channel.id)
|
clearUnread(channel.id)
|
||||||
console.log("Cleared unread count for channel", channel.id)
|
console.log("Cleared unread count for channel", channel.id)
|
||||||
router.push(`/channels/me/${channel.id}`)
|
handleNavigation(`/channels/me/${channel.id}`)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<UserCard
|
<UserCard
|
||||||
userName={channel.name}
|
userName={channel.name}
|
||||||
image={channel.metadata?.icon ?? undefined}
|
image={channel.metadata?.icon ?? undefined}
|
||||||
status={"none"}
|
status={"none"}
|
||||||
/>
|
/>
|
||||||
{channelUnreadCount > 0 && (
|
{channelUnreadCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full bg-red-500 text-[10px] font-bold text-white shadow-sm">
|
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full bg-linear-to-br from-red-500 to-red-600 text-[10px] font-bold text-white shadow-md ring-2 ring-background">
|
||||||
{channelUnreadCount > 99 ? '99+' : channelUnreadCount}
|
{channelUnreadCount > 99 ? '99+' : channelUnreadCount}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Channel Info */}
|
|
||||||
<div className="flex flex-col justify-center flex-1 min-w-0 overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-sm font-semibold truncate text-foreground">
|
|
||||||
{channel.name}
|
|
||||||
</span>
|
|
||||||
{lastMessageTime && (
|
|
||||||
<span className="text-[10px] text-muted-foreground/70 shrink-0">
|
|
||||||
{formatDistanceToNow(lastMessageTime, { addSuffix: false })}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{lastMessage && (
|
|
||||||
<span className="text-xs text-muted-foreground/80 truncate">
|
{/* Channel Info */}
|
||||||
{lastMessage.content}
|
<div className="flex flex-col justify-center flex-1 min-w-0 overflow-hidden">
|
||||||
</span>
|
<div className="flex items-center justify-between gap-2">
|
||||||
)}
|
<span className={`text-sm truncate ${isActive ? 'font-bold' : 'font-semibold'} text-foreground`}>
|
||||||
|
{channel.name}
|
||||||
|
</span>
|
||||||
|
{lastMessageTime && (
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 shrink-0 font-medium">
|
||||||
|
{formatDistanceToNow(lastMessageTime, { addSuffix: false })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{lastMessage && (
|
||||||
|
<span className="text-xs text-muted-foreground/70 truncate mt-0.5">
|
||||||
|
{lastMessage.content}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close button - always visible on mobile, hover-visible on desktop */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`size-7 p-0 shrink-0 hover:bg-destructive/10 hover:text-destructive rounded-md transition-all ${isMobile ? 'opacity-60' : 'opacity-0 group-hover:opacity-100'}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const isCurrentlyViewing = isActive
|
||||||
|
|
||||||
|
db.channels.where("id").equals(channel.id).modify((channel) => {
|
||||||
|
channel.isOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate away if we're closing the currently viewed channel
|
||||||
|
if (isCurrentlyViewing) {
|
||||||
|
console.log("Navigating away from channel")
|
||||||
|
handleNavigation("/")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Close DM"
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
{/* Close button */}
|
})}
|
||||||
<Button
|
</div>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-5 p-0 shrink-0 opacity-0 group-hover:opacity-100 hover:bg-background/80 transition-opacity"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
const isCurrentlyViewing = isActive
|
|
||||||
|
|
||||||
db.channels.where("id").equals(channel.id).modify((channel) => {
|
|
||||||
channel.isOpen = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate away if we're closing the currently viewed channel
|
|
||||||
if (isCurrentlyViewing) {
|
|
||||||
console.log("Navigating away from channel")
|
|
||||||
router.push("/")
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Close DM"
|
|
||||||
>
|
|
||||||
<XIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center min-h-10 max-h-10 p-2">
|
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||||
<span className="text-xs font-medium text-muted-foreground text-center text-wrap">
|
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-muted/50 mb-3">
|
||||||
|
<Sparkles className="size-5 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground/70 leading-relaxed">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center min-h-10 max-h-10">
|
<div className="flex items-center justify-center py-8">
|
||||||
<span className="text-sm font-medium">No channels</span>
|
<span className="text-sm font-medium text-muted-foreground">No channels</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import FriendRequestModal from "@/components/home/modals/friendRequest"
|
import FriendRequestModal from "@/components/home/modals/friendRequest"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import LogoIcon from "@/components/ui/logo-icon"
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { db } from "@/lib/db"
|
import { db } from "@/lib/db"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CompassIcon } from "@phosphor-icons/react"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
import { useLiveQuery } from "dexie-react-hooks"
|
import { useLiveQuery } from "dexie-react-hooks"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useEffect, useMemo } from "react"
|
import { useEffect, useMemo } from "react"
|
||||||
import { api } from "../../../../convex/_generated/api"
|
import { api } from "../../../../convex/_generated/api"
|
||||||
|
|
@ -39,6 +46,8 @@ export function MainContentLayout({
|
||||||
const [friendsPage, setFriendsPage] = React.useState<"all" | "available">("all")
|
const [friendsPage, setFriendsPage] = React.useState<"all" | "available">("all")
|
||||||
const [friendModal, setFriendModal] = React.useState(false)
|
const [friendModal, setFriendModal] = React.useState(false)
|
||||||
const [currentChannel] = React.useState<SiPher.Channel | null>(null)
|
const [currentChannel] = React.useState<SiPher.Channel | null>(null)
|
||||||
|
const [mobileChannelListOpen, setMobileChannelListOpen] = React.useState(false)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
// Use useLiveQuery to reactively fetch channels - automatically updates when DB changes
|
// Use useLiveQuery to reactively fetch channels - automatically updates when DB changes
|
||||||
const openDmChannels = useLiveQuery(
|
const openDmChannels = useLiveQuery(
|
||||||
|
|
@ -70,6 +79,7 @@ export function MainContentLayout({
|
||||||
displayUsername: participant.displayUsername ?? "",
|
displayUsername: participant.displayUsername ?? "",
|
||||||
image: participant.image ?? "",
|
image: participant.image ?? "",
|
||||||
status: participant.status,
|
status: participant.status,
|
||||||
|
isCurrentUser: participant.id === userId,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}, [openDmChannels, dmChannelId, getParticipantDetails])
|
}, [openDmChannels, dmChannelId, getParticipantDetails])
|
||||||
|
|
@ -85,6 +95,34 @@ export function MainContentLayout({
|
||||||
}
|
}
|
||||||
}, [dmChannelId, serverChannelId]);
|
}, [dmChannelId, serverChannelId]);
|
||||||
|
|
||||||
|
// Close mobile channel list when navigating to a channel
|
||||||
|
const handlePageChange = React.useCallback((newPage: "friends" | "support" | "dm" | "server") => {
|
||||||
|
setPage(newPage);
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileChannelListOpen(false);
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
// Close mobile sheet when a DM channel is selected
|
||||||
|
const handleMobileChannelSelect = React.useCallback(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileChannelListOpen(false);
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
const channelListContent = (
|
||||||
|
<ChannelList
|
||||||
|
currentChannel={currentChannel}
|
||||||
|
openDmChannels={openDmChannels}
|
||||||
|
page={page}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
emptyMessage={emptyChannelMessage}
|
||||||
|
dmChannel={dmChannel}
|
||||||
|
onChannelSelect={handleMobileChannelSelect}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
|
@ -98,19 +136,70 @@ export function MainContentLayout({
|
||||||
dmChannel={dmChannel}
|
dmChannel={dmChannel}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
serverChannelId={serverChannelId}
|
serverChannelId={serverChannelId}
|
||||||
|
onToggleMobileChannelList={() => setMobileChannelListOpen(true)}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Area - Channel List + Main Content */}
|
{/* Content Area - Channel List + Main Content */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Desktop: Always visible channel list */}
|
||||||
|
<div className="hidden md:flex">
|
||||||
|
{channelListContent}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ChannelList
|
{/* Mobile: Sheet-based channel list - Discord-style two-panel layout */}
|
||||||
currentChannel={currentChannel}
|
{isMobile && (
|
||||||
openDmChannels={openDmChannels}
|
<Sheet open={mobileChannelListOpen} onOpenChange={setMobileChannelListOpen}>
|
||||||
page={page}
|
<SheetContent side="left" className="w-[calc(100%-3rem)] max-w-[340px] p-0 [&>button]:hidden">
|
||||||
onPageChange={setPage}
|
<SheetHeader className="sr-only">
|
||||||
emptyMessage={emptyChannelMessage}
|
<SheetTitle>Channels</SheetTitle>
|
||||||
dmChannel={dmChannel}
|
<SheetDescription>Navigate between channels and DMs</SheetDescription>
|
||||||
/>
|
</SheetHeader>
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Left Rail - Server/Home Icons (Discord-style) */}
|
||||||
|
<div className="flex flex-col items-center w-[72px] shrink-0 bg-muted/50 py-3 gap-2">
|
||||||
|
{/* Home/DMs Button */}
|
||||||
|
<MobileServerIcon
|
||||||
|
isActive={true}
|
||||||
|
isHome
|
||||||
|
label="Direct Messages"
|
||||||
|
>
|
||||||
|
<LogoIcon className="size-6" />
|
||||||
|
</MobileServerIcon>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="w-8 h-0.5 rounded-full bg-border/60 my-1" />
|
||||||
|
|
||||||
|
{/* Discover */}
|
||||||
|
<MobileServerIcon label="Discover">
|
||||||
|
<CompassIcon className="size-5" weight="fill" />
|
||||||
|
</MobileServerIcon>
|
||||||
|
|
||||||
|
{/* Future: Server icons will go here */}
|
||||||
|
{/* Placeholder for servers */}
|
||||||
|
|
||||||
|
{/* Add Server Button */}
|
||||||
|
<MobileServerIcon label="Add a Server" isAddButton>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
</MobileServerIcon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Channel List */}
|
||||||
|
<div className="flex-1 flex flex-col bg-background min-w-0 border-l border-border/30">
|
||||||
|
{/* Panel Header */}
|
||||||
|
<div className="flex items-center px-4 h-12 shrink-0 border-b border-border/30">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Direct Messages</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel List Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{channelListContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
|
|
@ -152,3 +241,54 @@ export function MainContentLayout({
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discord-style mobile server icon component
|
||||||
|
function MobileServerIcon({
|
||||||
|
children,
|
||||||
|
isActive,
|
||||||
|
isHome,
|
||||||
|
isAddButton,
|
||||||
|
label,
|
||||||
|
onClick
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
isActive?: boolean
|
||||||
|
isHome?: boolean
|
||||||
|
isAddButton?: boolean
|
||||||
|
label?: string
|
||||||
|
onClick?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center w-full group">
|
||||||
|
{/* Left pill indicator */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 w-1 bg-foreground rounded-r-full transition-all duration-200",
|
||||||
|
isActive ? "h-9" : "h-0 group-active:h-5"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Icon button */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center justify-center size-12 transition-all duration-200 overflow-hidden",
|
||||||
|
isHome && isActive
|
||||||
|
? "bg-primary text-primary-foreground rounded-2xl"
|
||||||
|
: isHome
|
||||||
|
? "bg-primary/80 text-primary-foreground rounded-[24px] active:rounded-2xl"
|
||||||
|
: isAddButton
|
||||||
|
? "bg-muted text-emerald-500 rounded-[24px] active:rounded-2xl active:bg-emerald-500 active:text-white"
|
||||||
|
: isActive
|
||||||
|
? "bg-primary text-primary-foreground rounded-2xl"
|
||||||
|
: "bg-muted text-muted-foreground rounded-[24px] active:rounded-2xl active:bg-primary active:text-primary-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { PhoneIcon, SearchIcon, UserIcon, UsersIcon, VideoIcon } from "lucide-react"
|
import { MenuIcon, PhoneIcon, SearchIcon, UserIcon, UserPlusIcon, UsersIcon, VideoIcon } from "lucide-react"
|
||||||
import UserCard from "../user/user-card"
|
import UserCard from "../user/user-card"
|
||||||
|
|
||||||
export interface PageHeaderProps {
|
export interface PageHeaderProps {
|
||||||
|
|
@ -19,10 +19,13 @@ export interface PageHeaderProps {
|
||||||
displayUsername: string
|
displayUsername: string
|
||||||
image: string
|
image: string
|
||||||
status: "online" | "busy" | "offline" | "away"
|
status: "online" | "busy" | "offline" | "away"
|
||||||
|
isCurrentUser: boolean
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
serverId?: string
|
serverId?: string
|
||||||
serverChannelId?: string
|
serverChannelId?: string
|
||||||
|
onToggleMobileChannelList?: () => void
|
||||||
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHeader({
|
export function PageHeader({
|
||||||
|
|
@ -34,11 +37,28 @@ export function PageHeader({
|
||||||
dmChannel,
|
dmChannel,
|
||||||
serverId,
|
serverId,
|
||||||
serverChannelId,
|
serverChannelId,
|
||||||
|
onToggleMobileChannelList,
|
||||||
|
isMobile,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
|
|
||||||
|
const otherParticipant = dmChannel && dmChannel.participantDetails.find((p) => !p.isCurrentUser)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center min-h-10 max-h-10 border-b border-border/40 sticky top-0 z-10 bg-background">
|
<div className="flex items-center min-h-12 md:min-h-10 max-h-12 md:max-h-10 border-b border-border/40 sticky top-0 z-10 bg-background">
|
||||||
{/* SCS or DM Selector */}
|
{/* Mobile: Menu toggle button */}
|
||||||
<div className="flex justify-center items-center gap-2 max-w-72 min-w-72 border-r h-10 border-border/40">
|
{isMobile && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 shrink-0 ml-1"
|
||||||
|
onClick={onToggleMobileChannelList}
|
||||||
|
>
|
||||||
|
<MenuIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop: SCS or DM Selector */}
|
||||||
|
<div className="hidden md:flex justify-center items-center gap-2 max-w-72 min-w-72 border-r h-10 border-border/40">
|
||||||
{!currentChannel || currentChannel.type === "DM" ? (
|
{!currentChannel || currentChannel.type === "DM" ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -53,16 +73,16 @@ export function PageHeader({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page title/options */}
|
{/* Page title/options */}
|
||||||
{dmChannel ? (
|
{dmChannel && otherParticipant ? (
|
||||||
<div className="flex flex-row justify-start items-center gap-2 w-full px-4">
|
<div className="flex flex-row justify-start items-center gap-2 w-full px-2 md:px-4">
|
||||||
<UserCard
|
<UserCard
|
||||||
userName={dmChannel.participantDetails[0].name}
|
userName={otherParticipant.name}
|
||||||
image={dmChannel.participantDetails[0].image}
|
image={otherParticipant.image}
|
||||||
status={dmChannel.participantDetails[0].status}
|
status={otherParticipant.status}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">{dmChannel.participantDetails[0].name}</span>
|
<span className="text-sm font-medium truncate">{otherParticipant.name}</span>
|
||||||
<div className="flex flex-row gap-2 ml-auto">
|
<div className="flex flex-row gap-1 md:gap-2 ml-auto shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -80,48 +100,51 @@ export function PageHeader({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8 hidden sm:flex"
|
||||||
>
|
>
|
||||||
<UserIcon className="size-4" />
|
<UserIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : serverChannelId ? (
|
) : serverChannelId ? (
|
||||||
<div className="flex flex-row justify-start items-center gap-2 w-full px-4">
|
<div className="flex flex-row justify-start items-center gap-2 w-full px-2 md:px-4">
|
||||||
<span className="text-sm font-medium">#{serverChannelId}</span>
|
<span className="text-sm font-medium">#{serverChannelId}</span>
|
||||||
</div>
|
</div>
|
||||||
) : page === "friends" ? (
|
) : page === "friends" ? (
|
||||||
<div className="flex flex-row justify-start items-center gap-2 w-full">
|
<div className="flex flex-row justify-start items-center gap-1 md:gap-2 w-full overflow-x-auto">
|
||||||
<div className="flex flex-row gap-2 justify-start p-2">
|
<div className="flex flex-row gap-2 justify-start p-2 shrink-0">
|
||||||
<UsersIcon className="size-4" />
|
<UsersIcon className="size-4" />
|
||||||
<span className="text-sm font-medium">Friends</span>
|
<span className="text-sm font-medium hidden sm:inline">Friends</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">•</span>
|
<span className="text-sm font-medium hidden sm:inline">•</span>
|
||||||
<div className="flex flex-row gap-2 h-full">
|
<div className="flex flex-row gap-1 md:gap-2 h-full">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={friendsPage === "available"}
|
disabled={friendsPage === "available"}
|
||||||
className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "available" ? "bg-primary text-primary-foreground" : ""
|
className={`h-full hover:cursor-pointer justify-start px-2 md:p-2 text-xs md:text-sm ${friendsPage === "available" ? "bg-primary text-primary-foreground" : ""
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onFriendsPageChange?.("available")}
|
onClick={() => onFriendsPageChange?.("available")}
|
||||||
>
|
>
|
||||||
Available
|
<span className="hidden sm:inline">Available</span>
|
||||||
|
<span className="sm:hidden">Online</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={friendsPage === "all"}
|
disabled={friendsPage === "all"}
|
||||||
className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "all" ? "bg-primary text-primary-foreground" : ""
|
className={`h-full hover:cursor-pointer justify-start px-2 md:p-2 text-xs md:text-sm ${friendsPage === "all" ? "bg-primary text-primary-foreground" : ""
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onFriendsPageChange?.("all")}
|
onClick={() => onFriendsPageChange?.("all")}
|
||||||
>
|
>
|
||||||
All Known
|
<span className="hidden sm:inline">All Known</span>
|
||||||
|
<span className="sm:hidden">All</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-full bg-primary text-primary-foreground hover:cursor-pointer justify-start p-2"
|
className="h-full bg-primary text-primary-foreground hover:cursor-pointer justify-start px-2 md:p-2 text-xs md:text-sm"
|
||||||
onClick={onAddFriend}
|
onClick={onAddFriend}
|
||||||
>
|
>
|
||||||
Add Friend
|
<UserPlusIcon className="size-4 sm:hidden" />
|
||||||
|
<span className="hidden sm:inline">Add Friend</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export interface SettingsPageProps {
|
||||||
|
|
||||||
export function SettingsPage({}: SettingsPageProps) {
|
export function SettingsPage({}: SettingsPageProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-y-auto p-4">
|
<div className="flex flex-col flex-1 overflow-y-auto p-2 md:p-4">
|
||||||
<div className="flex items-center min-h-10 max-h-10">
|
<div className="flex items-center min-h-10 max-h-10">
|
||||||
<span className="text-sm font-medium">Servers</span>
|
<span className="text-sm font-medium">Servers</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue