227 lines
11 KiB
TypeScript
227 lines
11 KiB
TypeScript
import { useState, type FormEvent } from 'react';
|
||
import { Hash, Lock, Users, Pin, Send, Plus } from 'lucide-react';
|
||
import { useCommunicationsStore } from '../services/communicationsService';
|
||
import { Button } from '@omnichannel/ui';
|
||
|
||
interface ChannelsViewProps {
|
||
onCreateChannelModal: () => void;
|
||
}
|
||
|
||
export function ChannelsView({ onCreateChannelModal }: ChannelsViewProps) {
|
||
const {
|
||
channels,
|
||
activeChannelId,
|
||
sendChannelMessage,
|
||
toggleChannelReaction,
|
||
} = useCommunicationsStore();
|
||
|
||
const [currentChannelId, setCurrentChannelId] = useState(activeChannelId);
|
||
const [inputText, setInputText] = useState('');
|
||
const [showMembers, setShowMembers] = useState(false);
|
||
|
||
const activeChannel = channels.find((c) => c.id === currentChannelId) || channels[0];
|
||
|
||
const handleSend = (e: FormEvent) => {
|
||
e.preventDefault();
|
||
if (!inputText.trim()) return;
|
||
sendChannelMessage(activeChannel.id, inputText);
|
||
setInputText('');
|
||
};
|
||
|
||
return (
|
||
<div className="flex-1 min-h-0 p-3 sm:p-4 lg:p-5 flex gap-4 bg-[var(--krona-bg-workspace)] overflow-hidden">
|
||
{/* Channels directory list */}
|
||
<aside className="w-64 lg:w-72 bg-[var(--krona-surface-raised)] rounded-2xl border border-[var(--krona-border-subtle)] shadow-[var(--krona-shadow-soft)] p-4 flex flex-col gap-4 shrink-0 overflow-y-auto">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-[var(--krona-text-secondary)] uppercase tracking-wider">
|
||
Внутренние каналы
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={onCreateChannelModal}
|
||
className="w-7 h-7 rounded-md border border-[var(--krona-border)] flex items-center justify-center hover:bg-[var(--krona-surface-hover)] text-[var(--krona-text-primary)] transition-colors"
|
||
title="Создать канал"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<nav className="space-y-1 overflow-y-auto">
|
||
{channels.map((ch) => {
|
||
const isActive = ch.id === activeChannel.id;
|
||
return (
|
||
<button
|
||
key={ch.id}
|
||
type="button"
|
||
onClick={() => setCurrentChannelId(ch.id)}
|
||
className={`w-full p-2.5 rounded-xl text-left flex items-center justify-between gap-2 transition-colors min-h-[44px] ${
|
||
isActive
|
||
? 'bg-[var(--krona-primary-soft)] text-[var(--krona-primary)] font-bold'
|
||
: 'text-[var(--krona-text-primary)] hover:bg-[var(--krona-surface-hover)] font-medium'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2 truncate">
|
||
{ch.isPrivate ? (
|
||
<Lock className="w-4 h-4 shrink-0 text-amber-600" />
|
||
) : (
|
||
<Hash className="w-4 h-4 shrink-0 text-[var(--krona-primary)]" />
|
||
)}
|
||
<span className="text-xs truncate">{ch.name}</span>
|
||
</div>
|
||
{ch.unreadCount > 0 && (
|
||
<span className="px-1.5 py-0.5 rounded-full bg-[var(--krona-primary)] text-white text-[10px] font-bold shrink-0">
|
||
{ch.unreadCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</aside>
|
||
|
||
{/* Main channel conversation area */}
|
||
<section className="flex-1 min-h-0 bg-[var(--krona-surface-raised)] rounded-2xl border border-[var(--krona-border-subtle)] shadow-[var(--krona-shadow-soft)] flex flex-col overflow-hidden">
|
||
{/* Channel Header */}
|
||
<header className="h-16 px-6 bg-[var(--krona-surface)] border-b border-[var(--krona-border-subtle)] flex items-center justify-between gap-3 shrink-0">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<div className="w-10 h-10 rounded-xl bg-[var(--krona-primary-soft)] text-[var(--krona-primary)] flex items-center justify-center font-bold">
|
||
{activeChannel.isPrivate ? <Lock className="w-5 h-5" /> : <Hash className="w-5 h-5" />}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="font-bold text-sm sm:text-base text-[var(--krona-text-primary)] truncate">
|
||
{activeChannel.name}
|
||
</h2>
|
||
{activeChannel.isPrivate && (
|
||
<span className="px-2 py-0.5 rounded text-[10px] bg-amber-100 dark:bg-amber-950 text-amber-700 font-semibold">
|
||
Закрытый
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-[var(--krona-text-secondary)] truncate">
|
||
{activeChannel.topic}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowMembers(!showMembers)}
|
||
className="px-3 py-1.5 rounded-lg border border-[var(--krona-border)] text-xs font-semibold text-[var(--krona-text-primary)] hover:bg-[var(--krona-surface-hover)] flex items-center gap-1.5 transition-colors"
|
||
>
|
||
<Users className="w-3.5 h-3.5" />
|
||
<span>{activeChannel.membersCount} участников</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Pinned announcement banner */}
|
||
{activeChannel.pinnedMessage && (
|
||
<div className="px-6 py-2.5 bg-amber-50/70 dark:bg-amber-950/30 border-b border-amber-200 dark:border-amber-900/50 flex items-center gap-2.5 text-xs text-amber-900 dark:text-amber-200">
|
||
<Pin className="w-4 h-4 text-amber-600 shrink-0" />
|
||
<span className="font-semibold shrink-0">Закреплено:</span>
|
||
<span className="truncate">{activeChannel.pinnedMessage}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Message feed */}
|
||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4">
|
||
{activeChannel.messages.map((msg) => (
|
||
<div key={msg.id} className="flex items-start gap-3">
|
||
<div className="w-8 h-8 rounded-full bg-[var(--krona-surface-raised)] border border-[var(--krona-border)] flex items-center justify-center font-bold text-xs text-[var(--krona-primary)] shrink-0">
|
||
{msg.senderName[0]}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-bold text-xs text-[var(--krona-text-primary)]">
|
||
{msg.senderName}
|
||
</span>
|
||
<span className="text-[10px] text-[var(--krona-text-muted)]">
|
||
{msg.timestamp}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 text-sm text-[var(--krona-text-primary)] bg-[var(--krona-surface)] p-3 rounded-xl border border-[var(--krona-border-subtle)] inline-block max-w-xl shadow-xs">
|
||
{msg.text}
|
||
</div>
|
||
|
||
{/* Reactions */}
|
||
<div className="mt-1.5 flex items-center gap-1.5">
|
||
{msg.reactions &&
|
||
Object.entries(msg.reactions).map(([emoji, count]) => (
|
||
<button
|
||
key={emoji}
|
||
type="button"
|
||
onClick={() => toggleChannelReaction(activeChannel.id, msg.id, emoji)}
|
||
className="px-2 py-0.5 rounded-full bg-[var(--krona-surface-raised)] border border-[var(--krona-border)] text-xs flex items-center gap-1 hover:bg-[var(--krona-surface-hover)] transition-colors"
|
||
>
|
||
<span>{emoji}</span>
|
||
<span className="font-bold text-[10px]">{count}</span>
|
||
</button>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleChannelReaction(activeChannel.id, msg.id, '👍')}
|
||
className="w-6 h-6 rounded-full border border-dashed border-[var(--krona-border)] flex items-center justify-center text-xs text-[var(--krona-text-muted)] hover:text-[var(--krona-text-primary)]"
|
||
title="Добавить реакцию"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Input bar */}
|
||
<form onSubmit={handleSend} className="p-4 bg-[var(--krona-surface)] border-t border-[var(--krona-border-subtle)] flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={inputText}
|
||
onChange={(e) => setInputText(e.target.value)}
|
||
placeholder={`Сообщение в #${activeChannel.name}...`}
|
||
className="flex-1 h-11 px-4 rounded-xl bg-[var(--krona-bg-workspace)] border border-[var(--krona-border)] text-sm text-[var(--krona-text-primary)] placeholder-[var(--krona-text-muted)] focus:outline-none focus:border-[var(--krona-primary)]"
|
||
/>
|
||
<Button type="submit" variant="primary" size="md" disabled={!inputText.trim()} leftIcon={<Send className="w-4 h-4" />}>
|
||
Отправить
|
||
</Button>
|
||
</form>
|
||
</section>
|
||
|
||
{/* Members drawer/sidebar */}
|
||
{showMembers && (
|
||
<aside className="w-64 border-l border-[var(--krona-border-subtle)] bg-[var(--krona-surface)] p-4 flex flex-col gap-3 shrink-0 animate-slide-left">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-bold text-xs uppercase text-[var(--krona-text-secondary)]">
|
||
Участники канала
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowMembers(false)}
|
||
className="text-xs text-[var(--krona-text-muted)] hover:text-[var(--krona-text-primary)]"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2 overflow-y-auto">
|
||
{activeChannel.members.map((m) => (
|
||
<div key={m.id} className="flex items-center gap-2 text-xs">
|
||
<div className="w-7 h-7 rounded-full bg-[var(--krona-primary-soft)] text-[var(--krona-primary)] flex items-center justify-center font-bold">
|
||
{m.name[0]}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<span className="block font-semibold text-[var(--krona-text-primary)] truncate">
|
||
{m.name}
|
||
</span>
|
||
<span className="block text-[10px] text-[var(--krona-text-muted)] truncate">
|
||
{m.role}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</aside>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|