-
-
Notifications
You must be signed in to change notification settings - Fork 628
Expand file tree
/
Copy pathShareProjectDialog.tsx
More file actions
355 lines (341 loc) · 13.1 KB
/
Copy pathShareProjectDialog.tsx
File metadata and controls
355 lines (341 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
} from "@geolibre/ui";
import { Check, Copy, ExternalLink, KeyRound, Loader2, Share2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDesktopSettingsStore } from "../../hooks/useDesktopSettings";
import { openExternalLink } from "../../lib/open-external";
import {
isShareableTitle,
MAX_PROJECT_TITLE_LENGTH,
resolveShareBaseUrl,
shareHostLabel,
ShareUploadError,
uploadProjectToShare,
type ShareUploadErrorCode,
type ShareUploadResult,
type ShareVisibility,
} from "../../lib/share-geolibre";
import { openSettingsSection } from "./SettingsDialog";
interface ShareProjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The current project name, used to seed the title field. */
currentTitle: string;
/**
* Lazily serialize the current project (under the given title) when the user
* confirms the upload.
*/
getProject: (
title: string,
) => Promise<{ content: string; filename: string; redactedCount?: number }>;
}
/**
* The share host's account settings page, where the user both creates API tokens
* and sets the username required for sharing.
*
* Derived from the resolved host rather than hardcoded, so a self-hosted
* deployment sends its users to its own settings page. Null when no share host is
* configured, in which case the dialog does not render the link.
*/
function accountSettingsUrl(): string | null {
const base = resolveShareBaseUrl();
return base ? `${base}/settings` : null;
}
export function ShareProjectDialog({
open,
onOpenChange,
currentTitle,
getProject,
}: ShareProjectDialogProps) {
const { t } = useTranslation();
// Resolved per render rather than at module load so a deployment env written
// after this module was imported is still honored.
const settingsUrl = accountSettingsUrl();
// Named in the copy below, so a self-hosted deployment reads its own host.
const shareHost = shareHostLabel();
const shareToken = useDesktopSettingsStore((s) => s.desktopSettings.shareToken);
const [title, setTitle] = useState("");
const [visibility, setVisibility] = useState<ShareVisibility>("unlisted");
const [status, setStatus] = useState<"idle" | "uploading">("idle");
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<ShareUploadErrorCode | null>(null);
const [result, setResult] = useState<ShareUploadResult | null>(null);
const [copied, setCopied] = useState(false);
const [redactedCount, setRedactedCount] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const copyTimeoutRef = useRef<number | null>(null);
// Reset transient state whenever the dialog is (re)opened so a prior result or
// error never lingers into a new share. Seed the title from the current
// project name, but leave it blank when the project still has its default
// placeholder name so the field reads as a prompt.
useEffect(() => {
if (open) {
setTitle(isShareableTitle(currentTitle) ? currentTitle.trim() : "");
setVisibility("unlisted");
setStatus("idle");
setError(null);
setErrorCode(null);
setResult(null);
setCopied(false);
setRedactedCount(0);
} else {
abortRef.current?.abort();
abortRef.current = null;
}
}, [open, currentTitle]);
// Cancel a pending "copied" reset if the dialog unmounts mid-window.
useEffect(
() => () => {
if (copyTimeoutRef.current !== null) {
window.clearTimeout(copyTimeoutRef.current);
}
},
[],
);
const hasToken = shareToken.trim().length > 0;
const titleValid = isShareableTitle(title);
const handleShare = async () => {
// Guard re-entry synchronously: a second click before the disabled state
// renders would otherwise start a concurrent, non-idempotent upload.
if (abortRef.current) return;
setError(null);
setErrorCode(null);
setStatus("uploading");
const controller = new AbortController();
abortRef.current = controller;
try {
const { content, filename, redactedCount: removed = 0 } = await getProject(title.trim());
const uploaded = await uploadProjectToShare({
token: shareToken,
filename,
content,
visibility,
signal: controller.signal,
});
setRedactedCount(removed);
setResult(uploaded);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
// A missing account username gets dedicated, actionable UI (a deep link to
// the website's settings) rather than the raw server string.
if (err instanceof ShareUploadError && err.code === "username-required") {
setErrorCode("username-required");
setError(null);
} else {
setError(err instanceof Error ? err.message : t("share.errorFallback"));
setErrorCode(null);
}
} finally {
// Only the controller that is still current clears state, so an aborted
// (superseded) request never flips a newer one back to idle.
if (abortRef.current === controller) {
abortRef.current = null;
setStatus("idle");
}
}
};
// Close this dialog and deep-link into Settings → Environment Variables with
// the share token field focused, so the user can paste the token right away.
const handleConfigureToken = () => {
onOpenChange(false);
openSettingsSection("environment", { focus: "shareToken" });
};
const handleCopy = () => {
if (!result) return;
// Only show the "copied" checkmark if the write actually succeeds; the
// promise rejects when clipboard permission is denied or the page is
// unfocused, and swallowing it would flip the icon misleadingly.
navigator.clipboard
.writeText(result.projectUrl)
.then(() => {
if (copyTimeoutRef.current !== null) {
window.clearTimeout(copyTimeoutRef.current);
}
setCopied(true);
copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 2000);
})
.catch(() => {
// Clipboard unavailable; leave the icon unchanged.
});
};
// Defensive: the Share entry points (menu item and command palette) are gated on
// the same state, so this should be unreachable. Guarding here anyway keeps a
// future caller from rendering setup guidance that names the public hosted
// service on a deployment that configured no share host.
if (!settingsUrl) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="h-4 w-4" />
{t("share.title")}
</DialogTitle>
<DialogDescription>{t("gallery.errorNotConfigured")}</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="h-4 w-4" />
{t("share.title")}
</DialogTitle>
<DialogDescription>{t("share.description", { shareHost })}</DialogDescription>
</DialogHeader>
{!hasToken ? (
<div className="space-y-4 text-sm">
<p className="text-muted-foreground">{t("share.setupIntro", { shareHost })}</p>
<ol className="space-y-3">
<li className="space-y-2 rounded-md border p-3">
<p className="font-medium">{t("share.step1Title")}</p>
<p className="text-muted-foreground">
{t("share.step1Description", { shareHost })}
</p>
<Button
type="button"
variant="outline"
onClick={() => void openExternalLink(settingsUrl)}
>
<ExternalLink className="me-2 h-3.5 w-3.5" />
{t("share.getToken")}
</Button>
</li>
<li className="space-y-2 rounded-md border p-3">
<p className="font-medium">{t("share.step2Title")}</p>
<p className="text-muted-foreground">{t("share.step2Description")}</p>
<Button type="button" onClick={handleConfigureToken}>
<KeyRound className="me-2 h-3.5 w-3.5" />
{t("share.configureToken")}
</Button>
</li>
</ol>
</div>
) : result ? (
<div className="space-y-3">
{redactedCount > 0 ? (
<p className="rounded-md bg-muted p-2 text-sm text-muted-foreground">
{t("share.credentialsRemoved", { count: redactedCount })}
</p>
) : null}
<p className="text-sm text-muted-foreground">{t("share.liveAt")}</p>
<div className="flex gap-2">
<Input readOnly value={result.projectUrl} className="text-xs" />
<Button
type="button"
variant="secondary"
aria-label={t("share.copyLink")}
onClick={handleCopy}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => void openExternalLink(result.projectUrl)}
>
<ExternalLink className="me-2 h-3.5 w-3.5" />
{t("share.open")}
</Button>
<Button type="button" onClick={() => onOpenChange(false)}>
{t("share.done")}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="share-title">{t("share.projectTitle")}</Label>
<Input
id="share-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("share.titlePlaceholder")}
maxLength={MAX_PROJECT_TITLE_LENGTH}
disabled={status === "uploading"}
autoFocus={!titleValid}
/>
{!titleValid && (
<p className="text-xs text-muted-foreground">{t("share.titleRequired")}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="share-visibility">{t("share.visibility")}</Label>
<Select
id="share-visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as ShareVisibility)}
disabled={status === "uploading"}
>
<option value="unlisted">{t("share.visibilityUnlisted")}</option>
<option value="public">{t("share.visibilityPublic")}</option>
<option value="private">{t("share.visibilityPrivate")}</option>
</Select>
</div>
{errorCode === "username-required" ? (
<div
role="alert"
className="space-y-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive"
>
<p>{t("share.usernameRequired", { shareHost })}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void openExternalLink(settingsUrl)}
>
<ExternalLink className="me-2 h-3.5 w-3.5" />
{t("share.openAccountSettings")}
</Button>
</div>
) : error ? (
<p role="alert" className="rounded-md bg-destructive/10 p-2 text-sm text-destructive">
{error}
</p>
) : null}
<div className="flex justify-end gap-2">
{/* Stays enabled during upload: closing the dialog aborts the
in-flight request via the open effect's cleanup. */}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={() => void handleShare()}
disabled={status === "uploading" || !titleValid}
>
{status === "uploading" ? (
<>
<Loader2 className="me-2 h-3.5 w-3.5 animate-spin" />
{t("share.sharing")}
</>
) : (
<>
<Share2 className="me-2 h-3.5 w-3.5" />
{t("share.shareButton")}
</>
)}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}