- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
919 lines
28 KiB
JSON
919 lines
28 KiB
JSON
const React = ctx.libs.React;
|
||
const { useState, useEffect, useMemo, useCallback, useRef } = React;
|
||
const {
|
||
Card,
|
||
Typography,
|
||
Spin,
|
||
Input,
|
||
Button,
|
||
Empty,
|
||
Dropdown,
|
||
Modal,
|
||
Row,
|
||
Col,
|
||
Tag,
|
||
Space,
|
||
Grid,
|
||
} = ctx.libs.antd;
|
||
const { MoreOutlined } = ctx.libs.antdIcons;
|
||
|
||
const { Title, Text } = Typography;
|
||
const { useBreakpoint } = Grid;
|
||
const TASK_ID = 1;
|
||
|
||
const THEME = {
|
||
bg: '#17191d',
|
||
bg2: '#1d2025',
|
||
panel: '#1f2329',
|
||
panel2: '#242931',
|
||
panel3: '#2a3038',
|
||
border: '#343b46',
|
||
text: '#eef2f6',
|
||
muted: '#9aa4b2',
|
||
faint: '#768191',
|
||
accent: '#27c2b1',
|
||
accent2: '#2f7cf6',
|
||
accentSoft: 'rgba(39,194,177,.12)',
|
||
msgBg: '#2a2e35',
|
||
msgBgAlt: '#252a31',
|
||
quoteBg: '#21262d',
|
||
danger: '#ff7373',
|
||
inputBg: '#1b1f25',
|
||
top: '#1b1e23',
|
||
};
|
||
|
||
const STATUS = {
|
||
new: { label: 'Новая', color: '#8b949e' },
|
||
in_progress: { label: 'В работе', color: '#2f81f7' },
|
||
done: { label: 'Выполнена', color: '#3fb950' },
|
||
cancelled: { label: 'Отменена', color: '#f85149' },
|
||
};
|
||
|
||
const URGENCY = {
|
||
today: { label: 'Сегодня', color: '#f85149' },
|
||
tomorrow: { label: 'Завтра', color: '#d29922' },
|
||
later: { label: 'Позже', color: '#2f81f7' },
|
||
};
|
||
|
||
function getCurrentUserId() {
|
||
try {
|
||
return Number(ctx?.auth?.user?.id || 1);
|
||
} catch (e) {
|
||
return 1;
|
||
}
|
||
}
|
||
|
||
function getUserLabel(id) {
|
||
return `User #${id || '?'}`;
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) return '';
|
||
try {
|
||
return new Date(value).toLocaleString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
} catch (e) {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
function formatClock(value) {
|
||
if (!value) return '';
|
||
try {
|
||
return new Date(value).toLocaleTimeString('ru-RU', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function formatDayLabel(value) {
|
||
if (!value) return '';
|
||
const d = new Date(value);
|
||
const today = new Date();
|
||
const yesterday = new Date();
|
||
yesterday.setDate(today.getDate() - 1);
|
||
|
||
const key = (x) => `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`;
|
||
if (key(d) === key(today)) return 'Сегодня';
|
||
if (key(d) === key(yesterday)) return 'Вчера';
|
||
return d.toLocaleDateString('ru-RU');
|
||
}
|
||
|
||
function sortFlat(list) {
|
||
return [...(Array.isArray(list) ? list : [])].sort(
|
||
(a, b) => new Date(a.createdAt) - new Date(b.createdAt)
|
||
);
|
||
}
|
||
|
||
function avatarColor(id) {
|
||
const colors = ['#b45309', '#7c3aed', '#0ea5e9', '#15803d', '#db2777', '#ea580c'];
|
||
return colors[Math.abs(Number(id || 0)) % colors.length];
|
||
}
|
||
|
||
function Avatar({ userId, size = 34 }) {
|
||
return React.createElement('div', {
|
||
style: {
|
||
width: size,
|
||
height: size,
|
||
borderRadius: 10,
|
||
background: avatarColor(userId),
|
||
color: '#fff',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
flexShrink: 0,
|
||
}
|
||
}, String(userId || '?'));
|
||
}
|
||
|
||
function InfoRow({ label, value }) {
|
||
return React.createElement('div', {
|
||
style: {
|
||
display: 'grid',
|
||
gridTemplateColumns: '110px 1fr',
|
||
gap: 10,
|
||
padding: '10px 0',
|
||
borderBottom: `1px solid ${THEME.border}`,
|
||
alignItems: 'start',
|
||
}
|
||
},
|
||
React.createElement('div', {
|
||
style: {
|
||
color: THEME.faint,
|
||
fontSize: 12,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '.04em',
|
||
fontWeight: 700,
|
||
}
|
||
}, label),
|
||
React.createElement('div', {
|
||
style: {
|
||
color: THEME.text,
|
||
fontSize: 14,
|
||
minWidth: 0,
|
||
wordBreak: 'break-word',
|
||
}
|
||
}, value || '—')
|
||
);
|
||
}
|
||
|
||
function App() {
|
||
const screens = useBreakpoint();
|
||
const isMobile = !screens.lg;
|
||
|
||
const [task, setTask] = useState(null);
|
||
const [comments, setComments] = useState([]);
|
||
const [loadingTask, setLoadingTask] = useState(true);
|
||
const [loadingComments, setLoadingComments] = useState(true);
|
||
const [posting, setPosting] = useState(false);
|
||
const [savingEdit, setSavingEdit] = useState(false);
|
||
const [text, setText] = useState('');
|
||
const [replyTo, setReplyTo] = useState(null);
|
||
const [editing, setEditing] = useState(null);
|
||
const [editText, setEditText] = useState('');
|
||
const [allowedDestroyIds, setAllowedDestroyIds] = useState([]);
|
||
const [highlightId, setHighlightId] = useState(null);
|
||
const [activeTab, setActiveTab] = useState('info');
|
||
|
||
const listRef = useRef(null);
|
||
const messageRefs = useRef({});
|
||
const previousCountRef = useRef(0);
|
||
const currentUserId = getCurrentUserId();
|
||
|
||
const loadTask = useCallback(async () => {
|
||
setLoadingTask(true);
|
||
try {
|
||
const resp = await ctx.request({
|
||
url: 'tasks:get',
|
||
params: { filterByTk: TASK_ID }
|
||
});
|
||
setTask(resp?.data?.data || null);
|
||
} catch (e) {
|
||
console.error(e);
|
||
ctx.message.error('Не удалось загрузить задачу');
|
||
} finally {
|
||
setLoadingTask(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadComments = useCallback(async () => {
|
||
setLoadingComments(true);
|
||
try {
|
||
const resp = await ctx.request({
|
||
url: `tasks/${TASK_ID}/comments:list`,
|
||
params: {
|
||
pageSize: 300,
|
||
sort: ['createdAt']
|
||
}
|
||
});
|
||
const rows = Array.isArray(resp?.data?.data) ? resp.data.data : [];
|
||
const destroyIds = resp?.data?.meta?.allowedActions?.destroy || [];
|
||
setComments(rows);
|
||
setAllowedDestroyIds(Array.isArray(destroyIds) ? destroyIds : []);
|
||
} catch (e) {
|
||
console.error(e);
|
||
ctx.message.error('Не удалось загрузить комментарии');
|
||
} finally {
|
||
setLoadingComments(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadTask();
|
||
loadComments();
|
||
}, []);
|
||
|
||
const sortedComments = useMemo(() => sortFlat(comments), [comments]);
|
||
|
||
const commentsMap = useMemo(() => {
|
||
const map = new Map();
|
||
comments.forEach((item) => map.set(item.id, item));
|
||
return map;
|
||
}, [comments]);
|
||
|
||
const commentBlocks = useMemo(() => {
|
||
const blocks = [];
|
||
let lastDay = null;
|
||
|
||
sortedComments.forEach((item) => {
|
||
const day = formatDayLabel(item.createdAt);
|
||
if (day !== lastDay) {
|
||
blocks.push({ type: 'day', key: `day-${day}-${item.id}`, label: day });
|
||
lastDay = day;
|
||
}
|
||
blocks.push({ type: 'message', key: `msg-${item.id}`, item });
|
||
});
|
||
|
||
return blocks;
|
||
}, [sortedComments]);
|
||
|
||
useEffect(() => {
|
||
const prevCount = previousCountRef.current;
|
||
const hasNewMessages = comments.length > prevCount;
|
||
previousCountRef.current = comments.length;
|
||
|
||
if (!listRef.current || !hasNewMessages) return;
|
||
|
||
const timer = setTimeout(() => {
|
||
if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
|
||
}, 0);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [comments.length]);
|
||
|
||
useEffect(() => {
|
||
if (!highlightId) return;
|
||
const timer = setTimeout(() => setHighlightId(null), 1800);
|
||
return () => clearTimeout(timer);
|
||
}, [highlightId]);
|
||
|
||
const jumpToMessage = (id) => {
|
||
const node = messageRefs.current[id];
|
||
if (node && typeof node.scrollIntoView === 'function') {
|
||
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
setHighlightId(id);
|
||
}
|
||
};
|
||
|
||
const submit = async () => {
|
||
if (!text.trim()) return;
|
||
setPosting(true);
|
||
try {
|
||
await ctx.request({
|
||
url: `tasks/${TASK_ID}/comments:create`,
|
||
method: 'post',
|
||
data: {
|
||
content: text.trim(),
|
||
task_id: TASK_ID,
|
||
parentId: replyTo?.id ?? null,
|
||
}
|
||
});
|
||
setText('');
|
||
setReplyTo(null);
|
||
await loadComments();
|
||
ctx.message.success('Комментарий отправлен');
|
||
} catch (e) {
|
||
console.error(e);
|
||
ctx.message.error('Не удалось отправить комментарий');
|
||
} finally {
|
||
setPosting(false);
|
||
}
|
||
};
|
||
|
||
const openEdit = (item) => {
|
||
setEditing(item);
|
||
setEditText(item?.content || '');
|
||
};
|
||
|
||
const updateComment = async () => {
|
||
if (!editing?.id || !editText.trim()) return;
|
||
setSavingEdit(true);
|
||
try {
|
||
await ctx.request({
|
||
url: `tasks/${TASK_ID}/comments:update`,
|
||
method: 'post',
|
||
params: { filterByTk: editing.id },
|
||
data: { content: editText.trim() }
|
||
});
|
||
setEditing(null);
|
||
setEditText('');
|
||
await loadComments();
|
||
ctx.message.success('Комментарий изменён');
|
||
} catch (e) {
|
||
console.error(e);
|
||
ctx.message.error('Не удалось изменить комментарий');
|
||
} finally {
|
||
setSavingEdit(false);
|
||
}
|
||
};
|
||
|
||
const removeComment = async (item) => {
|
||
try {
|
||
await ctx.request({
|
||
url: `tasks/${TASK_ID}/comments:destroy`,
|
||
method: 'post',
|
||
params: { filterByTk: item.id }
|
||
});
|
||
await loadComments();
|
||
ctx.message.success('Комментарий удалён');
|
||
} catch (e) {
|
||
console.error(e);
|
||
ctx.message.error('Не удалось удалить комментарий');
|
||
}
|
||
};
|
||
|
||
const renderMessage = (item, index) => {
|
||
const parent = item.parentId ? commentsMap.get(item.parentId) : null;
|
||
const canDelete = allowedDestroyIds.includes(item.id) || allowedDestroyIds.length > 0;
|
||
const canEdit = Number(item.createdById) === Number(currentUserId);
|
||
|
||
const menuItems = [
|
||
{ key: 'reply', label: 'Ответить' },
|
||
{ key: 'edit', label: 'Изменить', disabled: !canEdit },
|
||
{
|
||
key: 'delete',
|
||
label: React.createElement('span', { style: { color: THEME.danger } }, 'Удалить'),
|
||
disabled: !canDelete,
|
||
},
|
||
];
|
||
|
||
return React.createElement('div', {
|
||
key: item.id,
|
||
ref: (el) => { messageRefs.current[item.id] = el; },
|
||
style: {
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: isMobile ? 10 : 12,
|
||
padding: isMobile ? '12px' : '14px 16px',
|
||
background: index % 2 === 0 ? THEME.msgBg : THEME.msgBgAlt,
|
||
borderTop: `1px solid rgba(255,255,255,.03)`,
|
||
outline: highlightId === item.id ? '1px solid rgba(39,194,177,.75)' : '1px solid transparent',
|
||
}
|
||
},
|
||
React.createElement(Avatar, { userId: item.createdById, size: isMobile ? 30 : 34 }),
|
||
|
||
React.createElement('div', { style: { flex: 1, minWidth: 0 } },
|
||
React.createElement('div', {
|
||
style: {
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
justifyContent: 'space-between',
|
||
gap: 8,
|
||
marginBottom: 6,
|
||
}
|
||
},
|
||
React.createElement('div', {
|
||
style: {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
flexWrap: 'wrap',
|
||
minWidth: 0,
|
||
}
|
||
},
|
||
React.createElement('span', {
|
||
style: {
|
||
color: THEME.text,
|
||
fontWeight: 700,
|
||
fontSize: isMobile ? 14 : 15,
|
||
}
|
||
}, getUserLabel(item.createdById)),
|
||
React.createElement('span', {
|
||
style: {
|
||
color: THEME.muted,
|
||
fontSize: 12,
|
||
},
|
||
title: formatDateTime(item.createdAt)
|
||
}, formatClock(item.createdAt))
|
||
),
|
||
|
||
React.createElement(Dropdown, {
|
||
trigger: ['click'],
|
||
placement: 'bottomRight',
|
||
menu: {
|
||
items: menuItems,
|
||
onClick: ({ key }) => {
|
||
if (key === 'reply') setReplyTo(item);
|
||
if (key === 'edit') openEdit(item);
|
||
if (key === 'delete') removeComment(item);
|
||
},
|
||
},
|
||
},
|
||
React.createElement(Button, {
|
||
type: 'text',
|
||
size: 'small',
|
||
icon: React.createElement(MoreOutlined),
|
||
style: {
|
||
color: THEME.muted,
|
||
minWidth: 36,
|
||
height: 36,
|
||
borderRadius: 10,
|
||
}
|
||
})
|
||
)
|
||
),
|
||
|
||
parent && React.createElement('button', {
|
||
onClick: () => jumpToMessage(parent.id),
|
||
style: {
|
||
display: 'block',
|
||
width: '100%',
|
||
textAlign: 'left',
|
||
border: 'none',
|
||
background: THEME.quoteBg,
|
||
borderLeft: `3px solid ${THEME.accent2}`,
|
||
padding: '8px 10px',
|
||
marginBottom: 10,
|
||
borderRadius: 8,
|
||
cursor: 'pointer',
|
||
}
|
||
},
|
||
React.createElement('div', {
|
||
style: {
|
||
color: THEME.accent2,
|
||
fontWeight: 700,
|
||
fontSize: 13,
|
||
marginBottom: 2,
|
||
}
|
||
}, getUserLabel(parent.createdById)),
|
||
React.createElement('div', {
|
||
style: {
|
||
color: THEME.muted,
|
||
fontSize: 13,
|
||
whiteSpace: 'pre-wrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
display: '-webkit-box',
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: 'vertical',
|
||
}
|
||
}, parent.content || '')
|
||
),
|
||
|
||
React.createElement('div', {
|
||
style: {
|
||
color: THEME.text,
|
||
fontSize: isMobile ? 15 : 16,
|
||
lineHeight: 1.55,
|
||
whiteSpace: 'pre-wrap',
|
||
wordBreak: 'break-word',
|
||
}
|
||
}, item.content || '')
|
||
)
|
||
);
|
||
};
|
||
|
||
const taskStatus = STATUS[task?.status];
|
||
const taskUrgency = URGENCY[task?.urgency];
|
||
|
||
const renderSideContent = () => {
|
||
if (loadingTask) return React.createElement(Spin);
|
||
if (!task) return React.createElement(Empty, { description: 'Задача не найдена' });
|
||
|
||
if (activeTab === 'info') {
|
||
return React.createElement('div', null,
|
||
React.createElement(InfoRow, { label: 'ID', value: String(task.id || TASK_ID) }),
|
||
React.createElement(InfoRow, { label: 'Задача', value: task.what_to_do || '—' }),
|
||
React.createElement(InfoRow, { label: 'Статус', value: taskStatus?.label || task.status || '—' }),
|
||
React.createElement(InfoRow, { label: 'Срочность', value: taskUrgency?.label || task.urgency || '—' }),
|
||
React.createElement(InfoRow, { label: 'Срок', value: task.deadline ? formatDateTime(task.deadline) : '—' }),
|
||
React.createElement(InfoRow, { label: 'Создана', value: task.createdAt ? formatDateTime(task.createdAt) : '—' }),
|
||
React.createElement(InfoRow, { label: 'Комментариев', value: String(comments.length) })
|
||
);
|
||
}
|
||
|
||
if (activeTab === 'related') {
|
||
return React.createElement('div', {
|
||
style: { color: THEME.muted, fontSize: 14, lineHeight: 1.6 }
|
||
}, 'Здесь можно будет показать связанные записи, подзадачи, ссылки на объекты и связи задачи. Сейчас вкладка подготовлена как контейнер под дальнейшую интеграцию.');
|
||
}
|
||
|
||
if (activeTab === 'files') {
|
||
return React.createElement('div', {
|
||
style: { color: THEME.muted, fontSize: 14, lineHeight: 1.6 }
|
||
}, 'Здесь можно будет вывести вложения задачи и файловые записи. Сейчас вкладка готова как UX-каркас под файлы и действия с ними.');
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const tabButton = (key, label) => React.createElement('button', {
|
||
onClick: () => setActiveTab(key),
|
||
style: {
|
||
minHeight: 42,
|
||
padding: '10px 12px',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
background: activeTab === key ? THEME.panel3 : 'transparent',
|
||
color: activeTab === key ? THEME.text : THEME.muted,
|
||
borderBottom: !isMobile && activeTab === key ? `2px solid ${THEME.accent}` : '2px solid transparent',
|
||
borderRadius: isMobile ? 10 : 0,
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
whiteSpace: 'nowrap',
|
||
}
|
||
}, label);
|
||
|
||
return React.createElement('div', {
|
||
style: {
|
||
background: THEME.bg,
|
||
minHeight: '100vh',
|
||
padding: isMobile ? 8 : 12,
|
||
}
|
||
},
|
||
React.createElement(Card, {
|
||
style: {
|
||
background: THEME.panel,
|
||
borderColor: THEME.border,
|
||
borderRadius: isMobile ? 12 : 14,
|
||
marginBottom: 12,
|
||
},
|
||
bodyStyle: { padding: isMobile ? 10 : 12 }
|
||
},
|
||
React.createElement('div', {
|
||
style: {
|
||
display: 'flex',
|
||
flexDirection: isMobile ? 'column' : 'row',
|
||
alignItems: isMobile ? 'stretch' : 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 10,
|
||
}
|
||
},
|
||
React.createElement('div', null,
|
||
React.createElement('div', {
|
||
style: {
|
||
color: THEME.text,
|
||
fontSize: isMobile ? 20 : 24,
|
||
fontWeight: 700,
|
||
lineHeight: 1.2,
|
||
}
|
||
}, task?.what_to_do || `Задача #${TASK_ID}`),
|
||
React.createElement('div', {
|
||
style: {
|
||
display: 'flex',
|
||
gap: 8,
|
||
flexWrap: 'wrap',
|
||
marginTop: 8,
|
||
color: THEME.muted,
|
||
fontSize: 12,
|
||
}
|
||
},
|
||
taskStatus && React.createElement(Tag, { color: taskStatus.color }, taskStatus.label),
|
||
taskUrgency && React.createElement(Tag, { color: taskUrgency.color }, taskUrgency.label),
|
||
React.createElement('span', null, `${comments.length} комментариев`)
|
||
)
|
||
),
|
||
|
||
React.createElement('div', {
|
||
style: {
|
||
display: 'flex',
|
||
gap: 8,
|
||
flexWrap: 'wrap',
|
||
}
|
||
},
|
||
React.createElement(Button, {
|
||
style: {
|
||
minHeight: 40,
|
||
borderRadius: 10,
|
||
background: THEME.panel2,
|
||
color: THEME.text,
|
||
borderColor: THEME.border,
|
||
}
|
||
}, 'Назад'),
|
||
React.createElement(Button, {
|
||
style: {
|
||
minHeight: 40,
|
||
borderRadius: 10,
|
||
background: THEME.panel2,
|
||
color: THEME.text,
|
||
borderColor: THEME.border,
|
||
}
|
||
}, 'Действия'),
|
||
React.createElement(Button, {
|
||
type: 'primary',
|
||
style: {
|
||
minHeight: 40,
|
||
borderRadius: 10,
|
||
background: THEME.accent,
|
||
borderColor: THEME.accent,
|
||
color: '#08201d',
|
||
boxShadow: 'none',
|
||
}
|
||
}, 'Утвердить')
|
||
)
|
||
)
|
||
),
|
||
|
||
{isMobile
|
||
? React.createElement('div', {
|
||
style: {
|
||
display: 'flex',
|
||
gap: 8,
|
||
overflowX: 'auto',
|
||
paddingBottom: 8,
|
||
marginBottom: 10,
|
||
}
|
||
},
|
||
tabButton('info', 'Инфо'),
|
||
tabButton('related', 'Связанные'),
|
||
tabButton('files', 'Файлы')
|
||
)
|
||
: null}
|
||
|
||
,React.createElement(Row, { gutter: isMobile ? 0 : 16, align: 'stretch' },
|
||
React.createElement(Col, { xs: 24, lg: 15 },
|
||
React.createElement(Card, {
|
||
style: {
|
||
background: THEME.panel,
|
||
borderColor: THEME.border,
|
||
borderRadius: isMobile ? 12 : 14,
|
||
overflow: 'hidden',
|
||
marginBottom: isMobile ? 12 : 0,
|
||
},
|
||
bodyStyle: { padding: 0 }
|
||
},
|
||
React.createElement('div', {
|
||
style: {
|
||
padding: isMobile ? '10px 12px' : '12px 16px',
|
||
borderBottom: `1px solid ${THEME.border}`,
|
||
background: THEME.top,
|
||
position: 'sticky',
|
||
top: 0,
|
||
zIndex: 2,
|
||
}
|
||
},
|
||
React.createElement('div', {
|
||
style: {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 10,
|
||
}
|
||
},
|
||
React.createElement('div', {
|
||
style: { color: THEME.text, fontWeight: 700, fontSize: 15 }
|
||
}, 'Обсуждение'),
|
||
React.createElement(Text, {
|
||
style: { color: THEME.muted, fontSize: 12 }
|
||
}, `${comments.length} сообщений`)
|
||
)
|
||
),
|
||
|
||
React.createElement('div', {
|
||
ref: listRef,
|
||
style: {
|
||
maxHeight: isMobile ? '55vh' : '62vh',
|
||
overflowY: 'auto',
|
||
background: THEME.panel2,
|
||
}
|
||
},
|
||
loadingComments
|
||
? React.createElement('div', { style: { padding: 16 } }, React.createElement(Spin))
|
||
: commentBlocks.length === 0
|
||
? React.createElement('div', { style: { padding: 16 } }, React.createElement(Empty, { description: 'Комментариев пока нет' }))
|
||
: commentBlocks.map((block, idx) => {
|
||
if (block.type === 'day') {
|
||
return React.createElement('div', {
|
||
key: block.key,
|
||
style: {
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
padding: '12px 10px 8px',
|
||
background: THEME.panel2,
|
||
}
|
||
},
|
||
React.createElement('span', {
|
||
style: {
|
||
background: THEME.panel3,
|
||
color: THEME.muted,
|
||
fontSize: 12,
|
||
padding: '4px 10px',
|
||
borderRadius: 999,
|
||
border: `1px solid ${THEME.border}`,
|
||
}
|
||
}, block.label)
|
||
);
|
||
}
|
||
return renderMessage(block.item, idx);
|
||
})
|
||
),
|
||
|
||
React.createElement('div', {
|
||
style: {
|
||
padding: isMobile ? 12 : 16,
|
||
borderTop: `1px solid ${THEME.border}`,
|
||
background: THEME.panel,
|
||
position: 'sticky',
|
||
bottom: 0,
|
||
zIndex: 2,
|
||
}
|
||
},
|
||
replyTo && React.createElement('div', {
|
||
style: {
|
||
marginBottom: 10,
|
||
padding: '8px 10px',
|
||
borderLeft: `3px solid ${THEME.accent}`,
|
||
background: THEME.accentSoft,
|
||
borderRadius: 8,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
gap: 10,
|
||
}
|
||
},
|
||
React.createElement('button', {
|
||
onClick: () => jumpToMessage(replyTo.id),
|
||
style: {
|
||
flex: 1,
|
||
textAlign: 'left',
|
||
border: 'none',
|
||
background: 'transparent',
|
||
cursor: 'pointer',
|
||
padding: 0,
|
||
}
|
||
},
|
||
React.createElement('div', {
|
||
style: { color: THEME.accent, fontWeight: 700, fontSize: 13, marginBottom: 2 }
|
||
}, getUserLabel(replyTo.createdById)),
|
||
React.createElement('div', {
|
||
style: {
|
||
color: THEME.muted,
|
||
fontSize: 13,
|
||
whiteSpace: 'pre-wrap',
|
||
maxHeight: 36,
|
||
overflow: 'hidden',
|
||
}
|
||
}, replyTo.content || '')
|
||
),
|
||
React.createElement(Button, {
|
||
type: 'text',
|
||
size: 'small',
|
||
onClick: () => setReplyTo(null),
|
||
style: {
|
||
color: THEME.muted,
|
||
minWidth: 36,
|
||
height: 36,
|
||
}
|
||
}, '✕')
|
||
),
|
||
|
||
React.createElement('div', {
|
||
style: {
|
||
display: 'flex',
|
||
gap: 10,
|
||
alignItems: 'flex-start',
|
||
}
|
||
},
|
||
!isMobile && React.createElement(Avatar, { userId: currentUserId, size: 32 }),
|
||
React.createElement('div', { style: { flex: 1 } },
|
||
React.createElement(Input.TextArea, {
|
||
value: text,
|
||
onChange: (e) => setText(e.target.value),
|
||
placeholder: isMobile ? 'Комментарий...' : 'Написать комментарий...',
|
||
autoSize: { minRows: isMobile ? 3 : 4, maxRows: 8 },
|
||
style: {
|
||
background: THEME.inputBg,
|
||
color: THEME.text,
|
||
borderColor: THEME.border,
|
||
borderRadius: 12,
|
||
resize: 'none',
|
||
fontSize: 16,
|
||
},
|
||
onKeyDown: (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
submit();
|
||
}
|
||
}
|
||
}),
|
||
React.createElement('div', {
|
||
style: {
|
||
marginTop: 10,
|
||
display: 'flex',
|
||
flexDirection: isMobile ? 'column' : 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: isMobile ? 'stretch' : 'center',
|
||
gap: 10,
|
||
}
|
||
},
|
||
React.createElement(Text, {
|
||
style: { fontSize: 12, color: THEME.muted }
|
||
}, isMobile ? 'Enter — отправить' : 'Enter — отправить, Shift + Enter — новая строка'),
|
||
React.createElement(Button, {
|
||
type: 'primary',
|
||
loading: posting,
|
||
disabled: !text.trim(),
|
||
onClick: submit,
|
||
block: isMobile,
|
||
style: {
|
||
minHeight: 44,
|
||
borderRadius: 12,
|
||
background: THEME.accent,
|
||
borderColor: THEME.accent,
|
||
color: '#08201d',
|
||
boxShadow: 'none',
|
||
fontWeight: 700,
|
||
}
|
||
}, replyTo ? 'Ответить' : 'Отправить')
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
),
|
||
|
||
!isMobile && React.createElement(Col, { xs: 24, lg: 9 },
|
||
React.createElement(Card, {
|
||
style: {
|
||
background: THEME.panel,
|
||
borderColor: THEME.border,
|
||
borderRadius: 14,
|
||
height: '100%',
|
||
},
|
||
bodyStyle: { padding: 0 }
|
||
},
|
||
React.createElement('div', {
|
||
style: {
|
||
display: 'flex',
|
||
borderBottom: `1px solid ${THEME.border}`,
|
||
paddingInline: 8,
|
||
gap: 2,
|
||
}
|
||
},
|
||
tabButton('info', 'Инфо'),
|
||
tabButton('related', 'Связанные'),
|
||
tabButton('files', 'Файлы')
|
||
),
|
||
React.createElement('div', {
|
||
style: {
|
||
padding: 16,
|
||
minHeight: 420,
|
||
}
|
||
}, renderSideContent())
|
||
)
|
||
)
|
||
),
|
||
|
||
{isMobile
|
||
? React.createElement(Card, {
|
||
style: {
|
||
background: THEME.panel,
|
||
borderColor: THEME.border,
|
||
borderRadius: 12,
|
||
},
|
||
bodyStyle: { padding: 14 }
|
||
}, renderSideContent())
|
||
: null}
|
||
|
||
,React.createElement(Modal, {
|
||
open: !!editing,
|
||
title: 'Изменить комментарий',
|
||
onCancel: () => {
|
||
setEditing(null);
|
||
setEditText('');
|
||
},
|
||
onOk: updateComment,
|
||
okText: 'Сохранить',
|
||
cancelText: 'Отмена',
|
||
confirmLoading: savingEdit,
|
||
},
|
||
React.createElement(Input.TextArea, {
|
||
value: editText,
|
||
onChange: (e) => setEditText(e.target.value),
|
||
autoSize: { minRows: 4, maxRows: 10 },
|
||
placeholder: 'Новый текст комментария...'
|
||
})
|
||
)
|
||
);
|
||
}
|
||
|
||
ctx.render(React.createElement(App)); |