@polpo-ai/dashboard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -0
- package/README.md +44 -0
- package/app/(dashboard)/error.tsx +47 -0
- package/app/(dashboard)/layout.tsx +41 -0
- package/app/(dashboard)/loading.tsx +30 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/memory/view.tsx +36 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/models-view.tsx +30 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/page.tsx +51 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/prompt/view.tsx +211 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/skills/view.tsx +84 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/tools/view.tsx +525 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/vault/view.tsx +61 -0
- package/app/(dashboard)/projects/[id]/agents/page.tsx +39 -0
- package/app/(dashboard)/projects/[id]/agents/view.tsx +283 -0
- package/app/(dashboard)/projects/[id]/layout.tsx +11 -0
- package/app/(dashboard)/projects/[id]/logs/page.tsx +27 -0
- package/app/(dashboard)/projects/[id]/logs/view.tsx +184 -0
- package/app/(dashboard)/projects/[id]/memory/page.tsx +40 -0
- package/app/(dashboard)/projects/[id]/memory/view.tsx +17 -0
- package/app/(dashboard)/projects/[id]/missions/[missionId]/page.tsx +88 -0
- package/app/(dashboard)/projects/[id]/missions/[missionId]/view.tsx +569 -0
- package/app/(dashboard)/projects/[id]/missions/page.tsx +32 -0
- package/app/(dashboard)/projects/[id]/missions/view.tsx +282 -0
- package/app/(dashboard)/projects/[id]/onboarding-checklist.tsx +359 -0
- package/app/(dashboard)/projects/[id]/page.tsx +68 -0
- package/app/(dashboard)/projects/[id]/playbooks/[name]/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/playbooks/[name]/view.tsx +433 -0
- package/app/(dashboard)/projects/[id]/playbooks/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/playbooks/view.tsx +222 -0
- package/app/(dashboard)/projects/[id]/schedules/page.tsx +44 -0
- package/app/(dashboard)/projects/[id]/schedules/view.tsx +385 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/page.tsx +55 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/raw-view.tsx +373 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/view.tsx +124 -0
- package/app/(dashboard)/projects/[id]/sessions/page.tsx +31 -0
- package/app/(dashboard)/projects/[id]/sessions/view.tsx +242 -0
- package/app/(dashboard)/projects/[id]/settings/page.tsx +52 -0
- package/app/(dashboard)/projects/[id]/skills/[name]/page.tsx +65 -0
- package/app/(dashboard)/projects/[id]/skills/[name]/view.tsx +227 -0
- package/app/(dashboard)/projects/[id]/skills/page.tsx +31 -0
- package/app/(dashboard)/projects/[id]/skills/view.tsx +243 -0
- package/app/(dashboard)/projects/[id]/storage/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/storage/view.tsx +385 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view.tsx +336 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view.tsx +113 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint.ts +72 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view.tsx +90 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/page.tsx +63 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/view.tsx +137 -0
- package/app/(dashboard)/projects/[id]/tasks/page.tsx +40 -0
- package/app/(dashboard)/projects/[id]/tasks/view.tsx +421 -0
- package/app/(dashboard)/projects/[id]/view.tsx +421 -0
- package/app/(dashboard)/projects/[id]/welcome-banner.tsx +156 -0
- package/app/(dashboard)/projects/projects-list.tsx +126 -0
- package/app/(playground)/error.tsx +42 -0
- package/app/(playground)/layout.tsx +27 -0
- package/app/(playground)/projects/[id]/playground/page.tsx +57 -0
- package/app/(playground)/projects/[id]/playground/view.tsx +167 -0
- package/app/(playground)/projects/[id]/playground-legacy/page.tsx +47 -0
- package/app/(playground)/projects/[id]/playground-legacy/skeleton.tsx +63 -0
- package/app/(playground)/projects/[id]/playground-legacy/view.tsx +237 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +385 -0
- package/app/icon.svg +6 -0
- package/app/layout.tsx +39 -0
- package/app/page.tsx +11 -0
- package/components/ai-elements/code-block.tsx +562 -0
- package/components/dashboard/agent-capabilities.tsx +326 -0
- package/components/dashboard/agent-identity-header.tsx +50 -0
- package/components/dashboard/agent-model-picker.tsx +195 -0
- package/components/dashboard/agent-model-selector.tsx +121 -0
- package/components/dashboard/agent-picker-card.tsx +46 -0
- package/components/dashboard/agent-studio.tsx +242 -0
- package/components/dashboard/agents-table.tsx +48 -0
- package/components/dashboard/breadcrumb.tsx +70 -0
- package/components/dashboard/builder-chat.tsx +418 -0
- package/components/dashboard/chat-shell.tsx +40 -0
- package/components/dashboard/client-picker.tsx +63 -0
- package/components/dashboard/command-snippet.tsx +77 -0
- package/components/dashboard/connect-dialog.tsx +680 -0
- package/components/dashboard/copy-button.tsx +45 -0
- package/components/dashboard/copy-card.tsx +48 -0
- package/components/dashboard/file-browser.tsx +161 -0
- package/components/dashboard/hint.tsx +22 -0
- package/components/dashboard/inference-mode.tsx +15 -0
- package/components/dashboard/manual-refresh-button.tsx +52 -0
- package/components/dashboard/markdown.tsx +31 -0
- package/components/dashboard/mcp-install-panel.tsx +196 -0
- package/components/dashboard/mission-graph.tsx +262 -0
- package/components/dashboard/nav-tabs.tsx +86 -0
- package/components/dashboard/polpo-chat.tsx +468 -0
- package/components/dashboard/project-copilot.tsx +253 -0
- package/components/dashboard/sdk-snippet-panel.tsx +194 -0
- package/components/dashboard/section-header.tsx +37 -0
- package/components/dashboard/settings-form.tsx +2477 -0
- package/components/dashboard/sidebar.tsx +385 -0
- package/components/dashboard/skeletons.tsx +1054 -0
- package/components/dashboard/skills-install-wizard.tsx +273 -0
- package/components/dashboard/swarm-runs.tsx +316 -0
- package/components/dashboard/tab-toggle.tsx +35 -0
- package/components/dashboard/task-studio.tsx +224 -0
- package/components/dashboard/top-header.tsx +203 -0
- package/components/dashboard/webhook-deliveries.tsx +234 -0
- package/components/icons/coding-agents.tsx +151 -0
- package/components/json-ld.tsx +8 -0
- package/components/providers.tsx +25 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +60 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/chart.tsx +356 -0
- package/components/ui/command.tsx +196 -0
- package/components/ui/dialog.tsx +157 -0
- package/components/ui/input-group.tsx +158 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/multi-select.tsx +161 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/select.tsx +201 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +135 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/tabs.tsx +56 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/hooks/use-desktop-sidebar.tsx +50 -0
- package/hooks/use-mobile-sidebar.tsx +37 -0
- package/index.ts +14 -0
- package/lib/api.ts +194 -0
- package/lib/builder-context.ts +60 -0
- package/lib/data-client.ts +68 -0
- package/lib/get-query-client.ts +25 -0
- package/lib/polpo-client.tsx +49 -0
- package/lib/tool-catalog.ts +234 -0
- package/lib/use-event-catalog.ts +28 -0
- package/lib/utils.ts +6 -0
- package/package.json +99 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { useParams } from "next/navigation";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import {
|
|
7
|
+
RefreshCw,
|
|
8
|
+
Users,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
GitBranch,
|
|
11
|
+
BookMarked,
|
|
12
|
+
Terminal,
|
|
13
|
+
Download,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { usePolpoClient } from "#/lib/polpo-client";
|
|
16
|
+
import { SkillsInstallWizard } from "#/components/dashboard/skills-install-wizard";
|
|
17
|
+
import { CommandSnippet } from "#/components/dashboard/command-snippet";
|
|
18
|
+
import {
|
|
19
|
+
Dialog,
|
|
20
|
+
DialogContent,
|
|
21
|
+
DialogDescription,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
DialogTrigger,
|
|
25
|
+
} from "#/components/ui/dialog";
|
|
26
|
+
|
|
27
|
+
export interface SkillInfo {
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
/** Scope — "project" on cloud (always), kept for back-compat. */
|
|
31
|
+
source: string;
|
|
32
|
+
assignedTo: string[];
|
|
33
|
+
tags?: string[];
|
|
34
|
+
category?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function SkillsView({ initialSkills }: { initialSkills: SkillInfo[] }) {
|
|
38
|
+
const { id } = useParams<{ id: string }>();
|
|
39
|
+
|
|
40
|
+
const polpo = usePolpoClient(id);
|
|
41
|
+
const { data: skills = [], isFetching, refetch } = useQuery({
|
|
42
|
+
queryKey: ["skills", id],
|
|
43
|
+
queryFn: () => polpo.getSkills() as unknown as Promise<SkillInfo[]>,
|
|
44
|
+
initialData: initialSkills,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div>
|
|
49
|
+
{/* Header */}
|
|
50
|
+
<div className="flex items-center justify-between">
|
|
51
|
+
<div>
|
|
52
|
+
<h2 className="text-lg font-semibold tracking-tight">Skills</h2>
|
|
53
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
54
|
+
{skills.length} installed. Click a skill to view content.
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
<div className="flex items-center gap-3">
|
|
58
|
+
<SkillsInstallWizard />
|
|
59
|
+
<button
|
|
60
|
+
onClick={() => refetch()}
|
|
61
|
+
disabled={isFetching}
|
|
62
|
+
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
63
|
+
>
|
|
64
|
+
<RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
|
|
65
|
+
Refresh
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{/* Skills list */}
|
|
71
|
+
{skills.length > 0 ? (
|
|
72
|
+
<div className="mt-4 border border-border overflow-hidden">
|
|
73
|
+
<table className="w-full text-sm">
|
|
74
|
+
<thead>
|
|
75
|
+
<tr className="border-b border-border bg-muted/40 text-muted-foreground">
|
|
76
|
+
<th className="px-4 py-2.5 text-left font-medium">Skill</th>
|
|
77
|
+
<th className="px-4 py-2.5 text-left font-medium w-48 hidden md:table-cell">Assigned to</th>
|
|
78
|
+
<th className="px-4 py-2.5 text-left font-medium w-56 hidden lg:table-cell">Tags</th>
|
|
79
|
+
<th className="px-4 py-2.5 w-8" />
|
|
80
|
+
</tr>
|
|
81
|
+
</thead>
|
|
82
|
+
<tbody>
|
|
83
|
+
{skills.map((skill) => (
|
|
84
|
+
<tr key={skill.name} className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
|
|
85
|
+
<td className="px-4 py-3">
|
|
86
|
+
<Link
|
|
87
|
+
href={`/projects/${id}/skills/${encodeURIComponent(skill.name)}`}
|
|
88
|
+
className="block"
|
|
89
|
+
>
|
|
90
|
+
<div>
|
|
91
|
+
<p className="font-medium">{skill.name}</p>
|
|
92
|
+
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
|
|
93
|
+
{skill.description}
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
</Link>
|
|
97
|
+
</td>
|
|
98
|
+
<td className="px-4 py-3 hidden md:table-cell">
|
|
99
|
+
{skill.assignedTo.length > 0 ? (
|
|
100
|
+
<div className="flex items-center gap-1.5">
|
|
101
|
+
<Users className="h-3 w-3 text-muted-foreground" />
|
|
102
|
+
<span className="text-xs text-muted-foreground">
|
|
103
|
+
{skill.assignedTo.join(", ")}
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
) : (
|
|
107
|
+
<span className="text-xs text-muted-foreground/40">—</span>
|
|
108
|
+
)}
|
|
109
|
+
</td>
|
|
110
|
+
<td className="px-4 py-3 hidden lg:table-cell">
|
|
111
|
+
{skill.tags && skill.tags.length > 0 ? (
|
|
112
|
+
<div className="flex flex-wrap gap-1">
|
|
113
|
+
{skill.tags.slice(0, 3).map((tag) => (
|
|
114
|
+
<span
|
|
115
|
+
key={tag}
|
|
116
|
+
className="inline-flex h-5 items-center border border-border bg-muted/40 px-1.5 text-[10px] font-mono text-muted-foreground"
|
|
117
|
+
>
|
|
118
|
+
{tag}
|
|
119
|
+
</span>
|
|
120
|
+
))}
|
|
121
|
+
{skill.tags.length > 3 && (
|
|
122
|
+
<span className="text-[10px] text-muted-foreground/60">
|
|
123
|
+
+{skill.tags.length - 3}
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
) : (
|
|
128
|
+
<span className="text-xs text-muted-foreground/40">—</span>
|
|
129
|
+
)}
|
|
130
|
+
</td>
|
|
131
|
+
<td className="px-4 py-3">
|
|
132
|
+
<Link href={`/projects/${id}/skills/${encodeURIComponent(skill.name)}`}>
|
|
133
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
134
|
+
</Link>
|
|
135
|
+
</td>
|
|
136
|
+
</tr>
|
|
137
|
+
))}
|
|
138
|
+
</tbody>
|
|
139
|
+
</table>
|
|
140
|
+
</div>
|
|
141
|
+
) : (
|
|
142
|
+
<div className="mt-4 flex flex-col items-center gap-3 border border-dashed border-border p-10 text-center">
|
|
143
|
+
<BookMarked className="h-5 w-5 text-muted-foreground/40" strokeWidth={1.5} />
|
|
144
|
+
<p className="text-sm text-muted-foreground">No skills installed yet.</p>
|
|
145
|
+
<SkillsInstallWizard />
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Informational "how to install skills" dialog. Read-only for now — it
|
|
154
|
+
* explains the install paths (registry / GitHub / SDK); wiring an in-app
|
|
155
|
+
* installer comes later. Triggered by a button in the header and the
|
|
156
|
+
* empty state.
|
|
157
|
+
*/
|
|
158
|
+
function InstallSkillsDialog() {
|
|
159
|
+
return (
|
|
160
|
+
<Dialog>
|
|
161
|
+
<DialogTrigger className="inline-flex items-center gap-1.5 border border-foreground bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-colors hover:bg-foreground/90">
|
|
162
|
+
<Download className="h-3.5 w-3.5" strokeWidth={2} />
|
|
163
|
+
Install skills
|
|
164
|
+
</DialogTrigger>
|
|
165
|
+
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
|
166
|
+
<DialogHeader>
|
|
167
|
+
<DialogTitle>Install skills</DialogTitle>
|
|
168
|
+
<DialogDescription>
|
|
169
|
+
Skills inject domain knowledge into your agents' prompts — no
|
|
170
|
+
code changes. Install a pack, then assign it to agents.
|
|
171
|
+
</DialogDescription>
|
|
172
|
+
</DialogHeader>
|
|
173
|
+
|
|
174
|
+
<div className="flex flex-col gap-6 pt-1">
|
|
175
|
+
{/* Registry */}
|
|
176
|
+
<div>
|
|
177
|
+
<div className="flex items-center gap-2">
|
|
178
|
+
<BookMarked className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
|
|
179
|
+
<h3 className="text-xs font-semibold uppercase tracking-wider">
|
|
180
|
+
From the registry
|
|
181
|
+
</h3>
|
|
182
|
+
</div>
|
|
183
|
+
<p className="mb-2 mt-1.5 text-xs text-muted-foreground">
|
|
184
|
+
Clone a pre-built pack from{" "}
|
|
185
|
+
<a
|
|
186
|
+
href="https://skills.sh"
|
|
187
|
+
target="_blank"
|
|
188
|
+
rel="noopener noreferrer"
|
|
189
|
+
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
|
|
190
|
+
>
|
|
191
|
+
skills.sh
|
|
192
|
+
</a>{" "}
|
|
193
|
+
into <span className="font-mono">.polpo/skills/</span>.
|
|
194
|
+
</p>
|
|
195
|
+
<CommandSnippet command="git clone https://skills.sh/polpo-agents .polpo/skills/polpo-agents" />
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{/* GitHub */}
|
|
199
|
+
<div>
|
|
200
|
+
<div className="flex items-center gap-2">
|
|
201
|
+
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
|
|
202
|
+
<h3 className="text-xs font-semibold uppercase tracking-wider">
|
|
203
|
+
From GitHub
|
|
204
|
+
</h3>
|
|
205
|
+
</div>
|
|
206
|
+
<p className="mb-2 mt-1.5 text-xs text-muted-foreground">
|
|
207
|
+
Any public repo with a skill pack works the same way.
|
|
208
|
+
</p>
|
|
209
|
+
<CommandSnippet command="git clone https://github.com/you/your-skills .polpo/skills/your-skills" />
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* Deploy */}
|
|
213
|
+
<div>
|
|
214
|
+
<div className="flex items-center gap-2">
|
|
215
|
+
<Terminal className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
|
|
216
|
+
<h3 className="text-xs font-semibold uppercase tracking-wider">
|
|
217
|
+
Then deploy
|
|
218
|
+
</h3>
|
|
219
|
+
</div>
|
|
220
|
+
<p className="mb-2 mt-1.5 text-xs text-muted-foreground">
|
|
221
|
+
Sync the new skills, then assign them to agents.
|
|
222
|
+
</p>
|
|
223
|
+
<CommandSnippet command="polpo deploy" />
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<p className="text-[11px] leading-relaxed text-muted-foreground/60">
|
|
227
|
+
Prefer code? Use{" "}
|
|
228
|
+
<span className="font-mono">client.installSkills(url)</span> from the
|
|
229
|
+
SDK.{" "}
|
|
230
|
+
<a
|
|
231
|
+
href="https://docs.polpo.sh/docs/ecosystem/skills-registry"
|
|
232
|
+
target="_blank"
|
|
233
|
+
rel="noopener noreferrer"
|
|
234
|
+
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
|
|
235
|
+
>
|
|
236
|
+
Full guide →
|
|
237
|
+
</a>
|
|
238
|
+
</p>
|
|
239
|
+
</div>
|
|
240
|
+
</DialogContent>
|
|
241
|
+
</Dialog>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
4
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useParams } from "next/navigation";
|
|
6
|
+
import { usePolpoClient } from "#/lib/polpo-client";
|
|
7
|
+
import {
|
|
8
|
+
Folder, FileText, File, Image, Music, Video, Archive,
|
|
9
|
+
ChevronRight, ChevronLeft, Download, Search,
|
|
10
|
+
RefreshCw, HardDrive, FolderOpen, Upload, Pencil, Trash2, MoreHorizontal,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
|
|
13
|
+
// ── Types ──
|
|
14
|
+
interface FileEntry {
|
|
15
|
+
name: string;
|
|
16
|
+
type: "file" | "directory";
|
|
17
|
+
size?: number;
|
|
18
|
+
mimeType?: string;
|
|
19
|
+
modifiedAt?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FileRoot {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
path: string;
|
|
26
|
+
description: string;
|
|
27
|
+
totalFiles: number;
|
|
28
|
+
totalSize: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Helpers ──
|
|
32
|
+
function formatSize(bytes?: number) {
|
|
33
|
+
if (!bytes) return "—";
|
|
34
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
35
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
36
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatDate(iso?: string) {
|
|
40
|
+
if (!iso) return "";
|
|
41
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
42
|
+
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fileIcon(entry: FileEntry) {
|
|
47
|
+
if (entry.type === "directory") return <Folder className="h-5 w-5 text-blue-500" />;
|
|
48
|
+
const m = entry.mimeType ?? "";
|
|
49
|
+
if (m.startsWith("image/")) return <Image className="h-5 w-5 text-amber-500" />;
|
|
50
|
+
if (m.startsWith("audio/")) return <Music className="h-5 w-5 text-purple-500" />;
|
|
51
|
+
if (m.startsWith("video/")) return <Video className="h-5 w-5 text-red-500" />;
|
|
52
|
+
if (m === "application/pdf") return <FileText className="h-5 w-5 text-red-500" />;
|
|
53
|
+
if (m.startsWith("text/") || m === "application/json") return <FileText className="h-5 w-5 text-muted-foreground" />;
|
|
54
|
+
if (m.includes("zip") || m.includes("tar") || m.includes("gzip")) return <Archive className="h-5 w-5 text-muted-foreground" />;
|
|
55
|
+
return <File className="h-5 w-5 text-muted-foreground" />;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
59
|
+
|
|
60
|
+
// ── Main View ──
|
|
61
|
+
export default function StorageView() {
|
|
62
|
+
const { id } = useParams<{ id: string }>();
|
|
63
|
+
const queryClient = useQueryClient();
|
|
64
|
+
const [currentPath, setCurrentPath] = useState(".");
|
|
65
|
+
const [pathStack, setPathStack] = useState<string[]>([]);
|
|
66
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
67
|
+
const [uploading, setUploading] = useState(false);
|
|
68
|
+
const [renaming, setRenaming] = useState<string | null>(null);
|
|
69
|
+
const [renameValue, setRenameValue] = useState("");
|
|
70
|
+
const [menuOpen, setMenuOpen] = useState<string | null>(null);
|
|
71
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
72
|
+
const polpo = usePolpoClient(id);
|
|
73
|
+
|
|
74
|
+
// Roots — `.polpo` is an internal runtime directory; filter it out so
|
|
75
|
+
// users only see the meaningful ones (agent volumes, shared storage).
|
|
76
|
+
const { data: rawRoots = [] } = useQuery({
|
|
77
|
+
queryKey: ["files-roots", id],
|
|
78
|
+
queryFn: async () => {
|
|
79
|
+
const r = await polpo.getFileRoots();
|
|
80
|
+
return (r.roots ?? []) as unknown as FileRoot[];
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const roots = rawRoots.filter(
|
|
84
|
+
(r) => !r.path.endsWith(".polpo") && !r.path.endsWith("/.polpo") && r.name !== ".polpo",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// If filtering leaves a single root, drop the user straight into it —
|
|
88
|
+
// no reason to show a one-item root picker.
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (currentPath === "." && roots.length === 1) {
|
|
91
|
+
setCurrentPath(roots[0].path);
|
|
92
|
+
}
|
|
93
|
+
}, [roots, currentPath]);
|
|
94
|
+
|
|
95
|
+
// Directory listing
|
|
96
|
+
const { data: listing, isLoading, isFetching, refetch } = useQuery({
|
|
97
|
+
queryKey: ["files-list", id, currentPath],
|
|
98
|
+
queryFn: () => polpo.listFiles(currentPath) as unknown as Promise<{ path: string; entries: FileEntry[] }>,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const entries = listing?.entries ?? [];
|
|
102
|
+
const filtered = searchQuery
|
|
103
|
+
? entries.filter((e) => e.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
104
|
+
: entries;
|
|
105
|
+
|
|
106
|
+
const navigateTo = useCallback((dirName: string) => {
|
|
107
|
+
const newPath = currentPath === "." ? dirName : `${currentPath}/${dirName}`;
|
|
108
|
+
setPathStack((prev) => [...prev, currentPath]);
|
|
109
|
+
setCurrentPath(newPath);
|
|
110
|
+
setSearchQuery("");
|
|
111
|
+
}, [currentPath]);
|
|
112
|
+
|
|
113
|
+
const navigateBack = useCallback(() => {
|
|
114
|
+
const prev = pathStack[pathStack.length - 1] ?? ".";
|
|
115
|
+
setPathStack((ps) => ps.slice(0, -1));
|
|
116
|
+
setCurrentPath(prev);
|
|
117
|
+
setSearchQuery("");
|
|
118
|
+
}, [pathStack]);
|
|
119
|
+
|
|
120
|
+
const navigateToRoot = useCallback((root: FileRoot) => {
|
|
121
|
+
setPathStack(["."]);
|
|
122
|
+
setCurrentPath(root.path);
|
|
123
|
+
setSearchQuery("");
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
const getDownloadUrl = (filePath: string) =>
|
|
127
|
+
`${API_URL}/v1/projects/${id}/data/v1/files/read?path=${encodeURIComponent(filePath)}&download=1`;
|
|
128
|
+
|
|
129
|
+
const handleRowClick = (entry: FileEntry) => {
|
|
130
|
+
if (entry.type === "directory") {
|
|
131
|
+
navigateTo(entry.name);
|
|
132
|
+
} else {
|
|
133
|
+
// Direct download
|
|
134
|
+
const filePath = currentPath === "." ? entry.name : `${currentPath}/${entry.name}`;
|
|
135
|
+
window.open(getDownloadUrl(filePath), "_blank");
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
140
|
+
const files = e.target.files;
|
|
141
|
+
if (!files || files.length === 0) return;
|
|
142
|
+
|
|
143
|
+
setUploading(true);
|
|
144
|
+
try {
|
|
145
|
+
// SDK uploads one file at a time; the dashboard supports multi-select,
|
|
146
|
+
// so we loop. Each upload is independent — a failure mid-batch
|
|
147
|
+
// leaves earlier files uploaded (intentional, same as the raw-fetch
|
|
148
|
+
// implementation that preceded this).
|
|
149
|
+
for (const file of Array.from(files)) {
|
|
150
|
+
await polpo.uploadFile(currentPath, file, file.name);
|
|
151
|
+
}
|
|
152
|
+
queryClient.invalidateQueries({ queryKey: ["files-list", id, currentPath] });
|
|
153
|
+
queryClient.invalidateQueries({ queryKey: ["files-roots", id] });
|
|
154
|
+
refetch();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error("Upload error:", err);
|
|
157
|
+
} finally {
|
|
158
|
+
setUploading(false);
|
|
159
|
+
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const handleRename = async (oldName: string) => {
|
|
164
|
+
if (!renameValue.trim() || renameValue === oldName) {
|
|
165
|
+
setRenaming(null);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const filePath = currentPath === "." ? oldName : `${currentPath}/${oldName}`;
|
|
169
|
+
try {
|
|
170
|
+
await polpo.renameFile(filePath, renameValue.trim());
|
|
171
|
+
queryClient.invalidateQueries({ queryKey: ["files-list", id, currentPath] });
|
|
172
|
+
refetch();
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error("Rename error:", err);
|
|
175
|
+
}
|
|
176
|
+
setRenaming(null);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const handleDelete = async (entryName: string) => {
|
|
180
|
+
const filePath = currentPath === "." ? entryName : `${currentPath}/${entryName}`;
|
|
181
|
+
if (!confirm(`Delete "${entryName}"?`)) return;
|
|
182
|
+
try {
|
|
183
|
+
await polpo.deleteFile(filePath);
|
|
184
|
+
queryClient.invalidateQueries({ queryKey: ["files-list", id, currentPath] });
|
|
185
|
+
queryClient.invalidateQueries({ queryKey: ["files-roots", id] });
|
|
186
|
+
refetch();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error("Delete error:", err);
|
|
189
|
+
}
|
|
190
|
+
setMenuOpen(null);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const isRoot = pathStack.length === 0 && currentPath === ".";
|
|
194
|
+
// With a single (non-.polpo) root the user is auto-dropped into it; the
|
|
195
|
+
// back button would only surface the one-item root picker, which is dead
|
|
196
|
+
// weight. Hide back whenever the stack is empty.
|
|
197
|
+
const canNavigateBack = pathStack.length > 0;
|
|
198
|
+
const breadcrumb = currentPath === "." ? "Files" : currentPath;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<div className="space-y-4">
|
|
202
|
+
{/* Header */}
|
|
203
|
+
<div className="flex items-center justify-between">
|
|
204
|
+
<div className="flex items-center gap-2">
|
|
205
|
+
{canNavigateBack && (
|
|
206
|
+
<button onClick={navigateBack} className="p-1.5 rounded-lg hover:bg-muted transition-colors">
|
|
207
|
+
<ChevronLeft className="h-5 w-5" />
|
|
208
|
+
</button>
|
|
209
|
+
)}
|
|
210
|
+
<div className="flex items-center gap-2">
|
|
211
|
+
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
|
212
|
+
<h2 className="text-lg font-bold">{breadcrumb}</h2>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div className="flex items-center gap-2">
|
|
216
|
+
{/* Search */}
|
|
217
|
+
<div className="relative">
|
|
218
|
+
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
219
|
+
<input
|
|
220
|
+
type="text"
|
|
221
|
+
placeholder="Search files..."
|
|
222
|
+
value={searchQuery}
|
|
223
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
224
|
+
className="h-9 w-48 border border-border bg-background pl-8 pr-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
{/* Upload */}
|
|
228
|
+
<input ref={fileInputRef} type="file" multiple hidden onChange={handleUpload} />
|
|
229
|
+
<button
|
|
230
|
+
onClick={() => fileInputRef.current?.click()}
|
|
231
|
+
disabled={uploading || isRoot}
|
|
232
|
+
className="inline-flex items-center gap-1.5 border border-border px-3 py-2 text-xs font-medium transition-colors hover:bg-muted disabled:opacity-50"
|
|
233
|
+
>
|
|
234
|
+
<Upload className={`h-3.5 w-3.5 ${uploading ? "animate-spin" : ""}`} />
|
|
235
|
+
{uploading ? "Uploading..." : "Upload"}
|
|
236
|
+
</button>
|
|
237
|
+
{/* Refresh */}
|
|
238
|
+
<button
|
|
239
|
+
onClick={() => refetch()}
|
|
240
|
+
disabled={isFetching}
|
|
241
|
+
className="p-2 rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
|
|
242
|
+
>
|
|
243
|
+
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
{/* Root chips */}
|
|
249
|
+
{isRoot && roots.length > 0 && (
|
|
250
|
+
<div className="flex gap-3">
|
|
251
|
+
{roots.map((root) => (
|
|
252
|
+
<button
|
|
253
|
+
key={root.id}
|
|
254
|
+
onClick={() => navigateToRoot(root)}
|
|
255
|
+
className="flex items-center gap-2 border border-border bg-card px-4 py-3 text-left transition-colors hover:bg-muted"
|
|
256
|
+
>
|
|
257
|
+
<FolderOpen className="h-5 w-5 text-brand" />
|
|
258
|
+
<div>
|
|
259
|
+
<p className="text-sm font-semibold">{root.name}</p>
|
|
260
|
+
<p className="text-xs text-muted-foreground">
|
|
261
|
+
{root.totalFiles} files · {formatSize(root.totalSize)}
|
|
262
|
+
</p>
|
|
263
|
+
</div>
|
|
264
|
+
</button>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{/* File list */}
|
|
270
|
+
{isLoading ? (
|
|
271
|
+
<div className="mt-4 border border-border overflow-hidden">
|
|
272
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
273
|
+
<div key={i} className="flex items-center gap-3 border-b border-border last:border-0 px-4 py-3">
|
|
274
|
+
<div className="h-4 w-4 bg-secondary rounded animate-pulse" />
|
|
275
|
+
<div className="h-3.5 bg-secondary rounded animate-pulse" style={{ width: `${100 + i * 30}px` }} />
|
|
276
|
+
<div className="ml-auto h-3 w-16 bg-secondary/50 rounded animate-pulse" />
|
|
277
|
+
</div>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
) : filtered.length === 0 ? (
|
|
281
|
+
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
|
282
|
+
<FolderOpen className="h-10 w-10 mb-3" />
|
|
283
|
+
<p className="text-sm">{searchQuery ? "No matching files" : "Empty directory"}</p>
|
|
284
|
+
</div>
|
|
285
|
+
) : (
|
|
286
|
+
<div className="border border-border overflow-hidden">
|
|
287
|
+
<table className="w-full text-sm">
|
|
288
|
+
<thead>
|
|
289
|
+
<tr className="border-b border-border bg-muted/40 text-muted-foreground">
|
|
290
|
+
<th className="px-4 py-2.5 text-left font-medium">Name</th>
|
|
291
|
+
<th className="px-4 py-2.5 text-left font-medium w-24">Size</th>
|
|
292
|
+
<th className="px-4 py-2.5 text-left font-medium w-40 hidden md:table-cell">Modified</th>
|
|
293
|
+
<th className="px-4 py-2.5 w-10" />
|
|
294
|
+
</tr>
|
|
295
|
+
</thead>
|
|
296
|
+
<tbody>
|
|
297
|
+
{filtered.map((entry) => (
|
|
298
|
+
<tr
|
|
299
|
+
key={entry.name}
|
|
300
|
+
onClick={() => { if (renaming !== entry.name) handleRowClick(entry); }}
|
|
301
|
+
className="border-b border-border last:border-0 hover:bg-muted/30 cursor-pointer transition-colors group"
|
|
302
|
+
>
|
|
303
|
+
<td className="px-4 py-3">
|
|
304
|
+
<div className="flex items-center gap-3">
|
|
305
|
+
{fileIcon(entry)}
|
|
306
|
+
{renaming === entry.name ? (
|
|
307
|
+
<input
|
|
308
|
+
autoFocus
|
|
309
|
+
value={renameValue}
|
|
310
|
+
onChange={(e) => setRenameValue(e.target.value)}
|
|
311
|
+
onBlur={() => handleRename(entry.name)}
|
|
312
|
+
onKeyDown={(e) => {
|
|
313
|
+
if (e.key === "Enter") handleRename(entry.name);
|
|
314
|
+
if (e.key === "Escape") setRenaming(null);
|
|
315
|
+
}}
|
|
316
|
+
onClick={(e) => e.stopPropagation()}
|
|
317
|
+
className="h-7 rounded border border-border bg-background px-2 text-sm font-medium focus:outline-none focus:ring-1 focus:ring-ring"
|
|
318
|
+
/>
|
|
319
|
+
) : (
|
|
320
|
+
<span className="font-medium truncate max-w-xs">{entry.name}</span>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
</td>
|
|
324
|
+
<td className="px-4 py-3 text-muted-foreground">
|
|
325
|
+
{entry.type === "directory" ? "—" : formatSize(entry.size)}
|
|
326
|
+
</td>
|
|
327
|
+
<td className="px-4 py-3 text-muted-foreground hidden md:table-cell">
|
|
328
|
+
{formatDate(entry.modifiedAt)}
|
|
329
|
+
</td>
|
|
330
|
+
<td className="px-4 py-3">
|
|
331
|
+
<div className="relative">
|
|
332
|
+
{entry.type === "directory" ? (
|
|
333
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
334
|
+
) : (
|
|
335
|
+
<button
|
|
336
|
+
onClick={(e) => { e.stopPropagation(); setMenuOpen(menuOpen === entry.name ? null : entry.name); }}
|
|
337
|
+
className="p-1 rounded hover:bg-muted transition-colors"
|
|
338
|
+
>
|
|
339
|
+
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
|
340
|
+
</button>
|
|
341
|
+
)}
|
|
342
|
+
{/* Context menu */}
|
|
343
|
+
{menuOpen === entry.name && (
|
|
344
|
+
<div className="absolute right-0 top-8 z-20 w-36 border border-border bg-card shadow-lg py-1">
|
|
345
|
+
<button
|
|
346
|
+
onClick={(e) => {
|
|
347
|
+
e.stopPropagation();
|
|
348
|
+
const filePath = currentPath === "." ? entry.name : `${currentPath}/${entry.name}`;
|
|
349
|
+
window.open(getDownloadUrl(filePath), "_blank");
|
|
350
|
+
setMenuOpen(null);
|
|
351
|
+
}}
|
|
352
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-xs hover:bg-muted transition-colors"
|
|
353
|
+
>
|
|
354
|
+
<Download className="h-3.5 w-3.5" /> Download
|
|
355
|
+
</button>
|
|
356
|
+
<button
|
|
357
|
+
onClick={(e) => {
|
|
358
|
+
e.stopPropagation();
|
|
359
|
+
setRenaming(entry.name);
|
|
360
|
+
setRenameValue(entry.name);
|
|
361
|
+
setMenuOpen(null);
|
|
362
|
+
}}
|
|
363
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-xs hover:bg-muted transition-colors"
|
|
364
|
+
>
|
|
365
|
+
<Pencil className="h-3.5 w-3.5" /> Rename
|
|
366
|
+
</button>
|
|
367
|
+
<button
|
|
368
|
+
onClick={(e) => { e.stopPropagation(); handleDelete(entry.name); }}
|
|
369
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-red-500 hover:bg-muted transition-colors"
|
|
370
|
+
>
|
|
371
|
+
<Trash2 className="h-3.5 w-3.5" /> Delete
|
|
372
|
+
</button>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
</td>
|
|
377
|
+
</tr>
|
|
378
|
+
))}
|
|
379
|
+
</tbody>
|
|
380
|
+
</table>
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|