feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user

This commit is contained in:
2026-05-19 16:26:26 +03:00
parent 688d043dad
commit 3529c39b22
304 changed files with 18826 additions and 1176 deletions
Binary file not shown.
+27
View File
@@ -29,12 +29,14 @@ export interface AgentInfo {
description: string;
is_enabled: boolean;
version: string;
config: string | null;
last_run_at: string | null;
}
export interface AgentUpdate {
description?: string;
is_enabled?: boolean;
config?: string;
}
export interface LogEntry {
@@ -380,3 +382,28 @@ export function adminToggleBotCommand(id: string, enabled: boolean): Promise<Bot
export function adminSyncBotCommands(): Promise<{ synced_count: number; telegram_sync: boolean }> {
return apiFetch("/admin/bot/sync", { method: "POST" });
}
// ── Agent Execution ──
export interface AgentRunResult {
success: boolean;
message: string;
data: Record<string, unknown>;
errors: string[];
duration_ms: number;
timestamp: string;
}
export function adminRunAgent(name: string, context?: Record<string, unknown>): Promise<AgentRunResult> {
return apiFetch(`/admin/agents/${encodeURIComponent(name)}/run`, {
method: "POST",
body: JSON.stringify({ context }),
});
}
export function adminRunAllAgents(context?: Record<string, unknown>): Promise<Record<string, AgentRunResult>> {
return apiFetch("/admin/agents/run-all", {
method: "POST",
body: JSON.stringify({ context }),
});
}
+20
View File
@@ -0,0 +1,20 @@
import { apiFetch } from "./client";
export function addIdeaToCalendar(ideaId: string): Promise<{ success: boolean; message: string }> {
return apiFetch(`/ideas/${ideaId}/calendar`, { method: "POST" });
}
export function getCalendarStatus(): Promise<{
connected: boolean;
provider: string | null;
}> {
return apiFetch("/calendar/status");
}
export function disconnectCalendar(): Promise<{ success: boolean }> {
return apiFetch("/calendar/disconnect", { method: "POST" });
}
export function listCalendarProviders(): Promise<{ providers: string[] }> {
return apiFetch("/calendar/providers");
}
+101
View File
@@ -0,0 +1,101 @@
import { apiFetch } from "./client";
// ── Voting ──
export interface VoteCount {
up: number;
down: number;
user_vote: string | null;
}
export function toggleVote(ideaId: string, vote: "up" | "down"): Promise<VoteCount> {
return apiFetch(`/ideas/${ideaId}/vote`, {
method: "POST",
body: JSON.stringify({ vote }),
});
}
export function getVotes(ideaId: string): Promise<VoteCount> {
return apiFetch(`/ideas/${ideaId}/votes`);
}
// ── Comments ──
export interface Comment {
id: string;
idea_id: string;
user_id: string;
content: string;
parent_id: string | null;
display_name: string;
avatar_url: string | null;
created_at: string;
updated_at: string;
replies: Comment[];
}
export function listComments(ideaId: string): Promise<Comment[]> {
return apiFetch(`/ideas/${ideaId}/comments`);
}
export function createComment(
ideaId: string,
content: string,
parentId?: string,
): Promise<Comment> {
return apiFetch(`/ideas/${ideaId}/comments`, {
method: "POST",
body: JSON.stringify({ content, parent_id: parentId }),
});
}
export function deleteComment(ideaId: string, commentId: string): Promise<void> {
return apiFetch(`/ideas/${ideaId}/comments/${commentId}`, {
method: "DELETE",
});
}
// ── Activity ──
export interface Activity {
id: string;
idea_id: string;
user_id: string | null;
action: string;
details: Record<string, unknown> | null;
display_name: string;
created_at: string;
}
export function listActivity(ideaId: string, limit = 50): Promise<Activity[]> {
return apiFetch(`/ideas/${ideaId}/activity?limit=${limit}`);
}
// ── Notifications ──
export interface Notification {
id: string;
type: string;
title: string;
body: string;
link: string | null;
is_read: boolean;
created_at: string;
}
export function listNotifications(unreadOnly = false): Promise<Notification[]> {
const params = unreadOnly ? "?unread_only=true" : "";
return apiFetch(`/notifications${params}`);
}
export function getUnreadCount(): Promise<{ count: number }> {
return apiFetch("/notifications/unread-count");
}
export function markNotificationRead(id: string): Promise<Notification> {
return apiFetch(`/notifications/${id}/read`, { method: "POST" });
}
export function markAllNotificationsRead(): Promise<{ count: number }> {
return apiFetch("/notifications/read-all", { method: "POST" });
}
+22
View File
@@ -8,10 +8,22 @@ export interface Idea {
status: string;
tags: string[] | null;
is_public: boolean;
funnel_status: string | null;
created_at: string;
updated_at: string;
}
export type FunnelStatus = "raw" | "validated" | "backlog" | "in_progress" | "launched" | "retrospective";
export const FUNNEL_LABELS: Record<FunnelStatus, string> = {
raw: "Сырая",
validated: "Подтверждённая",
backlog: "Бэклог",
in_progress: "В работе",
launched: "Запущена",
retrospective: "Ретроспектива",
};
export interface IdeaCreatePayload {
title: string;
content: string;
@@ -84,3 +96,13 @@ export function getAnalysisResults(
const params = role ? `?role=${role}` : "";
return apiFetch(`/ideas/${id}/analysis${params}`);
}
export function updateIdeaFunnel(
id: string,
targetStatus: string,
): Promise<Idea> {
return apiFetch(`/ideas/${id}/funnel`, {
method: "POST",
body: JSON.stringify({ target_status: targetStatus }),
});
}
+80
View File
@@ -0,0 +1,80 @@
import { apiFetch } from "./client";
export interface Workspace {
id: string;
name: string;
type: "personal" | "team";
owner_id: string;
description: string | null;
member_count: number;
created_at: string;
updated_at: string;
}
export interface WorkspaceCreatePayload {
name: string;
type?: string;
}
export interface WorkspaceUpdatePayload {
name?: string;
description?: string;
}
export interface Member {
id: string;
user_id: string;
workspace_id: string;
role: string;
email: string;
display_name: string;
joined_at: string;
}
export interface AddMemberPayload {
user_id: string;
role?: string;
}
export function listWorkspaces(): Promise<Workspace[]> {
return apiFetch("/workspaces");
}
export function getWorkspace(id: string): Promise<Workspace> {
return apiFetch(`/workspaces/${id}`);
}
export function createWorkspace(data: WorkspaceCreatePayload): Promise<Workspace> {
return apiFetch("/workspaces", {
method: "POST",
body: JSON.stringify(data),
});
}
export function updateWorkspace(id: string, data: WorkspaceUpdatePayload): Promise<Workspace> {
return apiFetch(`/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export function deleteWorkspace(id: string): Promise<void> {
return apiFetch(`/workspaces/${id}`, { method: "DELETE" });
}
export function listMembers(workspaceId: string): Promise<Member[]> {
return apiFetch(`/workspaces/${workspaceId}/members`);
}
export function addMember(workspaceId: string, data: AddMemberPayload): Promise<Member> {
return apiFetch(`/workspaces/${workspaceId}/members`, {
method: "POST",
body: JSON.stringify(data),
});
}
export function removeMember(workspaceId: string, memberId: string): Promise<void> {
return apiFetch(`/workspaces/${workspaceId}/members/${memberId}`, {
method: "DELETE",
});
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { listActivity, type Activity } from "../api/collaboration";
interface Props {
ideaId: string;
}
const ACTION_LABELS: Record<string, string> = {
comment: "оставил(а) комментарий",
vote_up: "оценил(а) идею",
vote_down: "отклонил(а) идею",
status_change: "изменил(а) статус",
funnel: "переместил(а) в воронке",
share: "поделился(ась) идеей",
analyze: "запустил(а) анализ",
};
export default function ActivityFeed({ ideaId }: Props) {
const [activities, setActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
listActivity(ideaId)
.then(setActivities)
.catch(console.error)
.finally(() => setLoading(false));
}, [ideaId]);
if (loading) {
return <p className="text-sm text-gray-400">Загрузка активности</p>;
}
return (
<div className="space-y-3">
<h3 className="font-semibold">Активность</h3>
{activities.length === 0 && (
<p className="text-sm text-gray-400">Нет активности</p>
)}
<div className="space-y-2">
{activities.map((a) => (
<div key={a.id} className="flex items-start gap-2 text-sm">
<div className="mt-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 text-xs dark:bg-gray-800">
{a.display_name.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<p>
<span className="font-medium">{a.display_name}</span>{" "}
<span className="text-gray-500">
{ACTION_LABELS[a.action] || a.action}
</span>
</p>
<p className="text-xs text-gray-400">
{new Date(a.created_at).toLocaleString("ru-RU")}
</p>
</div>
</div>
))}
</div>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useState } from "react";
import {
listComments,
createComment,
deleteComment,
type Comment,
} from "../api/collaboration";
import { useAuth } from "../auth/AuthContext";
interface Props {
ideaId: string;
}
export default function CommentThread({ ideaId }: Props) {
const { user } = useAuth();
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState("");
const [replyTo, setReplyTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState("");
const [sending, setSending] = useState(false);
useEffect(() => {
loadComments();
}, [ideaId]);
async function loadComments() {
try {
const items = await listComments(ideaId);
// Build threaded structure
const topLevel = items.filter((c) => !c.parent_id);
const byParent: Record<string, Comment[]> = {};
for (const c of items) {
if (c.parent_id) {
if (!byParent[c.parent_id]) byParent[c.parent_id] = [];
byParent[c.parent_id].push(c);
}
}
setComments(
topLevel.map((c) => ({ ...c, replies: byParent[c.id] || [] })),
);
} catch (e) {
console.error(e);
}
}
async function handleSubmit() {
if (!newComment.trim() || sending) return;
setSending(true);
try {
await createComment(ideaId, newComment.trim());
setNewComment("");
await loadComments();
} catch (e) {
console.error(e);
} finally {
setSending(false);
}
}
async function handleReply(parentId: string) {
if (!replyText.trim() || sending) return;
setSending(true);
try {
await createComment(ideaId, replyText.trim(), parentId);
setReplyTo(null);
setReplyText("");
await loadComments();
} catch (e) {
console.error(e);
} finally {
setSending(false);
}
}
async function handleDelete(commentId: string) {
if (!confirm("Удалить комментарий?")) return;
try {
await deleteComment(ideaId, commentId);
await loadComments();
} catch (e) {
console.error(e);
}
}
function renderComment(c: Comment, depth = 0) {
const isOwner = user?.id === c.user_id;
return (
<div
key={c.id}
className={`${depth > 0 ? "ml-6 border-l-2 pl-4 dark:border-gray-700" : ""} ${
depth > 0 ? "" : "border-b pb-3 dark:border-gray-700"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{c.display_name}</span>
<span className="text-[10px] text-gray-400">
{new Date(c.created_at).toLocaleString("ru-RU")}
</span>
</div>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">
{c.content}
</p>
</div>
{isOwner && (
<button
onClick={() => handleDelete(c.id)}
className="text-xs text-red-500 hover:underline"
aria-label="Удалить"
>
</button>
)}
</div>
<div className="mt-1">
<button
onClick={() => setReplyTo(replyTo === c.id ? null : c.id)}
className="text-xs text-primary-600 hover:underline"
>
{replyTo === c.id ? "Отмена" : "Ответить"}
</button>
</div>
{replyTo === c.id && (
<form
onSubmit={(e) => {
e.preventDefault();
handleReply(c.id);
}}
className="mt-2 flex gap-2"
>
<input
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Написать ответ…"
className="flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800"
autoFocus
/>
<button
type="submit"
disabled={sending || !replyText.trim()}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
Отправить
</button>
</form>
)}
{c.replies.map((r) => renderComment(r, depth + 1))}
</div>
);
}
return (
<div className="space-y-4">
<h3 className="font-semibold">Комментарии ({comments.length})</h3>
{/* New comment form */}
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
className="flex gap-2"
>
<input
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Написать комментарий…"
className="flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
<button
type="submit"
disabled={sending || !newComment.trim()}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
Отправить
</button>
</form>
{/* Comments list */}
<div className="space-y-3">
{comments.length === 0 && (
<p className="text-sm text-gray-400">Нет комментариев</p>
)}
{comments.map((c) => renderComment(c))}
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
listIdeas,
updateIdeaFunnel,
type Idea,
type FunnelStatus,
FUNNEL_LABELS,
} from "../api/ideas";
const FUNNEL_ORDER: FunnelStatus[] = [
"raw", "validated", "backlog", "in_progress", "launched", "retrospective",
];
export default function IdeaFunnelKanban() {
const [ideas, setIdeas] = useState<Idea[]>([]);
const [loading, setLoading] = useState(true);
const [moving, setMoving] = useState<string | null>(null);
useEffect(() => {
listIdeas()
.then(setIdeas)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
async function handleMove(ideaId: string, direction: "prev" | "next") {
const idea = ideas.find((i) => i.id === ideaId);
if (!idea) return;
const currentIdx = FUNNEL_ORDER.indexOf(
(idea.funnel_status as FunnelStatus) || "raw",
);
const nextIdx =
direction === "next"
? Math.min(currentIdx + 1, FUNNEL_ORDER.length - 1)
: Math.max(currentIdx - 1, 0);
if (nextIdx === currentIdx) return;
setMoving(ideaId);
try {
const updated = await updateIdeaFunnel(ideaId, FUNNEL_ORDER[nextIdx]);
setIdeas((prev) =>
prev.map((i) => (i.id === ideaId ? updated : i)),
);
} catch (e) {
console.error(e);
} finally {
setMoving(null);
}
}
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
);
}
const columns: Record<string, Idea[]> = {};
for (const s of FUNNEL_ORDER) {
columns[s] = [];
}
for (const idea of ideas) {
const status = (idea.funnel_status as FunnelStatus) || "raw";
if (columns[status]) columns[status].push(idea);
}
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{FUNNEL_ORDER.map((status) => (
<div
key={status}
className="min-w-[250px] flex-shrink-0 rounded-xl border bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900"
>
<h3 className="mb-3 text-sm font-semibold text-gray-600 dark:text-gray-400">
{FUNNEL_LABELS[status]}
<span className="ml-1 text-xs text-gray-400">
({columns[status].length})
</span>
</h3>
<div className="space-y-2">
{columns[status].length === 0 && (
<p className="py-4 text-center text-xs text-gray-400">
Нет идей
</p>
)}
{columns[status].map((idea) => (
<div
key={idea.id}
className="rounded-lg border bg-white p-3 shadow-sm transition hover:shadow-md dark:border-gray-600 dark:bg-gray-800"
>
<Link
to={`/ideas/${idea.id}`}
className="mb-1 block text-sm font-medium hover:text-primary-600"
>
{idea.title}
</Link>
<p className="mb-2 line-clamp-2 text-xs text-gray-500">
{idea.content}
</p>
<div className="flex items-center justify-between">
<span className="text-[10px] text-gray-400">
{new Date(idea.created_at).toLocaleDateString("ru-RU")}
</span>
<div className="flex gap-1">
<button
onClick={() => handleMove(idea.id, "prev")}
disabled={
moving === idea.id ||
FUNNEL_ORDER.indexOf(status) === 0
}
className="rounded px-1.5 py-0.5 text-xs text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-gray-700"
aria-label="Переместить назад"
>
</button>
<button
onClick={() => handleMove(idea.id, "next")}
disabled={
moving === idea.id ||
FUNNEL_ORDER.indexOf(status) ===
FUNNEL_ORDER.length - 1
}
className="rounded px-1.5 py-0.5 text-xs text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-gray-700"
aria-label="Переместить вперёд"
>
</button>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}
+6
View File
@@ -3,6 +3,8 @@ import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import T from "./T";
import HelpFAB from "./HelpFAB";
import WorkspaceSwitcher from "./WorkspaceSwitcher";
import NotificationBell from "./NotificationBell";
function GitHubIcon() {
return (
@@ -46,6 +48,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<Link to="/dashboard" className="text-xl font-bold text-primary-700">
VoIdea
</Link>
<div className="mx-2">
<WorkspaceSwitcher />
</div>
<nav className="flex items-center gap-4 text-sm">
<Link to="/dashboard" className="hover:text-primary-600">
<T path="nav.ideas" />
@@ -67,6 +72,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<T path="nav.admin" />
</Link>
)}
<NotificationBell />
<span className="text-gray-400">{user?.display_name}</span>
<button
onClick={handleLogout}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { useNotificationStore } from "../stores/notifications";
export default function NotificationBell() {
const { notifications, unreadCount, loading, load, markRead, markAllRead } =
useNotificationStore();
const [open, setOpen] = useState(false);
useEffect(() => {
load();
const interval = setInterval(load, 30000);
return () => clearInterval(interval);
}, []);
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="relative rounded-lg p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label={`Уведомления${unreadCount > 0 ? ` (${unreadCount} новых)` : ""}`}
>
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 w-80 rounded-xl border bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center justify-between border-b px-4 py-2 dark:border-gray-700">
<h3 className="text-sm font-semibold">Уведомления</h3>
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="text-xs text-primary-600 hover:underline"
>
Прочитать все
</button>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{loading && (
<p className="p-4 text-center text-xs text-gray-400">Загрузка</p>
)}
{!loading && notifications.length === 0 && (
<p className="p-4 text-center text-xs text-gray-400">
Нет новых уведомлений
</p>
)}
{notifications.map((n) => (
<div
key={n.id}
className={`border-b p-3 text-sm last:border-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800 ${
!n.is_read ? "bg-primary-50/50 dark:bg-primary-950/30" : ""
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="font-medium">{n.title}</p>
<p className="mt-0.5 text-xs text-gray-500 line-clamp-2">
{n.body}
</p>
{n.link && (
<a
href={n.link}
className="mt-1 inline-block text-xs text-primary-600 hover:underline"
>
Открыть
</a>
)}
</div>
<button
onClick={() => markRead(n.id)}
className="ml-2 text-xs text-gray-400 hover:text-gray-600"
aria-label="Отметить прочитанным"
>
</button>
</div>
<p className="mt-1 text-[10px] text-gray-400">
{new Date(n.created_at).toLocaleString("ru-RU")}
</p>
</div>
))}
</div>
</div>
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { getVotes, toggleVote, type VoteCount } from "../api/collaboration";
interface Props {
ideaId: string;
}
export default function VoteButtons({ ideaId }: Props) {
const [votes, setVotes] = useState<VoteCount>({ up: 0, down: 0, user_vote: null });
const [sending, setSending] = useState(false);
useEffect(() => {
getVotes(ideaId).then(setVotes).catch(console.error);
}, [ideaId]);
async function handleVote(vote: "up" | "down") {
if (sending) return;
setSending(true);
try {
const updated = await toggleVote(ideaId, vote);
setVotes(updated);
} catch (e) {
console.error(e);
} finally {
setSending(false);
}
}
const upActive = votes.user_vote === "up";
const downActive = votes.user_vote === "down";
return (
<div className="flex items-center gap-1">
<button
onClick={() => handleVote("up")}
disabled={sending}
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-sm transition ${
upActive
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
: "hover:bg-gray-100 dark:hover:bg-gray-800"
} disabled:opacity-50`}
aria-label="Нравится"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
</svg>
{votes.up}
</button>
<button
onClick={() => handleVote("down")}
disabled={sending}
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-sm transition ${
downActive
? "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300"
: "hover:bg-gray-100 dark:hover:bg-gray-800"
} disabled:opacity-50`}
aria-label="Не нравится"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5" />
</svg>
{votes.down}
</button>
</div>
);
}
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { useWorkspaceStore } from "../stores/workspace";
import { createWorkspace } from "../api/workspaces";
export default function WorkspaceSwitcher() {
const {
workspaces, currentWorkspace, loading,
loadWorkspaces, switchWorkspace,
} = useWorkspaceStore();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
useEffect(() => {
loadWorkspaces();
}, []);
async function handleCreate() {
if (!newName.trim()) return;
setCreating(true);
try {
await createWorkspace({ name: newName.trim(), type: "team" });
setNewName("");
await loadWorkspaces();
} catch (e) {
console.error(e);
} finally {
setCreating(false);
}
}
if (loading) return null;
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span className="max-w-[150px] truncate">{currentWorkspace?.name || "Workspace"}</span>
<svg className={`h-4 w-4 transition ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 w-64 rounded-xl border bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
<div className="p-2">
{workspaces.map((ws) => (
<button
key={ws.id}
onClick={() => { switchWorkspace(ws.id); setOpen(false); }}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition ${
currentWorkspace?.id === ws.id
? "bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-xs font-medium dark:bg-gray-800">
{ws.type === "personal" ? "P" : "T"}
</span>
<div className="flex-1 truncate">
<p className="font-medium">{ws.name}</p>
<p className="text-xs text-gray-400">
{ws.type === "personal" ? "Личное" : "Команда"} · {ws.member_count} уч.
</p>
</div>
</button>
))}
</div>
<div className="border-t p-2 dark:border-gray-700">
<form
onSubmit={(e) => { e.preventDefault(); handleCreate(); }}
className="flex gap-2"
>
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Новое рабочее пространство"
className="flex-1 rounded-lg border border-gray-300 bg-gray-50 px-2 py-1.5 text-xs dark:border-gray-600 dark:bg-gray-800"
/>
<button
type="submit"
disabled={creating || !newName.trim()}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
+
</button>
</form>
</div>
</div>
)}
</div>
);
}
+138 -34
View File
@@ -51,6 +51,7 @@ import {
adminListBotCommands,
adminToggleBotCommand,
adminSyncBotCommands,
adminRunAgent,
type BotCommand,
type PipelineConfig,
type PipelineStageConfig,
@@ -262,11 +263,28 @@ function UsersTab() {
function AgentsTab() {
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
const [metaConfig, setMetaConfig] = useState("");
const [runResult, setRunResult] = useState<string | null>(null);
const [running, setRunning] = useState(false);
useEffect(() => {
adminListAgents().then(setAgents).catch(console.error);
loadAgents();
}, []);
async function loadAgents() {
try {
const list = await adminListAgents();
setAgents(list);
const meta = list.find(a => a.agent_name === "meta_agent");
if (meta?.config) {
try {
setMetaConfig(JSON.stringify(JSON.parse(meta.config), null, 2));
} catch { setMetaConfig(meta.config); }
}
} catch (e) { console.error(e); }
}
async function toggleAgent(name: string, is_enabled: boolean) {
try {
const updated = await adminUpdateAgent(name, { is_enabled });
@@ -274,41 +292,127 @@ function AgentsTab() {
} catch (e) { console.error(e); }
}
async function saveMetaConfig() {
try {
JSON.parse(metaConfig);
const updated = await adminUpdateAgent("meta_agent", { config: metaConfig });
setAgents(prev => prev.map(a => a.agent_name === "meta_agent" ? updated : a));
} catch (e) {
alert("Invalid JSON: " + (e instanceof Error ? e.message : String(e)));
}
}
async function runAgent(name: string) {
setRunning(true);
setRunResult(null);
try {
const result = await adminRunAgent(name);
setRunResult(JSON.stringify(result, null, 2));
} catch (e) {
setRunResult("Error: " + (e instanceof Error ? e.message : String(e)));
} finally { setRunning(false); }
}
return (
<div className="overflow-x-auto rounded-xl border bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b dark:border-gray-700">
<th className="px-4 py-3 font-medium">Агент</th>
<th className="px-4 py-3 font-medium">Описание</th>
<th className="px-4 py-3 font-medium">Версия</th>
<th className="px-4 py-3 font-medium">Статус</th>
<th className="px-4 py-3 font-medium">Действие</th>
</tr>
</thead>
<tbody>
{agents.map((a) => (
<tr key={a.agent_name} className="border-b last:border-0 dark:border-gray-700">
<td className="px-4 py-3 font-mono text-xs">{a.agent_name}</td>
<td className="px-4 py-3 text-gray-500">{a.description || "—"}</td>
<td className="px-4 py-3 text-xs">{a.version}</td>
<td className="px-4 py-3">
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${a.is_enabled ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"}`}>
{a.is_enabled ? "Вкл" : "Выкл"}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => toggleAgent(a.agent_name, !a.is_enabled)}
className={`text-xs hover:underline ${a.is_enabled ? "text-red-600" : "text-green-600"}`}
>
{a.is_enabled ? "Отключить" : "Включить"}
</button>
</td>
<div className="space-y-4">
<div className="overflow-x-auto rounded-xl border bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b dark:border-gray-700">
<th className="px-4 py-3 font-medium">Агент</th>
<th className="px-4 py-3 font-medium">Описание</th>
<th className="px-4 py-3 font-medium">Версия</th>
<th className="px-4 py-3 font-medium">Статус</th>
<th className="px-4 py-3 font-medium">Действие</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{agents.map((a) => (
<tr key={a.agent_name} className="border-b last:border-0 dark:border-gray-700">
<td className="px-4 py-3 font-mono text-xs">{a.agent_name}</td>
<td className="px-4 py-3 text-gray-500">{a.description || "—"}</td>
<td className="px-4 py-3 text-xs">{a.version}</td>
<td className="px-4 py-3">
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${a.is_enabled ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"}`}>
{a.is_enabled ? "Вкл" : "Выкл"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button
onClick={() => toggleAgent(a.agent_name, !a.is_enabled)}
className={`text-xs hover:underline ${a.is_enabled ? "text-red-600" : "text-green-600"}`}
>
{a.is_enabled ? "Отключить" : "Включить"}
</button>
<button
onClick={() => setSelectedAgent(selectedAgent === a.agent_name ? null : a.agent_name)}
className="text-xs text-primary-600 hover:underline"
>
{selectedAgent === a.agent_name ? "Скрыть" : "Настроить"}
</button>
<button
onClick={() => runAgent(a.agent_name)}
disabled={running}
className="text-xs text-amber-600 hover:underline disabled:opacity-50"
>
Запустить
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Meta-agent config panel */}
{selectedAgent === "meta_agent" && (
<div className="rounded-xl border bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-3 font-semibold">Мета-агент: Конфигурация</h3>
<p className="mb-3 text-xs text-gray-500">
JSON-конфигурация: cron_schedule, weights (агент вес), sources_count, auto_create
</p>
<textarea
value={metaConfig}
onChange={e => setMetaConfig(e.target.value)}
rows={10}
className="mb-3 w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 font-mono text-xs dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex gap-2">
<button
onClick={saveMetaConfig}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
Сохранить конфиг
</button>
<button
onClick={async () => {
setMetaConfig(JSON.stringify({
cron_schedule: "0 9 * * 1,4",
weights: {},
sources_count: 10,
auto_create: true,
}, null, 2));
}}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
Сбросить
</button>
</div>
</div>
)}
{/* Run result */}
{runResult && (
<div className="rounded-xl border bg-gray-50 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">Результат запуска</h3>
<button onClick={() => setRunResult(null)} className="text-xs text-gray-500 hover:underline">Закрыть</button>
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{runResult}</pre>
</div>
)}
</div>
);
}
+34 -6
View File
@@ -1,10 +1,12 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { listIdeas, type Idea } from "../api/ideas";
import IdeaFunnelKanban from "../components/IdeaFunnelKanban";
export default function Dashboard() {
const [ideas, setIdeas] = useState<Idea[]>([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<"list" | "kanban">("list");
useEffect(() => {
listIdeas()
@@ -25,12 +27,36 @@ export default function Dashboard() {
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Мои идеи</h1>
<Link
to="/create"
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
+ Новая идея
</Link>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border dark:border-gray-600">
<button
onClick={() => setView("list")}
className={`px-3 py-1.5 text-xs font-medium transition ${
view === "list"
? "bg-primary-600 text-white"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
Список
</button>
<button
onClick={() => setView("kanban")}
className={`px-3 py-1.5 text-xs font-medium transition ${
view === "kanban"
? "bg-primary-600 text-white"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
Канбан
</button>
</div>
<Link
to="/create"
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
+ Новая идея
</Link>
</div>
</div>
{ideas.length === 0 ? (
@@ -40,6 +66,8 @@ export default function Dashboard() {
Создайте первую идею, чтобы начать
</p>
</div>
) : view === "kanban" ? (
<IdeaFunnelKanban />
) : (
<div className="grid gap-4 sm:grid-cols-2">
{ideas.map((idea) => (
+31 -1
View File
@@ -8,6 +8,10 @@ import {
type AnalysisResult,
type Idea,
} from "../api/ideas";
import VoteButtons from "../components/VoteButtons";
import CommentThread from "../components/CommentThread";
import ActivityFeed from "../components/ActivityFeed";
import { addIdeaToCalendar } from "../api/calendar";
export default function IdeaView() {
const { id } = useParams<{ id: string }>();
@@ -97,7 +101,7 @@ export default function IdeaView() {
</div>
</div>
<div className="mb-4 flex gap-2">
<div className="mb-4 flex flex-wrap gap-2">
{(["md", "json", "html"] as const).map((fmt) => (
<a
key={fmt}
@@ -108,6 +112,19 @@ export default function IdeaView() {
Экспорт {fmt.toUpperCase()}
</a>
))}
<button
onClick={async () => {
try {
await addIdeaToCalendar(id!);
alert("Событие создано в календаре");
} catch (e) {
alert(e instanceof Error ? e.message : "Ошибка");
}
}}
className="rounded-lg border px-3 py-1.5 text-xs hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
+ Календарь
</button>
</div>
<p className="mb-4 whitespace-pre-wrap text-gray-700 dark:text-gray-300">
@@ -129,6 +146,19 @@ export default function IdeaView() {
Статус: {idea.status} {" "}
{new Date(idea.created_at).toLocaleDateString("ru-RU")}
</p>
<div className="mt-4">
<VoteButtons ideaId={id!} />
</div>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<div className="rounded-xl border bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<CommentThread ideaId={id!} />
</div>
<div className="rounded-xl border bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<ActivityFeed ideaId={id!} />
</div>
</div>
<div className="mt-6">
+76 -11
View File
@@ -419,20 +419,85 @@ function ThemeTab() {
}
function IntegrationsTab() {
const [calStatus, setCalStatus] = useState<{ connected: boolean; provider: string | null } | null>(null);
useEffect(() => {
import("../api/calendar").then((m) =>
m.getCalendarStatus().then(setCalStatus).catch(() => {}),
);
}, []);
return (
<div className="max-w-md space-y-4 rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="font-semibold">Интеграции</h3>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Яндекс.Диск</p>
<p className="text-xs text-gray-500">Автоматическое сохранение идей</p>
<div className="max-w-md space-y-4">
{/* Disk integrations */}
<div className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 font-semibold">Диски</h3>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Яндекс.Диск</p>
<p className="text-xs text-gray-500">Автоматическое сохранение идей</p>
</div>
<button className="rounded-lg bg-primary-600 px-4 py-2 text-xs font-medium text-white hover:bg-primary-700">
Подключить
</button>
</div>
<button className="rounded-lg bg-primary-600 px-4 py-2 text-xs font-medium text-white hover:bg-primary-700">
Подключить
</button>
<hr className="my-4 dark:border-gray-700" />
<p className="text-xs text-gray-400">Google Drive и Apple CloudKit по запросу.</p>
</div>
{/* Calendar integration */}
<div className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 font-semibold">Календарь</h3>
<p className="mb-3 text-xs text-gray-500">
Добавляйте идеи в календарь одним нажатием.
</p>
{calStatus?.connected ? (
<div>
<div className="mb-3 flex items-center gap-2 text-sm">
<span className="h-2 w-2 rounded-full bg-green-500" />
Подключено: {calStatus.provider}
</div>
<button
onClick={async () => {
const m = await import("../api/calendar");
await m.disconnectCalendar();
setCalStatus({ connected: false, provider: null });
}}
className="rounded-lg border border-red-300 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-950"
>
Отключить
</button>
</div>
) : (
<div className="space-y-2">
<button
onClick={async () => {
const m = await import("../api/calendar");
try {
const { providers } = await m.listCalendarProviders();
alert(
`Доступные провайдеры: ${providers.join(", ")}.\n\nПодключите календарь через OAuth в настройках профиля.`,
);
} catch (e) { console.error(e); }
}}
className="rounded-lg bg-primary-600 px-4 py-2 text-xs font-medium text-white hover:bg-primary-700"
>
Подключить Google
</button>
<button
onClick={async () => {
alert("Подключение Yandex Календаря — через OAuth в настройках профиля.");
}}
className="ml-2 rounded-lg border px-4 py-2 text-xs font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
Подключить Яндекс
</button>
<p className="mt-2 text-xs text-gray-400">
Apple Calendar (CalDAV) по запросу.
</p>
</div>
)}
</div>
<hr className="dark:border-gray-700" />
<p className="text-xs text-gray-400">Google Drive и Apple CloudKit по запросу.</p>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { create } from "zustand";
import {
listNotifications,
getUnreadCount,
markNotificationRead,
markAllNotificationsRead,
type Notification,
} from "../api/collaboration";
interface NotificationState {
notifications: Notification[];
unreadCount: number;
loading: boolean;
load: () => Promise<void>;
markRead: (id: string) => Promise<void>;
markAllRead: () => Promise<void>;
}
export const useNotificationStore = create<NotificationState>((set, get) => ({
notifications: [],
unreadCount: 0,
loading: true,
load: async () => {
try {
const [notifications, { count }] = await Promise.all([
listNotifications(true),
getUnreadCount(),
]);
set({ notifications, unreadCount: count, loading: false });
} catch {
set({ loading: false });
}
},
markRead: async (id: string) => {
try {
await markNotificationRead(id);
const notifications = get().notifications.filter((n) => n.id !== id);
set({
notifications,
unreadCount: Math.max(0, get().unreadCount - 1),
});
} catch (e) {
console.error(e);
}
},
markAllRead: async () => {
try {
await markAllNotificationsRead();
set({ notifications: [], unreadCount: 0 });
} catch (e) {
console.error(e);
}
},
}));
+40
View File
@@ -0,0 +1,40 @@
import { create } from "zustand";
import { listWorkspaces, type Workspace } from "../api/workspaces";
interface WorkspaceState {
workspaces: Workspace[];
currentWorkspace: Workspace | null;
loading: boolean;
loadWorkspaces: () => Promise<void>;
switchWorkspace: (id: string) => void;
setCurrentWorkspace: (ws: Workspace | null) => void;
}
export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
workspaces: [],
currentWorkspace: null,
loading: true,
loadWorkspaces: async () => {
try {
const workspaces = await listWorkspaces();
const current = get().currentWorkspace;
// Keep existing selection if still valid, otherwise pick first
const stillExists = current && workspaces.find(w => w.id === current.id);
set({
workspaces,
currentWorkspace: stillExists || workspaces[0] || null,
loading: false,
});
} catch {
set({ loading: false });
}
},
switchWorkspace: (id: string) => {
const ws = get().workspaces.find(w => w.id === id);
if (ws) set({ currentWorkspace: ws });
},
setCurrentWorkspace: (ws) => set({ currentWorkspace: ws }),
}));