577 lines
23 KiB
JavaScript
577 lines
23 KiB
JavaScript
import React from "react";
|
||
import {
|
||
Activity,
|
||
AlertTriangle,
|
||
CheckCircle2,
|
||
Cpu,
|
||
Database,
|
||
GitBranch,
|
||
LineChart,
|
||
MessageSquare,
|
||
Settings,
|
||
ShieldCheck,
|
||
User,
|
||
Vote,
|
||
Wallet,
|
||
Zap,
|
||
Bot,
|
||
Boxes,
|
||
BookOpen,
|
||
FileText,
|
||
Network,
|
||
Video,
|
||
Palette,
|
||
Bell,
|
||
Store,
|
||
Plug,
|
||
} from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||
import { Progress } from "@/components/ui/progress";
|
||
|
||
// ---------- Utility types ----------
|
||
type ModuleKey =
|
||
| "home"
|
||
| "messenger"
|
||
| "projects"
|
||
| "memory"
|
||
| "parser"
|
||
| "graph"
|
||
| "meetings"
|
||
| "agents"
|
||
| "training"
|
||
| "dao"
|
||
| "treasury"
|
||
| "integrations"
|
||
| "market"
|
||
| "analytics"
|
||
| "notifications"
|
||
| "admin"
|
||
| "creative"
|
||
| "security"
|
||
| "profile";
|
||
|
||
const MODULES: Array<{
|
||
key: ModuleKey;
|
||
label: string;
|
||
icon: React.ElementType;
|
||
}> = [
|
||
{ key: "home", label: "Головна / Оркестратор", icon: Cpu },
|
||
{ key: "messenger", label: "Чати та канали", icon: MessageSquare },
|
||
{ key: "projects", label: "Проєкти", icon: Boxes },
|
||
{ key: "memory", label: "База знань", icon: BookOpen },
|
||
{ key: "parser", label: "Парсер документів", icon: FileText },
|
||
{ key: "graph", label: "Граф знань", icon: Network },
|
||
{ key: "meetings", label: "Зустрічі", icon: Video },
|
||
{ key: "agents", label: "Агенти", icon: Bot },
|
||
{ key: "training", label: "Навчальний кабінет", icon: GitBranch },
|
||
{ key: "dao", label: "Голосування / DAO", icon: Vote },
|
||
{ key: "treasury", label: "Фінанси / Казна", icon: Wallet },
|
||
{ key: "integrations", label: "Інтеграції", icon: Plug },
|
||
{ key: "market", label: "Маркетплейс", icon: Store },
|
||
{ key: "analytics", label: "Аналітика", icon: LineChart },
|
||
{ key: "notifications", label: "Сповіщення", icon: Bell },
|
||
{ key: "admin", label: "Адмін-панель", icon: Settings },
|
||
{ key: "creative", label: "Креативна студія", icon: Palette },
|
||
{ key: "security", label: "Безпека / Аудит", icon: ShieldCheck },
|
||
{ key: "profile", label: "Профіль", icon: User },
|
||
];
|
||
|
||
// ---------- Reusable UI blocks ----------
|
||
function Sidebar({ active, onSelect }: { active: ModuleKey; onSelect: (k: ModuleKey) => void }) {
|
||
return (
|
||
<aside className="h-full w-72 border-r bg-white/60 backdrop-blur-sm">
|
||
<div className="p-4 border-b flex items-center gap-2">
|
||
<Cpu className="h-5 w-5" />
|
||
<span className="font-semibold">MicroDAO</span>
|
||
<Badge variant="secondary" className="ml-auto">orchestrator</Badge>
|
||
</div>
|
||
<nav className="p-2 space-y-1 overflow-y-auto h-[calc(100%-3.5rem)]">
|
||
{MODULES.map((m) => (
|
||
<button
|
||
key={m.key}
|
||
onClick={() => onSelect(m.key)}
|
||
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 hover:bg-slate-100 transition ${
|
||
active === m.key ? "bg-slate-900 text-white hover:bg-slate-900" : ""
|
||
}`}
|
||
aria-current={active === m.key}
|
||
>
|
||
<m.icon className="h-4 w-4" />
|
||
<span className="text-sm text-left truncate">{m.label}</span>
|
||
{m.key === "notifications" && (
|
||
<Badge className={`ml-auto ${active === m.key ? "bg-white text-slate-900" : ""}`}>3</Badge>
|
||
)}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function Topbar({ netOnline, orchOk }: { netOnline: boolean; orchOk: boolean }) {
|
||
return (
|
||
<div className="h-14 border-b flex items-center gap-3 px-4 bg-white/70 backdrop-blur">
|
||
<Input placeholder="Пошук по всіх модулях" className="max-w-md" />
|
||
<Tabs defaultValue="dao" className="ml-2">
|
||
<TabsList>
|
||
<TabsTrigger value="private">Private</TabsTrigger>
|
||
<TabsTrigger value="dao">DAO</TabsTrigger>
|
||
<TabsTrigger value="public">Public</TabsTrigger>
|
||
</TabsList>
|
||
</Tabs>
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<Badge variant="outline" className="flex items-center gap-1">
|
||
<Zap className={`h-4 w-4 ${netOnline ? 'text-emerald-600' : 'text-amber-600'}`}/>
|
||
{netOnline ? 'Online' : 'Offline'}
|
||
</Badge>
|
||
<Badge variant="outline" className="flex items-center gap-1">
|
||
<Database className={`h-4 w-4 ${orchOk ? 'text-emerald-600' : 'text-amber-600'}`}/>
|
||
Orchestrator {orchOk ? 'ok' : 'unreachable'}
|
||
</Badge>
|
||
<Button variant="secondary" size="sm" className="gap-2"><Settings className="h-4 w-4"/>Налаштування</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HealthGrid() {
|
||
const items = [
|
||
{ title: "Messenger", ok: true },
|
||
{ title: "Parser", ok: false },
|
||
{ title: "KB Core", ok: true },
|
||
{ title: "RAG", ok: true },
|
||
{ title: "Wallet", ok: true },
|
||
{ title: "DAO", ok: true },
|
||
];
|
||
return (
|
||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{items.map((x) => (
|
||
<Card key={x.title} className="rounded-2xl">
|
||
<CardHeader className="py-3">
|
||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||
{x.ok ? (
|
||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||
) : (
|
||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||
)}
|
||
{x.title}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="py-2">
|
||
<div className="text-xs text-slate-500">{x.ok ? "Працює стабільно" : "Черга задач > p95"}</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OrchestratorChat() {
|
||
return (
|
||
<Card className="rounded-2xl h-full flex flex-col">
|
||
<CardHeader className="py-3">
|
||
<CardTitle className="text-sm">Чат з Оркестратором</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="flex-1 flex flex-col gap-3">
|
||
<div className="flex-1 rounded-xl border bg-slate-50 p-3 text-sm overflow-auto">
|
||
<div className="text-slate-500">Вітаю. Чим допомогти? Наприклад: "Розбери PDF та створй короткий бріф у Проєктах".</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Textarea placeholder="Запитайте або використайте @agent" className="min-h-[44px]" />
|
||
<Button className="whitespace-nowrap">Надіслати</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function ActivityFeed() {
|
||
const rows = [
|
||
{ t: "ingest.completed", d: "USDO готово", ts: "09:15" },
|
||
{ t: "message.created", d: "#general", ts: "09:12" },
|
||
{ t: "vote.finalized", d: "Постанова #12", ts: "08:55" },
|
||
{ t: "payment.sent", d: "Reward 2.5 μUTIL", ts: "08:40" },
|
||
];
|
||
return (
|
||
<Card className="rounded-2xl h-full">
|
||
<CardHeader className="py-3"><CardTitle className="text-sm">Стрічка подій</CardTitle></CardHeader>
|
||
<CardContent>
|
||
<ul className="space-y-2 text-sm">
|
||
{rows.map((r, i) => (
|
||
<li key={i} className="flex items-center gap-2">
|
||
<Activity className="h-4 w-4 text-slate-500"/>
|
||
<span className="font-medium">{r.t}</span>
|
||
<span className="text-slate-500">— {r.d}</span>
|
||
<span className="ml-auto text-xs text-slate-400">{r.ts}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function TasksTable() {
|
||
const rows = [
|
||
{ agent: "parser-agent", task: "PDF→USDO", status: "running", eta: "2m" },
|
||
{ agent: "project-agent", task: "Бріф + Kanban", status: "queued", eta: "—" },
|
||
{ agent: "wallet-agent", task: "Нарахувати винагороду", status: "completed", eta: "0" },
|
||
];
|
||
const color = (s: string) => (s === "completed" ? "text-emerald-600" : s === "running" ? "text-amber-600" : "text-slate-500");
|
||
return (
|
||
<Card className="rounded-2xl h-full">
|
||
<CardHeader className="py-3"><CardTitle className="text-sm">Поточні задачі агентів</CardTitle></CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="text-left text-slate-500">
|
||
<tr>
|
||
<th className="py-2 pr-4">Агент</th>
|
||
<th className="py-2 pr-4">Задача</th>
|
||
<th className="py-2 pr-4">Статус</th>
|
||
<th className="py-2">ETA</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r, i) => (
|
||
<tr key={i} className="border-t">
|
||
<td className="py-2 pr-4 font-mono text-xs">{r.agent}</td>
|
||
<td className="py-2 pr-4">{r.task}</td>
|
||
<td className={`py-2 pr-4 ${color(r.status)}`}>{r.status}</td>
|
||
<td className="py-2">{r.eta}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function AgentGraph() {
|
||
// Minimal static SVG graph placeholder
|
||
return (
|
||
<Card className="rounded-2xl h-full">
|
||
<CardHeader className="py-3"><CardTitle className="text-sm">Граф агентів</CardTitle></CardHeader>
|
||
<CardContent>
|
||
<svg viewBox="0 0 400 220" className="w-full h-48">
|
||
<defs>
|
||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.2" />
|
||
</filter>
|
||
</defs>
|
||
<g filter="url(#shadow)">
|
||
<circle cx="200" cy="110" r="28" className="fill-slate-900" />
|
||
<text x="200" y="115" textAnchor="middle" className="fill-white text-xs">Orch</text>
|
||
{[
|
||
{ x: 80, y: 40, t: "Parser" },
|
||
{ x: 320, y: 40, t: "KB" },
|
||
{ x: 80, y: 180, t: "Wallet" },
|
||
{ x: 320, y: 180, t: "DAO" },
|
||
].map((n, i) => (
|
||
<g key={i}>
|
||
<line x1="200" y1="110" x2={n.x} y2={n.y} stroke="#94a3b8" />
|
||
<circle cx={n.x} cy={n.y} r="22" className="fill-white stroke-slate-300" />
|
||
<text x={n.x} y={n.y + 4} textAnchor="middle" className="fill-slate-700 text-xs">{n.t}</text>
|
||
</g>
|
||
))}
|
||
</g>
|
||
</svg>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function HomeOrchestrator() {
|
||
return (
|
||
<div className="p-4 space-y-4">
|
||
<HealthGrid />
|
||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||
<OrchestratorChat />
|
||
<div className="grid grid-rows-2 gap-4">
|
||
<ActivityFeed />
|
||
<TasksTable />
|
||
</div>
|
||
</div>
|
||
<AgentGraph />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Placeholder({ title }: { title: string }) {
|
||
return (
|
||
<div className="p-6 text-sm text-slate-600">
|
||
<p className="mb-2">Розділ: <span className="font-medium">{title}</span></p>
|
||
<p>Тут буде детальний екран модуля. Структура готова до підключення реальних даних та маршрутів.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StartScreen({ onCreateDAO, onJoinDAO, onSolo, agentReady, onInstallAgent, installing, progress, modelProfile, setModelProfile, indexSize, setIndexSize, recommendedProfile, onPickCustomIndex }: { onCreateDAO: () => void; onJoinDAO: () => void; onSolo: () => void; agentReady: boolean; onInstallAgent: () => void; installing: boolean; progress: number; modelProfile: string; setModelProfile: (v: string) => void; indexSize: string; setIndexSize: (v: string) => void; recommendedProfile: string; onPickCustomIndex: () => void; }) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center p-8">
|
||
<div className="max-w-3xl w-full grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||
<Card className="rounded-2xl lg:col-span-2">
|
||
<CardHeader>
|
||
<CardTitle>Ласкаво просимо до MicroDAO</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4 text-sm">
|
||
<p className="text-slate-600">Почніть зі створення спільноти DAO, приєднайтесь за інвайтом або спробуйте Solo-режим з локальним агентом.</p>
|
||
<div className="grid sm:grid-cols-3 gap-2">
|
||
<Button onClick={onCreateDAO} className="rounded-xl">Створити DAO</Button>
|
||
<Button variant="secondary" onClick={onJoinDAO} className="rounded-xl">Приєднатись</Button>
|
||
<Button variant="outline" onClick={onSolo} className="rounded-xl">Solo-режим</Button>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<div className="text-xs uppercase text-slate-500">Профіль моделі</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{['Lite','Base','Plus','Pro'].map(p => (
|
||
<Button key={p} variant={modelProfile===p? 'default':'outline'} size="sm" className="rounded-xl" onClick={() => setModelProfile(p)}>
|
||
{p}{p===recommendedProfile && <Badge className="ml-2" variant="secondary">рекоменд.</Badge>}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<div className="text-xs text-slate-500">Автовибір за можливостями пристрою. Можна змінити пізніше.</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<div className="text-xs uppercase text-slate-500">Локальний індекс</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{['200MB','500MB','1GB','5GB','20GB'].map(s => (
|
||
<Button key={s} variant={indexSize===s? 'default':'outline'} size="sm" className="rounded-xl" onClick={() => setIndexSize(s)}>{s}</Button>
|
||
))}
|
||
<Button variant="outline" size="sm" className="rounded-xl" onClick={onPickCustomIndex}>Власний шлях…</Button>
|
||
</div>
|
||
<div className="text-xs text-slate-500">int8 квантування заощаджує ×3–4 місця.</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-xs text-slate-500">Можна змінити вибір пізніше у Налаштуваннях.</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="rounded-2xl">
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm flex items-center gap-2"><Bot className="h-4 w-4"/>Локальний агент</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 text-sm">
|
||
{agentReady ? (
|
||
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-emerald-600"/>Готовий до роботи</div>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center gap-2">
|
||
{installing ? (
|
||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||
) : (
|
||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||
)}
|
||
{installing ? 'Встановлення моделі…' : 'Ще не встановлено'}
|
||
</div>
|
||
<Button size="sm" disabled={installing} onClick={onInstallAgent} className="rounded-xl w-full mt-1">
|
||
{installing ? 'Встановлення…' : 'Встановити агента'}
|
||
</Button>
|
||
{installing && (
|
||
<div className="space-y-1">
|
||
<Progress value={progress} />
|
||
<div className="text-xs text-slate-500">Завантаження моделі: {Math.round(progress)}%</div>
|
||
</div>
|
||
)}
|
||
<div className="text-xs text-slate-500">Обсяг моделі підбирається автоматично за можливостями пристрою.</div>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- helpers ---
|
||
async function pingHealthz(url: string, timeoutMs = 3000): Promise<boolean> {
|
||
try {
|
||
const ctrl = new AbortController();
|
||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||
const res = await fetch(url, { signal: ctrl.signal });
|
||
clearTimeout(t);
|
||
return res.ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function recommendModelProfile(): Promise<"Lite"|"Base"|"Plus"|"Pro"> {
|
||
// Heuristics: deviceMemory (GB), CPU cores, simple UA.
|
||
// @ts-ignore
|
||
const dm = (navigator as any).deviceMemory || 4;
|
||
const cores = navigator.hardwareConcurrency || 4;
|
||
const isMobile = /iPhone|Android/i.test(navigator.userAgent);
|
||
if (dm >= 24 && cores >= 8 && !isMobile) return "Pro";
|
||
if (dm >= 12 && cores >= 8) return "Plus";
|
||
if (dm >= 6) return "Base";
|
||
return "Lite";
|
||
}
|
||
|
||
export default function OrchestratorLayout() {
|
||
const [active, setActive] = React.useState<ModuleKey>("home");
|
||
const [orgId, setOrgId] = React.useState<string | null>(null); // null = ще немає DAO/спільноти
|
||
const [agentReady, setAgentReady] = React.useState<boolean>(false);
|
||
const [installing, setInstalling] = React.useState<boolean>(false);
|
||
const [progress, setProgress] = React.useState<number>(0);
|
||
const [modelProfile, setModelProfile] = React.useState<string>('Base');
|
||
const [recommendedProfile, setRecommendedProfile] = React.useState<string>('Base');
|
||
const [indexSize, setIndexSize] = React.useState<string>('500MB');
|
||
const [netOnline, setNetOnline] = React.useState<boolean>(typeof navigator !== 'undefined' ? navigator.onLine : true);
|
||
const [orchOk, setOrchOk] = React.useState<boolean>(true);
|
||
|
||
React.useEffect(() => {
|
||
const on = () => setNetOnline(true);
|
||
const off = () => setNetOnline(false);
|
||
window.addEventListener('online', on);
|
||
window.addEventListener('offline', off);
|
||
|
||
let mounted = true;
|
||
|
||
// real /healthz ping every 10s
|
||
const tick = async () => {
|
||
const ok = await pingHealthz('/healthz');
|
||
if (mounted) setOrchOk(ok);
|
||
};
|
||
tick();
|
||
const id = setInterval(tick, 10000);
|
||
|
||
// model profile recommendation
|
||
recommendModelProfile().then(p => { if (mounted) { setRecommendedProfile(p); setModelProfile(p); } });
|
||
|
||
// restore custom index path if saved
|
||
const savedPath = localStorage.getItem('microdao.indexPath');
|
||
if (savedPath) setIndexSize(`custom:${savedPath}`);
|
||
|
||
return () => { mounted = false; window.removeEventListener('online', on); window.removeEventListener('offline', off); clearInterval(id); };
|
||
}, []);
|
||
|
||
// Симуляція інсталяції агента/моделі
|
||
const handleInstallAgent = () => {
|
||
if (installing || agentReady) return;
|
||
setInstalling(true);
|
||
setProgress(0);
|
||
const timer = setInterval(() => {
|
||
setProgress((p) => {
|
||
const next = Math.min(100, p + Math.random() * 18 + 5);
|
||
if (next >= 100) {
|
||
clearInterval(timer);
|
||
setInstalling(false);
|
||
setAgentReady(true);
|
||
}
|
||
return next;
|
||
});
|
||
}, 400);
|
||
};
|
||
|
||
// Роутінг після вибору режиму — встановлюємо orgId і відкриваємо домашню
|
||
const goReady = (id: string) => { setOrgId(id); setActive('home'); };
|
||
|
||
const content = () => {
|
||
if (!orgId) {
|
||
return (
|
||
<StartScreen
|
||
onCreateDAO={() => goReady('dao-created')}
|
||
onJoinDAO={() => goReady('dao-joined')}
|
||
onSolo={() => goReady('solo-mode')}
|
||
agentReady={agentReady}
|
||
onInstallAgent={handleInstallAgent}
|
||
installing={installing}
|
||
progress={progress}
|
||
modelProfile={modelProfile}
|
||
setModelProfile={setModelProfile}
|
||
indexSize={indexSize}
|
||
setIndexSize={setIndexSize}
|
||
recommendedProfile={recommendedProfile}
|
||
onPickCustomIndex={handlePickCustomIndex}
|
||
/>
|
||
);
|
||
}
|
||
|
||
switch (active) {
|
||
case "home":
|
||
return <HomeOrchestrator />;
|
||
case "messenger":
|
||
return <Placeholder title="Чати та канали" />;
|
||
case "projects":
|
||
return <Placeholder title="Проєкти" />;
|
||
case "memory":
|
||
return <Placeholder title="База знань" />;
|
||
case "parser":
|
||
return <Placeholder title="Парсер документів" />;
|
||
case "graph":
|
||
return <Placeholder title="Граф знань" />;
|
||
case "meetings":
|
||
return <Placeholder title="Зустрічі" />;
|
||
case "agents":
|
||
return <Placeholder title="Агенти" />;
|
||
case "training":
|
||
return <Placeholder title="Навчальний кабінет" />;
|
||
case "dao":
|
||
return <Placeholder title="Голосування / DAO" />;
|
||
case "treasury":
|
||
return <Placeholder title="Фінанси / Казна" />;
|
||
case "integrations":
|
||
return <Placeholder title="Інтеграції" />;
|
||
case "market":
|
||
return <Placeholder title="Маркетплейс" />;
|
||
case "analytics":
|
||
return <Placeholder title="Аналітика" />;
|
||
case "notifications":
|
||
return <Placeholder title="Сповіщення" />;
|
||
case "admin":
|
||
return <Placeholder title="Адмін-панель" />;
|
||
case "creative":
|
||
return <Placeholder title="Креативна студія" />;
|
||
case "security":
|
||
return <Placeholder title="Безпека / Аудит" />;
|
||
case "profile":
|
||
return <Placeholder title="Профіль" />;
|
||
}
|
||
};
|
||
|
||
// Choose custom directory for local index and persist label
|
||
const handlePickCustomIndex = async () => {
|
||
// File System Access API if available
|
||
// @ts-ignore
|
||
if (window.showDirectoryPicker) {
|
||
try {
|
||
// @ts-ignore
|
||
const dir = await window.showDirectoryPicker();
|
||
const name = dir.name || 'custom-index';
|
||
localStorage.setItem('microdao.indexPath', name);
|
||
setIndexSize(`custom:${name}`);
|
||
return;
|
||
} catch {}
|
||
}
|
||
// Fallback via input directory (webkitdirectory)
|
||
const input = document.createElement('input');
|
||
// @ts-ignore
|
||
input.webkitdirectory = true; input.type = 'file';
|
||
input.onchange = () => {
|
||
const files = input.files || [] as any;
|
||
const any = files.length ? (files[0] as any) : null;
|
||
const guess = any?.webkitRelativePath?.split('/')?.[0] || 'custom-index';
|
||
localStorage.setItem('microdao.indexPath', guess);
|
||
setIndexSize(`custom:${guess}`);
|
||
};
|
||
input.click();
|
||
};
|
||
|
||
return (
|
||
<div className="h-[100vh] w-full grid grid-cols-[18rem_1fr] bg-slate-50 text-slate-900">
|
||
<Sidebar active={active} onSelect={setActive} />
|
||
<main className="flex flex-col">
|
||
<Topbar netOnline={netOnline} orchOk={orchOk} />
|
||
<div className="flex-1 overflow-auto">{content()}</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|