create-interview-cockpit 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/README.md +62 -0
- package/index.js +302 -0
- package/package.json +44 -0
- package/template/.env.example +14 -0
- package/template/client/index.html +12 -0
- package/template/client/package-lock.json +6012 -0
- package/template/client/package.json +34 -0
- package/template/client/postcss.config.cjs +6 -0
- package/template/client/src/App.tsx +120 -0
- package/template/client/src/api.ts +132 -0
- package/template/client/src/components/AnnotationDialog.tsx +307 -0
- package/template/client/src/components/ChatMessage.tsx +89 -0
- package/template/client/src/components/ChatView.tsx +763 -0
- package/template/client/src/components/CodeContextPanel.tsx +470 -0
- package/template/client/src/components/FileAttachments.tsx +107 -0
- package/template/client/src/components/FileViewerModal.tsx +470 -0
- package/template/client/src/components/MarkdownRenderer.tsx +333 -0
- package/template/client/src/components/MermaidDiagram.tsx +157 -0
- package/template/client/src/components/Sidebar.tsx +419 -0
- package/template/client/src/components/TextAnnotator.tsx +476 -0
- package/template/client/src/index.css +61 -0
- package/template/client/src/main.tsx +10 -0
- package/template/client/src/store.ts +321 -0
- package/template/client/src/types.ts +65 -0
- package/template/client/src/vite-env.d.ts +1 -0
- package/template/client/tailwind.config.cjs +8 -0
- package/template/client/tsconfig.json +16 -0
- package/template/client/tsconfig.tsbuildinfo +1 -0
- package/template/client/vite.config.ts +12 -0
- package/template/cockpit.json +3 -0
- package/template/data/context-files/.gitkeep +0 -0
- package/template/data/questions/.gitkeep +0 -0
- package/template/data/topics.json +1 -0
- package/template/package.json +14 -0
- package/template/server/package-lock.json +2266 -0
- package/template/server/package.json +31 -0
- package/template/server/src/index.ts +758 -0
- package/template/server/src/storage.ts +303 -0
- package/template/server/tsconfig.json +14 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
2
|
+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
3
|
+
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
|
4
|
+
import {
|
|
5
|
+
X,
|
|
6
|
+
GripVertical,
|
|
7
|
+
Maximize2,
|
|
8
|
+
Minimize2,
|
|
9
|
+
Loader2,
|
|
10
|
+
Plus,
|
|
11
|
+
Check,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import { useStore } from "../store";
|
|
14
|
+
import type { CodeSnippet } from "../types";
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
filePath: string;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Derive a Prism language tag from the file extension. */
|
|
22
|
+
function getLang(filePath: string): string {
|
|
23
|
+
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
|
24
|
+
const map: Record<string, string> = {
|
|
25
|
+
ts: "typescript",
|
|
26
|
+
tsx: "tsx",
|
|
27
|
+
js: "javascript",
|
|
28
|
+
jsx: "jsx",
|
|
29
|
+
py: "python",
|
|
30
|
+
rs: "rust",
|
|
31
|
+
go: "go",
|
|
32
|
+
java: "java",
|
|
33
|
+
kt: "kotlin",
|
|
34
|
+
cs: "csharp",
|
|
35
|
+
cpp: "cpp",
|
|
36
|
+
c: "c",
|
|
37
|
+
h: "c",
|
|
38
|
+
rb: "ruby",
|
|
39
|
+
php: "php",
|
|
40
|
+
swift: "swift",
|
|
41
|
+
sh: "bash",
|
|
42
|
+
bash: "bash",
|
|
43
|
+
zsh: "bash",
|
|
44
|
+
ps1: "powershell",
|
|
45
|
+
json: "json",
|
|
46
|
+
yaml: "yaml",
|
|
47
|
+
yml: "yaml",
|
|
48
|
+
toml: "toml",
|
|
49
|
+
xml: "xml",
|
|
50
|
+
html: "html",
|
|
51
|
+
css: "css",
|
|
52
|
+
scss: "scss",
|
|
53
|
+
sass: "scss",
|
|
54
|
+
less: "less",
|
|
55
|
+
sql: "sql",
|
|
56
|
+
md: "markdown",
|
|
57
|
+
mdx: "markdown",
|
|
58
|
+
graphql: "graphql",
|
|
59
|
+
gql: "graphql",
|
|
60
|
+
dockerfile: "docker",
|
|
61
|
+
};
|
|
62
|
+
return map[ext] ?? "text";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const MIN_W = 380;
|
|
66
|
+
const MIN_H = 260;
|
|
67
|
+
const DEFAULT_W = 720;
|
|
68
|
+
const DEFAULT_H = 520;
|
|
69
|
+
|
|
70
|
+
type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
|
|
71
|
+
|
|
72
|
+
export default function FileViewerModal({ filePath, onClose }: Props) {
|
|
73
|
+
const { addSnippet } = useStore();
|
|
74
|
+
const [content, setContent] = useState<string | null>(null);
|
|
75
|
+
const [loading, setLoading] = useState(true);
|
|
76
|
+
const [error, setError] = useState<string | null>(null);
|
|
77
|
+
const [maximized, setMaximized] = useState(false);
|
|
78
|
+
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
|
|
79
|
+
const [addedFeedback, setAddedFeedback] = useState(false);
|
|
80
|
+
const lastClickedLineRef = useRef<number | null>(null);
|
|
81
|
+
|
|
82
|
+
// Position & size (pre-maximise)
|
|
83
|
+
const [pos, setPos] = useState(() => ({
|
|
84
|
+
x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
|
|
85
|
+
y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
|
|
86
|
+
}));
|
|
87
|
+
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
|
|
88
|
+
|
|
89
|
+
const dragStart = useRef<{
|
|
90
|
+
mx: number;
|
|
91
|
+
my: number;
|
|
92
|
+
ox: number;
|
|
93
|
+
oy: number;
|
|
94
|
+
} | null>(null);
|
|
95
|
+
const resizeDir = useRef<ResizeDir>(null);
|
|
96
|
+
const resizeStart = useRef<{
|
|
97
|
+
mx: number;
|
|
98
|
+
my: number;
|
|
99
|
+
ox: number;
|
|
100
|
+
oy: number;
|
|
101
|
+
ow: number;
|
|
102
|
+
oh: number;
|
|
103
|
+
} | null>(null);
|
|
104
|
+
const savedPos = useRef(pos);
|
|
105
|
+
const savedSize = useRef(size);
|
|
106
|
+
|
|
107
|
+
const fileName = filePath.split("/").pop() ?? filePath;
|
|
108
|
+
|
|
109
|
+
// Fetch file content
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
setLoading(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
setSelectedLines(new Set());
|
|
114
|
+
lastClickedLineRef.current = null;
|
|
115
|
+
fetch(`/api/code-context/file?path=${encodeURIComponent(filePath)}`)
|
|
116
|
+
.then((r) => r.json())
|
|
117
|
+
.then((d) => {
|
|
118
|
+
if (d.error) setError(d.error);
|
|
119
|
+
else setContent(d.content as string);
|
|
120
|
+
})
|
|
121
|
+
.catch(() => setError("Failed to load file."))
|
|
122
|
+
.finally(() => setLoading(false));
|
|
123
|
+
}, [filePath]);
|
|
124
|
+
|
|
125
|
+
const handleLineClick = useCallback(
|
|
126
|
+
(e: React.MouseEvent, lineNumber: number) => {
|
|
127
|
+
// Don't toggle if user was drag-selecting text
|
|
128
|
+
if (window.getSelection()?.toString()) return;
|
|
129
|
+
setSelectedLines((prev) => {
|
|
130
|
+
const next = new Set(prev);
|
|
131
|
+
if (e.shiftKey && lastClickedLineRef.current !== null) {
|
|
132
|
+
const lo = Math.min(lastClickedLineRef.current, lineNumber);
|
|
133
|
+
const hi = Math.max(lastClickedLineRef.current, lineNumber);
|
|
134
|
+
for (let i = lo; i <= hi; i++) next.add(i);
|
|
135
|
+
} else {
|
|
136
|
+
if (next.has(lineNumber)) next.delete(lineNumber);
|
|
137
|
+
else next.add(lineNumber);
|
|
138
|
+
}
|
|
139
|
+
return next;
|
|
140
|
+
});
|
|
141
|
+
lastClickedLineRef.current = lineNumber;
|
|
142
|
+
},
|
|
143
|
+
[],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const handleAddSnippet = useCallback(() => {
|
|
147
|
+
if (!content || selectedLines.size === 0) return;
|
|
148
|
+
const lines = content.split("\n");
|
|
149
|
+
const sortedNums = [...selectedLines].sort((a, b) => a - b);
|
|
150
|
+
const code = sortedNums.map((n) => lines[n - 1] ?? "").join("\n");
|
|
151
|
+
const snippet: CodeSnippet = {
|
|
152
|
+
id: crypto.randomUUID(),
|
|
153
|
+
filePath,
|
|
154
|
+
fileName,
|
|
155
|
+
startLine: sortedNums[0],
|
|
156
|
+
endLine: sortedNums[sortedNums.length - 1],
|
|
157
|
+
code,
|
|
158
|
+
};
|
|
159
|
+
addSnippet(snippet);
|
|
160
|
+
setSelectedLines(new Set());
|
|
161
|
+
lastClickedLineRef.current = null;
|
|
162
|
+
setAddedFeedback(true);
|
|
163
|
+
setTimeout(() => setAddedFeedback(false), 1500);
|
|
164
|
+
}, [content, selectedLines, filePath, fileName, addSnippet]);
|
|
165
|
+
|
|
166
|
+
// ─── Drag ────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
const onTitleMouseDown = useCallback(
|
|
169
|
+
(e: React.MouseEvent) => {
|
|
170
|
+
if (maximized) return;
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
dragStart.current = {
|
|
173
|
+
mx: e.clientX,
|
|
174
|
+
my: e.clientY,
|
|
175
|
+
ox: pos.x,
|
|
176
|
+
oy: pos.y,
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
[maximized, pos],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
const onMove = (e: MouseEvent) => {
|
|
184
|
+
// Snapshot refs at the start — state setters run asynchronously and the
|
|
185
|
+
// refs may be nulled by onUp before the callback fires.
|
|
186
|
+
const drag = dragStart.current;
|
|
187
|
+
const resize = resizeStart.current;
|
|
188
|
+
const dir = resizeDir.current;
|
|
189
|
+
|
|
190
|
+
if (drag) {
|
|
191
|
+
const dx = e.clientX - drag.mx;
|
|
192
|
+
const dy = e.clientY - drag.my;
|
|
193
|
+
setPos({
|
|
194
|
+
x: Math.max(0, drag.ox + dx),
|
|
195
|
+
y: Math.max(0, drag.oy + dy),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (resize && dir) {
|
|
199
|
+
const dx = e.clientX - resize.mx;
|
|
200
|
+
const dy = e.clientY - resize.my;
|
|
201
|
+
|
|
202
|
+
setSize((prev) => {
|
|
203
|
+
let w = prev.w;
|
|
204
|
+
let h = prev.h;
|
|
205
|
+
if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
|
|
206
|
+
if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
|
|
207
|
+
if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
|
|
208
|
+
if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
|
|
209
|
+
return { w, h };
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (dir.includes("w")) {
|
|
213
|
+
setPos((prev) => ({
|
|
214
|
+
...prev,
|
|
215
|
+
x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
if (dir.includes("n")) {
|
|
219
|
+
setPos((prev) => ({
|
|
220
|
+
...prev,
|
|
221
|
+
y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const onUp = () => {
|
|
227
|
+
dragStart.current = null;
|
|
228
|
+
resizeStart.current = null;
|
|
229
|
+
resizeDir.current = null;
|
|
230
|
+
};
|
|
231
|
+
document.addEventListener("mousemove", onMove);
|
|
232
|
+
document.addEventListener("mouseup", onUp);
|
|
233
|
+
return () => {
|
|
234
|
+
document.removeEventListener("mousemove", onMove);
|
|
235
|
+
document.removeEventListener("mouseup", onUp);
|
|
236
|
+
};
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
// ─── Resize handles ──────────────────────────────────
|
|
240
|
+
|
|
241
|
+
const startResize = useCallback(
|
|
242
|
+
(dir: ResizeDir) => (e: React.MouseEvent) => {
|
|
243
|
+
if (maximized) return;
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
e.stopPropagation();
|
|
246
|
+
resizeDir.current = dir;
|
|
247
|
+
resizeStart.current = {
|
|
248
|
+
mx: e.clientX,
|
|
249
|
+
my: e.clientY,
|
|
250
|
+
ox: pos.x,
|
|
251
|
+
oy: pos.y,
|
|
252
|
+
ow: size.w,
|
|
253
|
+
oh: size.h,
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
[maximized, pos, size],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// ─── Maximise ────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
const toggleMax = useCallback(() => {
|
|
262
|
+
if (!maximized) {
|
|
263
|
+
savedPos.current = pos;
|
|
264
|
+
savedSize.current = size;
|
|
265
|
+
setMaximized(true);
|
|
266
|
+
} else {
|
|
267
|
+
setPos(savedPos.current);
|
|
268
|
+
setSize(savedSize.current);
|
|
269
|
+
setMaximized(false);
|
|
270
|
+
}
|
|
271
|
+
}, [maximized, pos, size]);
|
|
272
|
+
|
|
273
|
+
// ─── Keyboard close ──────────────────────────────────
|
|
274
|
+
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const handler = (e: KeyboardEvent) => {
|
|
277
|
+
if (e.key === "Escape") onClose();
|
|
278
|
+
};
|
|
279
|
+
document.addEventListener("keydown", handler);
|
|
280
|
+
return () => document.removeEventListener("keydown", handler);
|
|
281
|
+
}, [onClose]);
|
|
282
|
+
|
|
283
|
+
const lang = getLang(filePath);
|
|
284
|
+
|
|
285
|
+
const style: React.CSSProperties = maximized
|
|
286
|
+
? {
|
|
287
|
+
position: "fixed",
|
|
288
|
+
inset: 0,
|
|
289
|
+
width: "100vw",
|
|
290
|
+
height: "100vh",
|
|
291
|
+
borderRadius: 0,
|
|
292
|
+
}
|
|
293
|
+
: {
|
|
294
|
+
position: "fixed",
|
|
295
|
+
left: pos.x,
|
|
296
|
+
top: pos.y,
|
|
297
|
+
width: size.w,
|
|
298
|
+
height: size.h,
|
|
299
|
+
minWidth: MIN_W,
|
|
300
|
+
minHeight: MIN_H,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<>
|
|
305
|
+
{/* Modal */}
|
|
306
|
+
<div
|
|
307
|
+
className="fixed z-50 flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
|
|
308
|
+
style={style}
|
|
309
|
+
>
|
|
310
|
+
{/* ── Title bar ── */}
|
|
311
|
+
<div
|
|
312
|
+
className="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700 shrink-0 cursor-default"
|
|
313
|
+
onMouseDown={onTitleMouseDown}
|
|
314
|
+
style={{ cursor: maximized ? "default" : "grab" }}
|
|
315
|
+
>
|
|
316
|
+
<GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
|
317
|
+
<span
|
|
318
|
+
className="text-xs font-mono text-slate-300 truncate flex-1"
|
|
319
|
+
title={filePath}
|
|
320
|
+
>
|
|
321
|
+
{fileName}
|
|
322
|
+
</span>
|
|
323
|
+
<span className="text-[10px] text-slate-600 shrink-0 uppercase tracking-wider hidden sm:block">
|
|
324
|
+
{lang}
|
|
325
|
+
</span>
|
|
326
|
+
<button
|
|
327
|
+
onClick={toggleMax}
|
|
328
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
|
329
|
+
title={maximized ? "Restore" : "Maximise"}
|
|
330
|
+
>
|
|
331
|
+
{maximized ? (
|
|
332
|
+
<Minimize2 className="w-3.5 h-3.5" />
|
|
333
|
+
) : (
|
|
334
|
+
<Maximize2 className="w-3.5 h-3.5" />
|
|
335
|
+
)}
|
|
336
|
+
</button>
|
|
337
|
+
<button
|
|
338
|
+
onClick={onClose}
|
|
339
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
|
|
340
|
+
title="Close (Esc)"
|
|
341
|
+
>
|
|
342
|
+
<X className="w-3.5 h-3.5" />
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{/* ── Content ── */}
|
|
347
|
+
<div className="flex-1 overflow-auto">
|
|
348
|
+
{loading && (
|
|
349
|
+
<div className="flex items-center justify-center h-full">
|
|
350
|
+
<Loader2 className="w-5 h-5 text-cyan-400 animate-spin" />
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
{error && (
|
|
354
|
+
<div className="flex items-center justify-center h-full p-4">
|
|
355
|
+
<p className="text-sm text-red-400 text-center">{error}</p>
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
{!loading && !error && content !== null && (
|
|
359
|
+
<SyntaxHighlighter
|
|
360
|
+
language={lang}
|
|
361
|
+
style={oneDark}
|
|
362
|
+
showLineNumbers
|
|
363
|
+
wrapLines
|
|
364
|
+
wrapLongLines={false}
|
|
365
|
+
lineProps={(lineNumber) => ({
|
|
366
|
+
onClick: (e: React.MouseEvent) =>
|
|
367
|
+
lineNumber !== undefined && handleLineClick(e, lineNumber),
|
|
368
|
+
style: {
|
|
369
|
+
display: "block",
|
|
370
|
+
cursor: "pointer",
|
|
371
|
+
backgroundColor: selectedLines.has(lineNumber)
|
|
372
|
+
? "rgba(6, 182, 212, 0.15)"
|
|
373
|
+
: undefined,
|
|
374
|
+
outline: selectedLines.has(lineNumber)
|
|
375
|
+
? "1px solid rgba(6, 182, 212, 0.3)"
|
|
376
|
+
: undefined,
|
|
377
|
+
},
|
|
378
|
+
})}
|
|
379
|
+
customStyle={{
|
|
380
|
+
margin: 0,
|
|
381
|
+
borderRadius: 0,
|
|
382
|
+
background: "#0f172a",
|
|
383
|
+
fontSize: "0.75rem",
|
|
384
|
+
lineHeight: "1.6",
|
|
385
|
+
minHeight: "100%",
|
|
386
|
+
}}
|
|
387
|
+
lineNumberStyle={{ color: "#334155", minWidth: "2.8em" }}
|
|
388
|
+
>
|
|
389
|
+
{content}
|
|
390
|
+
</SyntaxHighlighter>
|
|
391
|
+
)}
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
{/* ── Snippet selection toolbar ── */}
|
|
395
|
+
{selectedLines.size > 0 && (
|
|
396
|
+
<div className="shrink-0 border-t border-cyan-900/50 bg-slate-800 px-3 py-2 flex items-center gap-2">
|
|
397
|
+
<span className="text-xs text-slate-400">
|
|
398
|
+
{selectedLines.size} line{selectedLines.size !== 1 ? "s" : ""}{" "}
|
|
399
|
+
selected
|
|
400
|
+
</span>
|
|
401
|
+
<span className="flex-1" />
|
|
402
|
+
<button
|
|
403
|
+
onClick={() => {
|
|
404
|
+
setSelectedLines(new Set());
|
|
405
|
+
lastClickedLineRef.current = null;
|
|
406
|
+
}}
|
|
407
|
+
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
|
408
|
+
>
|
|
409
|
+
Clear
|
|
410
|
+
</button>
|
|
411
|
+
<button
|
|
412
|
+
onClick={handleAddSnippet}
|
|
413
|
+
className="flex items-center gap-1 text-xs bg-cyan-700 hover:bg-cyan-600 text-white px-2.5 py-1 rounded-md transition-colors"
|
|
414
|
+
>
|
|
415
|
+
{addedFeedback ? (
|
|
416
|
+
<>
|
|
417
|
+
<Check className="w-3 h-3" /> Added!
|
|
418
|
+
</>
|
|
419
|
+
) : (
|
|
420
|
+
<>
|
|
421
|
+
<Plus className="w-3 h-3" /> Add to context
|
|
422
|
+
</>
|
|
423
|
+
)}
|
|
424
|
+
</button>
|
|
425
|
+
</div>
|
|
426
|
+
)}
|
|
427
|
+
|
|
428
|
+
{/* ── Resize handles (hidden when maximized) ── */}
|
|
429
|
+
{!maximized && (
|
|
430
|
+
<>
|
|
431
|
+
{/* edges */}
|
|
432
|
+
<div
|
|
433
|
+
onMouseDown={startResize("e")}
|
|
434
|
+
className="absolute top-0 right-0 w-1.5 h-full cursor-ew-resize z-10"
|
|
435
|
+
/>
|
|
436
|
+
<div
|
|
437
|
+
onMouseDown={startResize("s")}
|
|
438
|
+
className="absolute bottom-0 left-0 w-full h-1.5 cursor-ns-resize z-10"
|
|
439
|
+
/>
|
|
440
|
+
<div
|
|
441
|
+
onMouseDown={startResize("w")}
|
|
442
|
+
className="absolute top-0 left-0 w-1.5 h-full cursor-ew-resize z-10"
|
|
443
|
+
/>
|
|
444
|
+
<div
|
|
445
|
+
onMouseDown={startResize("n")}
|
|
446
|
+
className="absolute top-0 left-0 w-full h-1.5 cursor-ns-resize z-10"
|
|
447
|
+
/>
|
|
448
|
+
{/* corners */}
|
|
449
|
+
<div
|
|
450
|
+
onMouseDown={startResize("se")}
|
|
451
|
+
className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-20"
|
|
452
|
+
/>
|
|
453
|
+
<div
|
|
454
|
+
onMouseDown={startResize("sw")}
|
|
455
|
+
className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-20"
|
|
456
|
+
/>
|
|
457
|
+
<div
|
|
458
|
+
onMouseDown={startResize("ne")}
|
|
459
|
+
className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-20"
|
|
460
|
+
/>
|
|
461
|
+
<div
|
|
462
|
+
onMouseDown={startResize("nw")}
|
|
463
|
+
className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-20"
|
|
464
|
+
/>
|
|
465
|
+
</>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
</>
|
|
469
|
+
);
|
|
470
|
+
}
|