| 1 | <script lang="ts"> |
| 2 | import { onMount } from 'svelte'; |
| 3 | import { |
| 4 | buildTunnelDisplayCommand, |
| 5 | buildTunnelPreviewURL, |
| 6 | RELAY_ORIGIN, |
| 7 | type TunnelCommandOS |
| 8 | } from '$lib/tunnel-command'; |
| 9 | import { buildDefaultExposeName, resolveExposeName } from '$lib/expose-name'; |
| 10 | |
| 11 | const DEFAULT_HOST = '3000'; |
| 12 | |
| 13 | let target = $state('3000'); |
| 14 | let os: TunnelCommandOS = $state('unix'); |
| 15 | let name = $state(''); |
| 16 | let nameSeed = $state(''); |
| 17 | let copied = $state(false); |
| 18 | |
| 19 | onMount(() => { |
| 20 | nameSeed = crypto.randomUUID(); |
| 21 | }); |
| 22 | |
| 23 | const generatedName = $derived(buildDefaultExposeName(target, nameSeed)); |
| 24 | const effectiveName = $derived(name.trim() || generatedName); |
| 25 | |
| 26 | const installBlock = $derived.by(() => { |
| 27 | const cmd = buildTunnelDisplayCommand({ |
| 28 | currentOrigin: RELAY_ORIGIN, |
| 29 | target, |
| 30 | name: effectiveName, |
| 31 | nameSeed, |
| 32 | relayUrls: [RELAY_ORIGIN], |
| 33 | discovery: true, |
| 34 | thumbnailURL: '', |
| 35 | os |
| 36 | }); |
| 37 | const lines = cmd.split('\n'); |
| 38 | // Install is first line(s), expose is the rest |
| 39 | if (os === 'windows') { |
| 40 | // Windows: first two lines are install |
| 41 | return lines.slice(0, 2).join('\n'); |
| 42 | } |
| 43 | return lines[0] ?? ''; |
| 44 | }); |
| 45 | |
| 46 | const runBlock = $derived.by(() => { |
| 47 | const cmd = buildTunnelDisplayCommand({ |
| 48 | currentOrigin: RELAY_ORIGIN, |
| 49 | target, |
| 50 | name: effectiveName, |
| 51 | nameSeed, |
| 52 | relayUrls: [RELAY_ORIGIN], |
| 53 | discovery: true, |
| 54 | thumbnailURL: '', |
| 55 | os |
| 56 | }); |
| 57 | const lines = cmd.split('\n'); |
| 58 | if (os === 'windows') { |
| 59 | return lines.slice(2).join('\n'); |
| 60 | } |
| 61 | return lines.slice(1).join('\n'); |
| 62 | }); |
| 63 | |
| 64 | const previewURL = $derived( |
| 65 | buildTunnelPreviewURL(RELAY_ORIGIN, effectiveName, target, nameSeed) |
| 66 | ); |
| 67 | |
| 68 | function handleCopy() { |
| 69 | const fullCommand = installBlock + '\n' + runBlock; |
| 70 | navigator.clipboard.writeText(fullCommand).then(() => { |
| 71 | copied = true; |
| 72 | setTimeout(() => { |
| 73 | copied = false; |
| 74 | }, 2000); |
| 75 | }); |
| 76 | } |
| 77 | |
| 78 | function handleShuffleName() { |
| 79 | nameSeed = crypto.randomUUID(); |
| 80 | name = ''; |
| 81 | } |
| 82 | |
| 83 | function handleNameChange(event: Event) { |
| 84 | name = (event.target as HTMLInputElement).value; |
| 85 | } |
| 86 | </script> |
| 87 | |
| 88 | <div id="quick-start" class="relative mt-8 scroll-mt-24 sm:mt-10"> |
| 89 | <div class="mx-auto w-full max-w-6xl text-left"> |
| 90 | <div class="space-y-2"> |
| 91 | <p |
| 92 | class="text-sm font-semibold uppercase tracking-[0.3em]" |
| 93 | style="color: var(--neon-cyan);" |
| 94 | > |
| 95 | Quick Start |
| 96 | </p> |
| 97 | <h2 |
| 98 | class="text-3xl font-semibold tracking-tight" |
| 99 | style="color: var(--foreground);" |
| 100 | > |
| 101 | Expose service |
| 102 | </h2> |
| 103 | </div> |
| 104 | |
| 105 | <div |
| 106 | class="relative mx-auto mt-4 w-full max-w-[520px] rounded-xl border px-4 py-5 sm:px-5 sm:py-6" |
| 107 | style="background: var(--hero-terminal-bg); border-color: var(--hero-terminal-border); color: var(--hero-terminal-foreground); box-shadow: 0 30px 72px var(--hero-terminal-shadow);" |
| 108 | > |
| 109 | <div class="mb-5 flex min-w-0 items-center gap-3"> |
| 110 | <span |
| 111 | aria-hidden="true" |
| 112 | class="shrink-0 font-mono text-lg leading-none" |
| 113 | style="color: var(--hero-terminal-accent);" |
| 114 | > |
| 115 | {'>'} |
| 116 | </span> |
| 117 | <h3 |
| 118 | id="tunnel-preview" |
| 119 | class="min-w-0 text-xl font-bold tracking-tight sm:text-2xl" |
| 120 | > |
| 121 | Run this command |
| 122 | </h3> |
| 123 | </div> |
| 124 | |
| 125 | <div class="space-y-5"> |
| 126 | <!-- 1. Start your local app --> |
| 127 | <div class="space-y-2"> |
| 128 | <div class="space-y-1.5"> |
| 129 | <p class="text-[13px] font-semibold tracking-[0.04em] text-slate-100 sm:text-sm"> |
| 130 | 1. Start your local app |
| 131 | <span class="ml-1 normal-case tracking-normal text-slate-400"> |
| 132 | (e.g. |
| 133 | <span class="mx-1 font-mono text-slate-200">localhost:3000</span> |
| 134 | ) |
| 135 | </span> |
| 136 | </p> |
| 137 | </div> |
| 138 | </div> |
| 139 | |
| 140 | <!-- 2. Run this command --> |
| 141 | <div class="space-y-3"> |
| 142 | <div class="flex flex-wrap items-center justify-between gap-3"> |
| 143 | <p class="text-[13px] font-semibold tracking-[0.04em] text-slate-100 sm:text-sm"> |
| 144 | 2. Run this command |
| 145 | </p> |
| 146 | <div |
| 147 | class="flex shrink-0 rounded-lg border p-0.5" |
| 148 | style="border-color: rgba(255,255,255,0.06); background: rgba(255,255,255,0.035);" |
| 149 | > |
| 150 | <button |
| 151 | type="button" |
| 152 | onclick={() => { |
| 153 | os = 'unix'; |
| 154 | }} |
| 155 | class="min-w-[72px] whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold transition-colors {os === |
| 156 | 'unix' |
| 157 | ? 'bg-white/[0.08] text-slate-200' |
| 158 | : 'text-slate-500 hover:text-slate-300'}" |
| 159 | > |
| 160 | Linux |
| 161 | </button> |
| 162 | <button |
| 163 | type="button" |
| 164 | onclick={() => { |
| 165 | os = 'windows'; |
| 166 | }} |
| 167 | class="min-w-[72px] whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold transition-colors {os === |
| 168 | 'windows' |
| 169 | ? 'bg-white/[0.08] text-slate-200' |
| 170 | : 'text-slate-500 hover:text-slate-300'}" |
| 171 | > |
| 172 | Windows |
| 173 | </button> |
| 174 | </div> |
| 175 | </div> |
| 176 | |
| 177 | <!-- Port + Name controls --> |
| 178 | <div class="flex flex-wrap items-center gap-x-4 gap-y-2 sm:flex-nowrap"> |
| 179 | <div class="flex shrink-0 items-center gap-2"> |
| 180 | <span |
| 181 | class="shrink-0 text-[9px] font-semibold uppercase tracking-[0.16em] text-slate-500" |
| 182 | > |
| 183 | Port |
| 184 | </span> |
| 185 | <input |
| 186 | type="text" |
| 187 | bind:value={target} |
| 188 | placeholder={DEFAULT_HOST} |
| 189 | aria-label="Local port or address" |
| 190 | class="h-auto w-[76px] border-0 bg-transparent px-0 py-0 font-mono text-[13px] text-slate-200 shadow-none outline-none placeholder:text-slate-600" |
| 191 | /> |
| 192 | </div> |
| 193 | <div class="ml-auto flex min-w-0 items-center justify-end gap-2 sm:w-88"> |
| 194 | <span |
| 195 | class="shrink-0 text-[9px] font-semibold uppercase tracking-[0.16em] text-slate-500" |
| 196 | > |
| 197 | Name |
| 198 | </span> |
| 199 | <input |
| 200 | type="text" |
| 201 | oninput={handleNameChange} |
| 202 | placeholder={generatedName} |
| 203 | aria-label="Public name" |
| 204 | class="min-w-0 flex-1 border-0 bg-transparent px-0 py-0 text-[13px] text-slate-200 shadow-none outline-none placeholder:text-slate-600" |
| 205 | /> |
| 206 | <button |
| 207 | type="button" |
| 208 | onclick={handleShuffleName} |
| 209 | class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-white/[0.06] hover:text-slate-200" |
| 210 | aria-label="Shuffle public name" |
| 211 | title="Shuffle public name" |
| 212 | > |
| 213 | <svg |
| 214 | class="h-4 w-4" |
| 215 | fill="none" |
| 216 | viewBox="0 0 24 24" |
| 217 | stroke="currentColor" |
| 218 | stroke-width="2" |
| 219 | stroke-linecap="round" |
| 220 | stroke-linejoin="round" |
| 221 | > |
| 222 | <polyline points="23 4 23 10 17 10" /> |
| 223 | <polyline points="1 20 1 14 7 14" /> |
| 224 | <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" /> |
| 225 | <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" /> |
| 226 | </svg> |
| 227 | </button> |
| 228 | </div> |
| 229 | </div> |
| 230 | |
| 231 | <!-- Command block --> |
| 232 | <div |
| 233 | class="relative min-h-[148px] rounded-xl border px-4 py-4 pr-14 font-mono text-sm leading-7" |
| 234 | style="border-color: rgba(255,255,255,0.1); background: rgba(0,0,0,0.55); color: white; box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);" |
| 235 | > |
| 236 | <button |
| 237 | type="button" |
| 238 | onclick={handleCopy} |
| 239 | class="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-emerald-400/10" |
| 240 | style="color: rgba(110,231,183,0.75);" |
| 241 | aria-label="Copy command" |
| 242 | title={copied ? 'Copied' : 'Copy'} |
| 243 | > |
| 244 | {#if copied} |
| 245 | <svg |
| 246 | class="h-4 w-4" |
| 247 | fill="none" |
| 248 | viewBox="0 0 24 24" |
| 249 | stroke="currentColor" |
| 250 | stroke-width="2" |
| 251 | stroke-linecap="round" |
| 252 | stroke-linejoin="round" |
| 253 | > |
| 254 | <polyline points="20 6 9 17 4 12" /> |
| 255 | </svg> |
| 256 | {:else} |
| 257 | <svg |
| 258 | class="h-4 w-4" |
| 259 | fill="none" |
| 260 | viewBox="0 0 24 24" |
| 261 | stroke="currentColor" |
| 262 | stroke-width="2" |
| 263 | stroke-linecap="round" |
| 264 | stroke-linejoin="round" |
| 265 | > |
| 266 | <rect x="9" y="9" width="13" height="13" rx="2" ry="2" /> |
| 267 | <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> |
| 268 | </svg> |
| 269 | {/if} |
| 270 | </button> |
| 271 | <pre class="overflow-x-auto whitespace-pre-wrap break-all"><span class="block">{installBlock}</span><span class="mt-2 block">{runBlock}</span></pre> |
| 272 | </div> |
| 273 | </div> |
| 274 | |
| 275 | <!-- 3. Open this public URL --> |
| 276 | <div class="space-y-2 pt-1"> |
| 277 | <p class="text-[13px] font-semibold tracking-[0.04em] text-slate-100 sm:text-sm"> |
| 278 | 3. Open this public URL |
| 279 | </p> |
| 280 | <div |
| 281 | class="space-y-3 rounded-xl border px-3.5 py-3" |
| 282 | style="border-color: rgba(255,255,255,0.08); background: rgba(255,255,255,0.045);" |
| 283 | > |
| 284 | <a |
| 285 | href={previewURL} |
| 286 | target="_blank" |
| 287 | rel="noopener noreferrer" |
| 288 | class="block overflow-x-auto whitespace-nowrap font-mono text-[15px] font-medium text-sky-300 underline-offset-4 hover:underline sm:text-base" |
| 289 | > |
| 290 | {previewURL} |
| 291 | </a> |
| 292 | </div> |
| 293 | </div> |
| 294 | </div> |
| 295 | </div> |
| 296 | </div> |
| 297 | </div> |