frontend: now working fully, fuctionality tested
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 20:12:29 +03:00
parent 1f3a0e4351
commit ca0aa9ee9c
15 changed files with 1657 additions and 74 deletions
+222
View File
@@ -0,0 +1,222 @@
import { useMemo, useState } from 'react'
import Avatar from './Avatar'
import { IconLogout, IconPlus, IconSearch } from './icons'
import { formatListTime, peerForConversation, userTitle } from '../lib/format'
import type { Conversation, User } from '../lib/types'
interface SidebarProps {
className?: string
conversations: Conversation[]
loading: boolean
error: string | null
selectedId: number | null
currentUser: User
directory: Map<number, User>
peers: Record<number, number>
presence: Record<number, boolean>
typing: Record<number, boolean>
onSelect: (id: number) => void
onNewChat: () => void
onRetry: () => void
onLogout: () => void
}
function previewOf(conversation: Conversation, currentUserId: number): string {
const last = conversation.last_message
if (!last) return 'Нет сообщений'
const prefix = last.sender_id === currentUserId ? 'Вы: ' : ''
return `${prefix}${last.body}`
}
export default function Sidebar({
className = '',
conversations,
loading,
error,
selectedId,
currentUser,
directory,
peers,
presence,
typing,
onSelect,
onNewChat,
onRetry,
onLogout,
}: SidebarProps) {
const [query, setQuery] = useState('')
const items = useMemo(() => {
const term = query.trim().toLowerCase()
return conversations
.map((conversation) => {
const peer = peerForConversation(conversation, peers, directory, currentUser.id)
const title = peer ? userTitle(peer) : `Чат #${conversation.id}`
return { conversation, peer, title, preview: previewOf(conversation, currentUser.id) }
})
.filter((item) => !term || item.title.toLowerCase().includes(term) || item.preview.toLowerCase().includes(term))
}, [conversations, query, peers, directory, currentUser.id])
return (
<aside className={`${className} w-full flex-col border-line/80 bg-panel/60 md:w-[340px] md:border-r`}>
<div className="flex items-center justify-between gap-3 px-4 pt-4 pb-3">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/20">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
<path
d="M4 12.5h3l2-5 3.2 9 2.3-6 1.5 2H20"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span className="font-display text-lg font-bold tracking-tight text-fg">Пульс</span>
</div>
<button
type="button"
onClick={onNewChat}
aria-label="Новый чат"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-line bg-surface text-muted transition hover:border-accent/50 hover:text-accent"
>
<IconPlus className="h-5 w-5" />
</button>
</div>
<div className="px-4 pb-2">
<div className="relative">
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
<label htmlFor="chat-search" className="sr-only">
Поиск по чатам
</label>
<input
id="chat-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск"
className="w-full rounded-xl border border-line bg-surface/70 py-2 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
/>
</div>
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-2 py-1">
{loading && (
<div className="space-y-1.5 p-1">
{[0, 1, 2, 3, 4].map((row) => (
<div key={row} className="flex animate-pulse-soft items-center gap-3 rounded-xl p-2.5" style={{ animationDelay: `${row * 70}ms` }}>
<span className="h-11 w-11 rounded-full bg-surface-2" />
<span className="flex-1 space-y-2">
<span className="block h-3 w-1/2 rounded-full bg-surface-2" />
<span className="block h-3 w-3/4 rounded-full bg-surface-2/70" />
</span>
</div>
))}
</div>
)}
{!loading && error && (
<div className="m-2 rounded-2xl border border-line bg-surface/60 p-4 text-center">
<p className="text-sm text-danger">{error}</p>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-lg border border-line px-3 py-1.5 text-xs font-medium text-fg transition hover:border-accent/50 hover:text-accent"
>
Повторить
</button>
</div>
)}
{!loading && !error && items.length === 0 && (
<div className="flex flex-col items-center gap-3 px-6 py-12 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-accent">
<IconPlus className="h-6 w-6" />
</div>
<p className="text-sm text-muted">
{query ? 'Ничего не найдено' : 'Пока нет чатов. Начните первый разговор.'}
</p>
{!query && (
<button
type="button"
onClick={onNewChat}
className="rounded-xl bg-gradient-to-br from-accent to-accent-strong px-4 py-2 text-sm font-semibold text-white transition hover:brightness-110"
>
Новый чат
</button>
)}
</div>
)}
<ul className="space-y-0.5">
{!loading &&
!error &&
items.map(({ conversation, peer, title, preview }) => {
const selected = conversation.id === selectedId
const isTyping = typing[conversation.id] === true
const unread = conversation.unread_count ?? 0
const lastAt = conversation.last_message?.created_at ?? conversation.created_at
return (
<li key={conversation.id}>
<button
type="button"
onClick={() => onSelect(conversation.id)}
aria-current={selected ? 'true' : undefined}
className={`flex w-full items-center gap-3 rounded-xl px-2.5 py-2.5 text-left transition ${
selected ? 'bg-accent-ghost ring-1 ring-accent/30' : 'hover:bg-surface-2/70'
}`}
>
<Avatar
name={title}
seed={peer?.id ?? conversation.id}
size="lg"
online={peer ? presence[peer.id] === true : false}
/>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className={`truncate text-sm font-semibold ${selected ? 'text-fg' : 'text-fg/90'}`}>
{title}
</span>
<span className="shrink-0 text-[11px] text-faint">{formatListTime(lastAt)}</span>
</span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className={`truncate text-xs ${isTyping ? 'text-accent' : 'text-muted'}`}>
{isTyping ? 'печатает…' : preview}
</span>
{unread > 0 && (
<span className="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-accent px-1.5 text-[11px] font-bold text-white">
{unread > 99 ? '99+' : unread}
</span>
)}
</span>
</span>
</button>
</li>
)
})}
</ul>
</div>
<div className="flex items-center gap-3 border-t border-line/80 px-4 py-3">
<Avatar name={currentUser.display_name || currentUser.username} seed={currentUser.id} size="md" online />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-fg">
{currentUser.display_name || currentUser.username}
</p>
<p className="truncate text-xs text-muted">@{currentUser.username}</p>
</div>
<button
type="button"
onClick={onLogout}
aria-label="Выйти"
title="Выйти"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-danger"
>
<IconLogout className="h-5 w-5" />
</button>
</div>
</aside>
)
}