main
tsx 697 lines 24.1 KB
Raw
1 import {
2 useEffect,
3 useId,
4 useMemo,
5 useState,
6 type KeyboardEvent,
7 } from "react";
8 import { Check, Copy, RefreshCw, X } from "lucide-react";
9 import { Input } from "@/components/ui/input";
10 import { apiClient } from "@/lib/apiClient";
11 import { BROWSER_API_PATHS } from "@/lib/apiPaths";
12 import type { ServiceStatusResponse } from "@/types/api";
13 import { cn } from "@/lib/utils";
14 import {
15 buildTunnelPreviewURL,
16 buildServiceStatusHostname,
17 normalizeAbsoluteHTTPURL,
18 } from "@/lib/tunnelCommand";
19 import {
20 DEFAULT_HOST,
21 readCurrentOrigin,
22 useTunnelCommand,
23 } from "@/hooks/useTunnelCommand";
24
25 interface TunnelCommandFormProps {
26 className?: string;
27 theme?: "light" | "terminal";
28 mode?: "full" | "hero";
29 }
30
31 type ServiceStatus = "waiting" | "registered" | "alive";
32
33 export function TunnelCommandForm({
34 className,
35 theme = "light",
36 mode = "full",
37 }: TunnelCommandFormProps) {
38 if (mode === "hero") {
39 return <HeroTunnelCommandForm className={className} theme={theme} />;
40 }
41
42 return <FullTunnelCommandForm className={className} theme={theme} />;
43 }
44
45 function HeroTunnelCommandForm({
46 className,
47 theme,
48 }: Required<Pick<TunnelCommandFormProps, "theme">> &
49 Pick<TunnelCommandFormProps, "className">) {
50 const isTerminal = theme === "terminal";
51 const {
52 currentOrigin,
53 nameSeed,
54 target,
55 setTarget,
56 copied,
57 os,
58 setOs,
59 generatedName,
60 effectiveName,
61 installBlock,
62 runBlock,
63 handleCopy,
64 handleNameChange,
65 handleShuffleName,
66 } = useTunnelCommand();
67
68 const [serviceStatus, setServiceStatus] = useState<ServiceStatus>("waiting");
69
70 const previewURL = useMemo(
71 () => buildTunnelPreviewURL(currentOrigin, effectiveName, target, nameSeed),
72 [currentOrigin, effectiveName, nameSeed, target]
73 );
74 const statusHostname = useMemo(
75 () =>
76 buildServiceStatusHostname(currentOrigin, effectiveName, target, nameSeed),
77 [currentOrigin, effectiveName, nameSeed, target]
78 );
79
80 useEffect(() => {
81 if (statusHostname === "") {
82 return;
83 }
84
85 let cancelled = false;
86
87 const poll = async () => {
88 try {
89 const params = new URLSearchParams({ hostname: statusHostname });
90 const statusResponse = await apiClient.get<ServiceStatusResponse>(
91 `${BROWSER_API_PATHS.service.status}?${params.toString()}`
92 );
93 if (cancelled) {
94 return;
95 }
96
97 if (!statusResponse.registered) {
98 setServiceStatus("waiting");
99 return;
100 }
101
102 setServiceStatus(statusResponse.service_alive ? "alive" : "registered");
103 } catch {
104 if (!cancelled) {
105 setServiceStatus("waiting");
106 }
107 }
108 };
109
110 setServiceStatus("waiting");
111 void poll();
112 const interval = window.setInterval(() => {
113 void poll();
114 }, 1500);
115
116 return () => {
117 cancelled = true;
118 window.clearInterval(interval);
119 };
120 }, [statusHostname]);
121
122 const serviceStatusTone = {
123 alive: isTerminal ? "bg-green-400" : "bg-green-600",
124 registered: isTerminal ? "bg-sky-400" : "bg-sky-600",
125 waiting: isTerminal ? "bg-slate-500" : "bg-slate-400",
126 }[serviceStatus];
127 const serviceStatusHeadline = {
128 alive: "This URL is live now",
129 registered: "URL reserved",
130 waiting: "Waiting for Connection",
131 }[serviceStatus];
132 const isPreviewURLDisabled = serviceStatus === "waiting";
133 const heroSectionLabelClass = cn(
134 "text-[13px] font-semibold tracking-normal sm:text-sm",
135 isTerminal ? "text-slate-100" : "text-foreground/85"
136 );
137 const heroURLClass = cn(
138 "block overflow-x-auto whitespace-nowrap font-mono text-[15px] font-medium sm:text-base",
139 isTerminal ? "text-sky-300" : "text-primary"
140 );
141 const platformButtonGroupClass = cn(
142 "flex shrink-0 rounded-md border p-0.5",
143 isTerminal
144 ? "border-white/6 bg-white/[0.035]"
145 : "border-border bg-border"
146 );
147 const platformButtonClass = (selected: boolean) =>
148 cn(
149 "min-w-[72px] whitespace-nowrap rounded-sm px-2.5 py-1.5 text-[11px] font-semibold transition-colors",
150 selected
151 ? isTerminal
152 ? "bg-white/[0.08] text-slate-200"
153 : "bg-background text-foreground/85"
154 : isTerminal
155 ? "text-slate-500 hover:text-slate-300"
156 : "text-text-muted hover:text-foreground"
157 );
158 const heroControlLabelClass = cn(
159 "shrink-0 text-[9px] font-semibold uppercase tracking-normal",
160 isTerminal ? "text-slate-500" : "text-text-muted"
161 );
162 const heroControlInputClass = cn(
163 "h-auto border-0 bg-transparent px-0 py-0 text-[13px] shadow-none focus-visible:ring-0",
164 isTerminal
165 ? "text-slate-200 placeholder:text-slate-600"
166 : "text-foreground/85 placeholder:text-muted-foreground"
167 );
168 const heroShuffleButtonClass = cn(
169 "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-sm transition-colors",
170 isTerminal
171 ? "text-slate-500 hover:bg-white/[0.06] hover:text-slate-200"
172 : "text-text-muted hover:bg-foreground/5 hover:text-foreground"
173 );
174 return (
175 <div className={cn("space-y-5", className)}>
176 <div className="space-y-2">
177 <div className="space-y-1.5">
178 <p className={heroSectionLabelClass}>
179 1. Start your local app
180 <span
181 className={cn(
182 "ml-1 normal-case tracking-normal",
183 isTerminal ? "text-slate-400" : "text-text-muted"
184 )}
185 >
186 (e.g.
187 <span
188 className={cn(
189 "mx-1 font-mono",
190 isTerminal ? "text-slate-200" : "text-foreground"
191 )}
192 >
193 localhost:3000
194 </span>
195 )
196 </span>
197 </p>
198 </div>
199 </div>
200
201 <div className="space-y-3">
202 <div className="flex flex-wrap items-center justify-between gap-3">
203 <p className={heroSectionLabelClass}>2. Run this command</p>
204 <div className={platformButtonGroupClass}>
205 <button
206 type="button"
207 onClick={() => setOs("unix")}
208 className={platformButtonClass(os === "unix")}
209 >
210 Linux
211 </button>
212 <button
213 type="button"
214 onClick={() => setOs("windows")}
215 className={platformButtonClass(os === "windows")}
216 >
217 Windows
218 </button>
219 </div>
220 </div>
221 <div className="flex flex-wrap items-center gap-x-4 gap-y-2 sm:flex-nowrap">
222 <div className="flex shrink-0 items-center gap-2">
223 <span className={heroControlLabelClass}>Port</span>
224 <Input
225 type="text"
226 value={target}
227 onChange={(event) => setTarget(event.target.value)}
228 placeholder={DEFAULT_HOST}
229 aria-label="Local port or address"
230 className={cn(heroControlInputClass, "w-20 font-mono")}
231 />
232 </div>
233 <div className="ml-auto flex min-w-0 items-center justify-end gap-2 sm:w-88">
234 <span className={heroControlLabelClass}>Name</span>
235 <Input
236 type="text"
237 onChange={handleNameChange}
238 placeholder={generatedName}
239 aria-label="Public name"
240 className={cn(heroControlInputClass, "min-w-0 flex-1")}
241 />
242 <button
243 type="button"
244 onClick={handleShuffleName}
245 className={heroShuffleButtonClass}
246 aria-label="Shuffle public name"
247 title="Shuffle public name"
248 >
249 <RefreshCw className="h-4 w-4" aria-hidden="true" />
250 </button>
251 </div>
252 </div>
253 <div
254 className={cn(
255 "relative min-h-37 rounded-lg border px-4 py-4 pr-14 font-mono text-sm leading-7",
256 isTerminal
257 ? "border-white/10 bg-black/55 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]"
258 : "border-border/80 bg-border/90 text-foreground"
259 )}
260 >
261 <button
262 type="button"
263 onClick={handleCopy}
264 className={cn(
265 "absolute top-4 right-4 inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors",
266 isTerminal
267 ? "text-emerald-300/75 hover:bg-emerald-400/10 hover:text-emerald-200"
268 : "text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700"
269 )}
270 aria-label="Copy command"
271 title={copied ? "Copied" : "Copy"}
272 >
273 {copied ? (
274 <Check className="h-4 w-4" />
275 ) : (
276 <Copy className="h-4 w-4" />
277 )}
278 </button>
279 <pre className="overflow-x-auto whitespace-pre-wrap break-all">
280 <span className="block">{installBlock}</span>
281 <span className="mt-2 block">{runBlock}</span>
282 </pre>
283 </div>
284 </div>
285
286 <div className="space-y-2 pt-1">
287 <p className={heroSectionLabelClass}>3. Open this public URL</p>
288 <div
289 className={cn(
290 "space-y-3 rounded-lg border px-3.5 py-3",
291 isTerminal
292 ? "border-white/8 bg-white/4.5"
293 : "border-border bg-white"
294 )}
295 >
296 <div
297 className={cn(
298 "flex items-center gap-2 text-[13px] font-semibold",
299 isTerminal ? "text-slate-300" : "text-foreground"
300 )}
301 >
302 <span
303 className={cn("h-2 w-2 rounded-full", serviceStatusTone)}
304 aria-hidden="true"
305 />
306 <span>{serviceStatusHeadline}</span>
307 </div>
308 {isPreviewURLDisabled ? (
309 <span
310 aria-disabled="true"
311 className={cn(heroURLClass, "cursor-not-allowed opacity-70")}
312 >
313 {previewURL}
314 </span>
315 ) : (
316 <a
317 href={previewURL}
318 target="_blank"
319 rel="noopener noreferrer"
320 className={cn(heroURLClass, "underline-offset-4 hover:underline")}
321 >
322 {previewURL}
323 </a>
324 )}
325 </div>
326 </div>
327 </div>
328 );
329 }
330
331 function FullTunnelCommandForm({
332 className,
333 theme,
334 }: Required<Pick<TunnelCommandFormProps, "theme">> &
335 Pick<TunnelCommandFormProps, "className">) {
336 const inputId = useId();
337 const isTerminal = theme === "terminal";
338 const currentOrigin = readCurrentOrigin();
339
340 const [relayUrls, setRelayUrls] = useState<string[]>(() => [
341 currentOrigin,
342 ]);
343 const [discoveryEnabled, setDiscoveryEnabled] = useState(true);
344 const [showAdvanced, setShowAdvanced] = useState(false);
345 const [urlInput, setUrlInput] = useState("");
346 const [enableUDP, setEnableUDP] = useState(false);
347 const [udpPort, setUDPPort] = useState("");
348 const [thumbnailURL, setThumbnailURL] = useState("");
349
350 const normalizedThumbnailURL = useMemo(
351 () => normalizeAbsoluteHTTPURL(thumbnailURL),
352 [thumbnailURL]
353 );
354 const thumbnailError = useMemo(() => {
355 if (thumbnailURL.trim() === "" || normalizedThumbnailURL !== "") {
356 return "";
357 }
358
359 return "Thumbnail must be an absolute http:// or https:// URL.";
360 }, [normalizedThumbnailURL, thumbnailURL]);
361
362 const {
363 target,
364 setTarget,
365 name,
366 copied,
367 os,
368 setOs,
369 generatedName,
370 installBlock,
371 runBlock,
372 handleCopy,
373 handleNameChange,
374 } = useTunnelCommand({
375 relayUrls,
376 discovery: discoveryEnabled,
377 thumbnailURL: normalizedThumbnailURL,
378 enableUDP,
379 udpPort,
380 });
381
382 const addRelayURL = (url: string) => {
383 const trimmed = url.trim();
384 if (!trimmed || relayUrls.includes(trimmed)) {
385 return;
386 }
387
388 try {
389 new URL(trimmed);
390 setRelayUrls((prev) => [...prev, trimmed]);
391 setUrlInput("");
392 } catch {
393 // Ignore invalid relay URL input.
394 }
395 };
396
397 const removeRelayURL = (url: string) => {
398 if (!discoveryEnabled && relayUrls.length <= 1) {
399 return;
400 }
401 setRelayUrls((prev) => prev.filter((candidate) => candidate !== url));
402 };
403
404 const handleURLKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
405 if (event.key === "Enter") {
406 event.preventDefault();
407 addRelayURL(urlInput);
408 return;
409 }
410
411 if (event.key === "Backspace" && urlInput === "" && relayUrls.length > 0) {
412 setRelayUrls((prev) => prev.slice(0, -1));
413 }
414 };
415 const sectionLabelClass = isTerminal
416 ? "text-sm font-medium text-slate-200"
417 : "text-sm font-medium text-foreground dark:text-zinc-100";
418 const helpTextClass = isTerminal
419 ? "text-xs text-slate-400"
420 : "text-xs leading-5 text-muted-foreground dark:text-zinc-500";
421 const inlineFieldClass = isTerminal
422 ? "flex items-center overflow-hidden rounded-lg border border-white/10 bg-white/5"
423 : "flex items-center overflow-hidden rounded-md border border-border/80 bg-secondary/55 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/30 dark:border-white/10 dark:bg-secondary/60";
424 const inlinePrefixClass = isTerminal
425 ? "shrink-0 border-r border-white/10 px-3 text-xs font-medium uppercase tracking-normal text-slate-400"
426 : "shrink-0 border-r border-border px-3 text-[0.82rem] font-medium uppercase tracking-normal text-text-muted dark:border-border dark:text-zinc-400";
427 const inlineInputClass = isTerminal
428 ? "h-10 border-0 bg-transparent px-3 text-sm text-white placeholder:text-slate-500 shadow-none focus-visible:ring-0"
429 : "h-9 border-0 bg-transparent px-3 text-sm font-semibold text-foreground placeholder:text-text-muted shadow-none focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-500";
430 const relayFieldClass = isTerminal
431 ? "flex min-h-12 flex-wrap items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-2.5 py-2"
432 : "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-border bg-background px-2 py-2 shadow-sm focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/25 dark:border-white/10 dark:bg-transparent";
433 const relayChipClass = isTerminal
434 ? "inline-flex items-center gap-1 rounded-md bg-white/10 px-2.5 py-1.5 text-xs font-medium text-slate-100"
435 : "inline-flex items-center gap-1 rounded-md bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground dark:bg-primary/14 dark:text-primary";
436 const relayChipRemoveClass = isTerminal
437 ? "hover:bg-white/10"
438 : "text-text-muted hover:bg-background/80 dark:text-primary dark:hover:bg-black/20";
439 const advancedInputClass = isTerminal
440 ? "h-10 rounded-lg border-white/10 bg-white/5 text-white placeholder:text-slate-500"
441 : "h-10 rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground shadow-sm dark:border-white/10 dark:bg-background dark:text-zinc-100 dark:placeholder:text-zinc-500";
442 const osGroupClass = isTerminal
443 ? "flex rounded-lg bg-white/8 p-1"
444 : "flex rounded-md bg-border p-1 dark:bg-secondary/60";
445 const osButtonClass = (selected: boolean) =>
446 cn(
447 "flex-1 rounded-sm px-3 py-2 text-sm font-semibold transition-colors",
448 isTerminal
449 ? selected
450 ? "bg-white/8 text-slate-200"
451 : "text-slate-400 hover:text-slate-300"
452 : selected
453 ? "bg-card text-foreground shadow-sm ring-1 ring-border/80 dark:bg-background dark:text-zinc-100 dark:ring-transparent"
454 : "text-muted-foreground hover:text-foreground dark:text-zinc-500 dark:hover:text-zinc-300"
455 );
456 const commandPanelClass = isTerminal
457 ? "relative rounded-lg border border-white/10 bg-black/30"
458 : "relative rounded-md bg-secondary/55 shadow-sm ring-1 ring-border/70 dark:bg-secondary/60 dark:ring-transparent";
459 const commandPreClass = isTerminal
460 ? "overflow-x-auto whitespace-pre-wrap break-all p-4 pr-12 font-mono text-sm leading-7 text-white"
461 : "overflow-x-auto whitespace-pre-wrap break-all p-3 pr-12 font-mono text-sm leading-7 text-foreground dark:text-zinc-100";
462
463 return (
464 <div className={cn("space-y-4 py-1", className)}>
465 <div className="space-y-2">
466 <label
467 htmlFor={`${inputId}-host`}
468 className={sectionLabelClass}
469 >
470 Host
471 </label>
472 <div className={inlineFieldClass}>
473 <span className={inlinePrefixClass}>HOST=</span>
474 <Input
475 id={`${inputId}-host`}
476 type="text"
477 value={target}
478 onChange={(event) => setTarget(event.target.value)}
479 placeholder={DEFAULT_HOST}
480 className={inlineInputClass}
481 />
482 </div>
483 <p className={helpTextClass}>
484 The hostname or IP:Port where your service is running
485 </p>
486 </div>
487
488 <div className="space-y-2">
489 <label
490 htmlFor={`${inputId}-name`}
491 className={sectionLabelClass}
492 >
493 Service Name
494 </label>
495 <div className={inlineFieldClass}>
496 <span className={inlinePrefixClass}>NAME=</span>
497 <Input
498 id={`${inputId}-name`}
499 type="text"
500 value={name}
501 onChange={handleNameChange}
502 placeholder={generatedName}
503 className={inlineInputClass}
504 />
505 </div>
506 <p className={helpTextClass}>A unique identifier for your tunnel</p>
507 </div>
508
509 <div className="space-y-2">
510 <label className={sectionLabelClass}>
511 Relay URLs
512 </label>
513 <div className={relayFieldClass}>
514 {relayUrls.map((url) => (
515 <span
516 key={url}
517 className={relayChipClass}
518 >
519 {url}
520 <button
521 type="button"
522 onClick={() => removeRelayURL(url)}
523 className={cn(
524 "ml-1 rounded-sm p-0.5",
525 !discoveryEnabled && relayUrls.length <= 1
526 ? "cursor-not-allowed opacity-40"
527 : relayChipRemoveClass
528 )}
529 aria-label={`Remove ${url}`}
530 disabled={!discoveryEnabled && relayUrls.length <= 1}
531 >
532 <X className="h-3 w-3" />
533 </button>
534 </span>
535 ))}
536
537 <input
538 type="text"
539 value={urlInput}
540 onChange={(event) => setUrlInput(event.target.value)}
541 onKeyDown={handleURLKeyDown}
542 placeholder="Add relay URL..."
543 className={cn(
544 "min-w-[140px] flex-1 bg-transparent text-sm outline-none",
545 isTerminal
546 ? "text-white placeholder:text-slate-500"
547 : "text-foreground placeholder:text-muted-foreground dark:text-zinc-100 dark:placeholder:text-zinc-500"
548 )}
549 />
550 </div>
551 <p className={helpTextClass}>
552 Press Enter to add. Multiple relay servers for redundancy.
553 </p>
554 </div>
555
556 <div className="space-y-2">
557 <label className={sectionLabelClass}>Operating System</label>
558 <div className={osGroupClass}>
559 <button
560 type="button"
561 onClick={() => setOs("unix")}
562 className={osButtonClass(os === "unix")}
563 >
564 Linux / macOS
565 </button>
566 <button
567 type="button"
568 onClick={() => setOs("windows")}
569 className={osButtonClass(os === "windows")}
570 >
571 Windows (PowerShell)
572 </button>
573 </div>
574 </div>
575
576 <div className="space-y-2">
577 <label className={sectionLabelClass}>
578 Generated Command
579 </label>
580 <div className={commandPanelClass}>
581 <pre className={commandPreClass}>
582 <span className="block">{installBlock}</span>
583 <span className="mt-2 block">{runBlock}</span>
584 </pre>
585 <button
586 type="button"
587 onClick={handleCopy}
588 className={cn(
589 "absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-2 transition-colors",
590 isTerminal ? "hover:bg-white/10" : "hover:bg-background/70 dark:hover:bg-black/20"
591 )}
592 aria-label="Copy command"
593 >
594 {copied ? (
595 <Check className={cn("h-4 w-4", isTerminal ? "text-green-400" : "text-emerald-400")} />
596 ) : (
597 <Copy className={cn("h-4 w-4", isTerminal ? "text-slate-400" : "text-text-muted dark:text-zinc-500")} />
598 )}
599 </button>
600 </div>
601 </div>
602
603 {!isTerminal && (
604 <div className="pt-1">
605 <button
606 type="button"
607 onClick={() => setShowAdvanced((current) => !current)}
608 className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground dark:text-zinc-500 dark:hover:text-zinc-300"
609 >
610 {showAdvanced ? "Hide advanced options" : "Advanced options"}
611 </button>
612 {showAdvanced && (
613 <div className="mt-3 space-y-4 rounded-md border border-border bg-muted/45 px-4 py-4 dark:border-white/8 dark:bg-background/70">
614 <label className="flex items-center gap-2 text-sm text-foreground dark:text-zinc-300">
615 <input
616 type="checkbox"
617 checked={discoveryEnabled}
618 onChange={(event) => {
619 const nextEnabled = event.target.checked;
620 setDiscoveryEnabled(nextEnabled);
621 if (!nextEnabled && relayUrls.length === 0) {
622 setRelayUrls([currentOrigin]);
623 }
624 }}
625 className="h-4 w-4"
626 />
627 <span>Enable relay discovery</span>
628 </label>
629
630 <div className="space-y-2">
631 <label className="flex items-center gap-2 text-sm text-foreground dark:text-zinc-300">
632 <input
633 type="checkbox"
634 checked={enableUDP}
635 onChange={(event) => {
636 const nextEnabled = event.target.checked;
637 setEnableUDP(nextEnabled);
638 if (!nextEnabled) {
639 setUDPPort("");
640 }
641 }}
642 className="h-4 w-4"
643 />
644 <span>Enable UDP transport</span>
645 </label>
646 {enableUDP && (
647 <div className="space-y-1.5">
648 <Input
649 id={`${inputId}-udp-port`}
650 type="text"
651 value={udpPort}
652 onChange={(event) => setUDPPort(event.target.value)}
653 placeholder={target.trim() || DEFAULT_HOST}
654 className={advancedInputClass}
655 />
656 <p className={helpTextClass}>
657 Local UDP port to forward. Defaults to the same as Host.
658 </p>
659 </div>
660 )}
661 </div>
662
663 <div className="space-y-2">
664 <label
665 htmlFor={`${inputId}-thumbnail`}
666 className="text-sm font-medium text-foreground dark:text-zinc-300"
667 >
668 Thumbnail URL
669 </label>
670 <Input
671 id={`${inputId}-thumbnail`}
672 type="url"
673 value={thumbnailURL}
674 onChange={(event) => setThumbnailURL(event.target.value)}
675 placeholder="https://cdn.example.com/thumb.png"
676 className={advancedInputClass}
677 />
678 {normalizedThumbnailURL && (
679 <div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border border-border bg-background dark:border-white/10 dark:bg-background">
680 <img
681 src={normalizedThumbnailURL}
682 alt="Thumbnail preview"
683 className="h-full w-full object-cover"
684 />
685 </div>
686 )}
687 {thumbnailError && (
688 <p className="text-xs text-destructive">{thumbnailError}</p>
689 )}
690 </div>
691 </div>
692 )}
693 </div>
694 )}
695 </div>
696 );
697 }