feat: update messaging UI and online status indicators
This commit is contained in:
parent
814c12ceeb
commit
dfa8833e48
@ -11,6 +11,9 @@ const nextConfig: NextConfig = {
|
||||
NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL,
|
||||
NEXT_PUBLIC_BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL,
|
||||
},
|
||||
|
||||
// Полностью отключаем Next.js Dev Tools UI (кружок "N")
|
||||
devIndicators: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@ -65,7 +65,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -1469,7 +1468,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@ -1525,7 +1523,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
|
||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
@ -2006,7 +2003,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -2324,7 +2320,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@ -2872,7 +2867,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@ -3050,7 +3044,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@ -5108,7 +5101,6 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -5117,7 +5109,6 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@ -5763,7 +5754,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -5915,7 +5905,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -6176,7 +6165,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { AppShell, PageHeader } from "../../components/AppShell";
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
|
||||
export default function DepartmentsPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader title="Отделы" subtitle="Здесь будут списки отделов и контакты." badge="В разработке" />
|
||||
<p className="text-sm text-gray-500">Раздел в разработке.</p>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { AppShell, PageHeader } from "../../components/AppShell";
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
|
||||
export default function DocumentsPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader title="Документы" subtitle="Здесь будут договоры, отчёты и файлы." badge="В разработке" />
|
||||
<p className="text-sm text-gray-500">Раздел в разработке.</p>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell, PageHeader } from "../../components/AppShell";
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { User } from "@/types/api";
|
||||
@ -28,7 +28,6 @@ export default function EmployeesPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader title="Сотрудники" eyebrow="Команда" />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent"></div>
|
||||
@ -42,7 +41,6 @@ export default function EmployeesPage() {
|
||||
if (error) {
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader title="Сотрудники" eyebrow="Команда" />
|
||||
<div className="rounded-2xl bg-red-50 p-6 text-center">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
@ -58,8 +56,6 @@ export default function EmployeesPage() {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader title="Сотрудники" eyebrow="Команда" badge={`Всего: ${employees.length}`} />
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedEmployees.map((employee) => {
|
||||
const fullName = `${employee.last_name} ${employee.first_name} ${employee.patronymic || ''}`.trim();
|
||||
@ -72,12 +68,17 @@ export default function EmployeesPage() {
|
||||
|
||||
return (
|
||||
<article key={employee.id} className="flex items-center gap-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white overflow-hidden">
|
||||
{employee.avatar ? (
|
||||
<img src={employee.avatar} alt={fullName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
<div className="relative h-12 w-12">
|
||||
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{employee.avatar ? (
|
||||
<img src={employee.avatar} alt={fullName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
{isOnline ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">{fullName}</p>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { AppShell, PageHeader } from "../../components/AppShell";
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
|
||||
export default function FinancesPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader title="Финансы" subtitle="Здесь появится аналитика и отчётность." badge="В разработке" />
|
||||
<p className="text-sm text-gray-500">Раздел в разработке.</p>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
571
frontend/src/app/messages/[chatId]/page.tsx
Normal file
571
frontend/src/app/messages/[chatId]/page.tsx
Normal file
@ -0,0 +1,571 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText } from "lucide-react";
|
||||
import { AppShell } from "../../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { Chat, Message, MessageAttachment } from "@/types/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "";
|
||||
|
||||
function getChatTitle(chat: Chat): string {
|
||||
return chat.name?.trim() || "Диалог";
|
||||
}
|
||||
|
||||
function getChatInitials(chat: Chat): string {
|
||||
const title = getChatTitle(chat);
|
||||
return (
|
||||
title
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() || "")
|
||||
.join("") || "Ч"
|
||||
);
|
||||
}
|
||||
|
||||
function getMessageInitials(message: Message): string {
|
||||
const name = (message.author_name || "").trim();
|
||||
if (!name) return "С";
|
||||
const parts = name.split(" ").filter(Boolean);
|
||||
if (parts.length === 1) {
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase();
|
||||
}
|
||||
|
||||
function formatTime(date?: string): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function getMessageDate(message: Message): Date | null {
|
||||
if (message.created_at) {
|
||||
const d = new Date(message.created_at);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
if (message.created_ts) {
|
||||
const d = new Date(message.created_ts);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDayDivider(date: Date): string {
|
||||
return date.toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function messageTime(message: Message): string {
|
||||
const d = getMessageDate(message);
|
||||
if (!d) return "";
|
||||
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) {
|
||||
return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
return d.toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function isSameDay(a: Date | null, b: Date | null): boolean {
|
||||
if (!a || !b) return false;
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size?: number): string {
|
||||
if (!size || size <= 0) return "";
|
||||
const kb = size / 1024;
|
||||
if (kb < 1024) return `${Math.round(kb)} КБ`;
|
||||
const mb = kb / 1024;
|
||||
return `${mb.toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
function isImageAttachment(att: MessageAttachment): boolean {
|
||||
const mime = (att.mime_type || "").toLowerCase();
|
||||
const type = (att.file_type || "").toLowerCase();
|
||||
const name = (att.file_name || "").toLowerCase();
|
||||
const byExt = /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(name);
|
||||
return mime.startsWith("image/") || type === "image" || byExt;
|
||||
}
|
||||
|
||||
function isVideoAttachment(att: MessageAttachment): boolean {
|
||||
const mime = (att.mime_type || "").toLowerCase();
|
||||
const type = (att.file_type || "").toLowerCase();
|
||||
const name = (att.file_name || "").toLowerCase();
|
||||
const byExt = /\.(mp4|webm|mov|m4v|avi|mkv|3gp|mpeg|mpg)$/i.test(name);
|
||||
return mime.startsWith("video/") || type === "video" || byExt;
|
||||
}
|
||||
|
||||
function isAudioAttachment(att: MessageAttachment): boolean {
|
||||
const mime = (att.mime_type || "").toLowerCase();
|
||||
const type = (att.file_type || "").toLowerCase();
|
||||
return mime.startsWith("audio/") || type === "audio";
|
||||
}
|
||||
|
||||
function resolveAttachmentUrl(url?: string | null): string {
|
||||
if (!url) return "";
|
||||
if (/^https?:\/\//i.test(url)) return encodeURI(url);
|
||||
if (url.startsWith("//")) return encodeURI(`https:${url}`);
|
||||
if (url.startsWith("/") && BACKEND_URL) {
|
||||
return encodeURI(`${BACKEND_URL.replace(/\/$/, "")}${url}`);
|
||||
}
|
||||
return encodeURI(url);
|
||||
}
|
||||
|
||||
function resolveAvatarUrl(url?: string | null): string {
|
||||
if (!url) return "";
|
||||
if (/^https?:\/\//i.test(url)) return encodeURI(url);
|
||||
if (url.startsWith("//")) return encodeURI(`https:${url}`);
|
||||
if (url.startsWith("/") && BACKEND_URL) {
|
||||
return encodeURI(`${BACKEND_URL.replace(/\/$/, "")}${url}`);
|
||||
}
|
||||
return encodeURI(url);
|
||||
}
|
||||
|
||||
export default function MessageDialogPage() {
|
||||
const params = useParams<{ chatId: string }>();
|
||||
const chatId = Number(params.chatId);
|
||||
const { user } = useUser();
|
||||
|
||||
const [chat, setChat] = useState<Chat | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [messageText, setMessageText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
||||
const [brokenMedia, setBrokenMedia] = useState<Record<number, boolean>>({});
|
||||
const [mediaPreview, setMediaPreview] = useState<{ type: "image" | "video"; src: string; name: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const messagesViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [initialAnchorId, setInitialAnchorId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadChats() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiClient.getChats();
|
||||
const items = response.results || [];
|
||||
setChat(items.find((c) => c.id === chatId) || null);
|
||||
} catch (e: any) {
|
||||
setError("Не удалось загрузить чат. Проверьте подключение и попробуйте снова.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadChats();
|
||||
}, [chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadMessages() {
|
||||
if (!chatId || Number.isNaN(chatId) || !chat) {
|
||||
setMessages([]);
|
||||
setInitialAnchorId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setMessagesLoading(true);
|
||||
const around = await apiClient.getChatMessagesAround(chatId, { limit: 40 });
|
||||
const aroundMessages = around.messages || [];
|
||||
|
||||
if (aroundMessages.length > 0) {
|
||||
setMessages(aroundMessages);
|
||||
setInitialAnchorId(around.anchor_id ?? null);
|
||||
} else {
|
||||
const response = await apiClient.getChatMessages(chatId, { limit: 50 });
|
||||
setMessages(response.messages || []);
|
||||
setInitialAnchorId(null);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (String(e?.message || "").includes("403")) {
|
||||
setError("Нет доступа к этому чату");
|
||||
setMessages([]);
|
||||
} else {
|
||||
console.error("Ошибка загрузки сообщений:", e);
|
||||
}
|
||||
setInitialAnchorId(null);
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadMessages();
|
||||
}, [chatId, chat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesLoading || !messagesViewportRef.current || messages.length === 0) return;
|
||||
|
||||
const viewport = messagesViewportRef.current;
|
||||
|
||||
if (initialAnchorId) {
|
||||
const el = viewport.querySelector(`[data-message-id="${initialAnchorId}"]`) as HTMLElement | null;
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "center" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
}, [messagesLoading, messages, initialAnchorId]);
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = messageText.trim();
|
||||
if (!chatId || sending) return;
|
||||
if (!text && attachedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
setSending(true);
|
||||
const sent = attachedFiles.length
|
||||
? await apiClient.sendMessageWithFiles(chatId, text, attachedFiles)
|
||||
: await apiClient.sendMessage(chatId, text);
|
||||
setMessages((prev) => [...prev, sent]);
|
||||
setMessageText("");
|
||||
setAttachedFiles([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Ошибка отправки сообщения:", e);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickFiles = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
setAttachedFiles((prev) => [...prev, ...files]);
|
||||
};
|
||||
|
||||
const removeAttachedFile = (index: number) => {
|
||||
setAttachedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setMediaPreview(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
{loading ? (
|
||||
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
|
||||
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Загрузка чатов...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-2xl bg-red-50 p-6 text-center">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sticky top-22 self-start h-[calc(100vh-7.5rem)] overflow-hidden">
|
||||
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
{chat ? (
|
||||
<>
|
||||
<header className="mb-4 flex shrink-0 items-center justify-between gap-3 border-b border-gray-100 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{chat.avatar ? (
|
||||
<img src={resolveAvatarUrl(chat.avatar)} alt={getChatTitle(chat)} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
getChatInitials(chat)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{getChatTitle(chat)}</p>
|
||||
<p className="text-xs text-gray-500">{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/messages"
|
||||
aria-label="К списку чатов"
|
||||
title="К списку чатов"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div ref={messagesViewportRef} className="min-h-0 flex-1 overflow-y-auto rounded-xl bg-gray-50 p-4">
|
||||
{messagesLoading ? (
|
||||
<p className="text-center text-sm text-gray-500">Загрузка сообщений...</p>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-500">Пока нет сообщений. Напишите первым.</p>
|
||||
) : (
|
||||
<div className="flex min-h-full flex-col justify-end">
|
||||
{messages.map((message, index) => {
|
||||
const currentDate = getMessageDate(message);
|
||||
const prevDate = index > 0 ? getMessageDate(messages[index - 1]) : null;
|
||||
const showDayDivider = index === 0 || !isSameDay(currentDate, prevDate);
|
||||
|
||||
const isMine =
|
||||
user?.id &&
|
||||
(message.author_id === user.id || message.author?.id === user.id || message.sender?.id === user.id);
|
||||
|
||||
return (
|
||||
<div key={message.id} data-message-id={message.id} className="mb-3 last:mb-0">
|
||||
{showDayDivider && currentDate ? (
|
||||
<div className="sticky top-2 z-10 mb-2 text-center">
|
||||
<span className="inline-block rounded-full bg-white/95 px-3 py-1 text-xs text-gray-500 shadow-sm ring-1 ring-gray-200 backdrop-blur">
|
||||
{formatDayDivider(currentDate)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`flex ${isMine ? "justify-end" : "justify-start"}`}>
|
||||
{!isMine ? (
|
||||
<div className="relative mr-2 mt-1 h-8 w-8 shrink-0">
|
||||
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-[10px] font-semibold text-white">
|
||||
{message.avatar || message.author?.avatar ? (
|
||||
<img
|
||||
src={resolveAvatarUrl(message.avatar || message.author?.avatar || "")}
|
||||
alt={message.author_name || "Автор"}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
getMessageInitials(message)
|
||||
)}
|
||||
</div>
|
||||
{message.author?.is_active ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`max-w-[78%] rounded-2xl px-3 py-2 ${
|
||||
isMine ? "bg-sky-500 text-white" : "bg-white text-gray-900 ring-1 ring-gray-100"
|
||||
}`}
|
||||
>
|
||||
{!isMine ? (
|
||||
<p className="mb-1 text-[11px] font-medium text-gray-500">
|
||||
{message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{message.content ? (
|
||||
<p className="whitespace-pre-wrap text-sm leading-5">{message.content}</p>
|
||||
) : null}
|
||||
|
||||
{message.attachments && message.attachments.length > 0 ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{message.attachments.map((att) => (
|
||||
<div key={att.id}>
|
||||
{isImageAttachment(att) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMediaPreview({ type: "image", src: resolveAttachmentUrl(att.file_url), name: att.file_name })}
|
||||
className="block w-full overflow-hidden rounded-lg"
|
||||
>
|
||||
<img
|
||||
src={resolveAttachmentUrl(att.thumbnail || att.file_url)}
|
||||
alt={att.file_name}
|
||||
className="max-h-64 w-full rounded-lg object-cover"
|
||||
/>
|
||||
</button>
|
||||
) : isVideoAttachment(att) ? (
|
||||
brokenMedia[att.id] ? (
|
||||
<a
|
||||
href={resolveAttachmentUrl(att.file_url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
Видео не поддерживается в браузере — открыть файл
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMediaPreview({ type: "video", src: resolveAttachmentUrl(att.file_url), name: att.file_name })}
|
||||
className="block w-full overflow-hidden rounded-lg"
|
||||
>
|
||||
<video
|
||||
preload="metadata"
|
||||
playsInline
|
||||
muted
|
||||
src={resolveAttachmentUrl(att.file_url)}
|
||||
className="max-h-64 w-full rounded-lg bg-black"
|
||||
onError={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: true }))}
|
||||
onLoadedData={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: false }))}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
) : isAudioAttachment(att) ? (
|
||||
<audio
|
||||
controls
|
||||
preload="metadata"
|
||||
className="w-full"
|
||||
onError={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: true }))}
|
||||
>
|
||||
<source src={resolveAttachmentUrl(att.file_url)} type={att.mime_type || "audio/mpeg"} />
|
||||
</audio>
|
||||
) : (
|
||||
<a
|
||||
href={resolveAttachmentUrl(att.file_url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 text-sm text-sky-700 hover:bg-white"
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{att.file_name}</span>
|
||||
<span className="shrink-0 text-xs text-gray-500">{formatFileSize(att.file_size)}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className={`mt-1 text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>
|
||||
{messageTime(message)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 shrink-0 border-t border-gray-100 bg-white pt-3">
|
||||
{attachedFiles.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-700 ring-1 ring-sky-100"
|
||||
>
|
||||
<span className="truncate max-w-[180px]">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachedFile(index)}
|
||||
className="rounded-full p-0.5 hover:bg-sky-100"
|
||||
aria-label="Удалить файл"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFilesChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFiles}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 leading-none transition hover:bg-gray-50 hover:text-sky-700"
|
||||
title="Добавить файлы"
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
<textarea
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="Введите сообщение..."
|
||||
className="w-full resize-none rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 outline-none ring-0 transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={sending || (!messageText.trim() && attachedFiles.length === 0)}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-500 text-white leading-none transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Отправить"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[280px] items-center justify-center rounded-xl bg-gray-50 text-center">
|
||||
<div>
|
||||
<MessageCircle size={20} className="mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-500">Диалог не найден</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mediaPreview ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={() => setMediaPreview(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMediaPreview(null)}
|
||||
className="absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||
aria-label="Закрыть предпросмотр"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="max-h-full max-w-[92vw]" onClick={(e) => e.stopPropagation()}>
|
||||
{mediaPreview.type === "image" ? (
|
||||
<img src={mediaPreview.src} alt={mediaPreview.name} className="max-h-[88vh] max-w-[92vw] rounded-lg object-contain" />
|
||||
) : (
|
||||
<video controls autoPlay className="max-h-[88vh] max-w-[92vw] rounded-lg bg-black" src={mediaPreview.src} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,172 @@
|
||||
import { AppShell, PageHeader } from "../../components/AppShell";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { Chat } from "@/types/api";
|
||||
import { Search, MessageCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "";
|
||||
|
||||
function getChatTitle(chat: Chat): string {
|
||||
return chat.name?.trim() || "Диалог";
|
||||
}
|
||||
|
||||
function getChatInitials(chat: Chat): string {
|
||||
const title = getChatTitle(chat);
|
||||
return title
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() || "")
|
||||
.join("") || "Ч";
|
||||
}
|
||||
|
||||
function resolveAvatarUrl(url?: string | null): string {
|
||||
if (!url) return "";
|
||||
if (/^https?:\/\//i.test(url)) return encodeURI(url);
|
||||
if (url.startsWith("//")) return encodeURI(`https:${url}`);
|
||||
if (url.startsWith("/") && BACKEND_URL) {
|
||||
return encodeURI(`${BACKEND_URL.replace(/\/$/, "")}${url}`);
|
||||
}
|
||||
return encodeURI(url);
|
||||
}
|
||||
|
||||
function formatTime(date?: string): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) {
|
||||
return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||
}
|
||||
|
||||
function isChatOnline(chat: Chat, currentUserId?: number): boolean {
|
||||
const chatKind = chat.chat_type || chat.type;
|
||||
if (chatKind !== "direct" && chatKind !== "private") return false;
|
||||
const otherParticipant = (chat.participants || []).find((p) => p.id !== currentUserId);
|
||||
return Boolean(otherParticipant?.is_active);
|
||||
}
|
||||
|
||||
export default function MessagesPage() {
|
||||
const { user } = useUser();
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadChats() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiClient.getChats();
|
||||
const items = response.results || [];
|
||||
setChats(items);
|
||||
} catch (e: any) {
|
||||
console.error("Ошибка загрузки чатов:", e);
|
||||
setError("Не удалось загрузить чаты");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadChats();
|
||||
}, []);
|
||||
|
||||
const filteredChats = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return chats;
|
||||
|
||||
return chats.filter((chat) => {
|
||||
const title = getChatTitle(chat).toLowerCase();
|
||||
const lastMessage = chat.last_message?.content?.toLowerCase() || "";
|
||||
return title.includes(q) || lastMessage.includes(q);
|
||||
});
|
||||
}, [chats, search]);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader title="Сообщения" subtitle="Здесь появятся чаты и диалоги." badge="В разработке" />
|
||||
{loading ? (
|
||||
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
|
||||
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Загрузка чатов...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-2xl bg-red-50 p-6 text-center">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="relative mb-4">
|
||||
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по чатам"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{filteredChats.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50 p-6 text-center">
|
||||
<p className="text-sm text-gray-500">Чаты не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredChats.map((chat) => (
|
||||
<Link
|
||||
key={chat.id}
|
||||
href={`/messages/${chat.id}`}
|
||||
className="block w-full rounded-xl border border-transparent p-3 text-left transition hover:border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative h-10 w-10">
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-xs font-semibold text-white">
|
||||
{chat.avatar ? (
|
||||
<img
|
||||
src={resolveAvatarUrl(chat.avatar)}
|
||||
alt={getChatTitle(chat)}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
getChatInitials(chat)
|
||||
)}
|
||||
</div>
|
||||
{isChatOnline(chat, user?.id) ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-sm font-semibold text-gray-900">{getChatTitle(chat)}</p>
|
||||
<span className="shrink-0 text-xs text-gray-500">{formatTime(chat.last_message?.created_at)}</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-gray-500">
|
||||
{chat.last_message?.content || "Нет сообщений"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredChats.length === 0 ? (
|
||||
<div className="mt-4 flex min-h-[120px] items-center justify-center rounded-xl bg-gray-50 text-center">
|
||||
<div>
|
||||
<MessageCircle size={20} className="mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-500">Создайте или найдите диалог</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Heart, MessageSquare, Search, Star } from "lucide-react";
|
||||
import { Heart, MessageSquare } from "lucide-react";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useEffect, useState } from "react";
|
||||
@ -56,33 +56,6 @@ export default function Home() {
|
||||
}
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white overflow-hidden">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt={user ? `${user.last_name} ${user.first_name}`.trim() : 'Пользователь'} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
userInitials
|
||||
)}
|
||||
</div>
|
||||
<div className="relative w-full">
|
||||
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-10 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Поиск по ленте"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-sky-700">
|
||||
<span className="rounded-full bg-sky-50 px-3 py-1">Статья</span>
|
||||
<span className="rounded-full bg-sky-50 px-3 py-1">Фото</span>
|
||||
<span className="rounded-full bg-sky-50 px-3 py-1">Ссылка</span>
|
||||
<span className="rounded-full bg-sky-50 px-3 py-1">Опрос</span>
|
||||
<span className="rounded-full bg-sky-50 px-3 py-1">Событие</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{posts.length === 0 ? (
|
||||
<div className="rounded-2xl bg-gray-50 p-8 text-center">
|
||||
@ -96,6 +69,10 @@ export default function Home() {
|
||||
const authorInitials = post.author
|
||||
? `${post.author.last_name?.[0] || ''}${post.author.first_name?.[0] || ''}`
|
||||
: 'А';
|
||||
const isAuthorOnline =
|
||||
typeof post.author?.is_active === 'boolean'
|
||||
? post.author.is_active
|
||||
: Boolean(post.author?.id && user?.id && post.author.id === user.id && user.is_active);
|
||||
|
||||
// Форматируем дату
|
||||
const postDate = new Date(post.created_at);
|
||||
@ -112,12 +89,17 @@ export default function Home() {
|
||||
<article key={post.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<header className="mb-3 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white overflow-hidden">
|
||||
{post.author?.avatar ? (
|
||||
<img src={post.author.avatar} alt={authorName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
authorInitials
|
||||
)}
|
||||
<div className="relative h-10 w-10">
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{post.author?.avatar ? (
|
||||
<img src={post.author.avatar} alt={authorName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
authorInitials
|
||||
)}
|
||||
</div>
|
||||
{isAuthorOnline ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{authorName}</p>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { AppShell, PageHeader } from "../../components/AppShell";
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
|
||||
export default function RequestsPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader title="Заявления" subtitle="Здесь появятся заявки и согласования." badge="В разработке" />
|
||||
<p className="text-sm text-gray-500">Раздел в разработке.</p>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -68,7 +68,7 @@ function Header() {
|
||||
<Bell size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<div
|
||||
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white cursor-pointer hover:bg-sky-500 overflow-hidden"
|
||||
className="ml-1 relative h-10 w-10 cursor-pointer"
|
||||
title={userName}
|
||||
onClick={() => {
|
||||
if (confirm('Выйти из системы?')) {
|
||||
@ -76,11 +76,16 @@ function Header() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
userInitials
|
||||
)}
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white hover:bg-sky-500">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
userInitials
|
||||
)}
|
||||
</div>
|
||||
{user?.is_active ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -106,16 +111,21 @@ function LeftNav() {
|
||||
const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400");
|
||||
|
||||
return (
|
||||
<aside className="hidden w-64 flex-shrink-0 lg:block">
|
||||
<div className="sticky top-8 space-y-4">
|
||||
<aside className="hidden w-64 flex-shrink-0 lg:block lg:sticky lg:self-start lg:top-22 lg:max-h-[calc(100vh-5.5rem)] lg:overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white overflow-hidden">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
userInitials
|
||||
)}
|
||||
<div className="relative h-11 w-11">
|
||||
<div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
userInitials
|
||||
)}
|
||||
</div>
|
||||
{user?.is_active ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{userName}</p>
|
||||
@ -138,8 +148,8 @@ function LeftNav() {
|
||||
|
||||
function Calendar() {
|
||||
return (
|
||||
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block">
|
||||
<div className="sticky top-8 space-y-4">
|
||||
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block lg:sticky lg:self-start lg:top-22 lg:max-h-[calc(100vh-5.5rem)] lg:overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
|
||||
<span>Календарь</span>
|
||||
@ -226,9 +236,9 @@ export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
|
||||
<Header />
|
||||
<div className="mx-auto flex min-h-screen max-w-6xl gap-6 px-4 py-8 sm:px-8">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-6xl gap-6 px-4 py-8 sm:px-8">
|
||||
<LeftNav />
|
||||
<main className="flex-1 space-y-6">{children}</main>
|
||||
<main className="flex-1 min-w-0 space-y-6">{children}</main>
|
||||
<Calendar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -69,6 +69,7 @@ export interface Post {
|
||||
id: number;
|
||||
author: User;
|
||||
content: string;
|
||||
image?: string;
|
||||
tags?: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@ -116,7 +117,9 @@ export interface Request {
|
||||
export interface Chat {
|
||||
id: number;
|
||||
name?: string;
|
||||
chat_type: 'direct' | 'group' | 'department';
|
||||
avatar?: string | null;
|
||||
chat_type?: 'direct' | 'group' | 'department';
|
||||
type?: 'private' | 'group' | 'department' | 'announcement';
|
||||
participants: User[];
|
||||
last_message?: Message;
|
||||
unread_count?: number;
|
||||
@ -125,11 +128,33 @@ export interface Chat {
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
chat: number;
|
||||
sender: User;
|
||||
chat?: number;
|
||||
sender?: User;
|
||||
author?: User;
|
||||
author_id?: number;
|
||||
author_name?: string;
|
||||
avatar?: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
is_read?: boolean;
|
||||
created_at?: string;
|
||||
created?: string;
|
||||
created_ts?: number;
|
||||
is_edited?: boolean;
|
||||
is_deleted?: boolean;
|
||||
has_attachments?: boolean;
|
||||
attachments?: MessageAttachment[];
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_type?: string;
|
||||
file_url: string;
|
||||
file_size?: number;
|
||||
mime_type?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
thumbnail?: string | null;
|
||||
}
|
||||
|
||||
// Calendar types
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user