608 lines
17 KiB
TypeScript
608 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Bell,
|
|
Check,
|
|
ChevronsUpDown,
|
|
Hash,
|
|
Headphones,
|
|
Lock,
|
|
Mic,
|
|
MoreHorizontal,
|
|
PencilLine,
|
|
Pin,
|
|
Plus,
|
|
Settings,
|
|
Trash2,
|
|
Volume2,
|
|
} from "lucide-react";
|
|
import * as React from "react";
|
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Breadcrumb,
|
|
BreadcrumbItem,
|
|
BreadcrumbList,
|
|
BreadcrumbPage,
|
|
} from "@/components/ui/breadcrumb";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarInset,
|
|
SidebarMenu,
|
|
SidebarMenuAction,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarProvider,
|
|
SidebarTrigger,
|
|
} from "@/components/ui/sidebar";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// Types
|
|
type TextChannel = {
|
|
id: string;
|
|
name: string;
|
|
type: "text";
|
|
locked: boolean;
|
|
unread: number;
|
|
isActive?: boolean;
|
|
};
|
|
|
|
type VoiceChannel = {
|
|
id: string;
|
|
name: string;
|
|
type: "voice";
|
|
users: string[];
|
|
};
|
|
|
|
type Channel = TextChannel | VoiceChannel;
|
|
|
|
type ChannelCategory = {
|
|
id: string;
|
|
name: string;
|
|
isOpen: boolean;
|
|
channels: Channel[];
|
|
};
|
|
|
|
type Member = {
|
|
id: string;
|
|
name: string;
|
|
avatar: string;
|
|
role: string;
|
|
status: "online" | "idle" | "dnd" | "offline";
|
|
};
|
|
|
|
type Workspace = {
|
|
id: string;
|
|
name: string;
|
|
logo: string;
|
|
plan: string;
|
|
};
|
|
|
|
type UserData = {
|
|
name: string;
|
|
email: string;
|
|
avatar: string;
|
|
};
|
|
|
|
// Data
|
|
const workspaces: Workspace[] = [
|
|
{
|
|
id: "1",
|
|
name: "ShadcnBlocks",
|
|
logo: "/block-media/shadcnblocks/block/logos/ShadcnBlocks-logo.svg",
|
|
plan: "سازمانی",
|
|
},
|
|
{
|
|
id: "2",
|
|
name: "قالبهای پشتیبانییار",
|
|
logo: "/block-media/shadcnblocks/block/logos/ShadcnBlocks-logo.svg",
|
|
plan: "تیم کوچک",
|
|
},
|
|
{
|
|
id: "3",
|
|
name: "میزهای خدمت",
|
|
logo: "/block-media/shadcnblocks/block/logos/ShadcnBlocks-logo.svg",
|
|
plan: "آزمایشی",
|
|
},
|
|
];
|
|
|
|
const currentUser: UserData = {
|
|
name: "علی رضایی",
|
|
email: "ali@example.com",
|
|
avatar:
|
|
"/block-media/shadcnblocks/block/avatar-2.webp",
|
|
};
|
|
|
|
const channelCategories: ChannelCategory[] = [
|
|
{
|
|
id: "cat1",
|
|
name: "اطلاعیهها",
|
|
isOpen: true,
|
|
channels: [
|
|
{ id: "1", name: "اعلانها", type: "text", locked: true, unread: 0 },
|
|
{ id: "2", name: "SLA و قوانین", type: "text", locked: true, unread: 0 },
|
|
{ id: "3", name: "خوشآمدگویی", type: "text", locked: false, unread: 0 },
|
|
],
|
|
},
|
|
{
|
|
id: "cat2",
|
|
name: "عمومی",
|
|
isOpen: true,
|
|
channels: [
|
|
{
|
|
id: "4",
|
|
name: "عمومی",
|
|
type: "text",
|
|
locked: false,
|
|
unread: 12,
|
|
isActive: true,
|
|
},
|
|
{ id: "5", name: "گفتوگوی آزاد", type: "text", locked: false, unread: 3 },
|
|
{
|
|
id: "6",
|
|
name: "معرفی اعضا",
|
|
type: "text",
|
|
locked: false,
|
|
unread: 0,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "cat3",
|
|
name: "محصول",
|
|
isOpen: true,
|
|
channels: [
|
|
{ id: "7", name: "ورود مشتری", type: "text", locked: false, unread: 5 },
|
|
{ id: "8", name: "صورتحساب", type: "text", locked: false, unread: 0 },
|
|
{ id: "9", name: "زیرساخت", type: "text", locked: false, unread: 1 },
|
|
{ id: "10", name: "بازبینی کیفیت", type: "text", locked: false, unread: 0 },
|
|
],
|
|
},
|
|
{
|
|
id: "cat4",
|
|
name: "تماسهای پشتیبانی",
|
|
isOpen: true,
|
|
channels: [
|
|
{
|
|
id: "v1",
|
|
name: "خط عمومی",
|
|
type: "voice",
|
|
users: ["سارا", "میکاییل", "آرش"],
|
|
},
|
|
{ id: "v2", name: "اتاق هماهنگی", type: "voice", users: [] },
|
|
{ id: "v3", name: "همکاری دونفره", type: "voice", users: ["نیلوفر"] },
|
|
],
|
|
},
|
|
];
|
|
|
|
const members: Member[] = [
|
|
{
|
|
id: "1",
|
|
name: "سارا احمدی",
|
|
avatar:
|
|
"/block-media/shadcnblocks/block/avatar-1.webp",
|
|
role: "مدیر",
|
|
status: "online",
|
|
},
|
|
{
|
|
id: "2",
|
|
name: "میکاییل رضایی",
|
|
avatar:
|
|
"/block-media/shadcnblocks/block/avatar-3.webp",
|
|
role: "ناظر",
|
|
status: "online",
|
|
},
|
|
{
|
|
id: "3",
|
|
name: "نیلوفر حسینی",
|
|
avatar:
|
|
"/block-media/shadcnblocks/block/avatar-3.webp",
|
|
role: "عضو",
|
|
status: "idle",
|
|
},
|
|
{
|
|
id: "4",
|
|
name: "آرش کیانی",
|
|
avatar:
|
|
"/block-media/shadcnblocks/block/avatar-5.webp",
|
|
role: "عضو",
|
|
status: "dnd",
|
|
},
|
|
{
|
|
id: "5",
|
|
name: "کاوه ویلسون",
|
|
avatar:
|
|
"/block-media/shadcnblocks/block/avatar-4.webp",
|
|
role: "عضو",
|
|
status: "offline",
|
|
},
|
|
{
|
|
id: "6",
|
|
name: "جواد لطفی",
|
|
avatar:
|
|
"/block-media/shadcnblocks/block/avatar-2.webp",
|
|
role: "عضو",
|
|
status: "offline",
|
|
},
|
|
];
|
|
|
|
// Helper
|
|
const getInitials = (name: string) =>
|
|
name
|
|
.split(" ")
|
|
.map((n) => n[0])
|
|
.join("");
|
|
|
|
const statusColors: Record<Member["status"], string> = {
|
|
online: "bg-green-500",
|
|
idle: "bg-yellow-500",
|
|
dnd: "bg-red-500",
|
|
offline: "bg-gray-400",
|
|
};
|
|
|
|
// Components
|
|
const WorkspaceSwitcher = () => {
|
|
const [selected, setSelected] = React.useState(workspaces[0]);
|
|
|
|
return (
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<SidebarMenuButton
|
|
size="lg"
|
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
>
|
|
<div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-primary">
|
|
<img
|
|
src={selected.logo}
|
|
alt={selected.name}
|
|
className="size-6 invert dark:invert-0"
|
|
/>
|
|
</div>
|
|
<div className="grid flex-1 text-start text-sm leading-tight">
|
|
<span className="truncate font-medium">{selected.name}</span>
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
{selected.plan}
|
|
</span>
|
|
</div>
|
|
<ChevronsUpDown className="ms-auto size-4" />
|
|
</SidebarMenuButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
|
align="start"
|
|
sideOffset={4}
|
|
>
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
فضاهای کاری
|
|
</DropdownMenuLabel>
|
|
{workspaces.map((workspace) => (
|
|
<DropdownMenuItem
|
|
key={workspace.id}
|
|
onClick={() => setSelected(workspace)}
|
|
className="gap-2 p-2"
|
|
>
|
|
<div className="flex size-6 items-center justify-center rounded-sm bg-primary">
|
|
<img
|
|
src={workspace.logo}
|
|
alt={workspace.name}
|
|
className="size-4 invert dark:invert-0"
|
|
/>
|
|
</div>
|
|
<span>{workspace.name}</span>
|
|
{workspace.id === selected.id && (
|
|
<Check className="ms-auto size-4" />
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
);
|
|
};
|
|
|
|
const ChannelItem = ({ channel }: { channel: Channel }) => {
|
|
const isText = channel.type === "text";
|
|
const isVoice = channel.type === "voice";
|
|
|
|
return (
|
|
<SidebarMenuItem className="group/item">
|
|
<SidebarMenuButton isActive={isText && channel.isActive} className="ps-8">
|
|
{isVoice ? (
|
|
<Volume2 className="size-4 text-muted-foreground" />
|
|
) : channel.locked ? (
|
|
<Lock className="size-4 text-muted-foreground" />
|
|
) : (
|
|
<Hash className="size-4 text-muted-foreground" />
|
|
)}
|
|
<span
|
|
className={cn(
|
|
isText && channel.unread > 0 && "font-semibold text-foreground",
|
|
)}
|
|
>
|
|
{channel.name}
|
|
</span>
|
|
{isText && channel.unread > 0 && (
|
|
<Badge
|
|
variant="default"
|
|
className="ms-auto size-5 justify-center rounded-full p-0 text-[10px]"
|
|
>
|
|
{channel.unread}
|
|
</Badge>
|
|
)}
|
|
{isVoice && channel.users.length > 0 && (
|
|
<span className="ms-auto text-xs text-muted-foreground">
|
|
{channel.users.length}
|
|
</span>
|
|
)}
|
|
</SidebarMenuButton>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<SidebarMenuAction className="opacity-0 group-hover/item:opacity-100">
|
|
<MoreHorizontal className="size-3.5" />
|
|
</SidebarMenuAction>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="left" align="start">
|
|
<DropdownMenuItem>
|
|
<Bell className="me-2 size-4" />
|
|
بیصدا کردن کانال
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<Pin className="me-2 size-4" />
|
|
سنجاق کانال
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<PencilLine className="me-2 size-4" />
|
|
ویرایش کانال
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-destructive">
|
|
<Trash2 className="me-2 size-4" />
|
|
حذف کانال
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{isVoice && channel.users.length > 0 && (
|
|
<div className="mt-1 ms-6 space-y-1">
|
|
{channel.users.map((userName) => (
|
|
<div
|
|
key={userName}
|
|
className="flex items-center gap-2 py-0.5 text-xs text-muted-foreground"
|
|
>
|
|
<div className="size-4 rounded-full bg-muted" />
|
|
<span>{userName}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SidebarMenuItem>
|
|
);
|
|
};
|
|
|
|
const ChannelCategoryGroup = ({ category }: { category: ChannelCategory }) => {
|
|
const [isOpen, setIsOpen] = React.useState(category.isOpen);
|
|
|
|
return (
|
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
<SidebarGroup className="py-0">
|
|
<SidebarGroupLabel asChild className="group/label">
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
|
<span className="truncate text-xs font-semibold tracking-wider uppercase">
|
|
{category.name}
|
|
</span>
|
|
<Plus className="size-3.5 opacity-0 transition-opacity group-hover/label:opacity-100" />
|
|
</CollapsibleTrigger>
|
|
</SidebarGroupLabel>
|
|
<CollapsibleContent>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{category.channels.map((channel) => (
|
|
<ChannelItem key={channel.id} channel={channel} />
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</CollapsibleContent>
|
|
</SidebarGroup>
|
|
</Collapsible>
|
|
);
|
|
};
|
|
|
|
const MemberItem = ({
|
|
member,
|
|
showStatus = true,
|
|
}: {
|
|
member: Member;
|
|
showStatus?: boolean;
|
|
}) => (
|
|
<button
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted",
|
|
member.status === "offline" && "opacity-50 hover:opacity-100",
|
|
)}
|
|
>
|
|
<div className="relative">
|
|
<Avatar className="size-8">
|
|
<AvatarImage src={member.avatar} alt={member.name} />
|
|
<AvatarFallback className="text-xs">
|
|
{getInitials(member.name)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
{showStatus && member.status !== "offline" && (
|
|
<span
|
|
className={cn(
|
|
"absolute -start-0.5 -bottom-0.5 size-3 rounded-full border-2 border-background",
|
|
statusColors[member.status],
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 text-start">
|
|
<p className="text-sm font-medium">{member.name}</p>
|
|
<p className="text-xs text-muted-foreground">{member.role}</p>
|
|
</div>
|
|
</button>
|
|
);
|
|
|
|
const MembersList = () => {
|
|
const online = members.filter((m) => m.status !== "offline");
|
|
const offline = members.filter((m) => m.status === "offline");
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 p-2">
|
|
<div>
|
|
<p className="mb-2 px-2 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
|
|
آنلاین {online.length}
|
|
</p>
|
|
<div className="space-y-0.5">
|
|
{online.map((member) => (
|
|
<MemberItem key={member.id} member={member} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="mb-2 px-2 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
|
|
آفلاین {offline.length}
|
|
</p>
|
|
<div className="space-y-0.5">
|
|
{offline.map((member) => (
|
|
<MemberItem key={member.id} member={member} showStatus={false} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const NavUser = ({ user }: { user: UserData }) => (
|
|
<div className="flex items-center gap-2 rounded-md bg-sidebar-accent/50 p-2">
|
|
<Avatar className="size-8">
|
|
<AvatarImage src={user.avatar} alt={user.name} />
|
|
<AvatarFallback>{getInitials(user.name)}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 truncate">
|
|
<p className="truncate text-sm font-medium">{user.name}</p>
|
|
<p className="truncate text-xs text-muted-foreground">آنلاین</p>
|
|
</div>
|
|
<TooltipProvider delayDuration={0}>
|
|
<div className="flex gap-0.5">
|
|
{[
|
|
{ icon: Mic, label: "بیصدا" },
|
|
{ icon: Headphones, label: "قطع صدا" },
|
|
{ icon: Settings, label: "تنظیمات" },
|
|
].map(({ icon: Icon, label }) => (
|
|
<Tooltip key={label}>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="size-8">
|
|
<Icon className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{label}</TooltipContent>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
</TooltipProvider>
|
|
</div>
|
|
);
|
|
|
|
const SidebarLeft = (props: React.ComponentProps<typeof Sidebar>) => (
|
|
<Sidebar side="right" {...props}>
|
|
<SidebarHeader>
|
|
<WorkspaceSwitcher />
|
|
</SidebarHeader>
|
|
<SidebarContent className="overflow-hidden">
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
{channelCategories.map((category) => (
|
|
<ChannelCategoryGroup key={category.id} category={category} />
|
|
))}
|
|
</ScrollArea>
|
|
</SidebarContent>
|
|
<SidebarFooter>
|
|
<NavUser user={currentUser} />
|
|
</SidebarFooter>
|
|
</Sidebar>
|
|
);
|
|
|
|
const SidebarRight = (props: React.ComponentProps<typeof Sidebar>) => (
|
|
<Sidebar
|
|
side="left"
|
|
collapsible="none"
|
|
className="sticky top-0 hidden h-svh w-60 border-s lg:flex"
|
|
{...props}
|
|
>
|
|
<SidebarHeader className="h-16 justify-center border-b">
|
|
<div className="px-2">
|
|
<span className="font-semibold">اعضا</span>
|
|
</div>
|
|
</SidebarHeader>
|
|
<SidebarContent className="overflow-hidden">
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<MembersList />
|
|
</ScrollArea>
|
|
</SidebarContent>
|
|
</Sidebar>
|
|
);
|
|
|
|
interface Sidebar19Props {
|
|
className?: string;
|
|
}
|
|
|
|
const Sidebar19 = ({ className }: Sidebar19Props) => (
|
|
<SidebarProvider dir="rtl" lang="fa" className={cn(className)}>
|
|
<SidebarLeft />
|
|
<SidebarInset>
|
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
|
<SidebarTrigger className="-ms-1" />
|
|
<Separator
|
|
orientation="vertical"
|
|
className="ms-2 data-[orientation=vertical]:h-4"
|
|
/>
|
|
<Hash className="size-5 text-muted-foreground" />
|
|
<Breadcrumb>
|
|
<BreadcrumbList>
|
|
<BreadcrumbItem>
|
|
<BreadcrumbPage className="font-semibold">عمومی</BreadcrumbPage>
|
|
</BreadcrumbItem>
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
</header>
|
|
<div className="flex flex-1 flex-col gap-4 p-4">
|
|
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
|
|
</div>
|
|
</SidebarInset>
|
|
<SidebarRight />
|
|
</SidebarProvider>
|
|
);
|
|
|
|
export { Sidebar19 };
|