feat: project tabs (board/chat), fullscreen chat mode
All checks were successful
Deploy Web Client / deploy (push) Successful in 35s
All checks were successful
Deploy Web Client / deploy (push) Successful in 35s
This commit is contained in:
parent
be5e71d42e
commit
e3c4d528a8
@ -7,10 +7,16 @@ import Sidebar from "@/components/Sidebar";
|
|||||||
import KanbanBoard from "@/components/KanbanBoard";
|
import KanbanBoard from "@/components/KanbanBoard";
|
||||||
import ChatPanel from "@/components/ChatPanel";
|
import ChatPanel from "@/components/ChatPanel";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: "board", label: "📋 Доска" },
|
||||||
|
{ key: "chat", label: "💬 Чат" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ProjectPage() {
|
export default function ProjectPage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState("board");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getProjects()
|
getProjects()
|
||||||
@ -33,17 +39,44 @@ export default function ProjectPage() {
|
|||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
<Sidebar projects={projects} activeSlug={slug} />
|
<Sidebar projects={projects} activeSlug={slug} />
|
||||||
<main className="flex-1 flex flex-col overflow-hidden">
|
<main className="flex-1 flex flex-col overflow-hidden">
|
||||||
<header className="border-b border-[var(--border)] px-6 py-4 flex items-center gap-4 pl-14 md:pl-6">
|
<header className="border-b border-[var(--border)] px-6 py-3 pl-14 md:pl-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">{project.name}</h1>
|
<h1 className="text-xl font-bold">{project.name}</h1>
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-sm text-[var(--muted)]">{project.description}</p>
|
<p className="text-sm text-[var(--muted)]">{project.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`px-3 py-1.5 rounded text-sm transition-colors ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||||
|
: "text-[var(--muted)] hover:bg-white/5 hover:text-[var(--fg)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{project.chat_id && <ChatPanel chatId={project.chat_id} />}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{activeTab === "board" && (
|
||||||
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
|
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
|
||||||
|
)}
|
||||||
|
{activeTab === "chat" && project.chat_id && (
|
||||||
|
<ChatPanel chatId={project.chat_id} fullscreen />
|
||||||
|
)}
|
||||||
|
{activeTab === "chat" && !project.chat_id && (
|
||||||
|
<div className="flex items-center justify-center h-full text-[var(--muted)] text-sm">
|
||||||
|
Чат недоступен
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,9 +12,10 @@ const AUTHOR_ICON: Record<string, string> = {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
fullscreen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPanel({ chatId }: Props) {
|
export default function ChatPanel({ chatId, fullscreen = false }: Props) {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -27,7 +28,6 @@ export default function ChatPanel({ chatId }: Props) {
|
|||||||
}
|
}
|
||||||
}, [chatId]);
|
}, [chatId]);
|
||||||
|
|
||||||
// Listen for new messages via WS
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = wsClient.on("message.new", (data: any) => {
|
const unsub = wsClient.on("message.new", (data: any) => {
|
||||||
if (data.chat_id === chatId) {
|
if (data.chat_id === chatId) {
|
||||||
@ -51,12 +51,53 @@ export default function ChatPanel({ chatId }: Props) {
|
|||||||
setMessages((prev) => [...prev, msg]);
|
setMessages((prev) => [...prev, msg]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to send:", e);
|
console.error("Failed to send:", e);
|
||||||
setInput(text); // restore on error
|
setInput(text);
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (fullscreen) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id} className="text-sm">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
|
||||||
|
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
|
||||||
|
<span className="font-medium">{msg.author_slug}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-3 border-t border-[var(--border)] flex gap-2">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||||
|
placeholder="Сообщение..."
|
||||||
|
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending}
|
||||||
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
↵
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact mode (collapsible)
|
||||||
return (
|
return (
|
||||||
<div className={`border-b border-[var(--border)] transition-all ${open ? "h-64" : "h-10"}`}>
|
<div className={`border-b border-[var(--border)] transition-all ${open ? "h-64" : "h-10"}`}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user