@naviyra/cloud-panel 0.0.3-beta2 → 0.0.3-beta3

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.
@@ -0,0 +1,604 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { Download, Folder, Plus, Save, Trash2, Upload } from "lucide-react";
4
+ import { useConfirm } from "./confirm";
5
+ import { useToast } from "./toast";
6
+
7
+ async function api(url, options = {}) {
8
+ const response = await fetch(`/api/${url}`, {
9
+ ...options,
10
+ headers: { "Content-Type": "application/json", ...options.headers },
11
+ });
12
+ const body = await response.json().catch(() => ({}));
13
+ if (response.status === 401) {
14
+ window.location.href = "/login";
15
+ throw Error("Session expired");
16
+ }
17
+ if (!response.ok) throw Error(body.error || "Request failed");
18
+ return body;
19
+ }
20
+
21
+ function joinPath(dir, name) {
22
+ return dir ? `${dir}/${name}` : name;
23
+ }
24
+
25
+ function FileManager({ project, canWrite }) {
26
+ const confirm = useConfirm();
27
+ const toast = useToast();
28
+ const [path, setPath] = useState("");
29
+ const [entries, setEntries] = useState([]);
30
+ const [truncated, setTruncated] = useState(false);
31
+ const [loading, setLoading] = useState(true);
32
+ const [editor, setEditor] = useState(null);
33
+ const [content, setContent] = useState("");
34
+ const [draft, setDraft] = useState("");
35
+ const [busy, setBusy] = useState(false);
36
+ const load = async (next = path) => {
37
+ setLoading(true);
38
+ try {
39
+ const data = await api(
40
+ `projects/${project.id}/files?path=${encodeURIComponent(next)}`,
41
+ );
42
+ setPath(data.path || "");
43
+ setEntries(data.entries || []);
44
+ setTruncated(Boolean(data.truncated));
45
+ } catch (error) {
46
+ toast.failed(error.message);
47
+ } finally {
48
+ setLoading(false);
49
+ }
50
+ };
51
+ useEffect(() => {
52
+ load("");
53
+ }, [project.id]);
54
+ const openEntry = async (entry) => {
55
+ if (entry.kind === "link") {
56
+ toast.failed("Links cannot be opened");
57
+ return;
58
+ }
59
+ const next = joinPath(path, entry.name);
60
+ if (entry.kind === "dir") {
61
+ setEditor(null);
62
+ await load(next);
63
+ return;
64
+ }
65
+ setBusy(true);
66
+ try {
67
+ const file = await api(
68
+ `projects/${project.id}/files?path=${encodeURIComponent(next)}&read=1`,
69
+ );
70
+ if (file.binary) {
71
+ setEditor(null);
72
+ toast.failed("This file is binary. Download it or replace it with an upload.");
73
+ return;
74
+ }
75
+ setEditor(file.path);
76
+ setContent(file.content || "");
77
+ } catch (error) {
78
+ toast.failed(error.message);
79
+ } finally {
80
+ setBusy(false);
81
+ }
82
+ };
83
+ const save = async () => {
84
+ setBusy(true);
85
+ try {
86
+ await api(`projects/${project.id}/files`, {
87
+ method: "PUT",
88
+ body: JSON.stringify({ path: editor, content }),
89
+ });
90
+ toast.success("File saved. Restart the application if it is already running.");
91
+ } catch (error) {
92
+ toast.failed(error.message);
93
+ } finally {
94
+ setBusy(false);
95
+ }
96
+ };
97
+ const create = async (kind) => {
98
+ const name = draft.trim();
99
+ if (!name || name.includes("/") || name.includes("\\") || name === "..") {
100
+ toast.failed("Use a file or folder name without slashes");
101
+ return;
102
+ }
103
+ setBusy(true);
104
+ try {
105
+ const target = joinPath(path, name);
106
+ if (kind === "dir") {
107
+ await api(`projects/${project.id}/files`, {
108
+ method: "PUT",
109
+ body: JSON.stringify({ action: "mkdir", path: target }),
110
+ });
111
+ toast.success("Folder created.");
112
+ } else {
113
+ await api(`projects/${project.id}/files`, {
114
+ method: "PUT",
115
+ body: JSON.stringify({ path: target, content: "" }),
116
+ });
117
+ setEditor(target);
118
+ setContent("");
119
+ toast.success("File created.");
120
+ }
121
+ setDraft("");
122
+ await load(path);
123
+ } catch (error) {
124
+ toast.failed(error.message);
125
+ } finally {
126
+ setBusy(false);
127
+ }
128
+ };
129
+ const remove = async (entry) => {
130
+ const target = joinPath(path, entry.name);
131
+ if (
132
+ !(await confirm({
133
+ title: `Delete ${entry.name}?`,
134
+ message: "This removes it from the application directory.",
135
+ confirmLabel: "Delete",
136
+ }))
137
+ )
138
+ return;
139
+ setBusy(true);
140
+ try {
141
+ await api(
142
+ `projects/${project.id}/files?path=${encodeURIComponent(target)}`,
143
+ { method: "DELETE" },
144
+ );
145
+ if (editor === target) setEditor(null);
146
+ toast.success("Deleted.");
147
+ await load(path);
148
+ } catch (error) {
149
+ toast.failed(error.message);
150
+ } finally {
151
+ setBusy(false);
152
+ }
153
+ };
154
+ const upload = async (event) => {
155
+ const file = event.target.files?.[0];
156
+ event.target.value = "";
157
+ if (!file) return;
158
+ if (file.size > 32 * 1024 * 1024) {
159
+ toast.failed("File is larger than 32 MB");
160
+ return;
161
+ }
162
+ setBusy(true);
163
+ try {
164
+ const target = joinPath(path, file.name);
165
+ const response = await fetch(
166
+ `/api/projects/${project.id}/files?path=${encodeURIComponent(target)}`,
167
+ {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/octet-stream" },
170
+ body: file,
171
+ },
172
+ );
173
+ const body = await response.json().catch(() => ({}));
174
+ if (!response.ok) throw Error(body.error || "Upload failed");
175
+ toast.success("File uploaded.");
176
+ await load(path);
177
+ } catch (error) {
178
+ toast.failed(error.message);
179
+ } finally {
180
+ setBusy(false);
181
+ }
182
+ };
183
+ const crumbs = path ? path.split("/") : [];
184
+ return (
185
+ <section className="panel">
186
+ <div className="section-heading">
187
+ <h2>File manager</h2>
188
+ <Folder size={17} />
189
+ </div>
190
+ <p className="muted">
191
+ Files in this application&apos;s deployed directory. A new deployment
192
+ replaces this folder.
193
+ </p>
194
+ <div className="file-crumbs">
195
+ <button type="button" onClick={() => load("")}>
196
+ app
197
+ </button>
198
+ {crumbs.map((part, index) => (
199
+ <button
200
+ key={part + index}
201
+ type="button"
202
+ onClick={() => load(crumbs.slice(0, index + 1).join("/"))}
203
+ >
204
+ {part}
205
+ </button>
206
+ ))}
207
+ </div>
208
+ {canWrite && (
209
+ <div className="file-actions">
210
+ <input
211
+ value={draft}
212
+ onChange={(event) => setDraft(event.target.value)}
213
+ placeholder="New name"
214
+ aria-label="New file or folder name"
215
+ />
216
+ <button type="button" className="secondary" disabled={busy} onClick={() => create("file")}>
217
+ <Plus size={14} /> File
218
+ </button>
219
+ <button type="button" className="secondary" disabled={busy} onClick={() => create("dir")}>
220
+ <Folder size={14} /> Folder
221
+ </button>
222
+ <label className="secondary file-upload">
223
+ <Upload size={14} /> Upload
224
+ <input type="file" onChange={upload} disabled={busy} />
225
+ </label>
226
+ </div>
227
+ )}
228
+ {loading ? (
229
+ <p className="muted">Loading files…</p>
230
+ ) : entries.length === 0 ? (
231
+ <p className="muted">This folder is empty.</p>
232
+ ) : (
233
+ <div className="file-list">
234
+ {path && (
235
+ <button type="button" className="file-row" onClick={() => load(crumbs.slice(0, -1).join("/"))}>
236
+ <span>../</span>
237
+ </button>
238
+ )}
239
+ {entries.map((entry) => (
240
+ <div key={entry.name} className="file-row">
241
+ <button type="button" onClick={() => openEntry(entry)}>
242
+ <span>{entry.kind === "dir" ? `${entry.name}/` : entry.name}</span>
243
+ <small>
244
+ {entry.kind === "file" && entry.size != null
245
+ ? `${entry.size} B`
246
+ : entry.kind}
247
+ </small>
248
+ </button>
249
+ <span className="file-row-actions">
250
+ {entry.kind === "file" && (
251
+ <a
252
+ href={`/api/projects/${project.id}/files?path=${encodeURIComponent(joinPath(path, entry.name))}&download=1`}
253
+ aria-label={`Download ${entry.name}`}
254
+ >
255
+ <Download size={15} />
256
+ </a>
257
+ )}
258
+ {canWrite && (
259
+ <button type="button" onClick={() => remove(entry)} aria-label={`Delete ${entry.name}`}>
260
+ <Trash2 size={15} />
261
+ </button>
262
+ )}
263
+ </span>
264
+ </div>
265
+ ))}
266
+ </div>
267
+ )}
268
+ {truncated && <p className="muted">Showing the first 500 entries.</p>}
269
+ {editor && (
270
+ <div className="file-editor-wrap">
271
+ <div className="section-heading">
272
+ <h2>{editor}</h2>
273
+ </div>
274
+ <textarea
275
+ className="file-editor"
276
+ value={content}
277
+ readOnly={!canWrite}
278
+ onChange={(event) => setContent(event.target.value)}
279
+ spellCheck={false}
280
+ />
281
+ {canWrite && (
282
+ <button type="button" className="primary" disabled={busy} onClick={save}>
283
+ <Save size={15} /> Save
284
+ </button>
285
+ )}
286
+ </div>
287
+ )}
288
+ </section>
289
+ );
290
+ }
291
+
292
+ function ApplicationSettings({ project, canWrite }) {
293
+ const toast = useToast();
294
+ const type = project.type || "nodejs";
295
+ const [startup, setStartup] = useState("");
296
+ const [working, setWorking] = useState(".");
297
+ const [publicDir, setPublicDir] = useState(".");
298
+ const [server, setServer] = useState("asgi");
299
+ const [rows, setRows] = useState([]);
300
+ const [base, setBase] = useState(project.runtime_config || {});
301
+ const [busy, setBusy] = useState(false);
302
+ useEffect(() => {
303
+ let cancel = false;
304
+ api(`projects/${project.id}`)
305
+ .then((row) => {
306
+ if (cancel) return;
307
+ const config = row.runtime_config || {};
308
+ setBase(config);
309
+ setStartup(config.startup_file || "");
310
+ setWorking(config.working_dir || ".");
311
+ setPublicDir(config.public_dir || ".");
312
+ setServer(config.python_server || "asgi");
313
+ setRows(
314
+ Object.entries(config.env || {}).map(([key, value]) => ({ key, value })),
315
+ );
316
+ })
317
+ .catch((error) => toast.failed(error.message));
318
+ return () => {
319
+ cancel = true;
320
+ };
321
+ }, [project.id, toast]);
322
+ const save = async () => {
323
+ const env = {};
324
+ for (const row of rows) {
325
+ const key = row.key.trim();
326
+ if (!key) continue;
327
+ if (env[key] !== undefined) {
328
+ toast.failed(`${key} is listed more than once`);
329
+ return;
330
+ }
331
+ env[key] = row.value;
332
+ }
333
+ setBusy(true);
334
+ try {
335
+ await api(`projects/${project.id}/config`, {
336
+ method: "PUT",
337
+ body: JSON.stringify({
338
+ type,
339
+ runtime_config: {
340
+ ...base,
341
+ startup_file: startup.trim(),
342
+ working_dir: working.trim() || ".",
343
+ public_dir: publicDir.trim() || ".",
344
+ python_server: server,
345
+ env,
346
+ },
347
+ }),
348
+ });
349
+ toast.success("Application settings saved. Redeploy to apply them.");
350
+ } catch (error) {
351
+ toast.failed(error.message);
352
+ } finally {
353
+ setBusy(false);
354
+ }
355
+ };
356
+ const showStartup = ["nodejs", "python", "go"].includes(type);
357
+ const showWorking = ["nodejs", "python", "go", "custom"].includes(type);
358
+ const showPublic = ["php", "static"].includes(type);
359
+ return (
360
+ <section className="panel">
361
+ <div className="section-heading">
362
+ <h2>Application</h2>
363
+ </div>
364
+ <p className="muted">
365
+ Startup file, working directory, and environment for this application.
366
+ Redeploy after saving.
367
+ </p>
368
+ <div className="app-fields">
369
+ {showStartup && (
370
+ <label>
371
+ Startup file
372
+ <input
373
+ value={startup}
374
+ disabled={!canWrite}
375
+ placeholder={type === "python" ? "app.py" : "server.js"}
376
+ onChange={(event) => setStartup(event.target.value)}
377
+ />
378
+ </label>
379
+ )}
380
+ {showWorking && (
381
+ <label>
382
+ Working directory
383
+ <input
384
+ value={working}
385
+ disabled={!canWrite}
386
+ onChange={(event) => setWorking(event.target.value)}
387
+ />
388
+ </label>
389
+ )}
390
+ {showPublic && (
391
+ <label>
392
+ Document root
393
+ <input
394
+ value={publicDir}
395
+ disabled={!canWrite}
396
+ onChange={(event) => setPublicDir(event.target.value)}
397
+ />
398
+ </label>
399
+ )}
400
+ {type === "python" && (
401
+ <label>
402
+ Python server
403
+ <select
404
+ value={server}
405
+ disabled={!canWrite}
406
+ onChange={(event) => setServer(event.target.value)}
407
+ >
408
+ <option value="asgi">ASGI (uvicorn)</option>
409
+ <option value="wsgi">WSGI (waitress)</option>
410
+ </select>
411
+ </label>
412
+ )}
413
+ </div>
414
+ <h3>Environment</h3>
415
+ <div className="env-list">
416
+ {rows.map((row, index) => (
417
+ <div key={index} className="env-row">
418
+ <input
419
+ aria-label="Variable name"
420
+ value={row.key}
421
+ disabled={!canWrite}
422
+ placeholder="NAME"
423
+ onChange={(event) =>
424
+ setRows((current) =>
425
+ current.map((item, itemIndex) =>
426
+ itemIndex === index ? { ...item, key: event.target.value } : item,
427
+ ),
428
+ )
429
+ }
430
+ />
431
+ <input
432
+ aria-label="Variable value"
433
+ value={row.value}
434
+ disabled={!canWrite}
435
+ placeholder="value"
436
+ onChange={(event) =>
437
+ setRows((current) =>
438
+ current.map((item, itemIndex) =>
439
+ itemIndex === index
440
+ ? { ...item, value: event.target.value }
441
+ : item,
442
+ ),
443
+ )
444
+ }
445
+ />
446
+ {canWrite && (
447
+ <button
448
+ type="button"
449
+ className="secondary"
450
+ aria-label="Remove variable"
451
+ onClick={() =>
452
+ setRows((current) => current.filter((_, itemIndex) => itemIndex !== index))
453
+ }
454
+ >
455
+ <Trash2 size={14} />
456
+ </button>
457
+ )}
458
+ </div>
459
+ ))}
460
+ </div>
461
+ {canWrite && (
462
+ <div className="file-actions">
463
+ <button
464
+ type="button"
465
+ className="secondary"
466
+ onClick={() => setRows((current) => [...current, { key: "", value: "" }])}
467
+ >
468
+ <Plus size={14} /> Variable
469
+ </button>
470
+ <button type="button" className="primary" disabled={busy} onClick={save}>
471
+ <Save size={15} /> Save application
472
+ </button>
473
+ </div>
474
+ )}
475
+ </section>
476
+ );
477
+ }
478
+
479
+ function connectionUri(row) {
480
+ const scheme = ["mysql", "mariadb"].includes(row.engine) ? "mysql" : "postgresql";
481
+ return `${scheme}://${encodeURIComponent(row.user)}:${encodeURIComponent(row.password)}@${row.host}:${row.port}/${encodeURIComponent(row.database)}`;
482
+ }
483
+
484
+ function ProjectDatabase({ project, canWrite, onOpenDatabases }) {
485
+ const toast = useToast();
486
+ const [name, setName] = useState(
487
+ (project.name || "app").replaceAll("-", "_").slice(0, 25),
488
+ );
489
+ const [databases, setDatabases] = useState([]);
490
+ const [secret, setSecret] = useState(null);
491
+ const [busy, setBusy] = useState(false);
492
+ const load = async () => {
493
+ try {
494
+ const data = await api("databases");
495
+ setDatabases(data.databases || []);
496
+ } catch (error) {
497
+ toast.failed(error.message);
498
+ }
499
+ };
500
+ useEffect(() => {
501
+ load();
502
+ }, [project.id]);
503
+ const create = async (event) => {
504
+ event.preventDefault();
505
+ setBusy(true);
506
+ try {
507
+ const created = await api("databases", {
508
+ method: "POST",
509
+ body: JSON.stringify({ name }),
510
+ });
511
+ setSecret(created);
512
+ toast.success("Database created.");
513
+ await load();
514
+ } catch (error) {
515
+ toast.failed(error.message);
516
+ } finally {
517
+ setBusy(false);
518
+ }
519
+ };
520
+ const useInApplication = async () => {
521
+ if (!secret?.password) return;
522
+ setBusy(true);
523
+ try {
524
+ const current = await api(`projects/${project.id}`);
525
+ const config = current.runtime_config || {};
526
+ await api(`projects/${project.id}/config`, {
527
+ method: "PUT",
528
+ body: JSON.stringify({
529
+ type: current.type,
530
+ runtime_config: {
531
+ ...config,
532
+ env: { ...(config.env || {}), DATABASE_URL: connectionUri(secret) },
533
+ },
534
+ }),
535
+ });
536
+ toast.success("Connection saved on the application. Redeploy to apply it.");
537
+ } catch (error) {
538
+ toast.failed(error.message);
539
+ } finally {
540
+ setBusy(false);
541
+ }
542
+ };
543
+ return (
544
+ <section className="panel">
545
+ <div className="section-heading">
546
+ <h2>Database</h2>
547
+ </div>
548
+ <p className="muted">
549
+ Create a database for this application and store its connection in the
550
+ application environment.
551
+ </p>
552
+ {canWrite && (
553
+ <form className="app-fields" onSubmit={create}>
554
+ <label>
555
+ Database name
556
+ <input value={name} onChange={(event) => setName(event.target.value)} required />
557
+ </label>
558
+ <button type="submit" className="primary" disabled={busy}>
559
+ Create database
560
+ </button>
561
+ </form>
562
+ )}
563
+ {secret?.password && (
564
+ <div className="connection-box">
565
+ <p>Password shown once. Copy it into the application now.</p>
566
+ <code>{connectionUri(secret)}</code>
567
+ {canWrite && (
568
+ <button type="button" className="secondary" disabled={busy} onClick={useInApplication}>
569
+ Save connection on this application
570
+ </button>
571
+ )}
572
+ </div>
573
+ )}
574
+ {databases.length > 0 && (
575
+ <ul className="db-mini">
576
+ {databases.map((item) => (
577
+ <li key={item.id}>
578
+ <strong>{item.name}</strong>
579
+ <span>
580
+ {item.engine} · {item.host}:{item.port} · {item.database}
581
+ </span>
582
+ </li>
583
+ ))}
584
+ </ul>
585
+ )}
586
+ <button type="button" className="secondary" onClick={onOpenDatabases}>
587
+ Open database browser
588
+ </button>
589
+ </section>
590
+ );
591
+ }
592
+
593
+ export function ProjectWorkspace({ tab, project, canWrite, onOpenDatabases }) {
594
+ if (tab === "files") return <FileManager project={project} canWrite={canWrite} />;
595
+ if (tab === "application")
596
+ return <ApplicationSettings project={project} canWrite={canWrite} />;
597
+ return (
598
+ <ProjectDatabase
599
+ project={project}
600
+ canWrite={canWrite}
601
+ onOpenDatabases={onOpenDatabases}
602
+ />
603
+ );
604
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+ import {
3
+ createContext,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { X } from "lucide-react";
11
+
12
+ const ToastContext = createContext(null);
13
+
14
+ export function useToast() {
15
+ const toast = useContext(ToastContext);
16
+ if (!toast) throw Error("Toast is unavailable");
17
+ return toast;
18
+ }
19
+
20
+ export function ToastProvider({ children }) {
21
+ const [items, setItems] = useState([]);
22
+ const seq = useRef(0);
23
+ const dismiss = useCallback((id) => {
24
+ setItems((current) => current.filter((item) => item.id !== id));
25
+ }, []);
26
+ const show = useCallback((message, tone = "success") => {
27
+ const text = String(message || "").trim();
28
+ if (!text) return;
29
+ setItems((current) => {
30
+ if (current.some((item) => item.message === text && item.tone === tone))
31
+ return current;
32
+ seq.current += 1;
33
+ return [...current, { id: seq.current, message: text, tone }].slice(-4);
34
+ });
35
+ }, []);
36
+ const api = useRef({
37
+ success(message) {
38
+ show(message, "success");
39
+ },
40
+ progress(message) {
41
+ show(message, "progress");
42
+ },
43
+ failed(message) {
44
+ show(message, "failed");
45
+ },
46
+ });
47
+ api.current.success = (message) => show(message, "success");
48
+ api.current.progress = (message) => show(message, "progress");
49
+ api.current.failed = (message) => show(message, "failed");
50
+ const toast = useRef({
51
+ success: (message) => api.current.success(message),
52
+ progress: (message) => api.current.progress(message),
53
+ failed: (message) => api.current.failed(message),
54
+ }).current;
55
+ return (
56
+ <ToastContext.Provider value={toast}>
57
+ {children}
58
+ <div className="toast-stack">
59
+ {items.map((item) => (
60
+ <ToastCard key={item.id} item={item} onClose={() => dismiss(item.id)} />
61
+ ))}
62
+ </div>
63
+ </ToastContext.Provider>
64
+ );
65
+ }
66
+
67
+ function ToastCard({ item, onClose }) {
68
+ const close = useRef(onClose);
69
+ close.current = onClose;
70
+ useEffect(() => {
71
+ const timer = setTimeout(() => close.current(), 10000);
72
+ return () => clearTimeout(timer);
73
+ }, [item.id]);
74
+ return (
75
+ <div
76
+ className={`toast ${item.tone}`}
77
+ role={item.tone === "failed" ? "alert" : "status"}
78
+ >
79
+ <div className="toast-row">
80
+ <p>{item.message}</p>
81
+ <button type="button" aria-label="Dismiss notification" onClick={onClose}>
82
+ <X size={16} />
83
+ </button>
84
+ </div>
85
+ <div className="toast-track" aria-hidden="true">
86
+ <span className="toast-progress" />
87
+ </div>
88
+ </div>
89
+ );
90
+ }