fix: restore all original components (KanbanBoard, ChatPanel, TaskModal, etc.)

Previous migration stubbed most components. Now properly ported from
Next.js with type-only imports and react-router-dom Link/navigation.
This commit is contained in:
Markov 2026-02-24 09:33:04 +01:00
parent 83bc168c30
commit 3a2a32a50e
11 changed files with 1424 additions and 159 deletions

View File

@ -1,28 +1,257 @@
import { useState } from "react";
import type { Member } from "@/lib/api"; import type { Member } from "@/lib/api";
import { updateMember, regenerateToken, revokeToken } from "@/lib/api";
interface Props { interface Props {
agent: Member; agent: Member;
onClose: () => void; onClose: () => void;
onUpdated: () => void; onUpdated: (agent: Member) => void;
} }
export default function AgentModal({ agent, onClose, onUpdated: _onUpdated }: Props) { export default function AgentModal({ agent, onClose, onUpdated }: Props) {
const [name, setName] = useState(agent.name);
const [capabilities, setCapabilities] = useState(
agent.agent_config?.capabilities?.join(", ") || ""
);
const [chatListen, setChatListen] = useState(agent.agent_config?.chat_listen || "mentions");
const [taskListen, setTaskListen] = useState(agent.agent_config?.task_listen || "assigned");
const [prompt, setPrompt] = useState(agent.agent_config?.prompt || "");
const [model, setModel] = useState(agent.agent_config?.model || "");
const [saving, setSaving] = useState(false);
const [currentToken, setCurrentToken] = useState<string | null>(agent.token || null);
const [copied, setCopied] = useState(false);
const [confirmRegen, setConfirmRegen] = useState(false);
const [confirmRevoke, setConfirmRevoke] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
const caps = capabilities.split(",").map((c) => c.trim()).filter(Boolean);
const updated = await updateMember(agent.slug, {
name: name.trim(),
agent_config: {
capabilities: caps,
chat_listen: chatListen,
task_listen: taskListen,
prompt: prompt.trim() || null,
model: model.trim() || null,
},
});
onUpdated(updated);
onClose();
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
const handleRegenerate = async () => {
try {
const { token } = await regenerateToken(agent.slug);
setCurrentToken(token);
setConfirmRegen(false);
} catch (e) {
console.error(e);
}
};
const handleRevoke = async () => {
try {
await revokeToken(agent.slug);
setCurrentToken(null);
setConfirmRevoke(false);
} catch (e) {
console.error(e);
}
};
const copyToken = () => {
if (currentToken) {
navigator.clipboard.writeText(currentToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md"> <div
<h2 className="text-xl font-bold mb-4">Агент: {agent.name}</h2> className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-lg"
<p className="text-[var(--muted)] text-sm mb-4">@{agent.slug}</p> onClick={(e) => e.stopPropagation()}
<p className="text-[var(--muted)] text-sm mb-4">Coming soon...</p> >
<div className="flex gap-2"> <div className="p-5 border-b border-[var(--border)] flex items-center justify-between">
<div>
<h2 className="text-lg font-bold">{agent.name}</h2>
<div className="text-xs text-[var(--muted)]">@{agent.slug}</div>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full ${
agent.status === "online"
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
}`}
>
{agent.status === "online" ? "online" : "offline"}
</span>
</div>
<div className="p-5 space-y-4">
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Название</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Capabilities</label>
<input
value={capabilities}
onChange={(e) => setCapabilities(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
placeholder="code, review, deploy"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Chat listen</label>
<select
value={chatListen}
onChange={(e) => setChatListen(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
>
<option value="all">all</option>
<option value="mentions">mentions</option>
<option value="none">none</option>
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Task listen</label>
<select
value={taskListen}
onChange={(e) => setTaskListen(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
>
<option value="all">all</option>
<option value="assigned">assigned</option>
<option value="none">none</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Prompt</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm resize-y min-h-[60px]"
rows={3}
placeholder="Системный промпт агента..."
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Модель</label>
<input
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
placeholder="claude-sonnet-4-20250514, gpt-4o, ..."
/>
</div>
{/* Token section */}
<div className="pt-3 border-t border-[var(--border)]">
<div className="text-sm text-[var(--muted)] mb-2">Токен доступа</div>
{currentToken ? (
<div>
<div className="p-3 bg-[var(--bg)] border border-[var(--border)] rounded-lg font-mono text-xs break-all select-all mb-2">
{currentToken}
</div>
<div className="flex items-center gap-2">
<button
onClick={copyToken}
className="px-3 py-1.5 bg-[var(--accent)] text-white rounded text-xs hover:opacity-90"
>
{copied ? "Скопировано!" : "Скопировать"}
</button>
{confirmRegen ? (
<>
<button
onClick={handleRegenerate}
className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded text-xs hover:bg-yellow-500/30"
>
Да, новый
</button>
<button onClick={() => setConfirmRegen(false)} className="text-xs text-[var(--muted)]">
Отмена
</button>
</>
) : (
<button
onClick={() => setConfirmRegen(true)}
className="text-xs text-[var(--muted)] hover:text-[var(--fg)]"
>
Перегенерировать
</button>
)}
{confirmRevoke ? (
<>
<button
onClick={handleRevoke}
className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30"
>
Да, отозвать
</button>
<button onClick={() => setConfirmRevoke(false)} className="text-xs text-[var(--muted)]">
Отмена
</button>
</>
) : (
<button
onClick={() => setConfirmRevoke(true)}
className="text-xs text-red-400/60 hover:text-red-400"
>
Отозвать
</button>
)}
</div>
</div>
) : (
<div className="flex items-center gap-3">
<span className="text-xs text-[var(--muted)]">Токен отозван</span>
<button
onClick={handleRegenerate}
className="text-xs text-[var(--accent)] hover:underline"
>
Сгенерировать новый
</button>
</div>
)}
</div>
</div>
<div className="p-5 border-t border-[var(--border)] flex justify-end gap-2">
<button <button
onClick={onClose} onClick={onClose}
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
text-sm hover:bg-white/5 transition-colors"
> >
Закрыть Отмена
</button>
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
>
{saving ? "..." : "Сохранить"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,28 +1,27 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { isAuthenticated } from "@/lib/auth-client"; import { isAuthenticated } from "@/lib/auth-client";
import { wsClient } from "@/lib/ws"; import { wsClient } from "@/lib/ws";
export default function AuthGuard({ children }: { children: React.ReactNode }) { export default function AuthGuard({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const [checked, setChecked] = useState(false); const [checked, setChecked] = useState(false);
const [isAuth, setIsAuth] = useState(false);
useEffect(() => { useEffect(() => {
const authStatus = isAuthenticated(); if (!isAuthenticated()) {
setIsAuth(authStatus); navigate("/login");
setChecked(true); } else {
setChecked(true);
if (authStatus) {
// Connect WebSocket after auth check // Connect WebSocket after auth check
if (!wsClient.connected) { if (!wsClient.connected) {
wsClient.connect(); wsClient.connect();
} }
} }
return () => { return () => {
// Don't disconnect on unmount — singleton stays alive // Don't disconnect on unmount — singleton stays alive
}; };
}, []); }, [navigate]);
if (!checked) { if (!checked) {
return ( return (
@ -32,9 +31,5 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
); );
} }
if (!isAuth) {
return <Navigate to="/login" replace />;
}
return <>{children}</>; return <>{children}</>;
} }

View File

@ -1,16 +1,140 @@
import { useEffect, useRef, useState } from "react";
import type { Message } from "@/lib/api";
import { getMessages, sendMessage } from "@/lib/api";
import { wsClient } from "@/lib/ws";
const AUTHOR_ICON: Record<string, string> = {
human: "👤",
agent: "🤖",
system: "⚙️",
};
interface Props { interface Props {
chatId: string; chatId: string;
fullscreen?: boolean; fullscreen?: boolean;
} }
export default function ChatPanel({ chatId, fullscreen: _fullscreen }: Props) { export default function ChatPanel({ chatId, fullscreen = false }: Props) {
return ( const [messages, setMessages] = useState<Message[]>([]);
<div className="flex items-center justify-center h-full text-[var(--muted)]"> const [input, setInput] = useState("");
<div className="text-center"> const [open, setOpen] = useState(false);
<h3 className="text-lg mb-2">💬 Chat Panel</h3> const [sending, setSending] = useState(false);
<p className="text-sm">Chat ID: {chatId}</p> const bottomRef = useRef<HTMLDivElement>(null);
<p className="text-xs mt-1">Coming soon...</p>
useEffect(() => {
if (chatId) {
getMessages({ chat_id: chatId, limit: 50 }).then(setMessages).catch(() => {});
}
}, [chatId]);
useEffect(() => {
const unsub = wsClient.on("message.new", (data: any) => {
if (data.chat_id === chatId) {
setMessages((prev) => [...prev, data as Message]);
}
});
return () => { unsub?.(); };
}, [chatId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = async () => {
if (!input.trim() || !chatId || sending) return;
const text = input.trim();
setInput("");
setSending(true);
try {
const msg = await sendMessage({ chat_id: chatId, content: text });
setMessages((prev) => [...prev, msg]);
} catch (e) {
console.error("Failed to send:", e);
setInput(text);
} finally {
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> </div>
);
}
// Compact mode (collapsible)
return (
<div className={`border-b border-[var(--border)] transition-all ${open ? "h-64" : "h-10"}`}>
<button
onClick={() => setOpen(!open)}
className="w-full h-10 px-4 flex items-center text-sm text-[var(--muted)] hover:text-[var(--fg)]"
>
💬 Чат {open ? "▼" : "▲"}
</button>
{open && (
<div className="flex flex-col h-[calc(100%-2.5rem)]">
<div className="flex-1 overflow-y-auto px-4 py-2 space-y-2">
{messages.map((msg) => (
<div key={msg.id} className="text-sm">
<span className="text-xs text-[var(--muted)]">
{AUTHOR_ICON[msg.author_type] || "👤"} {msg.author_slug}
</span>
<span className="ml-2">{msg.content}</span>
</div>
))}
{messages.length === 0 && (
<div className="text-sm text-[var(--muted)] italic py-4 text-center">Нет сообщений</div>
)}
<div ref={bottomRef} />
</div>
<div className="px-4 py-2 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 px-3 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
/>
<button onClick={handleSend} disabled={sending} className="text-sm text-[var(--accent)] px-2 disabled:opacity-50"></button>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -1,24 +1,153 @@
import { useState } from "react";
import type { MemberCreateResponse } from "@/lib/api";
import { createMember } from "@/lib/api";
interface Props { interface Props {
onCreated: () => void; onCreated: (member: MemberCreateResponse) => void;
onClose: () => void; onClose: () => void;
} }
export default function CreateAgentModal({ onCreated: _onCreated, onClose }: Props) { export default function CreateAgentModal({ onCreated, onClose }: Props) {
return ( const [name, setName] = useState("");
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> const [capabilities, setCapabilities] = useState("");
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md"> const [loading, setLoading] = useState(false);
<h2 className="text-xl font-bold mb-4">Создать агента</h2> const [error, setError] = useState("");
<p className="text-[var(--muted)] text-sm mb-4">Coming soon...</p> const [token, setToken] = useState<string | null>(null);
<div className="flex gap-2"> const [copied, setCopied] = useState(false);
<button
onClick={onClose} const slug = name
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded .toLowerCase()
text-sm hover:bg-white/5 transition-colors" .replace(/[^a-zа-яё0-9\s-]/gi, "")
> .replace(/\s+/g, "-")
Закрыть .replace(/-+/g, "-")
</button> .slice(0, 50);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !slug) return;
setLoading(true);
setError("");
try {
const caps = capabilities
.split(",")
.map((c) => c.trim())
.filter(Boolean);
const member = await createMember({
name: name.trim(),
slug,
type: "agent",
agent_config: {
capabilities: caps,
chat_listen: "mentions",
task_listen: "assigned",
},
});
if (member.token) {
setToken(member.token);
}
onCreated(member);
} catch {
setError("Ошибка создания агента");
} finally {
setLoading(false);
}
};
const copyToken = () => {
if (token) {
navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (token) {
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-96"
>
<h2 className="text-lg font-bold mb-4">Агент создан</h2>
<div className="mb-3 p-2 bg-yellow-500/10 border border-yellow-500/30 rounded text-yellow-400 text-sm">
Токен показывается только один раз. Сохраните его сейчас!
</div>
<div className="mb-4 p-3 bg-[var(--bg)] border border-[var(--border)] rounded-lg font-mono text-xs break-all select-all">
{token}
</div>
<div className="flex gap-2 justify-end">
<button
onClick={copyToken}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90"
>
{copied ? "Скопировано!" : "Скопировать"}
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
>
Закрыть
</button>
</div>
</div> </div>
</div> </div>
);
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<form
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-96"
>
<h2 className="text-lg font-bold mb-4">Новый агент</h2>
{error && (
<div className="mb-3 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
{error}
</div>
)}
<label className="block text-sm text-[var(--muted)] mb-1">Название</label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full mb-1 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
outline-none focus:border-[var(--accent)] text-sm"
placeholder="Code Assistant"
/>
{slug && <div className="text-xs text-[var(--muted)] mb-3">Slug: {slug}</div>}
<label className="block text-sm text-[var(--muted)] mb-1">Capabilities</label>
<input
value={capabilities}
onChange={(e) => setCapabilities(e.target.value)}
className="w-full mb-4 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
outline-none focus:border-[var(--accent)] text-sm"
placeholder="code, review, deploy"
/>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
>
Отмена
</button>
<button
type="submit"
disabled={loading || !name.trim()}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm
hover:opacity-90 disabled:opacity-50"
>
{loading ? "..." : "Создать"}
</button>
</div>
</form>
</div> </div>
); );
} }

View File

@ -1,5 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { createProject, type Project } from "@/lib/api"; import type { Project } from "@/lib/api";
import { createProject } from "@/lib/api";
interface Props { interface Props {
onCreated: (project: Project) => void; onCreated: (project: Project) => void;
@ -8,107 +10,90 @@ interface Props {
export default function CreateProjectModal({ onCreated, onClose }: Props) { export default function CreateProjectModal({ onCreated, onClose }: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const slug = name
.toLowerCase()
.replace(/[^a-zа-яё0-9\s-]/gi, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 50);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); if (!name.trim() || !slug) return;
setLoading(true); setLoading(true);
setError("");
try { try {
const project = await createProject({ name, slug, description: description || undefined }); const project = await createProject({
name: name.trim(),
slug,
description: description.trim() || undefined,
});
onCreated(project); onCreated(project);
} catch (err: any) { } catch (err) {
setError(err.message || "Ошибка создания проекта"); setError("Ошибка создания проекта");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const generateSlug = () => {
if (name && !slug) {
const generated = name.toLowerCase()
.replace(/[^a-zа-я0-9\s]/g, '')
.replace(/\s+/g, '-');
setSlug(generated);
}
};
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md"> <form
<h2 className="text-xl font-bold mb-4">Создать проект</h2> onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-96"
>
<h2 className="text-lg font-bold mb-4">Новый проект</h2>
{error && ( {error && (
<div className="mb-4 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm"> <div className="mb-3 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <label className="block text-sm text-[var(--muted)] mb-1">Название</label>
<div> <input
<label className="block text-sm mb-1">Название</label> autoFocus
<input value={name}
type="text" onChange={(e) => setName(e.target.value)}
value={name} className="w-full mb-1 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
onChange={(e) => setName(e.target.value)} outline-none focus:border-[var(--accent)] text-sm"
onBlur={generateSlug} placeholder="Мой проект"
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded />
outline-none focus:border-[var(--accent)] text-sm" {slug && <div className="text-xs text-[var(--muted)] mb-3">Slug: {slug}</div>}
placeholder="Мой проект"
required <label className="block text-sm text-[var(--muted)] mb-1">Описание</label>
/> <textarea
</div> value={description}
onChange={(e) => setDescription(e.target.value)}
<div> className="w-full mb-4 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
<label className="block text-sm mb-1">Slug (URL)</label> outline-none focus:border-[var(--accent)] text-sm resize-none"
<input rows={3}
type="text" placeholder="Необязательно"
value={slug} />
onChange={(e) => setSlug(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded <div className="flex gap-2 justify-end">
outline-none focus:border-[var(--accent)] text-sm font-mono" <button
placeholder="my-project" type="button"
pattern="[a-z0-9-]+" onClick={onClose}
required className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
/> >
</div> Отмена
</button>
<div> <button
<label className="block text-sm mb-1">Описание (опционально)</label> type="submit"
<textarea disabled={loading || !name.trim()}
value={description} className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm
onChange={(e) => setDescription(e.target.value)} hover:opacity-90 disabled:opacity-50"
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded >
outline-none focus:border-[var(--accent)] text-sm resize-none" {loading ? "..." : "Создать"}
rows={3} </button>
placeholder="Краткое описание проекта" </div>
/> </form>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
text-sm hover:bg-white/5 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading || !name || !slug}
className="flex-1 py-2 bg-[var(--accent)] text-white rounded text-sm
hover:opacity-90 transition-opacity disabled:opacity-50"
>
{loading ? "Создаём..." : "Создать"}
</button>
</div>
</form>
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,112 @@
import { useState } from "react";
import type { Task } from "@/lib/api";
import { createTask } from "@/lib/api";
const PRIORITIES = [
{ key: "low", label: "Low", color: "#737373" },
{ key: "medium", label: "Medium", color: "#3b82f6" },
{ key: "high", label: "High", color: "#f59e0b" },
{ key: "critical", label: "Critical", color: "#ef4444" },
];
interface Props {
projectSlug: string;
initialStatus: string;
onClose: () => void;
onCreated: (task: Task) => void;
}
export default function CreateTaskModal({ projectSlug, initialStatus, onClose, onCreated }: Props) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState("medium");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
if (!title.trim()) return;
setSaving(true);
setError("");
try {
const task = await createTask(projectSlug, {
title: title.trim(),
description: description.trim() || undefined,
status: initialStatus,
priority,
});
onCreated(task);
onClose();
} catch (e: any) {
setError(e.message || "Ошибка");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" onClick={onClose}>
<div
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-md p-6"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold mb-4">Новая задача</h3>
<div className="space-y-4">
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Название</label>
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
placeholder="Название задачи..."
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)]"
/>
</div>
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Описание</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Описание (опционально)..."
rows={3}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)] resize-y"
/>
</div>
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Приоритет</label>
<div className="flex gap-1">
{PRIORITIES.map((p) => (
<button
key={p.key}
onClick={() => setPriority(p.key)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${priority === p.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: p.color }} />
{p.label}
</button>
))}
</div>
</div>
{error && <div className="text-xs text-red-400">{error}</div>}
<div className="flex gap-2 justify-end pt-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--muted)]">Отмена</button>
<button
onClick={handleSubmit}
disabled={!title.trim() || saving}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm disabled:opacity-50"
>
{saving ? "..." : "Создать"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,16 +1,226 @@
import { useEffect, useState } from "react";
import type { Task } from "@/lib/api";
import { getTasks, updateTask } from "@/lib/api";
import TaskModal from "@/components/TaskModal";
import CreateTaskModal from "@/components/CreateTaskModal";
import { wsClient } from "@/lib/ws";
const COLUMNS = [
{ key: "backlog", label: "Backlog", color: "#737373" },
{ key: "todo", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "in_review", label: "Review", color: "#a855f7" },
{ key: "done", label: "Done", color: "#22c55e" },
];
const PRIORITY_COLORS: Record<string, string> = {
critical: "#ef4444",
high: "#f59e0b",
medium: "#3b82f6",
low: "#737373",
};
interface Props { interface Props {
projectId: string; projectId: string;
projectSlug: string; projectSlug: string;
} }
export default function KanbanBoard({ projectId: _projectId, projectSlug }: Props) { export default function KanbanBoard({ projectId, projectSlug }: Props) {
return ( const [tasks, setTasks] = useState<Task[]>([]);
<div className="flex items-center justify-center h-full text-[var(--muted)]"> const [loading, setLoading] = useState(true);
<div className="text-center"> const [draggedTask, setDraggedTask] = useState<string | null>(null);
<h3 className="text-lg mb-2">📋 Kanban Board</h3> const [activeColumn, setActiveColumn] = useState<string | null>(null);
<p className="text-sm">Project: {projectSlug}</p> const [selectedTask, setSelectedTask] = useState<Task | null>(null);
<p className="text-xs mt-1">Coming soon...</p> const [createInStatus, setCreateInStatus] = useState<string | null>(null);
const prefix = projectSlug.slice(0, 2).toUpperCase();
const loadTasks = async () => {
try {
const data = await getTasks(projectId);
setTasks(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
// Subscribe to WebSocket events for real-time updates
const unsubscribeCreated = wsClient.on("task.created", (data: any) => {
if (data.project_id === projectId) {
setTasks((prev) => [...prev, data as Task]);
}
});
const unsubscribeUpdated = wsClient.on("task.updated", (data: any) => {
if (data.project_id === projectId) {
setTasks((prev) => prev.map((t) => t.id === data.id ? { ...t, ...data } : t));
}
});
const unsubscribeAssigned = wsClient.on("task.assigned", (data: any) => {
if (data.project_id === projectId) {
setTasks((prev) => prev.map((t) => t.id === data.id ? { ...t, assignee_slug: data.assignee_slug, assigned_at: data.assigned_at } : t));
}
});
return () => {
unsubscribeCreated?.();
unsubscribeUpdated?.();
unsubscribeAssigned?.();
};
}, [projectId]);
const handleDrop = async (status: string) => {
if (!draggedTask) return;
const task = tasks.find((t) => t.id === draggedTask);
if (!task || task.status === status) return;
setTasks((prev) => prev.map((t) => (t.id === draggedTask ? { ...t, status } : t)));
setDraggedTask(null);
try { await updateTask(draggedTask, { status }); } catch { loadTasks(); }
};
const handleMoveTask = async (taskId: string, newStatus: string) => {
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
try { await updateTask(taskId, { status: newStatus }); } catch { loadTasks(); }
};
if (loading) {
return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>;
}
const TaskCard = ({ task, showMoveButtons, colIndex }: { task: Task; showMoveButtons?: boolean; colIndex?: number }) => (
<div
key={task.id}
draggable
onDragStart={() => setDraggedTask(task.id)}
onClick={() => setSelectedTask(task)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-pointer
hover:border-[var(--accent)] transition-colors"
>
<div className="flex items-center gap-1.5 mb-1">
<div className="w-2 h-2 rounded-full shrink-0"
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
title={task.priority} />
<span className="text-xs text-[var(--muted)]">{prefix}-{task.number}</span>
{task.assignee_slug && (
<span className="text-xs text-[var(--muted)] ml-auto"> {task.assignee_slug}</span>
)}
</div> </div>
<div className="text-sm ml-3.5">{task.title}</div>
{showMoveButtons && colIndex !== undefined && (
<div className="flex gap-1 mt-2 ml-3.5">
{colIndex > 0 && (
<button
onClick={(e) => { e.stopPropagation(); handleMoveTask(task.id, COLUMNS[colIndex - 1].key); }}
className="text-xs px-2 py-0.5 rounded bg-white/5 text-[var(--muted)] hover:text-[var(--fg)]"
> {COLUMNS[colIndex - 1].label}</button>
)}
{colIndex < COLUMNS.length - 1 && (
<button
onClick={(e) => { e.stopPropagation(); handleMoveTask(task.id, COLUMNS[colIndex + 1].key); }}
className="text-xs px-2 py-0.5 rounded bg-white/5 text-[var(--muted)] hover:text-[var(--fg)]"
>{COLUMNS[colIndex + 1].label} </button>
)}
</div>
)}
</div> </div>
); );
}
return (
<div className="h-full flex flex-col">
{/* Mobile column tabs */}
<div className="md:hidden flex overflow-x-auto border-b border-[var(--border)] px-2 py-1 gap-1 shrink-0">
{COLUMNS.map((col) => {
const count = tasks.filter((t) => t.status === col.key).length;
return (
<button key={col.key} onClick={() => setActiveColumn(col.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs whitespace-nowrap shrink-0 transition-colors
${(activeColumn || "backlog") === col.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)]"}`}>
<span className="w-2 h-2 rounded-full" style={{ background: col.color }} />
{col.label}
{count > 0 && <span className="text-[var(--muted)]">{count}</span>}
</button>
);
})}
</div>
{/* Mobile: single column */}
<div className="md:hidden flex-1 overflow-y-auto p-3">
{(() => {
const col = COLUMNS.find((c) => c.key === (activeColumn || "backlog"))!;
const colTasks = tasks.filter((t) => t.status === col.key);
const colIndex = COLUMNS.findIndex((c) => c.key === col.key);
return (
<div className="flex flex-col gap-2">
{colTasks.map((task) => (
<TaskCard key={task.id} task={task} showMoveButtons colIndex={colIndex} />
))}
<button
className="text-sm text-[var(--muted)] hover:text-[var(--fg)] py-2"
onClick={() => setCreateInStatus(col.key)}
>+ Добавить задачу</button>
</div>
);
})()}
</div>
{/* Desktop: horizontal kanban */}
<div className="hidden md:flex gap-4 overflow-x-auto p-4 flex-1">
{COLUMNS.map((col) => {
const colTasks = tasks.filter((t) => t.status === col.key);
return (
<div key={col.key} className="flex flex-col min-w-[280px] w-[280px] shrink-0"
onDragOver={(e) => e.preventDefault()} onDrop={() => handleDrop(col.key)}>
<div className="flex items-center gap-2 mb-3 px-1">
<div className="w-3 h-3 rounded-full" style={{ background: col.color }} />
<span className="font-semibold text-sm">{col.label}</span>
<span className="text-xs text-[var(--muted)] ml-auto">{colTasks.length}</span>
</div>
<div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2">
{colTasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
<button
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] mt-1 text-left px-2 py-1"
onClick={() => setCreateInStatus(col.key)}
>+ Добавить</button>
</div>
</div>
);
})}
</div>
{/* Task modal */}
{selectedTask && (
<TaskModal
task={selectedTask}
projectId={projectId}
projectSlug={projectSlug}
onClose={() => setSelectedTask(null)}
onUpdated={(updated) => {
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setSelectedTask(updated);
}}
onDeleted={(id) => {
setTasks((prev) => prev.filter((t) => t.id !== id));
setSelectedTask(null);
}}
/>
)}
{/* Create task modal */}
{createInStatus && (
<CreateTaskModal
projectSlug={projectSlug}
initialStatus={createInStatus}
onClose={() => setCreateInStatus(null)}
onCreated={(task) => setTasks((prev) => [...prev, task])}
/>
)}
</div>
);
}

View File

@ -1,17 +1,16 @@
import type { Project } from "@/lib/api"; import type { Project } from "@/lib/api";
interface Props { interface Props {
project: Project; project: Project;
} }
export default function ProjectFiles({ project }: Props) { export default function ProjectFiles({ project: _project }: Props) {
return ( return (
<div className="flex items-center justify-center h-full text-[var(--muted)]"> <div className="flex flex-col items-center justify-center h-full text-[var(--muted)]">
<div className="text-center"> <div className="text-4xl mb-4">📁</div>
<h3 className="text-lg mb-2">📁 Project Files</h3> <div className="text-sm">Файлы проекта</div>
<p className="text-sm">Project: {project.name}</p> <div className="text-xs mt-1">Скоро здесь можно будет загружать и просматривать файлы</div>
<p className="text-xs mt-1">Coming soon...</p>
</div>
</div> </div>
); );
} }

View File

@ -1,18 +1,139 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import type { Project } from "@/lib/api"; import type { Project } from "@/lib/api";
import { updateProject, deleteProject } from "@/lib/api";
interface Props { interface Props {
project: Project; project: Project;
onUpdated: (project: Project) => void; onUpdated: (p: Project) => void;
} }
export default function ProjectSettings({ project, onUpdated: _onUpdated }: Props) { export default function ProjectSettings({ project, onUpdated }: Props) {
const navigate = useNavigate();
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description || "");
const [repoUrls, setRepoUrls] = useState(project.repo_urls?.join("\n") || "");
const [status, setStatus] = useState(project.status);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
const urls = repoUrls.split("\n").map((u) => u.trim()).filter(Boolean);
const updated = await updateProject(project.slug, {
name: name.trim(),
description: description.trim() || null,
repo_urls: urls,
status,
});
onUpdated(updated);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
try {
await deleteProject(project.slug);
navigate("/");
} catch (e) {
console.error(e);
}
};
return ( return (
<div className="flex items-center justify-center h-full text-[var(--muted)]"> <div className="max-w-xl mx-auto p-6 space-y-6">
<div className="text-center"> <h2 className="text-lg font-bold">Настройки проекта</h2>
<h3 className="text-lg mb-2"> Project Settings</h3>
<p className="text-sm">Project: {project.name}</p> <div>
<p className="text-xs mt-1">Coming soon...</p> <label className="block text-sm text-[var(--muted)] mb-1">Название</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Описание</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm resize-y"
rows={3}
placeholder="Описание проекта..."
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Репозитории (по одному на строку)</label>
<textarea
value={repoUrls}
onChange={(e) => setRepoUrls(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm font-mono resize-y"
rows={3}
placeholder="https://github.com/..."
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Статус</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
>
<option value="active">Active</option>
<option value="archived">Archived</option>
<option value="paused">Paused</option>
</select>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
>
{saving ? "..." : "Сохранить"}
</button>
{saved && <span className="text-sm text-green-400">Сохранено </span>}
</div>
<div className="pt-6 border-t border-[var(--border)]">
<div className="text-sm text-[var(--muted)] mb-2">Зона опасности</div>
{confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-400">Проект и все задачи будут удалены!</span>
<button
onClick={handleDelete}
className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30"
>
Да, удалить
</button>
<button
onClick={() => setConfirmDelete(false)}
className="px-3 py-1.5 text-xs text-[var(--muted)]"
>
Отмена
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="text-xs text-red-400/60 hover:text-red-400"
>
Удалить проект
</button>
)}
</div> </div>
</div> </div>
); );
} }

View File

@ -1,3 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Project } from "@/lib/api"; import type { Project } from "@/lib/api";
@ -108,4 +109,4 @@ export default function Sidebar({ projects, activeSlug }: Props) {
</aside> </aside>
</> </>
); );
} }

View File

@ -0,0 +1,360 @@
import { useEffect, useState } from "react";
import type { Task, Member, Step, Message } from "@/lib/api";
import {
updateTask, deleteTask, getMembers,
getSteps, createStep, updateStep, deleteStep as _deleteStepApi,
getMessages, sendMessage,
} from "@/lib/api";
const STATUSES = [
{ key: "backlog", label: "Backlog", color: "#737373" },
{ key: "todo", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "in_review", label: "Review", color: "#a855f7" },
{ key: "done", label: "Done", color: "#22c55e" },
];
const PRIORITIES = [
{ key: "low", label: "Low", color: "#737373" },
{ key: "medium", label: "Medium", color: "#3b82f6" },
{ key: "high", label: "High", color: "#f59e0b" },
{ key: "critical", label: "Critical", color: "#ef4444" },
];
const AUTHOR_ICON: Record<string, string> = {
human: "👤",
agent: "🤖",
system: "⚙️",
};
interface Props {
task: Task;
projectId: string;
projectSlug: string;
onClose: () => void;
onUpdated: (task: Task) => void;
onDeleted: (taskId: string) => void;
}
export default function TaskModal({ task, projectId: _projectId, projectSlug: _projectSlug, onClose, onUpdated, onDeleted }: Props) {
const [title, setTitle] = useState(task.title);
const [description, setDescription] = useState(task.description || "");
const [status, setStatus] = useState(task.status);
const [priority, setPriority] = useState(task.priority);
const [assigneeSlug, setAssigneeSlug] = useState(task.assignee_slug || "");
const [members, setMembers] = useState<Member[]>([]);
const [steps, setSteps] = useState<Step[]>(task.steps || []);
const [comments, setComments] = useState<Message[]>([]);
const [_saving, setSaving] = useState(false);
const [editingDesc, setEditingDesc] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [newStep, setNewStep] = useState("");
const [newComment, setNewComment] = useState("");
useEffect(() => {
getMembers().then(setMembers).catch(() => {});
getSteps(task.id).then(setSteps).catch(() => {});
getMessages({ task_id: task.id }).then(setComments).catch(() => {});
}, [task.id]);
const save = async (patch: Partial<Task>) => {
setSaving(true);
try {
const updated = await updateTask(task.id, patch);
onUpdated(updated);
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
try {
await deleteTask(task.id);
onDeleted(task.id);
onClose();
} catch (e) {
console.error(e);
}
};
const handleAddStep = async () => {
if (!newStep.trim()) return;
try {
const step = await createStep(task.id, newStep.trim());
setSteps((prev) => [...prev, step]);
setNewStep("");
} catch (e) {
console.error(e);
}
};
const handleToggleStep = async (step: Step) => {
try {
const updated = await updateStep(task.id, step.id, { done: !step.done });
setSteps((prev) => prev.map((s) => (s.id === step.id ? updated : s)));
} catch (e) {
console.error(e);
}
};
const handleAddComment = async () => {
if (!newComment.trim()) return;
try {
const msg = await sendMessage({ task_id: task.id, content: newComment.trim() });
setComments((prev) => [...prev, msg]);
setNewComment("");
} catch (e) {
console.error(e);
}
};
return (
<div
className="fixed inset-0 bg-black/60 flex items-start justify-center z-50 overflow-y-auto py-8 px-4"
onClick={onClose}
>
<div
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-4 md:p-6 border-b border-[var(--border)]">
<div className="flex items-start justify-between gap-2 mb-1">
{editingTitle ? (
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => {
setEditingTitle(false);
if (title.trim() && title !== task.title) save({ title: title.trim() });
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") { setTitle(task.title); setEditingTitle(false); }
}}
className="w-full text-xl font-bold bg-transparent border-b border-[var(--accent)] outline-none pb-1"
/>
) : (
<h2
className="text-xl font-bold cursor-pointer hover:text-[var(--accent)] transition-colors"
onClick={() => setEditingTitle(true)}
>
{title}
</h2>
)}
<button
onClick={onClose}
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors text-xl leading-none cursor-pointer shrink-0 mt-1"
title="Закрыть"
>
</button>
</div>
<div className="text-xs text-[var(--muted)]">{task.key}</div>
</div>
<div className="flex flex-col md:flex-row">
{/* Left: Description + Steps + Comments */}
<div className="flex-1 p-4 md:p-6 border-b md:border-b-0 md:border-r border-[var(--border)] space-y-6">
{/* Description */}
<div>
<div className="text-sm text-[var(--muted)] mb-2 flex items-center justify-between">
<span>Описание</span>
{!editingDesc && (
<button onClick={() => setEditingDesc(true)} className="text-xs text-[var(--accent)] hover:underline">
Редактировать
</button>
)}
</div>
{editingDesc ? (
<div>
<textarea
autoFocus
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)] resize-y min-h-[120px]"
rows={6}
placeholder="Описание задачи (Markdown)..."
/>
<div className="flex gap-2 mt-2">
<button
onClick={() => { setEditingDesc(false); save({ description: description.trim() || undefined }); }}
className="px-3 py-1 bg-[var(--accent)] text-white rounded text-xs"
>
Сохранить
</button>
<button
onClick={() => { setDescription(task.description || ""); setEditingDesc(false); }}
className="px-3 py-1 text-[var(--muted)] text-xs"
>
Отмена
</button>
</div>
</div>
) : description ? (
<div className="text-sm whitespace-pre-wrap">{description}</div>
) : (
<div className="text-sm text-[var(--muted)] italic">Нет описания</div>
)}
</div>
{/* Steps */}
<div>
<div className="text-sm text-[var(--muted)] mb-2">
Этапы {steps.length > 0 && `(${steps.filter((s) => s.done).length}/${steps.length})`}
</div>
<div className="space-y-1">
{steps.map((step) => (
<label key={step.id} className="flex items-center gap-2 text-sm cursor-pointer group">
<input
type="checkbox"
checked={step.done}
onChange={() => handleToggleStep(step)}
className="accent-[var(--accent)]"
/>
<span className={step.done ? "line-through text-[var(--muted)]" : ""}>{step.title}</span>
</label>
))}
</div>
<div className="flex gap-2 mt-2">
<input
value={newStep}
onChange={(e) => setNewStep(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddStep()}
placeholder="Новый этап..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1 text-xs outline-none focus:border-[var(--accent)]"
/>
<button onClick={handleAddStep} className="text-xs text-[var(--accent)]">+</button>
</div>
</div>
{/* Comments */}
<div>
<div className="text-sm text-[var(--muted)] mb-2">Комментарии</div>
<div className="flex gap-2 mb-3">
<input
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
placeholder="Написать комментарий..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
/>
<button onClick={handleAddComment} className="text-xs text-[var(--accent)] px-2"></button>
</div>
<div className="space-y-3 max-h-[300px] overflow-y-auto">
{[...comments].reverse().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>
))}
{comments.length === 0 && (
<div className="text-sm text-[var(--muted)] italic">Нет комментариев</div>
)}
</div>
</div>
</div>
{/* Right: Properties */}
<div className="w-full md:w-64 shrink-0 p-4 md:p-6 space-y-4">
{/* Status */}
<div>
<div className="text-xs text-[var(--muted)] mb-1">Статус</div>
<div className="flex flex-wrap gap-1">
{STATUSES.map((s) => (
<button
key={s.key}
onClick={() => { setStatus(s.key); save({ status: s.key }); }}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${status === s.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
{s.label}
</button>
))}
</div>
</div>
{/* Priority */}
<div>
<div className="text-xs text-[var(--muted)] mb-1">Приоритет</div>
<div className="flex flex-wrap gap-1">
{PRIORITIES.map((p) => (
<button
key={p.key}
onClick={() => { setPriority(p.key); save({ priority: p.key }); }}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${priority === p.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: p.color }} />
{p.label}
</button>
))}
</div>
</div>
{/* Assignee */}
<div>
<div className="text-xs text-[var(--muted)] mb-1">Исполнитель</div>
<select
value={assigneeSlug}
onChange={(e) => {
setAssigneeSlug(e.target.value);
save({ assignee_slug: e.target.value || undefined } as any);
}}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
>
<option value="">Не назначен</option>
{members.map((m) => (
<option key={m.slug} value={m.slug}>
{m.type === "agent" ? "🤖 " : "👤 "}{m.name}
</option>
))}
</select>
</div>
{/* Labels */}
{task.labels && task.labels.length > 0 && (
<div>
<div className="text-xs text-[var(--muted)] mb-1">Лейблы</div>
<div className="flex flex-wrap gap-1">
{task.labels.map((l) => (
<span key={l} className="text-xs px-2 py-0.5 rounded bg-white/10">{l}</span>
))}
</div>
</div>
)}
{/* Delete */}
<div className="pt-4 border-t border-[var(--border)]">
{confirmDelete ? (
<div className="flex gap-2">
<button onClick={handleDelete} className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30">
Да, удалить
</button>
<button onClick={() => setConfirmDelete(false)} className="px-3 py-1.5 text-[var(--muted)] text-xs">
Отмена
</button>
</div>
) : (
<button onClick={() => setConfirmDelete(true)} className="text-xs text-red-400/60 hover:text-red-400">
Удалить задачу
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}