mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
💄 feat(ui): адаптив ≤1024 — рейка 60px и выдвижная контекст-панель (кадр S2)
Сайдбар на узком экране сжимается в рейку (логотип, «Чат», развернуть, аватар), «развернуть» раскрывает полный сайдбар поверх контента. Контекст-панель диалога становится drawer'ом с кнопкой в шапке ленты; закрывается при смене диалога. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user