larkup 0.1.14
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/.env +2 -0
- package/app/actions/vercel.ts +417 -0
- package/app/api/chat/route.ts +192 -0
- package/app/api/chat/status/route.ts +55 -0
- package/app/api/chat/suggestions/route.ts +115 -0
- package/app/api/config/route.ts +73 -0
- package/app/api/config/test/route.ts +67 -0
- package/app/api/config/test-embedding/route.ts +40 -0
- package/app/api/config/test-llm/route.ts +47 -0
- package/app/api/config/test-provider/route.ts +36 -0
- package/app/api/demo/route.ts +176 -0
- package/app/api/deploy/ssh/route.ts +340 -0
- package/app/api/documents/route.ts +90 -0
- package/app/api/firecrawl/local/route.ts +46 -0
- package/app/api/index/route.ts +113 -0
- package/app/api/jobs/[id]/route.ts +44 -0
- package/app/api/jobs/route.ts +116 -0
- package/app/api/parse-file/route.ts +39 -0
- package/app/api/search/google/route.ts +162 -0
- package/app/api/search/route.ts +29 -0
- package/app/api/server/download/route.ts +112 -0
- package/app/api/server/generate/route.ts +46 -0
- package/app/api/server/local/route.ts +42 -0
- package/app/api/servers/route.ts +137 -0
- package/app/api/vector-stores/install/route.ts +65 -0
- package/app/api/vector-stores/status/route.ts +26 -0
- package/app/chat/page.tsx +15 -0
- package/app/configure/page.tsx +53 -0
- package/app/data/page.tsx +15 -0
- package/app/demo/page.tsx +15 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +190 -0
- package/app/index-data/page.tsx +15 -0
- package/app/layout.tsx +51 -0
- package/app/page.tsx +34 -0
- package/app/server/page.tsx +22 -0
- package/app/simple/chat/page.tsx +17 -0
- package/app/simple/docs/page.tsx +21 -0
- package/app/simple/layout.tsx +9 -0
- package/app/simple/settings/page.tsx +627 -0
- package/app/template.tsx +16 -0
- package/app/themes.css +733 -0
- package/components/alerts/generic-alert.tsx +74 -0
- package/components/app-sidebar.tsx +187 -0
- package/components/app-topnav.tsx +195 -0
- package/components/chat/chat-settings-modal.tsx +209 -0
- package/components/chat/chat-workspace.tsx +348 -0
- package/components/chat/knowledge-base-result.tsx +97 -0
- package/components/chat/message-item.tsx +103 -0
- package/components/client-layout-wrapper.tsx +182 -0
- package/components/configure/configure-form.tsx +1683 -0
- package/components/configure/store-fields.tsx +152 -0
- package/components/data/corpus-panel.tsx +499 -0
- package/components/data/data-workspace.tsx +272 -0
- package/components/data/firecrawl-notice.tsx +236 -0
- package/components/data/jobs-panel.tsx +485 -0
- package/components/data/paste-panel.tsx +79 -0
- package/components/data/scrape-panel.tsx +964 -0
- package/components/data/upload-panel.tsx +714 -0
- package/components/demo/demo-workspace.tsx +419 -0
- package/components/global-settings.tsx +286 -0
- package/components/index/index-workspace.tsx +482 -0
- package/components/onboarding/simple-setup.tsx +702 -0
- package/components/onboarding/tech-setup.tsx +121 -0
- package/components/onboarding/welcome-screen.tsx +183 -0
- package/components/page-header.tsx +44 -0
- package/components/server/code-viewer.tsx +87 -0
- package/components/server/deploy-button.tsx +1390 -0
- package/components/server/server-workspace.tsx +392 -0
- package/components/simple/sdk-connect-dialog.tsx +201 -0
- package/components/simple/simple-index-button.tsx +83 -0
- package/components/simple/simple-sidebar.tsx +161 -0
- package/components/theme-customizer-provider.tsx +171 -0
- package/components/theme-switcher.tsx +445 -0
- package/components/ui/alert-dialog.tsx +187 -0
- package/components/ui/alert.tsx +76 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/checkbox.tsx +28 -0
- package/components/ui/collapsible.tsx +21 -0
- package/components/ui/dialog.tsx +159 -0
- package/components/ui/dropdown-menu.tsx +281 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +20 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/progress.tsx +83 -0
- package/components/ui/provider-icon.tsx +94 -0
- package/components/ui/scroll-area.tsx +55 -0
- package/components/ui/select.tsx +248 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +138 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/sonner.tsx +63 -0
- package/components/ui/switch.tsx +32 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/tabs.tsx +84 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components/workspace/delete-server-dialog.tsx +71 -0
- package/components/workspace/server-form-dialog.tsx +103 -0
- package/components/workspace/server-switcher.tsx +163 -0
- package/components/workspace/workspace-provider.tsx +177 -0
- package/components/workspace/workspace-top-bar.tsx +23 -0
- package/components.json +21 -0
- package/lib/error-formatter.ts +15 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +18 -0
- package/package.json +65 -0
- package/postcss.config.mjs +8 -0
- package/public/docker.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/github.svg +3 -0
- package/public/hetzner.svg +1 -0
- package/public/icons/aws.svg +38 -0
- package/public/icons/azure.svg +1 -0
- package/public/icons/chroma.png +0 -0
- package/public/icons/cohere.svg +30 -0
- package/public/icons/digital-ocean.webp +0 -0
- package/public/icons/firecrawl2.png +0 -0
- package/public/icons/gcp.svg +1 -0
- package/public/icons/gemini.svg +1 -0
- package/public/icons/jina.svg +1 -0
- package/public/icons/lancedb2.png +0 -0
- package/public/icons/mistral.svg +469 -0
- package/public/icons/nomic.png +0 -0
- package/public/icons/openai.svg +1 -0
- package/public/icons/pgvector2.png +0 -0
- package/public/icons/pinecone.png +0 -0
- package/public/icons/qdrant.svg +35 -0
- package/public/icons/supabase.png +0 -0
- package/public/icons/vercel copy.svg +1 -0
- package/public/icons/vercel.svg +1 -0
- package/public/icons/voyage-light.png +0 -0
- package/public/icons/weaviate.webp +0 -0
- package/public/logo-light.png +0 -0
- package/public/logo.png +0 -0
- package/public/logo9.png +0 -0
- package/public/vercel.svg +1 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import {
|
|
6
|
+
CheckCircle2,
|
|
7
|
+
CircleDashed,
|
|
8
|
+
Loader2,
|
|
9
|
+
Octagon,
|
|
10
|
+
TriangleAlert,
|
|
11
|
+
Trash2,
|
|
12
|
+
ChevronLeft,
|
|
13
|
+
ChevronRight,
|
|
14
|
+
AlertTriangle,
|
|
15
|
+
Copy,
|
|
16
|
+
XIcon,
|
|
17
|
+
Square,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
import type { CrawlJob, CrawlJobStatus } from "@larkup/core/types";
|
|
20
|
+
import { Badge } from "@/components/ui/badge";
|
|
21
|
+
import { Button } from "@/components/ui/button";
|
|
22
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
23
|
+
import {
|
|
24
|
+
Dialog,
|
|
25
|
+
DialogContent,
|
|
26
|
+
DialogDescription,
|
|
27
|
+
DialogFooter,
|
|
28
|
+
DialogHeader,
|
|
29
|
+
DialogTitle,
|
|
30
|
+
} from "@/components/ui/dialog";
|
|
31
|
+
import { cn } from "@/lib/utils";
|
|
32
|
+
|
|
33
|
+
const PAGE_SIZE = 8;
|
|
34
|
+
|
|
35
|
+
const STATUS: Record<
|
|
36
|
+
CrawlJobStatus,
|
|
37
|
+
{
|
|
38
|
+
label: string;
|
|
39
|
+
icon: typeof Loader2;
|
|
40
|
+
className: string;
|
|
41
|
+
badgeVariant: string;
|
|
42
|
+
}
|
|
43
|
+
> = {
|
|
44
|
+
queued: {
|
|
45
|
+
label: "Queued",
|
|
46
|
+
icon: CircleDashed,
|
|
47
|
+
className: "text-orange-500 border-orange-500/30 bg-orange-500/10",
|
|
48
|
+
badgeVariant: "outline",
|
|
49
|
+
},
|
|
50
|
+
running: {
|
|
51
|
+
label: "Running",
|
|
52
|
+
icon: Loader2,
|
|
53
|
+
className: "text-primary border-primary/30 bg-primary/10",
|
|
54
|
+
badgeVariant: "outline",
|
|
55
|
+
},
|
|
56
|
+
completed: {
|
|
57
|
+
label: "Completed",
|
|
58
|
+
icon: CheckCircle2,
|
|
59
|
+
className: "text-green-600 border-green-600/50 bg-green-600/10",
|
|
60
|
+
badgeVariant: "outline",
|
|
61
|
+
},
|
|
62
|
+
failed: {
|
|
63
|
+
label: "Failed",
|
|
64
|
+
icon: TriangleAlert,
|
|
65
|
+
className: "text-destructive border-destructive/30 bg-destructive/10",
|
|
66
|
+
badgeVariant: "outline",
|
|
67
|
+
},
|
|
68
|
+
cancelled: {
|
|
69
|
+
label: "Cancelled",
|
|
70
|
+
icon: Octagon,
|
|
71
|
+
className: "text-muted-foreground border-muted-foreground/30 bg-muted/30",
|
|
72
|
+
badgeVariant: "outline",
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function progressFor(job: CrawlJob): number {
|
|
77
|
+
const domainTargets = job.targets.filter((t) => t.scope === "domain").length;
|
|
78
|
+
const denom =
|
|
79
|
+
domainTargets > 0 ? domainTargets * job.pageLimit : job.targets.length;
|
|
80
|
+
if (denom === 0) return 0;
|
|
81
|
+
if (job.status === "completed") return 100;
|
|
82
|
+
return Math.min(99, Math.round((job.pagesCrawled / denom) * 100));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function JobsPanel({
|
|
86
|
+
jobs,
|
|
87
|
+
onChanged,
|
|
88
|
+
}: {
|
|
89
|
+
jobs: CrawlJob[];
|
|
90
|
+
onChanged: () => void;
|
|
91
|
+
}) {
|
|
92
|
+
const [page, setPage] = useState(0);
|
|
93
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
94
|
+
const [confirmDelete, setConfirmDelete] = useState<{
|
|
95
|
+
ids: string[];
|
|
96
|
+
label: string;
|
|
97
|
+
} | null>(null);
|
|
98
|
+
const [deleting, setDeleting] = useState(false);
|
|
99
|
+
|
|
100
|
+
const [confirmCancel, setConfirmCancel] = useState<{
|
|
101
|
+
id: string;
|
|
102
|
+
label: string;
|
|
103
|
+
} | null>(null);
|
|
104
|
+
const [cancelling, setCancelling] = useState(false);
|
|
105
|
+
|
|
106
|
+
const totalPages = Math.max(1, Math.ceil(jobs.length / PAGE_SIZE));
|
|
107
|
+
const safePage = Math.min(page, totalPages - 1);
|
|
108
|
+
const pageJobs = jobs.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
|
109
|
+
|
|
110
|
+
const allPageSelected =
|
|
111
|
+
pageJobs.length > 0 && pageJobs.every((j) => selected.has(j.id));
|
|
112
|
+
const somePageSelected = pageJobs.some((j) => selected.has(j.id));
|
|
113
|
+
|
|
114
|
+
function toggleAll() {
|
|
115
|
+
const next = new Set(selected);
|
|
116
|
+
if (allPageSelected) {
|
|
117
|
+
pageJobs.forEach((j) => next.delete(j.id));
|
|
118
|
+
} else {
|
|
119
|
+
pageJobs.forEach((j) => next.add(j.id));
|
|
120
|
+
}
|
|
121
|
+
setSelected(next);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toggleOne(id: string) {
|
|
125
|
+
const next = new Set(selected);
|
|
126
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
127
|
+
setSelected(next);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function executeCancel(id: string) {
|
|
131
|
+
setCancelling(true);
|
|
132
|
+
try {
|
|
133
|
+
await fetch(`/api/jobs/${id}`, { method: "DELETE" });
|
|
134
|
+
toast.message("Job cancelled");
|
|
135
|
+
onChanged();
|
|
136
|
+
} finally {
|
|
137
|
+
setCancelling(false);
|
|
138
|
+
setConfirmCancel(null);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function requestCancel(id: string) {
|
|
143
|
+
const label = `"${jobs.find((j) => j.id === id)?.keywords ?? id}"`;
|
|
144
|
+
setConfirmCancel({ id, label });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function executeDelete(ids: string[]) {
|
|
148
|
+
setDeleting(true);
|
|
149
|
+
try {
|
|
150
|
+
await Promise.all(
|
|
151
|
+
ids.map((id) =>
|
|
152
|
+
fetch(`/api/jobs/${id}?remove=1`, { method: "DELETE" }),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
setSelected((prev) => {
|
|
156
|
+
const next = new Set(prev);
|
|
157
|
+
ids.forEach((id) => next.delete(id));
|
|
158
|
+
return next;
|
|
159
|
+
});
|
|
160
|
+
toast.success(
|
|
161
|
+
ids.length === 1 ? "Job removed" : `${ids.length} jobs removed`,
|
|
162
|
+
);
|
|
163
|
+
onChanged();
|
|
164
|
+
} finally {
|
|
165
|
+
setDeleting(false);
|
|
166
|
+
setConfirmDelete(null);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function requestDelete(ids: string[]) {
|
|
171
|
+
const label =
|
|
172
|
+
ids.length === 1
|
|
173
|
+
? `"${jobs.find((j) => j.id === ids[0])?.keywords ?? ids[0]}"`
|
|
174
|
+
: `${ids.length} selected jobs`;
|
|
175
|
+
setConfirmDelete({ ids, label });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (jobs.length === 0) {
|
|
179
|
+
return (
|
|
180
|
+
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-10 text-center">
|
|
181
|
+
<p className="text-sm text-muted-foreground">No ETL jobs yet.</p>
|
|
182
|
+
<p className="mt-1 text-xs text-muted-foreground/60">
|
|
183
|
+
Start a web scrape to aggregate sources here.
|
|
184
|
+
</p>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const selectedArray = Array.from(selected);
|
|
190
|
+
const removableSelected = selectedArray.filter((id) => {
|
|
191
|
+
const j = jobs.find((j) => j.id === id);
|
|
192
|
+
return j && j.status !== "running" && j.status !== "queued";
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<>
|
|
197
|
+
{/* Bulk action bar */}
|
|
198
|
+
{selected.size > 0 && (
|
|
199
|
+
<div className="mb-2 flex items-center justify-between rounded-md border border-border bg-muted/40 px-3 py-1.5">
|
|
200
|
+
<span className="text-xs text-muted-foreground">
|
|
201
|
+
{selected.size} selected
|
|
202
|
+
</span>
|
|
203
|
+
<Button
|
|
204
|
+
size="sm"
|
|
205
|
+
variant="ghost"
|
|
206
|
+
className="h-6 gap-1.5 text-xs text-destructive hover:text-destructive"
|
|
207
|
+
disabled={removableSelected.length === 0}
|
|
208
|
+
onClick={() => requestDelete(removableSelected)}
|
|
209
|
+
>
|
|
210
|
+
<Trash2 className="size-3" />
|
|
211
|
+
Delete ({removableSelected.length})
|
|
212
|
+
</Button>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
{/* Table */}
|
|
217
|
+
<div className="w-full overflow-auto">
|
|
218
|
+
<table className="w-full text-xs">
|
|
219
|
+
<thead>
|
|
220
|
+
<tr className="border-b border-border">
|
|
221
|
+
<th className="pb-2 pl-1 pr-2 text-left">
|
|
222
|
+
<Checkbox
|
|
223
|
+
checked={allPageSelected}
|
|
224
|
+
onCheckedChange={toggleAll}
|
|
225
|
+
aria-label="Select all on page"
|
|
226
|
+
className="size-3.5"
|
|
227
|
+
/>
|
|
228
|
+
</th>
|
|
229
|
+
<th className="pb-2 pr-3 text-left font-medium text-muted-foreground">
|
|
230
|
+
Title / Keywords
|
|
231
|
+
</th>
|
|
232
|
+
<th className="pb-2 pr-3 text-right font-medium text-muted-foreground whitespace-nowrap">
|
|
233
|
+
Pages
|
|
234
|
+
</th>
|
|
235
|
+
<th className="pb-2 pr-3 text-left font-medium text-muted-foreground">
|
|
236
|
+
Status
|
|
237
|
+
</th>
|
|
238
|
+
<th className="pb-2 text-right font-medium text-muted-foreground" />
|
|
239
|
+
</tr>
|
|
240
|
+
</thead>
|
|
241
|
+
<tbody>
|
|
242
|
+
{pageJobs.map((job) => {
|
|
243
|
+
const s = STATUS[job.status];
|
|
244
|
+
const Icon = s.icon;
|
|
245
|
+
const active =
|
|
246
|
+
job.status === "running" || job.status === "queued";
|
|
247
|
+
const pct = progressFor(job);
|
|
248
|
+
|
|
249
|
+
const domainTargets = job.targets.filter(
|
|
250
|
+
(t) => t.scope === "domain",
|
|
251
|
+
).length;
|
|
252
|
+
const denom =
|
|
253
|
+
domainTargets > 0
|
|
254
|
+
? domainTargets * job.pageLimit
|
|
255
|
+
: job.targets.length;
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<tr
|
|
259
|
+
key={job.id}
|
|
260
|
+
className={cn(
|
|
261
|
+
"border-b border-border/50 transition-colors last:border-0",
|
|
262
|
+
selected.has(job.id) && "bg-muted/30",
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
{/* Checkbox */}
|
|
266
|
+
<td className="py-2 pl-1 pr-2">
|
|
267
|
+
<Checkbox
|
|
268
|
+
checked={selected.has(job.id)}
|
|
269
|
+
onCheckedChange={() => toggleOne(job.id)}
|
|
270
|
+
aria-label={`Select job ${job.keywords}`}
|
|
271
|
+
className="size-3.5"
|
|
272
|
+
/>
|
|
273
|
+
</td>
|
|
274
|
+
|
|
275
|
+
{/* Title */}
|
|
276
|
+
<td className="py-2 pr-3 max-w-36">
|
|
277
|
+
<p className="truncate font-medium text-foreground leading-tight">
|
|
278
|
+
{job.keywords}
|
|
279
|
+
</p>
|
|
280
|
+
<p className="text-muted-foreground/70 text-[10px]">
|
|
281
|
+
{job.targets.length} target
|
|
282
|
+
{job.targets.length === 1 ? "" : "s"}
|
|
283
|
+
</p>
|
|
284
|
+
</td>
|
|
285
|
+
|
|
286
|
+
{/* Pages crawled + progress */}
|
|
287
|
+
<td className="py-2 pr-3 text-right tabular-nums">
|
|
288
|
+
<span className="text-green-700">
|
|
289
|
+
{job.pagesCrawled} / {denom}
|
|
290
|
+
</span>
|
|
291
|
+
{active && (
|
|
292
|
+
<div className="mt-1 h-0.5 w-full rounded-full bg-muted overflow-hidden">
|
|
293
|
+
<div
|
|
294
|
+
className="h-full rounded-full bg-green-600 transition-all"
|
|
295
|
+
style={{ width: `${pct}%` }}
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</td>
|
|
300
|
+
|
|
301
|
+
{/* Status badge */}
|
|
302
|
+
<td className="py-2 pr-3">
|
|
303
|
+
<Badge
|
|
304
|
+
variant="outline"
|
|
305
|
+
className={cn(
|
|
306
|
+
s.className,
|
|
307
|
+
"gap-1 px-1.5 py-0.5 font-mono text-[10px] whitespace-nowrap",
|
|
308
|
+
)}
|
|
309
|
+
>
|
|
310
|
+
<Icon
|
|
311
|
+
className={cn(
|
|
312
|
+
"size-2.5 shrink-0",
|
|
313
|
+
job.status === "running" && "animate-spin",
|
|
314
|
+
)}
|
|
315
|
+
/>
|
|
316
|
+
{s.label}
|
|
317
|
+
</Badge>
|
|
318
|
+
</td>
|
|
319
|
+
|
|
320
|
+
{/* Action */}
|
|
321
|
+
<td className="py-2 text-right">
|
|
322
|
+
<div className="flex items-center justify-end gap-1">
|
|
323
|
+
{job.targets.some((t) => t.status === "failed") && (
|
|
324
|
+
<button
|
|
325
|
+
onClick={() => {
|
|
326
|
+
const failedUrls = job.targets
|
|
327
|
+
.filter((t) => t.status === "failed")
|
|
328
|
+
.map((t) => t.url)
|
|
329
|
+
.join("\n");
|
|
330
|
+
navigator.clipboard.writeText(failedUrls);
|
|
331
|
+
toast.success("Failed URLs copied to clipboard");
|
|
332
|
+
}}
|
|
333
|
+
className="rounded p-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
334
|
+
title="Copy failed URLs"
|
|
335
|
+
>
|
|
336
|
+
<Copy className="size-3" />
|
|
337
|
+
</button>
|
|
338
|
+
)}
|
|
339
|
+
{active ? (
|
|
340
|
+
<button
|
|
341
|
+
onClick={() => requestCancel(job.id)}
|
|
342
|
+
className="cursor-pointer rounded-xl p-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
343
|
+
title="Cancel job"
|
|
344
|
+
>
|
|
345
|
+
<Square className="size-3.5 text-orange-400" />
|
|
346
|
+
</button>
|
|
347
|
+
) : (
|
|
348
|
+
<button
|
|
349
|
+
onClick={() => requestDelete([job.id])}
|
|
350
|
+
className="rounded p-1 text-muted-foreground hover:text-destructive transition-colors"
|
|
351
|
+
title="Remove job"
|
|
352
|
+
>
|
|
353
|
+
<Trash2 className="size-3" />
|
|
354
|
+
</button>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
</td>
|
|
358
|
+
</tr>
|
|
359
|
+
);
|
|
360
|
+
})}
|
|
361
|
+
</tbody>
|
|
362
|
+
</table>
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
{/* Pagination */}
|
|
366
|
+
{totalPages > 1 && (
|
|
367
|
+
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
|
368
|
+
<span>
|
|
369
|
+
{safePage * PAGE_SIZE + 1}–
|
|
370
|
+
{Math.min((safePage + 1) * PAGE_SIZE, jobs.length)} of {jobs.length}
|
|
371
|
+
</span>
|
|
372
|
+
<div className="flex items-center gap-1">
|
|
373
|
+
<button
|
|
374
|
+
disabled={safePage === 0}
|
|
375
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
376
|
+
className="rounded p-1 hover:bg-muted disabled:opacity-40 transition-colors"
|
|
377
|
+
>
|
|
378
|
+
<ChevronLeft className="size-3.5" />
|
|
379
|
+
</button>
|
|
380
|
+
<span className="tabular-nums">
|
|
381
|
+
{safePage + 1} / {totalPages}
|
|
382
|
+
</span>
|
|
383
|
+
<button
|
|
384
|
+
disabled={safePage >= totalPages - 1}
|
|
385
|
+
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
386
|
+
className="rounded p-1 hover:bg-muted disabled:opacity-40 transition-colors"
|
|
387
|
+
>
|
|
388
|
+
<ChevronRight className="size-3.5" />
|
|
389
|
+
</button>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
|
|
394
|
+
{/* Confirm delete dialog */}
|
|
395
|
+
<Dialog
|
|
396
|
+
open={!!confirmDelete}
|
|
397
|
+
onOpenChange={(open) => !open && setConfirmDelete(null)}
|
|
398
|
+
>
|
|
399
|
+
<DialogContent className="max-w-sm">
|
|
400
|
+
<DialogHeader>
|
|
401
|
+
<DialogTitle className="flex items-center gap-2">
|
|
402
|
+
<AlertTriangle className="size-4 text-destructive" />
|
|
403
|
+
Confirm deletion
|
|
404
|
+
</DialogTitle>
|
|
405
|
+
<DialogDescription>
|
|
406
|
+
Are you sure you want to remove{" "}
|
|
407
|
+
<span className="font-medium text-foreground">
|
|
408
|
+
{confirmDelete?.label}
|
|
409
|
+
</span>
|
|
410
|
+
? This action cannot be undone.
|
|
411
|
+
</DialogDescription>
|
|
412
|
+
</DialogHeader>
|
|
413
|
+
<DialogFooter className="gap-2 sm:gap-0">
|
|
414
|
+
<Button
|
|
415
|
+
variant="outline"
|
|
416
|
+
size="sm"
|
|
417
|
+
onClick={() => setConfirmDelete(null)}
|
|
418
|
+
disabled={deleting}
|
|
419
|
+
>
|
|
420
|
+
Cancel
|
|
421
|
+
</Button>
|
|
422
|
+
<Button
|
|
423
|
+
variant="destructive"
|
|
424
|
+
size="sm"
|
|
425
|
+
disabled={deleting}
|
|
426
|
+
onClick={() => confirmDelete && executeDelete(confirmDelete.ids)}
|
|
427
|
+
>
|
|
428
|
+
{deleting ? (
|
|
429
|
+
<Loader2 className="size-3 animate-spin" />
|
|
430
|
+
) : (
|
|
431
|
+
<Trash2 className="size-3" />
|
|
432
|
+
)}
|
|
433
|
+
Delete
|
|
434
|
+
</Button>
|
|
435
|
+
</DialogFooter>
|
|
436
|
+
</DialogContent>
|
|
437
|
+
</Dialog>
|
|
438
|
+
|
|
439
|
+
{/* Confirm cancel dialog */}
|
|
440
|
+
<Dialog
|
|
441
|
+
open={!!confirmCancel}
|
|
442
|
+
onOpenChange={(open) => !open && setConfirmCancel(null)}
|
|
443
|
+
>
|
|
444
|
+
<DialogContent className="max-w-lg">
|
|
445
|
+
<DialogHeader>
|
|
446
|
+
<DialogTitle className="flex items-center gap-2">
|
|
447
|
+
<AlertTriangle className="size-4 text-orange-500" />
|
|
448
|
+
Confirm cancellation
|
|
449
|
+
</DialogTitle>
|
|
450
|
+
<DialogDescription>
|
|
451
|
+
Are you sure you want to cancel the job{" "}
|
|
452
|
+
<span className="font-medium text-foreground">
|
|
453
|
+
{confirmCancel?.label}
|
|
454
|
+
</span>
|
|
455
|
+
? In-progress pages will be discarded.
|
|
456
|
+
</DialogDescription>
|
|
457
|
+
</DialogHeader>
|
|
458
|
+
<DialogFooter className="gap-2">
|
|
459
|
+
<Button
|
|
460
|
+
variant="outline"
|
|
461
|
+
size="sm"
|
|
462
|
+
onClick={() => setConfirmCancel(null)}
|
|
463
|
+
disabled={cancelling}
|
|
464
|
+
>
|
|
465
|
+
Go Back
|
|
466
|
+
</Button>
|
|
467
|
+
<Button
|
|
468
|
+
variant="default"
|
|
469
|
+
size="sm"
|
|
470
|
+
disabled={cancelling}
|
|
471
|
+
onClick={() => confirmCancel && executeCancel(confirmCancel.id)}
|
|
472
|
+
>
|
|
473
|
+
{cancelling ? (
|
|
474
|
+
<Loader2 className="size-3 animate-spin" />
|
|
475
|
+
) : (
|
|
476
|
+
<Octagon className="size-3" />
|
|
477
|
+
)}
|
|
478
|
+
Cancel Job
|
|
479
|
+
</Button>
|
|
480
|
+
</DialogFooter>
|
|
481
|
+
</DialogContent>
|
|
482
|
+
</Dialog>
|
|
483
|
+
</>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { formatErrorMessage } from "@/lib/error-formatter";
|
|
6
|
+
import { Plus, Loader2 } from "lucide-react";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
import { Label } from "@/components/ui/label";
|
|
10
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
11
|
+
|
|
12
|
+
export function PastePanel({ onAdded }: { onAdded: () => void }) {
|
|
13
|
+
const [title, setTitle] = useState("");
|
|
14
|
+
const [content, setContent] = useState("");
|
|
15
|
+
const [saving, setSaving] = useState(false);
|
|
16
|
+
|
|
17
|
+
async function submit() {
|
|
18
|
+
if (!content.trim()) {
|
|
19
|
+
toast.error("Nothing to add — paste some text first.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
setSaving(true);
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch("/api/documents", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
title: title.trim() || "Pasted text",
|
|
29
|
+
content,
|
|
30
|
+
source: "paste",
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
|
|
34
|
+
toast.success("Added to corpus");
|
|
35
|
+
setTitle("");
|
|
36
|
+
setContent("");
|
|
37
|
+
onAdded();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
toast.error(formatErrorMessage(err));
|
|
40
|
+
} finally {
|
|
41
|
+
setSaving(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="space-y-4">
|
|
47
|
+
<div className="space-y-2">
|
|
48
|
+
<Label htmlFor="paste-title">Title</Label>
|
|
49
|
+
<Input
|
|
50
|
+
id="paste-title"
|
|
51
|
+
placeholder="e.g. TUHH — About page"
|
|
52
|
+
value={title}
|
|
53
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="space-y-2">
|
|
57
|
+
<Label htmlFor="paste-content">Content</Label>
|
|
58
|
+
<Textarea
|
|
59
|
+
id="paste-content"
|
|
60
|
+
placeholder="Paste any text, markdown, or notes here…"
|
|
61
|
+
className="min-h-48 font-mono text-xs leading-relaxed bg-white"
|
|
62
|
+
value={content}
|
|
63
|
+
onChange={(e) => setContent(e.target.value)}
|
|
64
|
+
/>
|
|
65
|
+
<p className="text-xs text-muted-foreground tabular-nums">
|
|
66
|
+
{content.length.toLocaleString()} characters
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
<Button onClick={submit} disabled={saving}>
|
|
70
|
+
{saving ? (
|
|
71
|
+
<Loader2 className="size-4 animate-spin" />
|
|
72
|
+
) : (
|
|
73
|
+
<Plus className="size-4" />
|
|
74
|
+
)}
|
|
75
|
+
Add to corpus
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|