💄 feat(ui): адаптив ≤1024 — рейка 60px и выдвижная контекст-панель (кадр S2)

Сайдбар на узком экране сжимается в рейку (логотип, «Чат», развернуть,
аватар), «развернуть» раскрывает полный сайдбар поверх контента.
Контекст-панель диалога становится drawer'ом с кнопкой в шапке ленты;
закрывается при смене диалога.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5 committed 2026-09-05 01:23:55 +03:00
1 parent 60bb7e8a54
commit 12107e02aa
5 files changed
+180 -4

No files matched your search

@@ -15,7 +15,7 @@ function fmtTime(value: string): string {
return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam, onReturnQueue, onArchive }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean> }) {
export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam, onReturnQueue, onArchive, onToggleContext }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean>; onToggleContext?: () => void }) {
const timelineRef = useRef<HTMLDivElement>(null);
const messages = detail?.messages ?? [];
const lastMessageId = messages.length ? messages[messages.length - 1].id : 0;
@@ -55,6 +55,8 @@ export function ConversationThread({ controlMode, dialog, detail, isOwner = fals
<IconButton icon="video" label="Запросить видеозвонок" className="is-video" onClick={() => onCall("VIDEO")} />
</>
)}
{/* Кадр S2: на узком экране контекст-панель — выдвижная, кнопка в шапке. */}
{onToggleContext && <IconButton icon="user" label="Контекст диалога" className="ctx-toggle" onClick={onToggleContext} />}
<ConversationActions open={detail?.lifecycle === "OPEN"} canReturnQueue={controlMode === "human"} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} />
</div>
</div>
@@ -59,6 +59,8 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
showScopeSwitcher?: boolean;
}) {
const [listTab, setListTab] = useState<ListTab>("all");
// Кадр S2: на ≤1024px контекст-панель — выдвижная поверх ленты.
const [ctxOpen, setCtxOpen] = useState(false);
const [search, setSearch] = useState("");
const [conversations, setConversations] = useState<ApiConversation[]>([]);
const [listLoaded, setListLoaded] = useState(false);
@@ -106,6 +108,10 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
if (initialConversationId != null) setSelectedId(initialConversationId);
}, [initialConversationId]);
useEffect(() => {
setCtxOpen(false);
}, [selectedId]);
useEffect(() => {
if (selectedId == null) return;
setDetail(null);
@@ -168,7 +174,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
};
return (
<div className="sales-dialogs">
<div className={`sales-dialogs ${ctxOpen ? "is-ctx-open" : ""}`}>
<DialogList
scope={scope}
counters={counters}
@@ -187,7 +193,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
setSelectedId={setSelectedId}
/>
<section className="sales-conversation">
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} />
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} />
{(detailError || actionError) && <div className="sales-conversation-error">{detailError || actionError}</div>}
<CallOverlay
open={callController.open}
@@ -18,3 +18,37 @@
display: flex;
overflow: hidden;
}
/* Кадр S2 (≤1024px): рабочее поле сжимается, контекст-панель — выдвижная. */
.sales-conversation-actions .ctx-toggle {
display: none;
}
@media (max-width: 1024px) {
.sales-dialogs {
min-width: 0;
position: relative;
}
.sales-conversation-actions .ctx-toggle {
display: inline-flex;
color: var(--primary-text);
background: var(--primary-bg);
border-color: var(--primary-border);
}
.sales-dialogs .sales-context {
display: none;
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 320px;
z-index: 4;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.1);
}
.sales-dialogs.is-ctx-open .sales-context {
display: flex;
}
}
+35 -1
View File
@@ -1,3 +1,5 @@
import { useState } from "react";
import type { RouteKey, SessionUser } from "../types";
import { Icon, LogoIcon } from "../shared/icons";
import { defaultRoute, isManager } from "../auth/access";
@@ -67,12 +69,43 @@ export function Sidebar({
chatCounters: ConversationCounters | null;
}) {
const manager = isManager(user);
// Кадр S2: на ≤1024px сайдбар сжимается в рейку 60px; «развернуть»
// раскрывает полный сайдбар поверх контента.
const [railExpanded, setRailExpanded] = useState(false);
const sectionStorageKey = (section: string) => (
`chatbolls.sidebar.${user.organizationPublicId}.${section}.expanded`
);
const initials = (user.fullName || user.email)
.split(/\s+/)
.map((part) => part[0] ?? "")
.join("")
.slice(0, 2)
.toUpperCase();
return (
<aside className="hub-sidebar">
<aside className={`hub-sidebar ${railExpanded ? "is-rail-expanded" : ""}`}>
<div className="hub-rail">
<div className={`hub-brand-mark ${user.organizationLogoUrl ? "has-logo" : ""}`}>
{user.organizationLogoUrl ? <img src={user.organizationLogoUrl} alt="" /> : <LogoIcon />}
</div>
<button
className={`hub-rail-button ${route === "chat" ? "is-active" : ""}`}
title="Чат"
type="button"
onClick={() => setRoute("chat")}
>
<Icon name="message" size={18} />
</button>
<button className="hub-rail-button" title="Развернуть" type="button" onClick={() => setRailExpanded(true)}>
<Icon name="chevron" size={17} />
</button>
<div className="hub-rail-spacer" />
<button className="hub-rail-avatar" title={user.fullName || user.email} type="button" onClick={() => setRailExpanded(true)}>
{initials}
</button>
</div>
{railExpanded && <button className="hub-rail-backdrop" aria-label="Свернуть меню" type="button" onClick={() => setRailExpanded(false)} />}
<div className="hub-sidebar-body" onClick={() => setRailExpanded(false)}>
<button className="hub-brand" type="button" onClick={() => setRoute(defaultRoute(user))}>
<div className={`hub-brand-mark ${user.organizationLogoUrl ? "has-logo" : ""}`}>
{user.organizationLogoUrl
@@ -113,6 +146,7 @@ export function Sidebar({
)}
{manager && <LaunchChecklist user={user} setRoute={setRoute} />}
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} />
</div>
</aside>
);
}
+100
View File
@@ -15,6 +15,106 @@
border-right: 1px solid var(--n-8);
}
.hub-sidebar-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Рейка 60px (дизайн-базлайн v2, кадр S2): показывается только на ≤1024px. */
.hub-rail {
display: none;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 14px 0 12px;
height: 100%;
}
.hub-rail .hub-brand-mark {
margin-bottom: 8px;
}
.hub-rail-button {
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 9px;
color: var(--n-4);
background: transparent;
}
.hub-rail-button:hover {
background: var(--n-9);
}
.hub-rail-button.is-active {
color: var(--primary-text);
background: var(--primary-bg);
}
.hub-rail-spacer {
flex: 1;
}
.hub-rail-avatar {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 50%;
color: #ffffff;
background: var(--primary);
font-size: 12px;
font-weight: 600;
}
.hub-rail-backdrop {
display: none;
position: fixed;
inset: 0;
z-index: 19;
border: 0;
background: rgba(0, 0, 0, 0.25);
}
@media (max-width: 1024px) {
.hub-sidebar {
width: 60px;
}
.hub-rail {
display: flex;
}
.hub-sidebar-body {
display: none;
}
.hub-sidebar.is-rail-expanded .hub-rail-backdrop {
display: block;
}
.hub-sidebar.is-rail-expanded .hub-sidebar-body {
display: flex;
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 248px;
z-index: 20;
background: var(--surface-card);
border-right: 1px solid var(--n-8);
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.1);
}
}
.hub-brand {
width: 100%;
height: 56px;