@hienlh/ppm 0.7.34 → 0.7.36
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/CHANGELOG.md +15 -0
- package/dist/web/assets/{chat-tab-mN5_K1SN.js → chat-tab-BYDRNrD8.js} +1 -1
- package/dist/web/assets/{code-editor-CtVQCgrH.js → code-editor-4C6EgzPR.js} +1 -1
- package/dist/web/assets/{database-viewer-Clz1Un15.js → database-viewer-59U5HapI.js} +1 -1
- package/dist/web/assets/{diff-viewer-D1X1uUpH.js → diff-viewer-CCaAP8X7.js} +1 -1
- package/dist/web/assets/{git-graph-Ca9ICEli.js → git-graph-DMC6jCKi.js} +1 -1
- package/dist/web/assets/index-Dgwazmyy.js +28 -0
- package/dist/web/assets/index-M3zwguMz.css +2 -0
- package/dist/web/assets/keybindings-store-QCkzpfSx.js +1 -0
- package/dist/web/assets/{markdown-renderer-P4B27yZE.js → markdown-renderer-6j6zXseA.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CtCRnfFr.js → postgres-viewer-DHAVUBOr.js} +1 -1
- package/dist/web/assets/{settings-tab-C-EIP5gB.js → settings-tab-oCr6rOO2.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DeyFFG69.js → sqlite-viewer-9ikZrlnj.js} +1 -1
- package/dist/web/assets/{terminal-tab-Ca3TXosj.js → terminal-tab-FX85YKLG.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/cloud.ts +20 -1
- package/src/server/index.ts +4 -0
- package/src/server/routes/cloud.ts +108 -0
- package/src/web/components/layout/cloud-share-popover.tsx +312 -0
- package/src/web/components/layout/project-bar.tsx +19 -172
- package/dist/web/assets/index-BCibi3mV.css +0 -2
- package/dist/web/assets/index-CvVV2MmZ.js +0 -28
- package/dist/web/assets/keybindings-store-DUYtWibd.js +0 -1
|
@@ -89,7 +89,26 @@ export function registerCloudCommands(program: Command): void {
|
|
|
89
89
|
console.log(` ✓ Machine linked`);
|
|
90
90
|
console.log(` Name: ${device.name}`);
|
|
91
91
|
console.log(` ID: ${device.device_id}`);
|
|
92
|
-
|
|
92
|
+
|
|
93
|
+
// Auto-detect running tunnel and start heartbeat immediately
|
|
94
|
+
try {
|
|
95
|
+
const { resolve } = await import("node:path");
|
|
96
|
+
const { homedir } = await import("node:os");
|
|
97
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
98
|
+
const statusFile = resolve(homedir(), ".ppm", "status.json");
|
|
99
|
+
if (existsSync(statusFile)) {
|
|
100
|
+
const status = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
101
|
+
if (status.shareUrl) {
|
|
102
|
+
const { sendHeartbeat } = await import("../../services/cloud.service.ts");
|
|
103
|
+
const ok = await sendHeartbeat(status.shareUrl);
|
|
104
|
+
if (ok) {
|
|
105
|
+
console.log(`\n ➜ Cloud: synced tunnel URL (${status.shareUrl})`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch { /* non-blocking */ }
|
|
110
|
+
|
|
111
|
+
console.log();
|
|
93
112
|
} catch (err: unknown) {
|
|
94
113
|
const msg = err instanceof Error ? err.message : String(err);
|
|
95
114
|
console.error(` ✗ Link failed: ${msg}\n`);
|
package/src/server/index.ts
CHANGED
|
@@ -118,6 +118,10 @@ app.route("/api/postgres", postgresRoutes);
|
|
|
118
118
|
app.route("/api/db", databaseRoutes);
|
|
119
119
|
app.route("/api/accounts", accountsRoutes);
|
|
120
120
|
|
|
121
|
+
// Cloud device registry
|
|
122
|
+
import { cloudRoutes } from "./routes/cloud.ts";
|
|
123
|
+
app.route("/api/cloud", cloudRoutes);
|
|
124
|
+
|
|
121
125
|
// Static files / SPA fallback (non-API routes)
|
|
122
126
|
app.route("/", staticRoutes);
|
|
123
127
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { ok, err } from "../../types/api.ts";
|
|
3
|
+
import {
|
|
4
|
+
getCloudAuth,
|
|
5
|
+
getCloudDevice,
|
|
6
|
+
saveCloudAuth,
|
|
7
|
+
removeCloudAuth,
|
|
8
|
+
linkDevice,
|
|
9
|
+
unlinkDevice,
|
|
10
|
+
sendHeartbeat,
|
|
11
|
+
startHeartbeat,
|
|
12
|
+
DEFAULT_CLOUD_URL,
|
|
13
|
+
} from "../../services/cloud.service.ts";
|
|
14
|
+
import { tunnelService } from "../../services/tunnel.service.ts";
|
|
15
|
+
import { configService } from "../../services/config.service.ts";
|
|
16
|
+
import { VERSION } from "../../version.ts";
|
|
17
|
+
|
|
18
|
+
export const cloudRoutes = new Hono();
|
|
19
|
+
|
|
20
|
+
/** GET /api/cloud/status — cloud connection status */
|
|
21
|
+
cloudRoutes.get("/status", (c) => {
|
|
22
|
+
const auth = getCloudAuth();
|
|
23
|
+
const device = getCloudDevice();
|
|
24
|
+
const tunnelUrl = tunnelService.getTunnelUrl();
|
|
25
|
+
|
|
26
|
+
return c.json(ok({
|
|
27
|
+
logged_in: !!auth,
|
|
28
|
+
email: auth?.email ?? null,
|
|
29
|
+
cloud_url: auth?.cloud_url ?? configService.get("cloud_url") ?? DEFAULT_CLOUD_URL,
|
|
30
|
+
linked: !!device,
|
|
31
|
+
device_name: device?.name ?? null,
|
|
32
|
+
device_id: device?.device_id ?? null,
|
|
33
|
+
tunnel_active: !!tunnelUrl,
|
|
34
|
+
tunnel_url: tunnelUrl,
|
|
35
|
+
}));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** POST /api/cloud/login — save cloud auth from web UI OAuth flow */
|
|
39
|
+
cloudRoutes.post("/login", async (c) => {
|
|
40
|
+
const body = await c.req.json<{
|
|
41
|
+
access_token: string;
|
|
42
|
+
email: string;
|
|
43
|
+
cloud_url?: string;
|
|
44
|
+
}>();
|
|
45
|
+
|
|
46
|
+
if (!body.access_token || !body.email) {
|
|
47
|
+
return c.json(err("access_token and email required"), 400);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cloudUrl = body.cloud_url ?? configService.get("cloud_url") ?? DEFAULT_CLOUD_URL;
|
|
51
|
+
|
|
52
|
+
saveCloudAuth({
|
|
53
|
+
access_token: body.access_token,
|
|
54
|
+
refresh_token: "",
|
|
55
|
+
email: body.email,
|
|
56
|
+
cloud_url: cloudUrl,
|
|
57
|
+
saved_at: new Date().toISOString(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return c.json(ok({ email: body.email }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** POST /api/cloud/logout — remove cloud auth */
|
|
64
|
+
cloudRoutes.post("/logout", (_c) => {
|
|
65
|
+
removeCloudAuth();
|
|
66
|
+
return _c.json(ok(true));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** POST /api/cloud/link — register device with cloud */
|
|
70
|
+
cloudRoutes.post("/link", async (c) => {
|
|
71
|
+
const body = await c.req.json<{ name?: string }>().catch(() => ({}));
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const device = await linkDevice(body.name);
|
|
75
|
+
|
|
76
|
+
// Auto-start heartbeat if tunnel is active
|
|
77
|
+
const tunnelUrl = tunnelService.getTunnelUrl();
|
|
78
|
+
if (tunnelUrl) {
|
|
79
|
+
startHeartbeat(tunnelUrl);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return c.json(ok({
|
|
83
|
+
device_id: device.device_id,
|
|
84
|
+
name: device.name,
|
|
85
|
+
synced: !!tunnelUrl,
|
|
86
|
+
}));
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return c.json(err((e as Error).message), 500);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** POST /api/cloud/unlink — remove device from cloud */
|
|
93
|
+
cloudRoutes.post("/unlink", async (c) => {
|
|
94
|
+
try {
|
|
95
|
+
await unlinkDevice();
|
|
96
|
+
return c.json(ok(true));
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return c.json(err((e as Error).message), 500);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/** GET /api/cloud/login-url — get cloud OAuth login URL for web redirect */
|
|
103
|
+
cloudRoutes.get("/login-url", (c) => {
|
|
104
|
+
const cloudUrl = configService.get("cloud_url") ?? DEFAULT_CLOUD_URL;
|
|
105
|
+
// Web UI opens this URL in a new tab/popup; cloud handles OAuth and returns token
|
|
106
|
+
const loginUrl = `${cloudUrl}/auth/google/login`;
|
|
107
|
+
return c.json(ok({ url: loginUrl, cloud_url: cloudUrl }));
|
|
108
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { Cloud, Share2, Loader2, Copy, Check, X, LogOut, Link, Unlink, ExternalLink } from "lucide-react";
|
|
3
|
+
import { QRCodeSVG } from "qrcode.react";
|
|
4
|
+
import { api } from "@/lib/api-client";
|
|
5
|
+
|
|
6
|
+
interface CloudStatus {
|
|
7
|
+
logged_in: boolean;
|
|
8
|
+
email: string | null;
|
|
9
|
+
cloud_url: string;
|
|
10
|
+
linked: boolean;
|
|
11
|
+
device_name: string | null;
|
|
12
|
+
device_id: string | null;
|
|
13
|
+
tunnel_active: boolean;
|
|
14
|
+
tunnel_url: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TunnelStatus {
|
|
18
|
+
active: boolean;
|
|
19
|
+
url: string | null;
|
|
20
|
+
localUrl: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function CloudSharePopover({ onClose }: Props) {
|
|
28
|
+
const [cloud, setCloud] = useState<CloudStatus | null>(null);
|
|
29
|
+
const [tunnel, setTunnel] = useState<TunnelStatus | null>(null);
|
|
30
|
+
const [loading, setLoading] = useState(true);
|
|
31
|
+
const [tunnelStarting, setTunnelStarting] = useState(false);
|
|
32
|
+
const [linking, setLinking] = useState(false);
|
|
33
|
+
const [unlinking, setUnlinking] = useState(false);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const [copied, setCopied] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
// Load status on mount
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
(async () => {
|
|
40
|
+
try {
|
|
41
|
+
const [cloudRes, tunnelRes] = await Promise.all([
|
|
42
|
+
api.get<CloudStatus>("/api/cloud/status"),
|
|
43
|
+
api.get<TunnelStatus>("/api/tunnel"),
|
|
44
|
+
]);
|
|
45
|
+
setCloud(cloudRes);
|
|
46
|
+
setTunnel(tunnelRes);
|
|
47
|
+
} catch { /* ignore */ }
|
|
48
|
+
setLoading(false);
|
|
49
|
+
})();
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const handleCopy = useCallback((url: string) => {
|
|
53
|
+
navigator.clipboard.writeText(url);
|
|
54
|
+
setCopied(url);
|
|
55
|
+
setTimeout(() => setCopied(null), 2000);
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const handleStartTunnel = useCallback(async () => {
|
|
59
|
+
setTunnelStarting(true);
|
|
60
|
+
setError(null);
|
|
61
|
+
try {
|
|
62
|
+
const result = await api.post<{ url: string }>("/api/tunnel/start", {});
|
|
63
|
+
setTunnel({ active: true, url: result.url, localUrl: tunnel?.localUrl ?? null });
|
|
64
|
+
// Refresh cloud status (heartbeat may have started)
|
|
65
|
+
const cs = await api.get<CloudStatus>("/api/cloud/status");
|
|
66
|
+
setCloud(cs);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
setError(e instanceof Error ? e.message : "Failed to start tunnel");
|
|
69
|
+
} finally {
|
|
70
|
+
setTunnelStarting(false);
|
|
71
|
+
}
|
|
72
|
+
}, [tunnel]);
|
|
73
|
+
|
|
74
|
+
const handleCloudLogin = useCallback(async () => {
|
|
75
|
+
try {
|
|
76
|
+
const { url, cloud_url } = await api.get<{ url: string; cloud_url: string }>("/api/cloud/login-url");
|
|
77
|
+
// Open popup for OAuth
|
|
78
|
+
const popup = window.open(url, "ppm-cloud-login", "width=500,height=600,menubar=no,toolbar=no");
|
|
79
|
+
|
|
80
|
+
// Poll for completion — user will be redirected to /dashboard on cloud
|
|
81
|
+
// We check cloud status periodically until logged_in becomes true
|
|
82
|
+
const poll = setInterval(async () => {
|
|
83
|
+
try {
|
|
84
|
+
// Check if cloud set cookies/session
|
|
85
|
+
const res = await fetch(`${cloud_url}/auth/session`, { credentials: "include" });
|
|
86
|
+
if (res.ok) {
|
|
87
|
+
const data = await res.json();
|
|
88
|
+
if (data.user?.email) {
|
|
89
|
+
clearInterval(poll);
|
|
90
|
+
popup?.close();
|
|
91
|
+
// Save auth to PPM server
|
|
92
|
+
await api.post("/api/cloud/login", {
|
|
93
|
+
access_token: "web-session", // web uses cookie-based auth
|
|
94
|
+
email: data.user.email,
|
|
95
|
+
cloud_url,
|
|
96
|
+
});
|
|
97
|
+
const cs = await api.get<CloudStatus>("/api/cloud/status");
|
|
98
|
+
setCloud(cs);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch { /* keep polling */ }
|
|
102
|
+
}, 2000);
|
|
103
|
+
|
|
104
|
+
// Stop polling after 2 minutes
|
|
105
|
+
setTimeout(() => clearInterval(poll), 120_000);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
setError(e instanceof Error ? e.message : "Failed to get login URL");
|
|
108
|
+
}
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const handleLink = useCallback(async () => {
|
|
112
|
+
setLinking(true);
|
|
113
|
+
setError(null);
|
|
114
|
+
try {
|
|
115
|
+
// If not sharing yet, start tunnel first
|
|
116
|
+
if (!tunnel?.active) {
|
|
117
|
+
const result = await api.post<{ url: string }>("/api/tunnel/start", {});
|
|
118
|
+
setTunnel({ active: true, url: result.url, localUrl: tunnel?.localUrl ?? null });
|
|
119
|
+
}
|
|
120
|
+
const result = await api.post<{ device_id: string; name: string; synced: boolean }>("/api/cloud/link", {});
|
|
121
|
+
const cs = await api.get<CloudStatus>("/api/cloud/status");
|
|
122
|
+
setCloud(cs);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
setError(e instanceof Error ? e.message : "Failed to link device");
|
|
125
|
+
} finally {
|
|
126
|
+
setLinking(false);
|
|
127
|
+
}
|
|
128
|
+
}, [tunnel]);
|
|
129
|
+
|
|
130
|
+
const handleUnlink = useCallback(async () => {
|
|
131
|
+
setUnlinking(true);
|
|
132
|
+
try {
|
|
133
|
+
await api.post("/api/cloud/unlink", {});
|
|
134
|
+
const cs = await api.get<CloudStatus>("/api/cloud/status");
|
|
135
|
+
setCloud(cs);
|
|
136
|
+
} catch { /* ignore */ }
|
|
137
|
+
setUnlinking(false);
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
const handleLogout = useCallback(async () => {
|
|
141
|
+
await api.post("/api/cloud/logout", {});
|
|
142
|
+
setCloud((prev) => prev ? { ...prev, logged_in: false, email: null, linked: false, device_name: null, device_id: null } : null);
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const shareUrl = tunnel?.url || cloud?.tunnel_url;
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className="w-72 bg-background border border-border rounded-lg shadow-lg p-3 space-y-3">
|
|
149
|
+
{/* Header */}
|
|
150
|
+
<div className="flex items-center justify-between">
|
|
151
|
+
<div className="flex items-center gap-1.5">
|
|
152
|
+
<Cloud className="size-4 text-primary" />
|
|
153
|
+
<span className="text-sm font-medium text-foreground">PPM Cloud</span>
|
|
154
|
+
</div>
|
|
155
|
+
<button onClick={onClose} className="text-text-subtle hover:text-foreground">
|
|
156
|
+
<X className="size-3.5" />
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{loading && (
|
|
161
|
+
<div className="flex items-center gap-2 text-muted-foreground text-xs py-2">
|
|
162
|
+
<Loader2 className="size-4 animate-spin" />
|
|
163
|
+
<span>Loading...</span>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{!loading && (
|
|
168
|
+
<>
|
|
169
|
+
{/* Cloud Account Section */}
|
|
170
|
+
<div className="space-y-1.5">
|
|
171
|
+
{cloud?.logged_in ? (
|
|
172
|
+
<div className="flex items-center justify-between">
|
|
173
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
174
|
+
<div className="size-2 rounded-full bg-green-500 shrink-0" />
|
|
175
|
+
<span className="text-xs text-foreground truncate">{cloud.email}</span>
|
|
176
|
+
</div>
|
|
177
|
+
<button
|
|
178
|
+
onClick={handleLogout}
|
|
179
|
+
className="text-text-subtle hover:text-foreground p-1 rounded hover:bg-muted transition-colors shrink-0"
|
|
180
|
+
title="Sign out"
|
|
181
|
+
>
|
|
182
|
+
<LogOut className="size-3" />
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
) : (
|
|
186
|
+
<button
|
|
187
|
+
onClick={handleCloudLogin}
|
|
188
|
+
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium rounded-md border border-border hover:bg-muted transition-colors"
|
|
189
|
+
>
|
|
190
|
+
<Cloud className="size-3.5" />
|
|
191
|
+
Sign in to PPM Cloud
|
|
192
|
+
</button>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{/* Device Link Section */}
|
|
197
|
+
{cloud?.logged_in && (
|
|
198
|
+
<div className="space-y-1.5">
|
|
199
|
+
{cloud.linked ? (
|
|
200
|
+
<div className="flex items-center justify-between text-xs">
|
|
201
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
202
|
+
<Link className="size-3 text-primary shrink-0" />
|
|
203
|
+
<span className="text-foreground truncate">{cloud.device_name}</span>
|
|
204
|
+
</div>
|
|
205
|
+
<button
|
|
206
|
+
onClick={handleUnlink}
|
|
207
|
+
disabled={unlinking}
|
|
208
|
+
className="text-text-subtle hover:text-destructive p-1 rounded hover:bg-muted transition-colors shrink-0"
|
|
209
|
+
title="Unlink device"
|
|
210
|
+
>
|
|
211
|
+
{unlinking ? <Loader2 className="size-3 animate-spin" /> : <Unlink className="size-3" />}
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
) : (
|
|
215
|
+
<button
|
|
216
|
+
onClick={handleLink}
|
|
217
|
+
disabled={linking}
|
|
218
|
+
className="w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
|
219
|
+
>
|
|
220
|
+
{linking ? (
|
|
221
|
+
<><Loader2 className="size-3.5 animate-spin" /> Linking...</>
|
|
222
|
+
) : (
|
|
223
|
+
<><Link className="size-3.5" /> Link this machine</>
|
|
224
|
+
)}
|
|
225
|
+
</button>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{/* Separator */}
|
|
231
|
+
<div className="border-t border-border" />
|
|
232
|
+
|
|
233
|
+
{/* Local Network URL */}
|
|
234
|
+
{tunnel?.localUrl && (
|
|
235
|
+
<div className="space-y-1">
|
|
236
|
+
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Local Network</span>
|
|
237
|
+
<UrlRow url={tunnel.localUrl} copied={copied} onCopy={handleCopy} />
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{/* Tunnel / Share Section */}
|
|
242
|
+
{!shareUrl && !tunnelStarting && (
|
|
243
|
+
<button
|
|
244
|
+
onClick={handleStartTunnel}
|
|
245
|
+
className="w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
246
|
+
>
|
|
247
|
+
<Share2 className="size-3.5" />
|
|
248
|
+
Start Sharing
|
|
249
|
+
</button>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
{tunnelStarting && (
|
|
253
|
+
<div className="flex flex-col items-center gap-2 py-2">
|
|
254
|
+
<Loader2 className="size-5 animate-spin text-primary" />
|
|
255
|
+
<span className="text-xs text-muted-foreground">Starting tunnel...</span>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{shareUrl && (
|
|
260
|
+
<div className="space-y-1.5">
|
|
261
|
+
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Public URL</span>
|
|
262
|
+
<div className="flex justify-center">
|
|
263
|
+
<div className="bg-white p-2 rounded">
|
|
264
|
+
<QRCodeSVG value={shareUrl} size={140} bgColor="#ffffff" fgColor="#000000" />
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
<UrlRow url={shareUrl} copied={copied} onCopy={handleCopy} />
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
{/* Cloud Dashboard Link */}
|
|
272
|
+
{cloud?.logged_in && cloud.linked && (
|
|
273
|
+
<a
|
|
274
|
+
href={cloud.cloud_url + "/dashboard"}
|
|
275
|
+
target="_blank"
|
|
276
|
+
rel="noopener noreferrer"
|
|
277
|
+
className="flex items-center justify-center gap-1.5 w-full px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground rounded-md border border-border hover:bg-muted transition-colors"
|
|
278
|
+
>
|
|
279
|
+
<ExternalLink className="size-3" />
|
|
280
|
+
Open Cloud Dashboard
|
|
281
|
+
</a>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
{/* Error */}
|
|
285
|
+
{error && (
|
|
286
|
+
<p className="text-xs text-destructive">{error}</p>
|
|
287
|
+
)}
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function UrlRow({ url, copied, onCopy }: { url: string; copied: string | null; onCopy: (u: string) => void }) {
|
|
295
|
+
return (
|
|
296
|
+
<div className="flex items-center gap-1">
|
|
297
|
+
<input
|
|
298
|
+
readOnly
|
|
299
|
+
value={url}
|
|
300
|
+
className="flex-1 text-xs font-mono text-foreground bg-muted px-2 py-1.5 rounded border border-border truncate"
|
|
301
|
+
onClick={(e) => (e.target as HTMLInputElement).select()}
|
|
302
|
+
/>
|
|
303
|
+
<button
|
|
304
|
+
onClick={() => onCopy(url)}
|
|
305
|
+
className="flex items-center justify-center size-7 rounded border border-border text-muted-foreground bg-muted hover:bg-accent hover:text-foreground transition-colors shrink-0"
|
|
306
|
+
title="Copy URL"
|
|
307
|
+
>
|
|
308
|
+
{copied === url ? <Check className="size-3.5 text-primary" /> : <Copy className="size-3.5" />}
|
|
309
|
+
</button>
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|