+36
.github/workflows/deploy.yml
+36
.github/workflows/deploy.yml
···
1
+
name: Deploy
2
+
on:
3
+
push:
4
+
branches: main
5
+
pull_request:
6
+
branches: main
7
+
8
+
jobs:
9
+
deploy:
10
+
name: Deploy
11
+
runs-on: ubuntu-latest
12
+
13
+
permissions:
14
+
id-token: write # Needed for auth with Deno Deploy
15
+
contents: read # Needed to clone the repository
16
+
17
+
steps:
18
+
- name: Clone repository
19
+
uses: actions/checkout@v4
20
+
21
+
- name: Install Deno
22
+
uses: denoland/setup-deno@v2
23
+
with:
24
+
deno-version: v2.x
25
+
26
+
- name: Build step
27
+
run: "deno task build"
28
+
29
+
- name: Upload to Deno Deploy
30
+
uses: denoland/deployctl@v1
31
+
with:
32
+
project: "roscoerubin-airport-67"
33
+
entrypoint: "main.ts"
34
+
root: "."
35
+
36
+
+4
-11
README.md
+4
-11
README.md
···
1
1
# Airport
2
2
3
-
Your terminal for seamless AT Protocol PDS (Personal Data Server) migration and
4
-
backup.
3
+
Your terminal for seamless AT Protocol PDS (Personal Data Server) migration and backup.
5
4
6
-
Airport is a web application built with Fresh and Deno that helps users safely
7
-
migrate and backup their Bluesky PDS data. It provides a user-friendly interface
8
-
for managing your AT Protocol data.
5
+
Airport is a web application built with Fresh and Deno that helps users safely migrate and backup their Bluesky PDS data. It provides a user-friendly interface for managing your AT Protocol data.
9
6
10
7
## Features
11
8
···
33
30
34
31
## About
35
32
36
-
Airport is developed with โค๏ธ by [Roscoe](https://bsky.app/profile/knotbin.com)
37
-
for [Spark](https://sprk.so), a new short-video platform for AT Protocol.
33
+
Airport is developed with โค๏ธ by [Roscoe](https://bsky.app/profile/knotbin.com) for [Spark](https://sprk.so), a new short-video platform for AT Protocol.
38
34
39
35
## Contributing
40
36
41
-
We welcome contributions! Please feel free to submit a Pull Request. Please only
42
-
submit pull requests that are relevant to the project. This project targets
43
-
people with a non-advanced understanding of AT Protocol, so please avoid
44
-
submitting pull requests that add features that complicate the user experience.
37
+
We welcome contributions! Please feel free to submit a Pull Request. Please only submit pull requests that are relevant to the project. This project targets people with a non-advanced understanding of AT Protocol, so please avoid submitting pull requests that add features that complicate the user experience.
45
38
46
39
## License
47
40
+11
-33
components/Button.tsx
+11
-33
components/Button.tsx
···
9
9
condensed?: boolean;
10
10
};
11
11
12
-
type ButtonProps =
13
-
& ButtonBaseProps
14
-
& Omit<JSX.HTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps>;
15
-
type AnchorProps =
16
-
& ButtonBaseProps
17
-
& Omit<JSX.HTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps>
18
-
& { href: string };
12
+
type ButtonProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps>;
13
+
type AnchorProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps> & { href: string };
19
14
20
15
/**
21
16
* The button props or anchor props for a button or link.
···
30
25
* @component
31
26
*/
32
27
export function Button(props: Props) {
33
-
const {
34
-
color = "blue",
35
-
icon,
36
-
iconAlt,
37
-
label,
38
-
className = "",
39
-
condensed = false,
40
-
...rest
41
-
} = props;
42
-
const isAnchor = "href" in props;
28
+
const { color = "blue", icon, iconAlt, label, className = "", condensed = false, ...rest } = props;
29
+
const isAnchor = 'href' in props;
43
30
44
31
const baseStyles = "airport-sign flex items-center [transition:none]";
45
-
const paddingStyles = condensed ? "px-2 py-1.5" : "px-3 py-2 sm:px-6 sm:py-3";
46
-
const transformStyles =
47
-
"translate-y-0 hover:translate-y-1 hover:transition-transform hover:duration-200 hover:ease-in-out";
32
+
const paddingStyles = condensed ? 'px-2 py-1.5' : 'px-3 py-2 sm:px-6 sm:py-3';
33
+
const transformStyles = "translate-y-0 hover:translate-y-1 hover:transition-transform hover:duration-200 hover:ease-in-out";
48
34
const colorStyles = {
49
-
blue:
50
-
"bg-gradient-to-r from-blue-400 to-blue-500 text-white hover:from-blue-500 hover:to-blue-600",
51
-
amber:
52
-
"bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 hover:from-amber-500 hover:to-amber-600",
35
+
blue: "bg-gradient-to-r from-blue-400 to-blue-500 text-white hover:from-blue-500 hover:to-blue-600",
36
+
amber: "bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 hover:from-amber-500 hover:to-amber-600",
53
37
};
54
38
55
39
const buttonContent = (
···
58
42
<img
59
43
src={icon}
60
44
alt={iconAlt || ""}
61
-
className={`${condensed ? "w-4 h-4" : "w-6 h-6"} mr-2`}
62
-
style={{
63
-
filter: color === "blue"
64
-
? "brightness(0) invert(1)"
65
-
: "brightness(0)",
66
-
}}
45
+
className={`${condensed ? 'w-4 h-4' : 'w-6 h-6'} mr-2`}
46
+
style={{ filter: color === 'blue' ? "brightness(0) invert(1)" : "brightness(0)" }}
67
47
/>
68
48
)}
69
49
{label && (
···
74
54
</>
75
55
);
76
56
77
-
const buttonStyles = `${baseStyles} ${paddingStyles} ${transformStyles} ${
78
-
colorStyles[color]
79
-
} ${className}`;
57
+
const buttonStyles = `${baseStyles} ${paddingStyles} ${transformStyles} ${colorStyles[color]} ${className}`;
80
58
81
59
if (isAnchor) {
82
60
return (
-77
components/MigrationCompletion.tsx
-77
components/MigrationCompletion.tsx
···
1
-
export interface MigrationCompletionProps {
2
-
isVisible: boolean;
3
-
}
4
-
5
-
export default function MigrationCompletion(
6
-
{ isVisible }: MigrationCompletionProps,
7
-
) {
8
-
if (!isVisible) return null;
9
-
10
-
const handleLogout = async () => {
11
-
try {
12
-
const response = await fetch("/api/logout", {
13
-
method: "POST",
14
-
credentials: "include",
15
-
});
16
-
if (!response.ok) {
17
-
throw new Error("Logout failed");
18
-
}
19
-
globalThis.location.href = "/";
20
-
} catch (error) {
21
-
console.error("Failed to logout:", error);
22
-
}
23
-
};
24
-
25
-
return (
26
-
<div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800">
27
-
<p class="text-sm text-green-800 dark:text-green-200 pb-2">
28
-
Migration completed successfully! Sign out to finish the process and
29
-
return home.<br />
30
-
Please consider donating to Airport to support server and development
31
-
costs.
32
-
</p>
33
-
<div class="flex space-x-4">
34
-
<button
35
-
type="button"
36
-
onClick={handleLogout}
37
-
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
38
-
>
39
-
<svg
40
-
class="w-5 h-5"
41
-
fill="none"
42
-
stroke="currentColor"
43
-
viewBox="0 0 24 24"
44
-
>
45
-
<path
46
-
stroke-linecap="round"
47
-
stroke-linejoin="round"
48
-
stroke-width="2"
49
-
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
50
-
/>
51
-
</svg>
52
-
<span>Sign Out</span>
53
-
</button>
54
-
<a
55
-
href="https://ko-fi.com/knotbin"
56
-
target="_blank"
57
-
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
58
-
>
59
-
<svg
60
-
class="w-5 h-5"
61
-
fill="none"
62
-
stroke="currentColor"
63
-
viewBox="0 0 24 24"
64
-
>
65
-
<path
66
-
stroke-linecap="round"
67
-
stroke-linejoin="round"
68
-
stroke-width="2"
69
-
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
70
-
/>
71
-
</svg>
72
-
<span>Support Us</span>
73
-
</a>
74
-
</div>
75
-
</div>
76
-
);
77
-
}
-208
components/MigrationStep.tsx
-208
components/MigrationStep.tsx
···
1
-
import { IS_BROWSER } from "fresh/runtime";
2
-
import { ComponentChildren } from "preact";
3
-
4
-
export type StepStatus =
5
-
| "pending"
6
-
| "in-progress"
7
-
| "verifying"
8
-
| "completed"
9
-
| "error";
10
-
11
-
export interface MigrationStepProps {
12
-
name: string;
13
-
status: StepStatus;
14
-
error?: string;
15
-
isVerificationError?: boolean;
16
-
index: number;
17
-
onRetryVerification?: (index: number) => void;
18
-
children?: ComponentChildren;
19
-
}
20
-
21
-
export function MigrationStep({
22
-
name,
23
-
status,
24
-
error,
25
-
isVerificationError,
26
-
index,
27
-
onRetryVerification,
28
-
children,
29
-
}: MigrationStepProps) {
30
-
return (
31
-
<div key={name} class={getStepClasses(status)}>
32
-
{getStepIcon(status)}
33
-
<div class="flex-1">
34
-
<p
35
-
class={`font-medium ${
36
-
status === "error"
37
-
? "text-red-900 dark:text-red-200"
38
-
: status === "completed"
39
-
? "text-green-900 dark:text-green-200"
40
-
: status === "in-progress"
41
-
? "text-blue-900 dark:text-blue-200"
42
-
: "text-gray-900 dark:text-gray-200"
43
-
}`}
44
-
>
45
-
{getStepDisplayName(
46
-
{ name, status, error, isVerificationError },
47
-
index,
48
-
)}
49
-
</p>
50
-
{error && (
51
-
<div class="mt-1">
52
-
<p class="text-sm text-red-600 dark:text-red-400">
53
-
{(() => {
54
-
try {
55
-
const err = JSON.parse(error);
56
-
return err.message || error;
57
-
} catch {
58
-
return error;
59
-
}
60
-
})()}
61
-
</p>
62
-
{isVerificationError && onRetryVerification && (
63
-
<div class="flex space-x-2 mt-2">
64
-
<button
65
-
type="button"
66
-
onClick={() => onRetryVerification(index)}
67
-
class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors duration-200 dark:bg-blue-500 dark:hover:bg-blue-400"
68
-
disabled={!IS_BROWSER}
69
-
>
70
-
Retry Verification
71
-
</button>
72
-
</div>
73
-
)}
74
-
</div>
75
-
)}
76
-
{children}
77
-
</div>
78
-
</div>
79
-
);
80
-
}
81
-
82
-
function getStepDisplayName(
83
-
step: Pick<
84
-
MigrationStepProps,
85
-
"name" | "status" | "error" | "isVerificationError"
86
-
>,
87
-
index: number,
88
-
) {
89
-
if (step.status === "completed") {
90
-
switch (index) {
91
-
case 0:
92
-
return "Account Created";
93
-
case 1:
94
-
return "Data Migrated";
95
-
case 2:
96
-
return "Identity Migrated";
97
-
case 3:
98
-
return "Migration Finalized";
99
-
}
100
-
}
101
-
102
-
if (step.status === "in-progress") {
103
-
switch (index) {
104
-
case 0:
105
-
return "Creating your new account...";
106
-
case 1:
107
-
return "Migrating your data...";
108
-
case 2:
109
-
return step.name ===
110
-
"Enter the token sent to your email to complete identity migration"
111
-
? step.name
112
-
: "Migrating your identity...";
113
-
case 3:
114
-
return "Finalizing migration...";
115
-
}
116
-
}
117
-
118
-
if (step.status === "verifying") {
119
-
switch (index) {
120
-
case 0:
121
-
return "Verifying account creation...";
122
-
case 1:
123
-
return "Verifying data migration...";
124
-
case 2:
125
-
return "Verifying identity migration...";
126
-
case 3:
127
-
return "Verifying migration completion...";
128
-
}
129
-
}
130
-
131
-
return step.name;
132
-
}
133
-
134
-
function getStepIcon(status: StepStatus) {
135
-
switch (status) {
136
-
case "pending":
137
-
return (
138
-
<div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center">
139
-
<div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" />
140
-
</div>
141
-
);
142
-
case "in-progress":
143
-
return (
144
-
<div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center">
145
-
<div class="w-3 h-3 rounded-full bg-blue-500" />
146
-
</div>
147
-
);
148
-
case "verifying":
149
-
return (
150
-
<div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center">
151
-
<div class="w-3 h-3 rounded-full bg-yellow-500" />
152
-
</div>
153
-
);
154
-
case "completed":
155
-
return (
156
-
<div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center">
157
-
<svg
158
-
class="w-5 h-5 text-white"
159
-
fill="none"
160
-
stroke="currentColor"
161
-
viewBox="0 0 24 24"
162
-
>
163
-
<path
164
-
stroke-linecap="round"
165
-
stroke-linejoin="round"
166
-
stroke-width="2"
167
-
d="M5 13l4 4L19 7"
168
-
/>
169
-
</svg>
170
-
</div>
171
-
);
172
-
case "error":
173
-
return (
174
-
<div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center">
175
-
<svg
176
-
class="w-5 h-5 text-white"
177
-
fill="none"
178
-
stroke="currentColor"
179
-
viewBox="0 0 24 24"
180
-
>
181
-
<path
182
-
stroke-linecap="round"
183
-
stroke-linejoin="round"
184
-
stroke-width="2"
185
-
d="M6 18L18 6M6 6l12 12"
186
-
/>
187
-
</svg>
188
-
</div>
189
-
);
190
-
}
191
-
}
192
-
193
-
function getStepClasses(status: StepStatus) {
194
-
const baseClasses =
195
-
"flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200";
196
-
switch (status) {
197
-
case "pending":
198
-
return `${baseClasses} bg-gray-50 dark:bg-gray-800`;
199
-
case "in-progress":
200
-
return `${baseClasses} bg-blue-50 dark:bg-blue-900`;
201
-
case "verifying":
202
-
return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`;
203
-
case "completed":
204
-
return `${baseClasses} bg-green-50 dark:bg-green-900`;
205
-
case "error":
206
-
return `${baseClasses} bg-red-50 dark:bg-red-900`;
207
-
}
208
-
}
+3
-3
deno.json
+3
-3
deno.json
···
2
2
"tasks": {
3
3
"check": "deno fmt --check . && deno lint . && deno check **/*.ts && deno check **/*.tsx",
4
4
"dev": "deno run -A --env --watch=static/,routes/ dev.ts",
5
-
"build": "deno run -A dev.ts build",
6
-
"start": "deno run -A main.ts",
5
+
"build": "deno run -A --unstable-otel dev.ts build",
6
+
"start": "deno run -A --unstable-otel main.ts",
7
7
"update": "deno run -A -r jsr:@fresh/update ."
8
8
},
9
9
"lint": {
···
55
55
"kv",
56
56
"otel"
57
57
]
58
-
}
58
+
}
+161
-159
islands/DidPlcProgress.tsx
+161
-159
islands/DidPlcProgress.tsx
···
1
-
import { useState } from "preact/hooks";
1
+
import { useState, useEffect } from "preact/hooks";
2
2
import { Link } from "../components/Link.tsx";
3
3
4
4
interface PlcUpdateStep {
5
5
name: string;
6
6
status: "pending" | "in-progress" | "verifying" | "completed" | "error";
7
7
error?: string;
8
-
}
9
-
10
-
interface KeyJson {
11
-
publicKeyDid: string;
12
-
[key: string]: unknown;
13
8
}
14
9
15
10
// Content chunks for the description
···
102
97
account and identity
103
98
</li>
104
99
<li>
105
-
Store securely, like a password (e.g. <strong>DO NOT</strong>
106
-
{" "}
100
+
Store securely, like a password (e.g. <strong>DO NOT</strong>{" "}
107
101
keep it in Notes or any easily accessible app on an unlocked
108
102
device).
109
103
</li>
···
113
107
<div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
114
108
<p class="text-slate-700 dark:text-slate-300">
115
109
๐ก We recommend adding a custom rotation key but recommend{" "}
116
-
<strong class="italic">against</strong>{" "}
117
-
having more than one custom rotation key, as more than one increases
118
-
risk.
110
+
<strong class="italic">against</strong> having more than one custom
111
+
rotation key, as more than one increases risk.
119
112
</p>
120
113
</div>
121
114
</>
···
163
156
{ name: "Complete PLC update", status: "pending" },
164
157
]);
165
158
const [generatedKey, setGeneratedKey] = useState<string>("");
166
-
const [keyJson, setKeyJson] = useState<KeyJson | null>(null);
159
+
const [keyJson, setKeyJson] = useState<any>(null);
167
160
const [emailToken, setEmailToken] = useState<string>("");
161
+
const [updateResult, setUpdateResult] = useState<string>("");
162
+
const [showDownload, setShowDownload] = useState(false);
163
+
const [showKeyInfo, setShowKeyInfo] = useState(false);
168
164
const [hasDownloadedKey, setHasDownloadedKey] = useState(false);
169
165
const [downloadedKeyId, setDownloadedKeyId] = useState<string | null>(null);
170
166
171
167
const updateStepStatus = (
172
168
index: number,
173
169
status: PlcUpdateStep["status"],
174
-
error?: string,
170
+
error?: string
175
171
) => {
176
172
console.log(
177
173
`Updating step ${index} to ${status}${
178
174
error ? ` with error: ${error}` : ""
179
-
}`,
175
+
}`
180
176
);
181
177
setSteps((prevSteps) =>
182
178
prevSteps.map((step, i) =>
···
217
213
return "Requesting PLC Operation Token...";
218
214
case 2:
219
215
return step.name ===
220
-
"Enter the code sent to your email to complete PLC update"
216
+
"Enter the code sent to your email to complete PLC update"
221
217
? step.name
222
218
: "Completing PLC Update...";
223
219
}
···
268
264
updateStepStatus(
269
265
1,
270
266
"error",
271
-
"Please ensure you have the correct key loaded",
267
+
"Please ensure you have the correct key loaded"
272
268
);
273
269
return;
274
270
}
···
298
294
if (!data.success) {
299
295
throw new Error(data.message || "Failed to request token");
300
296
}
301
-
} catch {
297
+
} catch (error) {
302
298
throw new Error("Invalid response from server");
303
299
}
304
300
···
308
304
prevSteps.map((step, i) =>
309
305
i === 1
310
306
? {
311
-
...step,
312
-
name: "Enter the code sent to your email to complete PLC update",
313
-
status: "in-progress",
314
-
}
307
+
...step,
308
+
name: "Enter the code sent to your email to complete PLC update",
309
+
status: "in-progress",
310
+
}
315
311
: step
316
312
)
317
313
);
···
320
316
updateStepStatus(
321
317
1,
322
318
"error",
323
-
error instanceof Error ? error.message : String(error),
319
+
error instanceof Error ? error.message : String(error)
324
320
);
325
321
}
326
322
};
···
384
380
385
381
// Only proceed if we have a successful response
386
382
console.log("Update completed successfully!");
383
+
setUpdateResult("PLC update completed successfully!");
387
384
388
385
// Add a delay before marking steps as completed for better UX
389
386
updateStepStatus(2, "verifying");
···
407
404
}
408
405
409
406
if (!verifyRes.ok || !verifyData.success) {
410
-
const errorMessage = verifyData.message ||
411
-
"Failed to verify PLC update";
407
+
const errorMessage =
408
+
verifyData.message || "Failed to verify PLC update";
412
409
console.error("Verification failed:", errorMessage);
413
410
throw new Error(errorMessage);
414
411
}
···
421
418
updateStepStatus(
422
419
1,
423
420
"error",
424
-
error instanceof Error ? error.message : String(error),
421
+
error instanceof Error ? error.message : String(error)
425
422
);
426
423
updateStepStatus(2, "pending"); // Reset the final step
424
+
setUpdateResult(error instanceof Error ? error.message : String(error));
427
425
428
426
// If token is invalid, we should clear it so user can try again
429
427
if (
···
435
433
}
436
434
};
437
435
436
+
const handleCompletePlcUpdate = async () => {
437
+
// This function is no longer needed as we handle everything in handleTokenSubmit
438
+
return;
439
+
};
440
+
438
441
const handleDownload = () => {
439
442
console.log("=== Download Debug ===");
440
443
console.log("Download started with:", {
···
479
482
const handleGenerateKey = async () => {
480
483
console.log("=== Generate Key Debug ===");
481
484
updateStepStatus(0, "in-progress");
485
+
setShowDownload(false);
482
486
setKeyJson(null);
483
487
setGeneratedKey("");
484
488
setHasDownloadedKey(false);
···
516
520
517
521
setGeneratedKey(data.publicKeyDid);
518
522
setKeyJson(data);
523
+
setShowDownload(true);
519
524
updateStepStatus(0, "completed");
520
525
} catch (error) {
521
526
console.error("Key generation failed:", error);
522
527
updateStepStatus(
523
528
0,
524
529
"error",
525
-
error instanceof Error ? error.message : String(error),
530
+
error instanceof Error ? error.message : String(error)
526
531
);
527
532
}
528
533
};
···
635
640
updateStepStatus(
636
641
1,
637
642
"error",
638
-
error instanceof Error ? error.message : String(error),
643
+
error instanceof Error ? error.message : String(error)
639
644
);
640
645
}
641
646
};
···
661
666
<div class="mt-8 border-t border-dashed border-slate-200 dark:border-slate-700 pt-4">
662
667
<div class="flex justify-between items-center">
663
668
<button
664
-
type="button"
665
669
onClick={() =>
666
-
setCurrentChunkIndex((prev) => Math.max(0, prev - 1))}
670
+
setCurrentChunkIndex((prev) => Math.max(0, prev - 1))
671
+
}
667
672
class={`px-4 py-2 font-mono text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors duration-200 flex items-center space-x-2 ${
668
673
currentChunkIndex === 0 ? "invisible" : ""
669
674
}`}
···
684
689
<span>Previous Gate</span>
685
690
</button>
686
691
687
-
{currentChunkIndex === contentChunks.length - 1
688
-
? (
689
-
<button
690
-
type="button"
691
-
onClick={handleStart}
692
-
class="px-6 py-2 bg-amber-500 hover:bg-amber-600 text-white font-mono rounded-md transition-colors duration-200 flex items-center space-x-2"
692
+
{currentChunkIndex === contentChunks.length - 1 ? (
693
+
<button
694
+
onClick={handleStart}
695
+
class="px-6 py-2 bg-amber-500 hover:bg-amber-600 text-white font-mono rounded-md transition-colors duration-200 flex items-center space-x-2"
696
+
>
697
+
<span>Begin Key Generation</span>
698
+
<svg
699
+
class="w-5 h-5"
700
+
fill="none"
701
+
stroke="currentColor"
702
+
viewBox="0 0 24 24"
693
703
>
694
-
<span>Begin Key Generation</span>
695
-
<svg
696
-
class="w-5 h-5"
697
-
fill="none"
698
-
stroke="currentColor"
699
-
viewBox="0 0 24 24"
700
-
>
701
-
<path
702
-
stroke-linecap="round"
703
-
stroke-linejoin="round"
704
-
stroke-width="2"
705
-
d="M9 5l7 7-7 7"
706
-
/>
707
-
</svg>
708
-
</button>
709
-
)
710
-
: (
711
-
<button
712
-
type="button"
713
-
onClick={() =>
714
-
setCurrentChunkIndex((prev) =>
715
-
Math.min(contentChunks.length - 1, prev + 1)
716
-
)}
717
-
class="px-4 py-2 font-mono text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors duration-200 flex items-center space-x-2"
704
+
<path
705
+
stroke-linecap="round"
706
+
stroke-linejoin="round"
707
+
stroke-width="2"
708
+
d="M9 5l7 7-7 7"
709
+
/>
710
+
</svg>
711
+
</button>
712
+
) : (
713
+
<button
714
+
onClick={() =>
715
+
setCurrentChunkIndex((prev) =>
716
+
Math.min(contentChunks.length - 1, prev + 1)
717
+
)
718
+
}
719
+
class="px-4 py-2 font-mono text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors duration-200 flex items-center space-x-2"
720
+
>
721
+
<span>Next Gate</span>
722
+
<svg
723
+
class="w-5 h-5"
724
+
fill="none"
725
+
stroke="currentColor"
726
+
viewBox="0 0 24 24"
718
727
>
719
-
<span>Next Gate</span>
720
-
<svg
721
-
class="w-5 h-5"
722
-
fill="none"
723
-
stroke="currentColor"
724
-
viewBox="0 0 24 24"
725
-
>
726
-
<path
727
-
stroke-linecap="round"
728
-
stroke-linejoin="round"
729
-
stroke-width="2"
730
-
d="M9 5l7 7-7 7"
731
-
/>
732
-
</svg>
733
-
</button>
734
-
)}
728
+
<path
729
+
stroke-linecap="round"
730
+
stroke-linejoin="round"
731
+
stroke-width="2"
732
+
d="M9 5l7 7-7 7"
733
+
/>
734
+
</svg>
735
+
</button>
736
+
)}
735
737
</div>
736
738
737
739
{/* Progress Dots */}
···
763
765
</h3>
764
766
{/* Add a help tooltip */}
765
767
<div class="relative group">
766
-
<button class="text-gray-400 hover:text-gray-500" type="button">
768
+
<button class="text-gray-400 hover:text-gray-500">
767
769
<svg
768
770
class="w-5 h-5"
769
771
fill="none"
···
851
853
{index === 0 &&
852
854
step.status === "completed" &&
853
855
!hasDownloadedKey && (
854
-
<div class="mt-4 space-y-4">
855
-
<div class="bg-yellow-50 dark:bg-yellow-900/50 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800">
856
-
<div class="flex items-start">
857
-
<div class="flex-shrink-0">
858
-
<svg
859
-
class="h-5 w-5 text-yellow-400"
860
-
viewBox="0 0 20 20"
861
-
fill="currentColor"
862
-
>
863
-
<path
864
-
fill-rule="evenodd"
865
-
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
866
-
clip-rule="evenodd"
867
-
/>
868
-
</svg>
869
-
</div>
870
-
<div class="ml-3">
871
-
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
872
-
Critical Security Step
873
-
</h3>
874
-
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
875
-
<p class="mb-2">
876
-
Your rotation key grants control over your identity:
877
-
</p>
878
-
<ul class="list-disc pl-5 space-y-2">
879
-
<li>
880
-
<strong>Store Securely:</strong>{" "}
881
-
Use a password manager
882
-
</li>
883
-
<li>
884
-
<strong>Keep Private:</strong>{" "}
885
-
Never share with anyone
886
-
</li>
887
-
<li>
888
-
<strong>Backup:</strong> Keep a secure backup copy
889
-
</li>
890
-
<li>
891
-
<strong>Required:</strong>{" "}
892
-
Needed for future DID modifications
893
-
</li>
894
-
</ul>
856
+
<div class="mt-4 space-y-4">
857
+
<div class="bg-yellow-50 dark:bg-yellow-900/50 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800">
858
+
<div class="flex items-start">
859
+
<div class="flex-shrink-0">
860
+
<svg
861
+
class="h-5 w-5 text-yellow-400"
862
+
viewBox="0 0 20 20"
863
+
fill="currentColor"
864
+
>
865
+
<path
866
+
fill-rule="evenodd"
867
+
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
868
+
clip-rule="evenodd"
869
+
/>
870
+
</svg>
871
+
</div>
872
+
<div class="ml-3">
873
+
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
874
+
Critical Security Step
875
+
</h3>
876
+
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
877
+
<p class="mb-2">
878
+
Your rotation key grants control over your
879
+
identity:
880
+
</p>
881
+
<ul class="list-disc pl-5 space-y-2">
882
+
<li>
883
+
<strong>Store Securely:</strong> Use a password
884
+
manager
885
+
</li>
886
+
<li>
887
+
<strong>Keep Private:</strong> Never share with
888
+
anyone
889
+
</li>
890
+
<li>
891
+
<strong>Backup:</strong> Keep a secure backup
892
+
copy
893
+
</li>
894
+
<li>
895
+
<strong>Required:</strong> Needed for future DID
896
+
modifications
897
+
</li>
898
+
</ul>
899
+
</div>
895
900
</div>
896
901
</div>
897
902
</div>
898
-
</div>
899
903
900
-
<div class="flex items-center justify-between">
901
-
<button
902
-
type="button"
903
-
onClick={handleDownload}
904
-
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
905
-
>
906
-
<svg
907
-
class="w-5 h-5"
908
-
fill="none"
909
-
stroke="currentColor"
910
-
viewBox="0 0 24 24"
904
+
<div class="flex items-center justify-between">
905
+
<button
906
+
onClick={handleDownload}
907
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
911
908
>
912
-
<path
913
-
stroke-linecap="round"
914
-
stroke-linejoin="round"
915
-
stroke-width="2"
916
-
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
917
-
/>
918
-
</svg>
919
-
<span>Download Key</span>
920
-
</button>
909
+
<svg
910
+
class="w-5 h-5"
911
+
fill="none"
912
+
stroke="currentColor"
913
+
viewBox="0 0 24 24"
914
+
>
915
+
<path
916
+
stroke-linecap="round"
917
+
stroke-linejoin="round"
918
+
stroke-width="2"
919
+
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
920
+
/>
921
+
</svg>
922
+
<span>Download Key</span>
923
+
</button>
921
924
922
-
<div class="flex items-center text-sm text-red-600 dark:text-red-400">
923
-
<svg
924
-
class="w-4 h-4 mr-1"
925
-
fill="none"
926
-
stroke="currentColor"
927
-
viewBox="0 0 24 24"
928
-
>
929
-
<path
930
-
stroke-linecap="round"
931
-
stroke-linejoin="round"
932
-
stroke-width="2"
933
-
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
934
-
/>
935
-
</svg>
936
-
Download required to proceed
925
+
<div class="flex items-center text-sm text-red-600 dark:text-red-400">
926
+
<svg
927
+
class="w-4 h-4 mr-1"
928
+
fill="none"
929
+
stroke="currentColor"
930
+
viewBox="0 0 24 24"
931
+
>
932
+
<path
933
+
stroke-linecap="round"
934
+
stroke-linejoin="round"
935
+
stroke-width="2"
936
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
937
+
/>
938
+
</svg>
939
+
Download required to proceed
940
+
</div>
937
941
</div>
938
942
</div>
939
-
</div>
940
-
)}
943
+
)}
941
944
942
945
{/* Email Code Input */}
943
946
{index === 1 &&
944
947
(step.status === "in-progress" ||
945
948
step.status === "verifying") &&
946
949
step.name ===
947
-
"Enter the code sent to your email to complete PLC update" &&
948
-
(
950
+
"Enter the code sent to your email to complete PLC update" && (
949
951
<div class="mt-4 space-y-4">
950
952
<div class="bg-blue-50 dark:bg-blue-900/50 p-4 rounded-lg">
951
953
<p class="text-sm text-blue-800 dark:text-blue-200 mb-3">
···
958
960
type="text"
959
961
value={emailToken}
960
962
onChange={(e) =>
961
-
setEmailToken(e.currentTarget.value)}
963
+
setEmailToken(e.currentTarget.value)
964
+
}
962
965
placeholder="Enter verification code"
963
966
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400"
964
967
/>
···
1016
1019
a new code to try again.
1017
1020
</p>
1018
1021
<button
1019
-
type="button"
1020
1022
onClick={requestNewToken}
1021
1023
class="text-sm px-3 py-1 bg-red-100 hover:bg-red-200 dark:bg-red-800 dark:hover:bg-red-700 text-red-700 dark:text-red-200 rounded-md transition-colors duration-200 flex items-center space-x-1"
1022
1024
>
+37
-39
islands/Header.tsx
+37
-39
islands/Header.tsx
···
51
51
setUser(
52
52
userData
53
53
? {
54
-
did: userData.did,
55
-
handle: userData.handle,
56
-
}
57
-
: null,
54
+
did: userData.did,
55
+
handle: userData.handle,
56
+
}
57
+
: null
58
58
);
59
59
} catch (error) {
60
60
console.error("Failed to fetch user:", error);
···
117
117
118
118
{/* Check-in (Login/Profile) */}
119
119
<div className="relative">
120
-
{user?.did
121
-
? (
122
-
<div className="relative">
123
-
<Button
124
-
color="amber"
125
-
icon="/icons/account.svg"
126
-
iconAlt="Check-in"
127
-
label="CHECKED IN"
128
-
onClick={() => setShowDropdown(!showDropdown)}
129
-
/>
130
-
{showDropdown && (
131
-
<div className="absolute right-0 mt-2 w-64 bg-white dark:bg-slate-800 rounded-lg shadow-lg p-4 border border-slate-200 dark:border-slate-700">
132
-
<div className="text-sm font-mono mb-2 pb-2 border-b border-slate-900/10">
133
-
<div title={user.handle || "Anonymous"}>
134
-
{truncateText(user.handle || "Anonymous", 20)}
135
-
</div>
136
-
<div className="text-xs opacity-75" title={user.did}>
137
-
{truncateText(user.did, 25)}
138
-
</div>
139
-
</div>
140
-
<button
141
-
type="button"
142
-
onClick={handleLogout}
143
-
className="text-sm font-mono text-slate-900 hover:text-slate-700 w-full text-left transition-colors"
144
-
>
145
-
Sign Out
146
-
</button>
147
-
</div>
148
-
)}
149
-
</div>
150
-
)
151
-
: (
120
+
{user?.did ? (
121
+
<div className="relative">
152
122
<Button
153
-
href="/login"
154
123
color="amber"
155
124
icon="/icons/account.svg"
156
125
iconAlt="Check-in"
157
-
label="CHECK-IN"
126
+
label="CHECKED IN"
127
+
onClick={() => setShowDropdown(!showDropdown)}
158
128
/>
159
-
)}
129
+
{showDropdown && (
130
+
<div className="absolute right-0 mt-2 w-64 bg-white dark:bg-slate-800 rounded-lg shadow-lg p-4 border border-slate-200 dark:border-slate-700">
131
+
<div className="text-sm font-mono mb-2 pb-2 border-b border-slate-900/10">
132
+
<div title={user.handle || "Anonymous"}>
133
+
{truncateText(user.handle || "Anonymous", 20)}
134
+
</div>
135
+
<div className="text-xs opacity-75" title={user.did}>
136
+
{truncateText(user.did, 25)}
137
+
</div>
138
+
</div>
139
+
<button
140
+
type="button"
141
+
onClick={handleLogout}
142
+
className="text-sm font-mono text-slate-900 hover:text-slate-700 w-full text-left transition-colors"
143
+
>
144
+
Sign Out
145
+
</button>
146
+
</div>
147
+
)}
148
+
</div>
149
+
) : (
150
+
<Button
151
+
href="/login"
152
+
color="amber"
153
+
icon="/icons/account.svg"
154
+
iconAlt="Check-in"
155
+
label="CHECK-IN"
156
+
/>
157
+
)}
160
158
</div>
161
159
</div>
162
160
</div>
+3
-5
islands/LoginButton.tsx
+3
-5
islands/LoginButton.tsx
···
13
13
checkMobile();
14
14
15
15
// Listen for resize events
16
-
globalThis.addEventListener("resize", checkMobile);
17
-
return () => globalThis.removeEventListener("resize", checkMobile);
16
+
globalThis.addEventListener('resize', checkMobile);
17
+
return () => globalThis.removeEventListener('resize', checkMobile);
18
18
}, []);
19
19
20
20
return (
···
23
23
href={isMobile ? undefined : "/login"}
24
24
color="blue"
25
25
label={isMobile ? "MOBILE NOT SUPPORTED" : "GET STARTED"}
26
-
className={isMobile
27
-
? "opacity-50 cursor-not-allowed"
28
-
: "opacity-100 cursor-pointer"}
26
+
className={isMobile ? "opacity-50 cursor-not-allowed" : "opacity-100 cursor-pointer"}
29
27
onClick={(e: MouseEvent) => {
30
28
if (isMobile) {
31
29
e.preventDefault();
+15
-17
islands/LoginSelector.tsx
+15
-17
islands/LoginSelector.tsx
···
1
-
import { useState } from "preact/hooks";
2
-
import HandleInput from "./HandleInput.tsx";
3
-
import CredLogin from "./CredLogin.tsx";
1
+
import { useState } from "preact/hooks"
2
+
import HandleInput from "./HandleInput.tsx"
3
+
import CredLogin from "./CredLogin.tsx"
4
4
5
5
/**
6
6
* The login method selector for OAuth or Credential.
···
8
8
* @component
9
9
*/
10
10
export default function LoginMethodSelector() {
11
-
const [loginMethod, setLoginMethod] = useState<"oauth" | "password">(
12
-
"password",
13
-
);
11
+
const [loginMethod, setLoginMethod] = useState<'oauth' | 'password'>('password')
14
12
15
13
return (
16
14
<div className="flex flex-col gap-8">
···
20
18
<div className="flex gap-4 mb-6">
21
19
<button
22
20
type="button"
23
-
onClick={() => setLoginMethod("oauth")}
21
+
onClick={() => setLoginMethod('oauth')}
24
22
className={`flex-1 px-4 py-2 rounded-md transition-colors ${
25
-
loginMethod === "oauth"
26
-
? "bg-blue-500 text-white"
27
-
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
23
+
loginMethod === 'oauth'
24
+
? 'bg-blue-500 text-white'
25
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
28
26
}`}
29
27
>
30
28
OAuth
31
29
</button>
32
30
<button
33
31
type="button"
34
-
onClick={() => setLoginMethod("password")}
32
+
onClick={() => setLoginMethod('password')}
35
33
className={`flex-1 px-4 py-2 rounded-md transition-colors ${
36
-
loginMethod === "password"
37
-
? "bg-blue-500 text-white"
38
-
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
34
+
loginMethod === 'password'
35
+
? 'bg-blue-500 text-white'
36
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
39
37
}`}
40
38
>
41
39
Credential
42
40
</button>
43
41
</div>
44
42
45
-
{loginMethod === "oauth" && (
43
+
{loginMethod === 'oauth' && (
46
44
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 rounded-md text-sm">
47
45
Note: OAuth login cannot be used for migrations.
48
46
</div>
49
47
)}
50
48
51
-
{loginMethod === "oauth" ? <HandleInput /> : <CredLogin />}
49
+
{loginMethod === 'oauth' ? <HandleInput /> : <CredLogin />}
52
50
53
51
<div className="mt-4 text-center">
54
52
<a
···
60
58
</div>
61
59
</div>
62
60
</div>
63
-
);
61
+
)
64
62
}
+786
-73
islands/MigrationProgress.tsx
+786
-73
islands/MigrationProgress.tsx
···
1
1
import { useEffect, useState } from "preact/hooks";
2
-
import { MigrationStateInfo } from "../lib/migration-types.ts";
3
-
import AccountCreationStep from "./migration-steps/AccountCreationStep.tsx";
4
-
import DataMigrationStep from "./migration-steps/DataMigrationStep.tsx";
5
-
import IdentityMigrationStep from "./migration-steps/IdentityMigrationStep.tsx";
6
-
import FinalizationStep from "./migration-steps/FinalizationStep.tsx";
7
-
import MigrationCompletion from "../components/MigrationCompletion.tsx";
2
+
3
+
/**
4
+
* The migration state info.
5
+
* @type {MigrationStateInfo}
6
+
*/
7
+
interface MigrationStateInfo {
8
+
state: "up" | "issue" | "maintenance";
9
+
message: string;
10
+
allowMigration: boolean;
11
+
}
8
12
9
13
/**
10
14
* The migration progress props.
···
16
20
email: string;
17
21
password: string;
18
22
invite?: string;
23
+
}
24
+
25
+
/**
26
+
* The migration step.
27
+
* @type {MigrationStep}
28
+
*/
29
+
interface MigrationStep {
30
+
name: string;
31
+
status: "pending" | "in-progress" | "verifying" | "completed" | "error";
32
+
error?: string;
33
+
isVerificationError?: boolean;
19
34
}
20
35
21
36
/**
···
25
40
* @component
26
41
*/
27
42
export default function MigrationProgress(props: MigrationProgressProps) {
43
+
const [token, setToken] = useState("");
28
44
const [migrationState, setMigrationState] = useState<
29
45
MigrationStateInfo | null
30
46
>(null);
31
-
const [currentStep, setCurrentStep] = useState(0);
32
-
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
33
-
const [hasError, setHasError] = useState(false);
47
+
const [retryAttempts, setRetryAttempts] = useState<Record<number, number>>(
48
+
{},
49
+
);
50
+
const [showContinueAnyway, setShowContinueAnyway] = useState<
51
+
Record<number, boolean>
52
+
>({});
53
+
54
+
const [steps, setSteps] = useState<MigrationStep[]>([
55
+
{ name: "Create Account", status: "pending" },
56
+
{ name: "Migrate Data", status: "pending" },
57
+
{ name: "Migrate Identity", status: "pending" },
58
+
{ name: "Finalize Migration", status: "pending" },
59
+
]);
34
60
35
-
const credentials = {
36
-
service: props.service,
37
-
handle: props.handle,
38
-
email: props.email,
39
-
password: props.password,
40
-
invite: props.invite,
61
+
const updateStepStatus = (
62
+
index: number,
63
+
status: MigrationStep["status"],
64
+
error?: string,
65
+
isVerificationError?: boolean,
66
+
) => {
67
+
console.log(
68
+
`Updating step ${index} to ${status}${
69
+
error ? ` with error: ${error}` : ""
70
+
}`,
71
+
);
72
+
setSteps((prevSteps) =>
73
+
prevSteps.map((step, i) =>
74
+
i === index
75
+
? { ...step, status, error, isVerificationError }
76
+
: i > index
77
+
? {
78
+
...step,
79
+
status: "pending",
80
+
error: undefined,
81
+
isVerificationError: undefined,
82
+
}
83
+
: step
84
+
)
85
+
);
41
86
};
42
87
43
88
const validateParams = () => {
44
89
if (!props.service?.trim()) {
45
-
setHasError(true);
90
+
updateStepStatus(0, "error", "Missing service URL");
46
91
return false;
47
92
}
48
93
if (!props.handle?.trim()) {
49
-
setHasError(true);
94
+
updateStepStatus(0, "error", "Missing handle");
50
95
return false;
51
96
}
52
97
if (!props.email?.trim()) {
53
-
setHasError(true);
98
+
updateStepStatus(0, "error", "Missing email");
54
99
return false;
55
100
}
56
101
if (!props.password?.trim()) {
57
-
setHasError(true);
102
+
updateStepStatus(0, "error", "Missing password");
58
103
return false;
59
104
}
60
105
return true;
···
78
123
setMigrationState(migrationData);
79
124
80
125
if (!migrationData.allowMigration) {
81
-
setHasError(true);
126
+
updateStepStatus(0, "error", migrationData.message);
82
127
return;
83
128
}
84
129
}
85
130
} catch (error) {
86
131
console.error("Failed to check migration state:", error);
87
-
setHasError(true);
132
+
updateStepStatus(0, "error", "Unable to verify migration availability");
88
133
return;
89
134
}
90
135
···
93
138
return;
94
139
}
95
140
96
-
// Start with the first step
97
-
setCurrentStep(0);
141
+
startMigration().catch((error) => {
142
+
console.error("Unhandled migration error:", error);
143
+
updateStepStatus(
144
+
0,
145
+
"error",
146
+
error.message || "Unknown error occurred",
147
+
);
148
+
});
98
149
};
99
150
100
151
checkMigrationState();
101
152
}, []);
102
153
103
-
const handleStepComplete = (stepIndex: number) => {
104
-
console.log(`Step ${stepIndex} completed`);
105
-
setCompletedSteps((prev) => new Set([...prev, stepIndex]));
154
+
const getStepDisplayName = (step: MigrationStep, index: number) => {
155
+
if (step.status === "completed") {
156
+
switch (index) {
157
+
case 0:
158
+
return "Account Created";
159
+
case 1:
160
+
return "Data Migrated";
161
+
case 2:
162
+
return "Identity Migrated";
163
+
case 3:
164
+
return "Migration Finalized";
165
+
}
166
+
}
167
+
168
+
if (step.status === "in-progress") {
169
+
switch (index) {
170
+
case 0:
171
+
return "Creating your new account...";
172
+
case 1:
173
+
return "Migrating your data...";
174
+
case 2:
175
+
return step.name ===
176
+
"Enter the token sent to your email to complete identity migration"
177
+
? step.name
178
+
: "Migrating your identity...";
179
+
case 3:
180
+
return "Finalizing migration...";
181
+
}
182
+
}
183
+
184
+
if (step.status === "verifying") {
185
+
switch (index) {
186
+
case 0:
187
+
return "Verifying account creation...";
188
+
case 1:
189
+
return "Verifying data migration...";
190
+
case 2:
191
+
return "Verifying identity migration...";
192
+
case 3:
193
+
return "Verifying migration completion...";
194
+
}
195
+
}
196
+
197
+
return step.name;
198
+
};
199
+
200
+
const startMigration = async () => {
201
+
try {
202
+
// Step 1: Create Account
203
+
updateStepStatus(0, "in-progress");
204
+
console.log("Starting account creation...");
205
+
206
+
try {
207
+
const createRes = await fetch("/api/migrate/create", {
208
+
method: "POST",
209
+
headers: { "Content-Type": "application/json" },
210
+
body: JSON.stringify({
211
+
service: props.service,
212
+
handle: props.handle,
213
+
password: props.password,
214
+
email: props.email,
215
+
...(props.invite ? { invite: props.invite } : {}),
216
+
}),
217
+
});
218
+
219
+
console.log("Create account response status:", createRes.status);
220
+
const responseText = await createRes.text();
221
+
console.log("Create account response:", responseText);
222
+
223
+
if (!createRes.ok) {
224
+
try {
225
+
const json = JSON.parse(responseText);
226
+
throw new Error(json.message || "Failed to create account");
227
+
} catch {
228
+
throw new Error(responseText || "Failed to create account");
229
+
}
230
+
}
231
+
232
+
try {
233
+
const jsonData = JSON.parse(responseText);
234
+
if (!jsonData.success) {
235
+
throw new Error(jsonData.message || "Account creation failed");
236
+
}
237
+
} catch (e) {
238
+
console.log("Response is not JSON or lacks success field:", e);
239
+
}
240
+
241
+
updateStepStatus(0, "verifying");
242
+
const verified = await verifyStep(0);
243
+
if (!verified) {
244
+
console.log(
245
+
"Account creation: Verification failed, waiting for user action",
246
+
);
247
+
return;
248
+
}
249
+
250
+
// If verification succeeds, continue to data migration
251
+
await startDataMigration();
252
+
} catch (error) {
253
+
updateStepStatus(
254
+
0,
255
+
"error",
256
+
error instanceof Error ? error.message : String(error),
257
+
);
258
+
throw error;
259
+
}
260
+
} catch (error) {
261
+
console.error("Migration error in try/catch:", error);
262
+
}
263
+
};
264
+
265
+
const handleIdentityMigration = async () => {
266
+
if (!token) return;
267
+
268
+
try {
269
+
const identityRes = await fetch(
270
+
`/api/migrate/identity/sign?token=${encodeURIComponent(token)}`,
271
+
{
272
+
method: "POST",
273
+
headers: { "Content-Type": "application/json" },
274
+
},
275
+
);
276
+
277
+
const identityData = await identityRes.text();
278
+
if (!identityRes.ok) {
279
+
try {
280
+
const json = JSON.parse(identityData);
281
+
throw new Error(
282
+
json.message || "Failed to complete identity migration",
283
+
);
284
+
} catch {
285
+
throw new Error(
286
+
identityData || "Failed to complete identity migration",
287
+
);
288
+
}
289
+
}
290
+
291
+
let data;
292
+
try {
293
+
data = JSON.parse(identityData);
294
+
if (!data.success) {
295
+
throw new Error(data.message || "Identity migration failed");
296
+
}
297
+
} catch {
298
+
throw new Error("Invalid response from server");
299
+
}
300
+
301
+
updateStepStatus(2, "verifying");
302
+
const verified = await verifyStep(2);
303
+
if (!verified) {
304
+
console.log(
305
+
"Identity migration: Verification failed, waiting for user action",
306
+
);
307
+
return;
308
+
}
309
+
310
+
// If verification succeeds, continue to finalization
311
+
await startFinalization();
312
+
} catch (error) {
313
+
console.error("Identity migration error:", error);
314
+
updateStepStatus(
315
+
2,
316
+
"error",
317
+
error instanceof Error ? error.message : String(error),
318
+
);
319
+
}
320
+
};
321
+
322
+
const getStepIcon = (status: MigrationStep["status"]) => {
323
+
switch (status) {
324
+
case "pending":
325
+
return (
326
+
<div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center">
327
+
<div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" />
328
+
</div>
329
+
);
330
+
case "in-progress":
331
+
return (
332
+
<div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center">
333
+
<div class="w-3 h-3 rounded-full bg-blue-500" />
334
+
</div>
335
+
);
336
+
case "verifying":
337
+
return (
338
+
<div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center">
339
+
<div class="w-3 h-3 rounded-full bg-yellow-500" />
340
+
</div>
341
+
);
342
+
case "completed":
343
+
return (
344
+
<div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center">
345
+
<svg
346
+
class="w-5 h-5 text-white"
347
+
fill="none"
348
+
stroke="currentColor"
349
+
viewBox="0 0 24 24"
350
+
>
351
+
<path
352
+
stroke-linecap="round"
353
+
stroke-linejoin="round"
354
+
stroke-width="2"
355
+
d="M5 13l4 4L19 7"
356
+
/>
357
+
</svg>
358
+
</div>
359
+
);
360
+
case "error":
361
+
return (
362
+
<div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center">
363
+
<svg
364
+
class="w-5 h-5 text-white"
365
+
fill="none"
366
+
stroke="currentColor"
367
+
viewBox="0 0 24 24"
368
+
>
369
+
<path
370
+
stroke-linecap="round"
371
+
stroke-linejoin="round"
372
+
stroke-width="2"
373
+
d="M6 18L18 6M6 6l12 12"
374
+
/>
375
+
</svg>
376
+
</div>
377
+
);
378
+
}
379
+
};
380
+
381
+
const getStepClasses = (status: MigrationStep["status"]) => {
382
+
const baseClasses =
383
+
"flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200";
384
+
switch (status) {
385
+
case "pending":
386
+
return `${baseClasses} bg-gray-50 dark:bg-gray-800`;
387
+
case "in-progress":
388
+
return `${baseClasses} bg-blue-50 dark:bg-blue-900`;
389
+
case "verifying":
390
+
return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`;
391
+
case "completed":
392
+
return `${baseClasses} bg-green-50 dark:bg-green-900`;
393
+
case "error":
394
+
return `${baseClasses} bg-red-50 dark:bg-red-900`;
395
+
}
396
+
};
397
+
398
+
// Helper to verify a step after completion
399
+
const verifyStep = async (stepNum: number) => {
400
+
console.log(`Verification: Starting step ${stepNum + 1}`);
401
+
updateStepStatus(stepNum, "verifying");
402
+
try {
403
+
console.log(`Verification: Fetching status for step ${stepNum + 1}`);
404
+
const res = await fetch(`/api/migrate/status?step=${stepNum + 1}`);
405
+
console.log(`Verification: Status response status:`, res.status);
406
+
const data = await res.json();
407
+
console.log(`Verification: Status data for step ${stepNum + 1}:`, data);
408
+
409
+
if (data.ready) {
410
+
console.log(`Verification: Step ${stepNum + 1} is ready`);
411
+
updateStepStatus(stepNum, "completed");
412
+
// Reset retry state on success
413
+
setRetryAttempts((prev) => ({ ...prev, [stepNum]: 0 }));
414
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
415
+
416
+
// Continue to next step if not the last one
417
+
if (stepNum < 3) {
418
+
setTimeout(() => continueToNextStep(stepNum + 1), 500);
419
+
}
420
+
421
+
return true;
422
+
} else {
423
+
console.log(
424
+
`Verification: Step ${stepNum + 1} is not ready:`,
425
+
data.reason,
426
+
);
427
+
const statusDetails = {
428
+
activated: data.activated,
429
+
validDid: data.validDid,
430
+
repoCommit: data.repoCommit,
431
+
repoRev: data.repoRev,
432
+
repoBlocks: data.repoBlocks,
433
+
expectedRecords: data.expectedRecords,
434
+
indexedRecords: data.indexedRecords,
435
+
privateStateValues: data.privateStateValues,
436
+
expectedBlobs: data.expectedBlobs,
437
+
importedBlobs: data.importedBlobs,
438
+
};
439
+
console.log(
440
+
`Verification: Step ${stepNum + 1} status details:`,
441
+
statusDetails,
442
+
);
443
+
const errorMessage = `${
444
+
data.reason || "Verification failed"
445
+
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
446
+
447
+
// Track retry attempts
448
+
const currentAttempts = retryAttempts[stepNum] || 0;
449
+
setRetryAttempts((prev) => ({
450
+
...prev,
451
+
[stepNum]: currentAttempts + 1,
452
+
}));
453
+
454
+
// Show continue anyway option if this is the second failure
455
+
if (currentAttempts >= 1) {
456
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
457
+
}
458
+
459
+
updateStepStatus(stepNum, "error", errorMessage, true);
460
+
return false;
461
+
}
462
+
} catch (e) {
463
+
console.error(`Verification: Error in step ${stepNum + 1}:`, e);
464
+
const currentAttempts = retryAttempts[stepNum] || 0;
465
+
setRetryAttempts((prev) => ({ ...prev, [stepNum]: currentAttempts + 1 }));
466
+
467
+
// Show continue anyway option if this is the second failure
468
+
if (currentAttempts >= 1) {
469
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
470
+
}
471
+
472
+
updateStepStatus(
473
+
stepNum,
474
+
"error",
475
+
e instanceof Error ? e.message : String(e),
476
+
true,
477
+
);
478
+
return false;
479
+
}
480
+
};
481
+
482
+
const retryVerification = async (stepNum: number) => {
483
+
console.log(`Retrying verification for step ${stepNum + 1}`);
484
+
await verifyStep(stepNum);
485
+
};
486
+
487
+
const continueAnyway = (stepNum: number) => {
488
+
console.log(`Continuing anyway for step ${stepNum + 1}`);
489
+
updateStepStatus(stepNum, "completed");
490
+
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
106
491
107
-
// Move to next step if not the last one
108
-
if (stepIndex < 3) {
109
-
setCurrentStep(stepIndex + 1);
492
+
// Continue with next step if not the last one
493
+
if (stepNum < 3) {
494
+
continueToNextStep(stepNum + 1);
110
495
}
111
496
};
112
497
113
-
const handleStepError = (
114
-
stepIndex: number,
115
-
error: string,
116
-
isVerificationError?: boolean,
117
-
) => {
118
-
console.error(`Step ${stepIndex} error:`, error, { isVerificationError });
119
-
// Errors are handled within each step component
498
+
const continueToNextStep = async (stepNum: number) => {
499
+
switch (stepNum) {
500
+
case 1:
501
+
// Continue to data migration
502
+
await startDataMigration();
503
+
break;
504
+
case 2:
505
+
// Continue to identity migration
506
+
await startIdentityMigration();
507
+
break;
508
+
case 3:
509
+
// Continue to finalization
510
+
await startFinalization();
511
+
break;
512
+
}
120
513
};
121
514
122
-
const isStepActive = (stepIndex: number) => {
123
-
return currentStep === stepIndex && !hasError;
515
+
const startDataMigration = async () => {
516
+
// Step 2: Migrate Data
517
+
updateStepStatus(1, "in-progress");
518
+
console.log("Starting data migration...");
519
+
520
+
try {
521
+
// Step 2.1: Migrate Repo
522
+
console.log("Data migration: Starting repo migration");
523
+
const repoRes = await fetch("/api/migrate/data/repo", {
524
+
method: "POST",
525
+
headers: { "Content-Type": "application/json" },
526
+
});
527
+
528
+
console.log("Repo migration: Response status:", repoRes.status);
529
+
const repoText = await repoRes.text();
530
+
console.log("Repo migration: Raw response:", repoText);
531
+
532
+
if (!repoRes.ok) {
533
+
try {
534
+
const json = JSON.parse(repoText);
535
+
console.error("Repo migration: Error response:", json);
536
+
throw new Error(json.message || "Failed to migrate repo");
537
+
} catch {
538
+
console.error("Repo migration: Non-JSON error response:", repoText);
539
+
throw new Error(repoText || "Failed to migrate repo");
540
+
}
541
+
}
542
+
543
+
// Step 2.2: Migrate Blobs
544
+
console.log("Data migration: Starting blob migration");
545
+
const blobsRes = await fetch("/api/migrate/data/blobs", {
546
+
method: "POST",
547
+
headers: { "Content-Type": "application/json" },
548
+
});
549
+
550
+
console.log("Blob migration: Response status:", blobsRes.status);
551
+
const blobsText = await blobsRes.text();
552
+
console.log("Blob migration: Raw response:", blobsText);
553
+
554
+
if (!blobsRes.ok) {
555
+
try {
556
+
const json = JSON.parse(blobsText);
557
+
console.error("Blob migration: Error response:", json);
558
+
throw new Error(json.message || "Failed to migrate blobs");
559
+
} catch {
560
+
console.error(
561
+
"Blob migration: Non-JSON error response:",
562
+
blobsText,
563
+
);
564
+
throw new Error(blobsText || "Failed to migrate blobs");
565
+
}
566
+
}
567
+
568
+
// Step 2.3: Migrate Preferences
569
+
console.log("Data migration: Starting preferences migration");
570
+
const prefsRes = await fetch("/api/migrate/data/prefs", {
571
+
method: "POST",
572
+
headers: { "Content-Type": "application/json" },
573
+
});
574
+
575
+
console.log("Preferences migration: Response status:", prefsRes.status);
576
+
const prefsText = await prefsRes.text();
577
+
console.log("Preferences migration: Raw response:", prefsText);
578
+
579
+
if (!prefsRes.ok) {
580
+
try {
581
+
const json = JSON.parse(prefsText);
582
+
console.error("Preferences migration: Error response:", json);
583
+
throw new Error(json.message || "Failed to migrate preferences");
584
+
} catch {
585
+
console.error(
586
+
"Preferences migration: Non-JSON error response:",
587
+
prefsText,
588
+
);
589
+
throw new Error(prefsText || "Failed to migrate preferences");
590
+
}
591
+
}
592
+
593
+
console.log("Data migration: Starting verification");
594
+
updateStepStatus(1, "verifying");
595
+
const verified = await verifyStep(1);
596
+
console.log("Data migration: Verification result:", verified);
597
+
if (!verified) {
598
+
console.log(
599
+
"Data migration: Verification failed, waiting for user action",
600
+
);
601
+
return;
602
+
}
603
+
604
+
// If verification succeeds, continue to next step
605
+
await startIdentityMigration();
606
+
} catch (error) {
607
+
console.error("Data migration: Error caught:", error);
608
+
updateStepStatus(
609
+
1,
610
+
"error",
611
+
error instanceof Error ? error.message : String(error),
612
+
);
613
+
throw error;
614
+
}
124
615
};
125
616
126
-
const _isStepCompleted = (stepIndex: number) => {
127
-
return completedSteps.has(stepIndex);
617
+
const startIdentityMigration = async () => {
618
+
// Step 3: Request Identity Migration
619
+
updateStepStatus(2, "in-progress");
620
+
console.log("Requesting identity migration...");
621
+
622
+
try {
623
+
const requestRes = await fetch("/api/migrate/identity/request", {
624
+
method: "POST",
625
+
headers: { "Content-Type": "application/json" },
626
+
});
627
+
628
+
console.log("Identity request response status:", requestRes.status);
629
+
const requestText = await requestRes.text();
630
+
console.log("Identity request response:", requestText);
631
+
632
+
if (!requestRes.ok) {
633
+
try {
634
+
const json = JSON.parse(requestText);
635
+
throw new Error(
636
+
json.message || "Failed to request identity migration",
637
+
);
638
+
} catch {
639
+
throw new Error(
640
+
requestText || "Failed to request identity migration",
641
+
);
642
+
}
643
+
}
644
+
645
+
try {
646
+
const jsonData = JSON.parse(requestText);
647
+
if (!jsonData.success) {
648
+
throw new Error(
649
+
jsonData.message || "Identity migration request failed",
650
+
);
651
+
}
652
+
console.log("Identity migration requested successfully");
653
+
654
+
// Update step name to prompt for token
655
+
setSteps((prevSteps) =>
656
+
prevSteps.map((step, i) =>
657
+
i === 2
658
+
? {
659
+
...step,
660
+
name:
661
+
"Enter the token sent to your email to complete identity migration",
662
+
}
663
+
: step
664
+
)
665
+
);
666
+
// Don't continue with migration - wait for token input
667
+
return;
668
+
} catch (e) {
669
+
console.error("Failed to parse identity request response:", e);
670
+
throw new Error(
671
+
"Invalid response from server during identity request",
672
+
);
673
+
}
674
+
} catch (error) {
675
+
updateStepStatus(
676
+
2,
677
+
"error",
678
+
error instanceof Error ? error.message : String(error),
679
+
);
680
+
throw error;
681
+
}
128
682
};
129
683
130
-
const allStepsCompleted = completedSteps.size === 4;
684
+
const startFinalization = async () => {
685
+
// Step 4: Finalize Migration
686
+
updateStepStatus(3, "in-progress");
687
+
try {
688
+
const finalizeRes = await fetch("/api/migrate/finalize", {
689
+
method: "POST",
690
+
headers: { "Content-Type": "application/json" },
691
+
});
692
+
693
+
const finalizeData = await finalizeRes.text();
694
+
if (!finalizeRes.ok) {
695
+
try {
696
+
const json = JSON.parse(finalizeData);
697
+
throw new Error(json.message || "Failed to finalize migration");
698
+
} catch {
699
+
throw new Error(finalizeData || "Failed to finalize migration");
700
+
}
701
+
}
702
+
703
+
try {
704
+
const jsonData = JSON.parse(finalizeData);
705
+
if (!jsonData.success) {
706
+
throw new Error(jsonData.message || "Finalization failed");
707
+
}
708
+
} catch {
709
+
throw new Error("Invalid response from server during finalization");
710
+
}
711
+
712
+
updateStepStatus(3, "verifying");
713
+
const verified = await verifyStep(3);
714
+
if (!verified) {
715
+
console.log(
716
+
"Finalization: Verification failed, waiting for user action",
717
+
);
718
+
return;
719
+
}
720
+
} catch (error) {
721
+
updateStepStatus(
722
+
3,
723
+
"error",
724
+
error instanceof Error ? error.message : String(error),
725
+
);
726
+
throw error;
727
+
}
728
+
};
131
729
132
730
return (
133
731
<div class="space-y-8">
···
163
761
)}
164
762
165
763
<div class="space-y-4">
166
-
<AccountCreationStep
167
-
credentials={credentials}
168
-
onStepComplete={() => handleStepComplete(0)}
169
-
onStepError={(error, isVerificationError) =>
170
-
handleStepError(0, error, isVerificationError)}
171
-
isActive={isStepActive(0)}
172
-
/>
173
-
174
-
<DataMigrationStep
175
-
credentials={credentials}
176
-
onStepComplete={() => handleStepComplete(1)}
177
-
onStepError={(error, isVerificationError) =>
178
-
handleStepError(1, error, isVerificationError)}
179
-
isActive={isStepActive(1)}
180
-
/>
181
-
182
-
<IdentityMigrationStep
183
-
credentials={credentials}
184
-
onStepComplete={() => handleStepComplete(2)}
185
-
onStepError={(error, isVerificationError) =>
186
-
handleStepError(2, error, isVerificationError)}
187
-
isActive={isStepActive(2)}
188
-
/>
189
-
190
-
<FinalizationStep
191
-
credentials={credentials}
192
-
onStepComplete={() => handleStepComplete(3)}
193
-
onStepError={(error, isVerificationError) =>
194
-
handleStepError(3, error, isVerificationError)}
195
-
isActive={isStepActive(3)}
196
-
/>
764
+
{steps.map((step, index) => (
765
+
<div key={step.name} class={getStepClasses(step.status)}>
766
+
{getStepIcon(step.status)}
767
+
<div class="flex-1">
768
+
<p
769
+
class={`font-medium ${
770
+
step.status === "error"
771
+
? "text-red-900 dark:text-red-200"
772
+
: step.status === "completed"
773
+
? "text-green-900 dark:text-green-200"
774
+
: step.status === "in-progress"
775
+
? "text-blue-900 dark:text-blue-200"
776
+
: "text-gray-900 dark:text-gray-200"
777
+
}`}
778
+
>
779
+
{getStepDisplayName(step, index)}
780
+
</p>
781
+
{step.error && (
782
+
<div class="mt-1">
783
+
<p class="text-sm text-red-600 dark:text-red-400">
784
+
{(() => {
785
+
try {
786
+
const err = JSON.parse(step.error);
787
+
return err.message || step.error;
788
+
} catch {
789
+
return step.error;
790
+
}
791
+
})()}
792
+
</p>
793
+
{step.isVerificationError && (
794
+
<div class="flex space-x-2 mt-2">
795
+
<button
796
+
type="button"
797
+
onClick={() => retryVerification(index)}
798
+
class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors duration-200 dark:bg-blue-500 dark:hover:bg-blue-400"
799
+
>
800
+
Retry Verification
801
+
</button>
802
+
{showContinueAnyway[index] && (
803
+
<button
804
+
type="button"
805
+
onClick={() => continueAnyway(index)}
806
+
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
807
+
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
808
+
>
809
+
Continue Anyway
810
+
</button>
811
+
)}
812
+
</div>
813
+
)}
814
+
</div>
815
+
)}
816
+
{index === 2 && step.status === "in-progress" &&
817
+
step.name ===
818
+
"Enter the token sent to your email to complete identity migration" &&
819
+
(
820
+
<div class="mt-4 space-y-4">
821
+
<p class="text-sm text-blue-800 dark:text-blue-200">
822
+
Please check your email for the migration token and enter
823
+
it below:
824
+
</p>
825
+
<div class="flex space-x-2">
826
+
<input
827
+
type="text"
828
+
value={token}
829
+
onChange={(e) => setToken(e.currentTarget.value)}
830
+
placeholder="Enter token"
831
+
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400"
832
+
/>
833
+
<button
834
+
type="button"
835
+
onClick={handleIdentityMigration}
836
+
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
837
+
>
838
+
Submit Token
839
+
</button>
840
+
</div>
841
+
</div>
842
+
)}
843
+
</div>
844
+
</div>
845
+
))}
197
846
</div>
198
847
199
-
<MigrationCompletion isVisible={allStepsCompleted} />
848
+
{steps[3].status === "completed" && (
849
+
<div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800">
850
+
<p class="text-sm text-green-800 dark:text-green-200 pb-2">
851
+
Migration completed successfully! Sign out to finish the process and
852
+
return home.<br />
853
+
Please consider donating to Airport to support server and
854
+
development costs.
855
+
</p>
856
+
<div class="flex space-x-4">
857
+
<button
858
+
type="button"
859
+
onClick={async () => {
860
+
try {
861
+
const response = await fetch("/api/logout", {
862
+
method: "POST",
863
+
credentials: "include",
864
+
});
865
+
if (!response.ok) {
866
+
throw new Error("Logout failed");
867
+
}
868
+
globalThis.location.href = "/";
869
+
} catch (error) {
870
+
console.error("Failed to logout:", error);
871
+
}
872
+
}}
873
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
874
+
>
875
+
<svg
876
+
class="w-5 h-5"
877
+
fill="none"
878
+
stroke="currentColor"
879
+
viewBox="0 0 24 24"
880
+
>
881
+
<path
882
+
stroke-linecap="round"
883
+
stroke-linejoin="round"
884
+
stroke-width="2"
885
+
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
886
+
/>
887
+
</svg>
888
+
<span>Sign Out</span>
889
+
</button>
890
+
<a
891
+
href="https://ko-fi.com/knotbin"
892
+
target="_blank"
893
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
894
+
>
895
+
<svg
896
+
class="w-5 h-5"
897
+
fill="none"
898
+
stroke="currentColor"
899
+
viewBox="0 0 24 24"
900
+
>
901
+
<path
902
+
stroke-linecap="round"
903
+
stroke-linejoin="round"
904
+
stroke-width="2"
905
+
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
906
+
/>
907
+
</svg>
908
+
<span>Support Us</span>
909
+
</a>
910
+
</div>
911
+
</div>
912
+
)}
200
913
</div>
201
914
);
202
915
}
+84
-285
islands/MigrationSetup.tsx
+84
-285
islands/MigrationSetup.tsx
···
1
-
import { useEffect, useState } from "preact/hooks";
1
+
import { useState, useEffect } from "preact/hooks";
2
2
import { IS_BROWSER } from "fresh/runtime";
3
3
4
4
/**
···
64
64
const [showConfirmation, setShowConfirmation] = useState(false);
65
65
const [confirmationText, setConfirmationText] = useState("");
66
66
const [passport, setPassport] = useState<UserPassport | null>(null);
67
-
const [migrationState, setMigrationState] = useState<
68
-
MigrationStateInfo | null
69
-
>(null);
67
+
const [migrationState, setMigrationState] = useState<MigrationStateInfo | null>(null);
70
68
71
69
const ensureServiceUrl = (url: string): string => {
72
70
if (!url) return url;
···
102
100
const userData = await response.json();
103
101
if (userData) {
104
102
// Get PDS URL from the current service
105
-
const pdsResponse = await fetch(
106
-
`/api/resolve-pds?did=${userData.did}`,
107
-
);
103
+
const pdsResponse = await fetch(`/api/resolve-pds?did=${userData.did}`);
108
104
const pdsData = await pdsResponse.json();
109
105
110
106
setPassport({
111
107
did: userData.did,
112
108
handle: userData.handle,
113
109
pds: pdsData.pds || "Unknown",
114
-
createdAt: new Date().toISOString(), // TODO: Get actual creation date from API
110
+
createdAt: new Date().toISOString() // TODO: Get actual creation date from API
115
111
});
116
112
}
117
113
} catch (error) {
···
221
217
<div class="max-w-2xl mx-auto p-6 bg-gradient-to-b from-blue-50 to-white dark:from-gray-800 dark:to-gray-900 rounded-lg shadow-xl relative overflow-hidden">
222
218
{/* Decorative airport elements */}
223
219
<div class="absolute top-0 left-0 w-full h-1 bg-blue-500"></div>
224
-
<div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">
225
-
TERMINAL 1
226
-
</div>
227
-
<div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">
228
-
GATE M1
229
-
</div>
220
+
<div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">TERMINAL 1</div>
221
+
<div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">GATE M1</div>
230
222
231
223
{/* Migration state alert */}
232
224
{migrationState && !migrationState.allowMigration && (
233
-
<div
234
-
class={`mb-6 mt-4 p-4 rounded-lg border ${
235
-
migrationState.state === "maintenance"
236
-
? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
237
-
: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
238
-
}`}
239
-
>
225
+
<div class={`mb-6 mt-4 p-4 rounded-lg border ${
226
+
migrationState.state === "maintenance"
227
+
? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
228
+
: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
229
+
}`}>
240
230
<div class="flex items-center">
241
-
<div
242
-
class={`mr-3 ${
243
-
migrationState.state === "maintenance"
244
-
? "text-yellow-600 dark:text-yellow-400"
245
-
: "text-red-600 dark:text-red-400"
246
-
}`}
247
-
>
231
+
<div class={`mr-3 ${
232
+
migrationState.state === "maintenance" ? "text-yellow-600 dark:text-yellow-400" : "text-red-600 dark:text-red-400"
233
+
}`}>
248
234
{migrationState.state === "maintenance" ? "โ ๏ธ" : "๐ซ"}
249
235
</div>
250
236
<div>
251
237
<h3 class="font-semibold mb-1">
252
-
{migrationState.state === "maintenance"
253
-
? "Maintenance Mode"
254
-
: "Service Unavailable"}
238
+
{migrationState.state === "maintenance" ? "Maintenance Mode" : "Service Unavailable"}
255
239
</h3>
256
240
<p class="text-sm">{migrationState.message}</p>
257
241
</div>
···
260
244
)}
261
245
262
246
<div class="text-center mb-8 relative">
263
-
<p class="text-gray-600 dark:text-gray-400 mt-4">
264
-
Please complete your migration check-in
265
-
</p>
266
-
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">
267
-
FLIGHT: MIG-2024
268
-
</div>
247
+
<p class="text-gray-600 dark:text-gray-400 mt-4">Please complete your migration check-in</p>
248
+
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">FLIGHT: MIG-2024</div>
269
249
</div>
270
250
271
251
{/* Passport Section */}
272
252
{passport && (
273
253
<div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-200 dark:border-gray-700">
274
254
<div class="flex items-center justify-between mb-4">
275
-
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
276
-
Current Passport
277
-
</h3>
278
-
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">
279
-
ISSUED: {new Date().toLocaleDateString()}
280
-
</div>
255
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Current Passport</h3>
256
+
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">ISSUED: {new Date().toLocaleDateString()}</div>
281
257
</div>
282
258
<div class="grid grid-cols-2 gap-4 text-sm">
283
259
<div>
284
260
<div class="text-gray-500 dark:text-gray-400 mb-1">Handle</div>
285
-
<div class="font-mono text-gray-900 dark:text-white">
286
-
{passport.handle}
287
-
</div>
261
+
<div class="font-mono text-gray-900 dark:text-white">{passport.handle}</div>
288
262
</div>
289
263
<div>
290
264
<div class="text-gray-500 dark:text-gray-400 mb-1">DID</div>
291
-
<div class="font-mono text-gray-900 dark:text-white break-all">
292
-
{passport.did}
293
-
</div>
265
+
<div class="font-mono text-gray-900 dark:text-white break-all">{passport.did}</div>
294
266
</div>
295
267
<div>
296
-
<div class="text-gray-500 dark:text-gray-400 mb-1">
297
-
Citizen of PDS
298
-
</div>
299
-
<div class="font-mono text-gray-900 dark:text-white break-all">
300
-
{passport.pds}
301
-
</div>
268
+
<div class="text-gray-500 dark:text-gray-400 mb-1">Citizen of PDS</div>
269
+
<div class="font-mono text-gray-900 dark:text-white break-all">{passport.pds}</div>
302
270
</div>
303
271
<div>
304
-
<div class="text-gray-500 dark:text-gray-400 mb-1">
305
-
Account Age
306
-
</div>
272
+
<div class="text-gray-500 dark:text-gray-400 mb-1">Account Age</div>
307
273
<div class="font-mono text-gray-900 dark:text-white">
308
-
{passport.createdAt
309
-
? new Date(passport.createdAt).toLocaleDateString()
310
-
: "Unknown"}
274
+
{passport.createdAt ? new Date(passport.createdAt).toLocaleDateString() : "Unknown"}
311
275
</div>
312
276
</div>
313
277
</div>
···
318
282
{error && (
319
283
<div class="bg-red-50 dark:bg-red-900 rounded-lg ">
320
284
<p class="text-red-800 dark:text-red-200 flex items-center">
321
-
<svg
322
-
class="w-5 h-5 mr-2"
323
-
fill="none"
324
-
stroke="currentColor"
325
-
viewBox="0 0 24 24"
326
-
>
327
-
<path
328
-
stroke-linecap="round"
329
-
stroke-linejoin="round"
330
-
stroke-width="2"
331
-
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
332
-
>
333
-
</path>
285
+
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
286
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
334
287
</svg>
335
288
{error}
336
289
</p>
···
341
294
<div>
342
295
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
343
296
Destination Server
344
-
<span class="text-xs text-gray-500 ml-1">
345
-
(Final Destination)
346
-
</span>
297
+
<span class="text-xs text-gray-500 ml-1">(Final Destination)</span>
347
298
</label>
348
299
<div class="relative">
349
300
<input
···
356
307
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed pl-10"
357
308
/>
358
309
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
359
-
<svg
360
-
class="h-5 w-5 text-gray-400"
361
-
fill="none"
362
-
stroke="currentColor"
363
-
viewBox="0 0 24 24"
364
-
>
365
-
<path
366
-
stroke-linecap="round"
367
-
stroke-linejoin="round"
368
-
stroke-width="2"
369
-
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
370
-
>
371
-
</path>
310
+
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
311
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
372
312
</svg>
373
313
</div>
374
314
</div>
375
315
{isLoading && (
376
316
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400 flex items-center">
377
-
<svg
378
-
class="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-500"
379
-
fill="none"
380
-
viewBox="0 0 24 24"
381
-
>
382
-
<circle
383
-
class="opacity-25"
384
-
cx="12"
385
-
cy="12"
386
-
r="10"
387
-
stroke="currentColor"
388
-
stroke-width="4"
389
-
>
390
-
</circle>
391
-
<path
392
-
class="opacity-75"
393
-
fill="currentColor"
394
-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
395
-
>
396
-
</path>
317
+
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24">
318
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
319
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
397
320
</svg>
398
321
Verifying destination server...
399
322
</p>
···
405
328
New Account Handle
406
329
<span class="text-xs text-gray-500 ml-1">(Passport ID)</span>
407
330
<div class="inline-block relative group ml-2">
408
-
<svg
409
-
class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help"
410
-
fill="currentColor"
411
-
viewBox="0 0 20 20"
412
-
>
413
-
<path
414
-
fill-rule="evenodd"
415
-
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
416
-
clip-rule="evenodd"
417
-
/>
331
+
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help" fill="currentColor" viewBox="0 0 20 20">
332
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
418
333
</svg>
419
334
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10">
420
335
You can change your handle to a custom domain later
421
-
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900">
422
-
</div>
336
+
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
423
337
</div>
424
338
</div>
425
339
</label>
···
433
347
placeholder="username"
434
348
required
435
349
class="w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10 pr-32"
436
-
style={{ fontFamily: "inherit" }}
350
+
style={{ fontFamily: 'inherit' }}
437
351
/>
438
352
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
439
-
<svg
440
-
class="h-5 w-5 text-gray-400"
441
-
fill="none"
442
-
stroke="currentColor"
443
-
viewBox="0 0 24 24"
444
-
>
445
-
<path
446
-
stroke-linecap="round"
447
-
stroke-linejoin="round"
448
-
stroke-width="2"
449
-
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
450
-
>
451
-
</path>
353
+
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
354
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
452
355
</svg>
453
356
</div>
454
357
{/* Suffix for domain ending */}
455
-
{availableDomains.length > 0
456
-
? (
457
-
availableDomains.length === 1
458
-
? (
459
-
<span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base">
460
-
{availableDomains[0]}
461
-
</span>
462
-
)
463
-
: (
464
-
<span class="absolute inset-y-0 right-0 flex items-center pr-1">
465
-
<select
466
-
value={selectedDomain}
467
-
onChange={(e) =>
468
-
setSelectedDomain(e.currentTarget.value)}
469
-
class="bg-transparent text-gray-400 font-mono text-base focus:outline-none focus:ring-0 border-0 pr-2"
470
-
style={{ appearance: "none" }}
471
-
>
472
-
{availableDomains.map((domain) => (
473
-
<option key={domain} value={domain}>
474
-
{domain}
475
-
</option>
476
-
))}
477
-
</select>
478
-
</span>
479
-
)
480
-
)
481
-
: (
358
+
{availableDomains.length > 0 ? (
359
+
availableDomains.length === 1 ? (
482
360
<span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base">
483
-
.example.com
361
+
{availableDomains[0]}
484
362
</span>
485
-
)}
363
+
) : (
364
+
<span class="absolute inset-y-0 right-0 flex items-center pr-1">
365
+
<select
366
+
value={selectedDomain}
367
+
onChange={(e) => setSelectedDomain(e.currentTarget.value)}
368
+
class="bg-transparent text-gray-400 font-mono text-base focus:outline-none focus:ring-0 border-0 pr-2"
369
+
style={{ appearance: 'none' }}
370
+
>
371
+
{availableDomains.map((domain) => (
372
+
<option key={domain} value={domain}>{domain}</option>
373
+
))}
374
+
</select>
375
+
</span>
376
+
)
377
+
) : (
378
+
<span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base">
379
+
.example.com
380
+
</span>
381
+
)}
486
382
</div>
487
383
</div>
488
384
</div>
···
491
387
<div>
492
388
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
493
389
Email
494
-
<span class="text-xs text-gray-500 ml-1">
495
-
(Emergency Contact)
496
-
</span>
390
+
<span class="text-xs text-gray-500 ml-1">(Emergency Contact)</span>
497
391
</label>
498
392
<div class="relative">
499
393
<input
···
504
398
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10"
505
399
/>
506
400
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
507
-
<svg
508
-
class="h-5 w-5 text-gray-400"
509
-
fill="none"
510
-
stroke="currentColor"
511
-
viewBox="0 0 24 24"
512
-
>
513
-
<path
514
-
stroke-linecap="round"
515
-
stroke-linejoin="round"
516
-
stroke-width="2"
517
-
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
518
-
>
519
-
</path>
401
+
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
402
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
520
403
</svg>
521
404
</div>
522
405
</div>
···
525
408
<div>
526
409
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
527
410
New Account Password
528
-
<span class="text-xs text-gray-500 ml-1">
529
-
(Security Clearance)
530
-
</span>
411
+
<span class="text-xs text-gray-500 ml-1">(Security Clearance)</span>
531
412
</label>
532
413
<div class="relative">
533
414
<input
···
538
419
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10"
539
420
/>
540
421
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
541
-
<svg
542
-
class="h-5 w-5 text-gray-400"
543
-
fill="none"
544
-
stroke="currentColor"
545
-
viewBox="0 0 24 24"
546
-
>
547
-
<path
548
-
stroke-linecap="round"
549
-
stroke-linejoin="round"
550
-
stroke-width="2"
551
-
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
552
-
>
553
-
</path>
422
+
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
423
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
554
424
</svg>
555
425
</div>
556
426
</div>
···
571
441
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10"
572
442
/>
573
443
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
574
-
<svg
575
-
class="h-5 w-5 text-gray-400"
576
-
fill="none"
577
-
stroke="currentColor"
578
-
viewBox="0 0 24 24"
579
-
>
580
-
<path
581
-
stroke-linecap="round"
582
-
stroke-linejoin="round"
583
-
stroke-width="2"
584
-
d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"
585
-
>
586
-
</path>
444
+
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
445
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
587
446
</svg>
588
447
</div>
589
448
</div>
···
593
452
594
453
<button
595
454
type="submit"
596
-
disabled={isLoading ||
597
-
Boolean(migrationState && !migrationState.allowMigration)}
455
+
disabled={isLoading || Boolean(migrationState && !migrationState.allowMigration)}
598
456
class="w-full flex justify-center items-center py-3 px-4 rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
599
457
>
600
-
<svg
601
-
class="w-5 h-5 mr-2"
602
-
fill="none"
603
-
stroke="currentColor"
604
-
viewBox="0 0 24 24"
605
-
>
606
-
<path
607
-
stroke-linecap="round"
608
-
stroke-linejoin="round"
609
-
stroke-width="2"
610
-
d="M5 13l4 4L19 7"
611
-
>
612
-
</path>
458
+
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
459
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
613
460
</svg>
614
461
Proceed to Check-in
615
462
</button>
···
619
466
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
620
467
<div
621
468
class="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-md w-full shadow-2xl border-0 relative animate-popin"
622
-
style={{
623
-
boxShadow:
624
-
"0 8px 32px 0 rgba(255, 0, 0, 0.15), 0 1.5px 8px 0 rgba(0,0,0,0.10)",
625
-
}}
469
+
style={{ boxShadow: '0 8px 32px 0 rgba(255, 0, 0, 0.15), 0 1.5px 8px 0 rgba(0,0,0,0.10)' }}
626
470
>
627
471
<div class="absolute -top-8 left-1/2 -translate-x-1/2">
628
472
<div class="bg-red-500 rounded-full p-3 shadow-lg animate-bounce-short">
629
-
<svg
630
-
class="w-8 h-8 text-white"
631
-
fill="none"
632
-
stroke="currentColor"
633
-
viewBox="0 0 24 24"
634
-
>
635
-
<path
636
-
stroke-linecap="round"
637
-
stroke-linejoin="round"
638
-
stroke-width="2"
639
-
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
640
-
/>
473
+
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
474
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
641
475
</svg>
642
476
</div>
643
477
</div>
644
478
<div class="text-center mb-4 mt-6">
645
-
<h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">
646
-
Final Boarding Call
647
-
</h3>
479
+
<h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">Final Boarding Call</h3>
648
480
<p class="text-gray-700 dark:text-gray-300 mb-2 text-base">
649
-
<span class="font-semibold text-red-500">Warning:</span>{" "}
650
-
This migration is <strong>irreversible</strong>{" "}
651
-
if coming from Bluesky servers.<br />Bluesky does not recommend
652
-
it for main accounts. Migrate at your own risk. We reccomend
653
-
backing up your data before proceeding.
481
+
<span class="font-semibold text-red-500">Warning:</span> This migration is <strong>irreversible</strong> if coming from Bluesky servers.<br />Bluesky does not recommend it for main accounts. Migrate at your own risk. We reccomend backing up your data before proceeding.
654
482
</p>
655
483
<p class="text-gray-700 dark:text-gray-300 mb-4 text-base">
656
-
Please type{" "}
657
-
<span class="font-mono font-bold text-blue-600">MIGRATE</span>
658
-
{" "}
659
-
below to confirm and proceed.
484
+
Please type <span class="font-mono font-bold text-blue-600">MIGRATE</span> below to confirm and proceed.
660
485
</p>
661
486
</div>
662
487
<div class="relative">
···
675
500
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md flex items-center transition"
676
501
type="button"
677
502
>
678
-
<svg
679
-
class="w-5 h-5 mr-2"
680
-
fill="none"
681
-
stroke="currentColor"
682
-
viewBox="0 0 24 24"
683
-
>
684
-
<path
685
-
stroke-linecap="round"
686
-
stroke-linejoin="round"
687
-
stroke-width="2"
688
-
d="M6 18L18 6M6 6l12 12"
689
-
>
690
-
</path>
503
+
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
504
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
691
505
</svg>
692
506
Cancel
693
507
</button>
694
508
<button
695
509
onClick={handleConfirmation}
696
-
class={`px-4 py-2 rounded-md flex items-center transition font-semibold ${
697
-
confirmationText.trim().toLowerCase() === "migrate"
698
-
? "bg-red-600 text-white hover:bg-red-700 cursor-pointer"
699
-
: "bg-red-300 text-white cursor-not-allowed"
700
-
}`}
510
+
class={`px-4 py-2 rounded-md flex items-center transition font-semibold ${confirmationText.trim().toLowerCase() === 'migrate' ? 'bg-red-600 text-white hover:bg-red-700 cursor-pointer' : 'bg-red-300 text-white cursor-not-allowed'}`}
701
511
type="button"
702
-
disabled={confirmationText.trim().toLowerCase() !== "migrate"}
512
+
disabled={confirmationText.trim().toLowerCase() !== 'migrate'}
703
513
>
704
-
<svg
705
-
class="w-5 h-5 mr-2"
706
-
fill="none"
707
-
stroke="currentColor"
708
-
viewBox="0 0 24 24"
709
-
>
710
-
<path
711
-
stroke-linecap="round"
712
-
stroke-linejoin="round"
713
-
stroke-width="2"
714
-
d="M5 13l4 4L19 7"
715
-
>
716
-
</path>
514
+
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
515
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
717
516
</svg>
718
517
Confirm Migration
719
518
</button>
+8
-8
islands/SocialLinks.tsx
+8
-8
islands/SocialLinks.tsx
···
1
1
import { useEffect, useState } from "preact/hooks";
2
-
import * as Icon from "npm:preact-feather";
2
+
import * as Icon from 'npm:preact-feather';
3
3
4
4
/**
5
5
* The GitHub repository.
···
18
18
const [starCount, setStarCount] = useState<number | null>(null);
19
19
20
20
useEffect(() => {
21
-
const CACHE_KEY = "github_stars";
21
+
const CACHE_KEY = 'github_stars';
22
22
const CACHE_DURATION = 15 * 60 * 1000; // 15 minutes in milliseconds
23
23
24
24
const fetchRepoInfo = async () => {
25
25
try {
26
-
const response = await fetch(
27
-
"https://api.github.com/repos/knotbin/airport",
28
-
);
26
+
const response = await fetch("https://api.github.com/repos/knotbin/airport");
29
27
const data: GitHubRepo = await response.json();
30
28
const cacheData = {
31
29
count: data.stargazers_count,
32
-
timestamp: Date.now(),
30
+
timestamp: Date.now()
33
31
};
34
32
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
35
33
setStarCount(data.stargazers_count);
···
80
78
stroke-linejoin="round"
81
79
xmlns="http://www.w3.org/2000/svg"
82
80
>
83
-
<path d="M55.491 15.172c29.35 22.035 60.917 66.712 72.509 90.686 11.592-23.974 43.159-68.651 72.509-90.686C221.686-.727 256-13.028 256 26.116c0 7.818-4.482 65.674-7.111 75.068-9.138 32.654-42.436 40.983-72.057 35.942 51.775 8.812 64.946 38 36.501 67.187-54.021 55.433-77.644-13.908-83.696-31.676-1.11-3.257-1.63-4.78-1.637-3.485-.008-1.296-.527.228-1.637 3.485-6.052 17.768-29.675 87.11-83.696 31.676-28.445-29.187-15.274-58.375 36.5-67.187-29.62 5.041-62.918-3.288-72.056-35.942C4.482 91.79 0 33.934 0 26.116 0-13.028 34.314-.727 55.491 15.172Z" />
81
+
<path
82
+
d="M55.491 15.172c29.35 22.035 60.917 66.712 72.509 90.686 11.592-23.974 43.159-68.651 72.509-90.686C221.686-.727 256-13.028 256 26.116c0 7.818-4.482 65.674-7.111 75.068-9.138 32.654-42.436 40.983-72.057 35.942 51.775 8.812 64.946 38 36.501 67.187-54.021 55.433-77.644-13.908-83.696-31.676-1.11-3.257-1.63-4.78-1.637-3.485-.008-1.296-.527.228-1.637 3.485-6.052 17.768-29.675 87.11-83.696 31.676-28.445-29.187-15.274-58.375 36.5-67.187-29.62 5.041-62.918-3.288-72.056-35.942C4.482 91.79 0 33.934 0 26.116 0-13.028 34.314-.727 55.491 15.172Z"
83
+
/>
84
84
</svg>
85
85
</a>
86
86
<a
···
102
102
</a>
103
103
</div>
104
104
);
105
-
}
105
+
}
+4
-4
islands/Ticket.tsx
+4
-4
islands/Ticket.tsx
-151
islands/migration-steps/AccountCreationStep.tsx
-151
islands/migration-steps/AccountCreationStep.tsx
···
1
-
import { useEffect, useState } from "preact/hooks";
2
-
import { MigrationStep } from "../../components/MigrationStep.tsx";
3
-
import {
4
-
parseApiResponse,
5
-
StepCommonProps,
6
-
verifyMigrationStep,
7
-
} from "../../lib/migration-types.ts";
8
-
9
-
interface AccountCreationStepProps extends StepCommonProps {
10
-
isActive: boolean;
11
-
}
12
-
13
-
export default function AccountCreationStep({
14
-
credentials,
15
-
onStepComplete,
16
-
onStepError,
17
-
isActive,
18
-
}: AccountCreationStepProps) {
19
-
const [status, setStatus] = useState<
20
-
"pending" | "in-progress" | "verifying" | "completed" | "error"
21
-
>("pending");
22
-
const [error, setError] = useState<string>();
23
-
const [retryCount, setRetryCount] = useState(0);
24
-
const [showContinueAnyway, setShowContinueAnyway] = useState(false);
25
-
26
-
useEffect(() => {
27
-
if (isActive && status === "pending") {
28
-
startAccountCreation();
29
-
}
30
-
}, [isActive]);
31
-
32
-
const startAccountCreation = async () => {
33
-
setStatus("in-progress");
34
-
setError(undefined);
35
-
36
-
try {
37
-
const createRes = await fetch("/api/migrate/create", {
38
-
method: "POST",
39
-
headers: { "Content-Type": "application/json" },
40
-
body: JSON.stringify({
41
-
service: credentials.service,
42
-
handle: credentials.handle,
43
-
password: credentials.password,
44
-
email: credentials.email,
45
-
...(credentials.invite ? { invite: credentials.invite } : {}),
46
-
}),
47
-
});
48
-
49
-
const responseText = await createRes.text();
50
-
51
-
if (!createRes.ok) {
52
-
const parsed = parseApiResponse(responseText);
53
-
throw new Error(parsed.message || "Failed to create account");
54
-
}
55
-
56
-
const parsed = parseApiResponse(responseText);
57
-
if (!parsed.success) {
58
-
throw new Error(parsed.message || "Account creation failed");
59
-
}
60
-
61
-
// Verify the account creation
62
-
await verifyAccountCreation();
63
-
} catch (error) {
64
-
const errorMessage = error instanceof Error
65
-
? error.message
66
-
: String(error);
67
-
setError(errorMessage);
68
-
setStatus("error");
69
-
onStepError(errorMessage);
70
-
}
71
-
};
72
-
73
-
const verifyAccountCreation = async () => {
74
-
setStatus("verifying");
75
-
76
-
try {
77
-
const result = await verifyMigrationStep(1);
78
-
79
-
if (result.ready) {
80
-
setStatus("completed");
81
-
setRetryCount(0);
82
-
setShowContinueAnyway(false);
83
-
onStepComplete();
84
-
} else {
85
-
const statusDetails = {
86
-
activated: result.activated,
87
-
validDid: result.validDid,
88
-
};
89
-
const errorMessage = `${
90
-
result.reason || "Verification failed"
91
-
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
92
-
93
-
setRetryCount((prev) => prev + 1);
94
-
if (retryCount >= 1) {
95
-
setShowContinueAnyway(true);
96
-
}
97
-
98
-
setError(errorMessage);
99
-
setStatus("error");
100
-
onStepError(errorMessage, true);
101
-
}
102
-
} catch (error) {
103
-
const errorMessage = error instanceof Error
104
-
? error.message
105
-
: String(error);
106
-
setRetryCount((prev) => prev + 1);
107
-
if (retryCount >= 1) {
108
-
setShowContinueAnyway(true);
109
-
}
110
-
111
-
setError(errorMessage);
112
-
setStatus("error");
113
-
onStepError(errorMessage, true);
114
-
}
115
-
};
116
-
117
-
const retryVerification = async () => {
118
-
await verifyAccountCreation();
119
-
};
120
-
121
-
const continueAnyway = () => {
122
-
setStatus("completed");
123
-
setShowContinueAnyway(false);
124
-
onStepComplete();
125
-
};
126
-
127
-
return (
128
-
<MigrationStep
129
-
name="Create Account"
130
-
status={status}
131
-
error={error}
132
-
isVerificationError={status === "error" &&
133
-
error?.includes("Verification failed")}
134
-
index={0}
135
-
onRetryVerification={retryVerification}
136
-
>
137
-
{status === "error" && showContinueAnyway && (
138
-
<div class="flex space-x-2 mt-2">
139
-
<button
140
-
type="button"
141
-
onClick={continueAnyway}
142
-
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
143
-
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
144
-
>
145
-
Continue Anyway
146
-
</button>
147
-
</div>
148
-
)}
149
-
</MigrationStep>
150
-
);
151
-
}
-172
islands/migration-steps/DataMigrationStep.tsx
-172
islands/migration-steps/DataMigrationStep.tsx
···
1
-
import { useEffect, useState } from "preact/hooks";
2
-
import { MigrationStep } from "../../components/MigrationStep.tsx";
3
-
import {
4
-
parseApiResponse,
5
-
StepCommonProps,
6
-
verifyMigrationStep,
7
-
} from "../../lib/migration-types.ts";
8
-
9
-
interface DataMigrationStepProps extends StepCommonProps {
10
-
isActive: boolean;
11
-
}
12
-
13
-
export default function DataMigrationStep({
14
-
credentials: _credentials,
15
-
onStepComplete,
16
-
onStepError,
17
-
isActive,
18
-
}: DataMigrationStepProps) {
19
-
const [status, setStatus] = useState<
20
-
"pending" | "in-progress" | "verifying" | "completed" | "error"
21
-
>("pending");
22
-
const [error, setError] = useState<string>();
23
-
const [retryCount, setRetryCount] = useState(0);
24
-
const [showContinueAnyway, setShowContinueAnyway] = useState(false);
25
-
26
-
useEffect(() => {
27
-
if (isActive && status === "pending") {
28
-
startDataMigration();
29
-
}
30
-
}, [isActive]);
31
-
32
-
const startDataMigration = async () => {
33
-
setStatus("in-progress");
34
-
setError(undefined);
35
-
36
-
try {
37
-
// Step 1: Migrate Repo
38
-
const repoRes = await fetch("/api/migrate/data/repo", {
39
-
method: "POST",
40
-
headers: { "Content-Type": "application/json" },
41
-
});
42
-
43
-
const repoText = await repoRes.text();
44
-
45
-
if (!repoRes.ok) {
46
-
const parsed = parseApiResponse(repoText);
47
-
throw new Error(parsed.message || "Failed to migrate repo");
48
-
}
49
-
50
-
// Step 2: Migrate Blobs
51
-
const blobsRes = await fetch("/api/migrate/data/blobs", {
52
-
method: "POST",
53
-
headers: { "Content-Type": "application/json" },
54
-
});
55
-
56
-
const blobsText = await blobsRes.text();
57
-
58
-
if (!blobsRes.ok) {
59
-
const parsed = parseApiResponse(blobsText);
60
-
throw new Error(parsed.message || "Failed to migrate blobs");
61
-
}
62
-
63
-
// Step 3: Migrate Preferences
64
-
const prefsRes = await fetch("/api/migrate/data/prefs", {
65
-
method: "POST",
66
-
headers: { "Content-Type": "application/json" },
67
-
});
68
-
69
-
const prefsText = await prefsRes.text();
70
-
71
-
if (!prefsRes.ok) {
72
-
const parsed = parseApiResponse(prefsText);
73
-
throw new Error(parsed.message || "Failed to migrate preferences");
74
-
}
75
-
76
-
// Verify the data migration
77
-
await verifyDataMigration();
78
-
} catch (error) {
79
-
const errorMessage = error instanceof Error
80
-
? error.message
81
-
: String(error);
82
-
setError(errorMessage);
83
-
setStatus("error");
84
-
onStepError(errorMessage);
85
-
}
86
-
};
87
-
88
-
const verifyDataMigration = async () => {
89
-
setStatus("verifying");
90
-
91
-
try {
92
-
const result = await verifyMigrationStep(2);
93
-
94
-
if (result.ready) {
95
-
setStatus("completed");
96
-
setRetryCount(0);
97
-
setShowContinueAnyway(false);
98
-
onStepComplete();
99
-
} else {
100
-
const statusDetails = {
101
-
repoCommit: result.repoCommit,
102
-
repoRev: result.repoRev,
103
-
repoBlocks: result.repoBlocks,
104
-
expectedRecords: result.expectedRecords,
105
-
indexedRecords: result.indexedRecords,
106
-
privateStateValues: result.privateStateValues,
107
-
expectedBlobs: result.expectedBlobs,
108
-
importedBlobs: result.importedBlobs,
109
-
};
110
-
const errorMessage = `${
111
-
result.reason || "Verification failed"
112
-
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
113
-
114
-
setRetryCount((prev) => prev + 1);
115
-
if (retryCount >= 1) {
116
-
setShowContinueAnyway(true);
117
-
}
118
-
119
-
setError(errorMessage);
120
-
setStatus("error");
121
-
onStepError(errorMessage, true);
122
-
}
123
-
} catch (error) {
124
-
const errorMessage = error instanceof Error
125
-
? error.message
126
-
: String(error);
127
-
setRetryCount((prev) => prev + 1);
128
-
if (retryCount >= 1) {
129
-
setShowContinueAnyway(true);
130
-
}
131
-
132
-
setError(errorMessage);
133
-
setStatus("error");
134
-
onStepError(errorMessage, true);
135
-
}
136
-
};
137
-
138
-
const retryVerification = async () => {
139
-
await verifyDataMigration();
140
-
};
141
-
142
-
const continueAnyway = () => {
143
-
setStatus("completed");
144
-
setShowContinueAnyway(false);
145
-
onStepComplete();
146
-
};
147
-
148
-
return (
149
-
<MigrationStep
150
-
name="Migrate Data"
151
-
status={status}
152
-
error={error}
153
-
isVerificationError={status === "error" &&
154
-
error?.includes("Verification failed")}
155
-
index={1}
156
-
onRetryVerification={retryVerification}
157
-
>
158
-
{status === "error" && showContinueAnyway && (
159
-
<div class="flex space-x-2 mt-2">
160
-
<button
161
-
type="button"
162
-
onClick={continueAnyway}
163
-
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
164
-
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
165
-
>
166
-
Continue Anyway
167
-
</button>
168
-
</div>
169
-
)}
170
-
</MigrationStep>
171
-
);
172
-
}
-143
islands/migration-steps/FinalizationStep.tsx
-143
islands/migration-steps/FinalizationStep.tsx
···
1
-
import { useEffect, useState } from "preact/hooks";
2
-
import { MigrationStep } from "../../components/MigrationStep.tsx";
3
-
import {
4
-
parseApiResponse,
5
-
StepCommonProps,
6
-
verifyMigrationStep,
7
-
} from "../../lib/migration-types.ts";
8
-
9
-
interface FinalizationStepProps extends StepCommonProps {
10
-
isActive: boolean;
11
-
}
12
-
13
-
export default function FinalizationStep({
14
-
credentials: _credentials,
15
-
onStepComplete,
16
-
onStepError,
17
-
isActive,
18
-
}: FinalizationStepProps) {
19
-
const [status, setStatus] = useState<
20
-
"pending" | "in-progress" | "verifying" | "completed" | "error"
21
-
>("pending");
22
-
const [error, setError] = useState<string>();
23
-
const [retryCount, setRetryCount] = useState(0);
24
-
const [showContinueAnyway, setShowContinueAnyway] = useState(false);
25
-
26
-
useEffect(() => {
27
-
if (isActive && status === "pending") {
28
-
startFinalization();
29
-
}
30
-
}, [isActive]);
31
-
32
-
const startFinalization = async () => {
33
-
setStatus("in-progress");
34
-
setError(undefined);
35
-
36
-
try {
37
-
const finalizeRes = await fetch("/api/migrate/finalize", {
38
-
method: "POST",
39
-
headers: { "Content-Type": "application/json" },
40
-
});
41
-
42
-
const finalizeData = await finalizeRes.text();
43
-
if (!finalizeRes.ok) {
44
-
const parsed = parseApiResponse(finalizeData);
45
-
throw new Error(parsed.message || "Failed to finalize migration");
46
-
}
47
-
48
-
const parsed = parseApiResponse(finalizeData);
49
-
if (!parsed.success) {
50
-
throw new Error(parsed.message || "Finalization failed");
51
-
}
52
-
53
-
// Verify the finalization
54
-
await verifyFinalization();
55
-
} catch (error) {
56
-
const errorMessage = error instanceof Error
57
-
? error.message
58
-
: String(error);
59
-
setError(errorMessage);
60
-
setStatus("error");
61
-
onStepError(errorMessage);
62
-
}
63
-
};
64
-
65
-
const verifyFinalization = async () => {
66
-
setStatus("verifying");
67
-
68
-
try {
69
-
const result = await verifyMigrationStep(4);
70
-
71
-
if (result.ready) {
72
-
setStatus("completed");
73
-
setRetryCount(0);
74
-
setShowContinueAnyway(false);
75
-
onStepComplete();
76
-
} else {
77
-
const statusDetails = {
78
-
activated: result.activated,
79
-
validDid: result.validDid,
80
-
};
81
-
const errorMessage = `${
82
-
result.reason || "Verification failed"
83
-
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
84
-
85
-
setRetryCount((prev) => prev + 1);
86
-
if (retryCount >= 1) {
87
-
setShowContinueAnyway(true);
88
-
}
89
-
90
-
setError(errorMessage);
91
-
setStatus("error");
92
-
onStepError(errorMessage, true);
93
-
}
94
-
} catch (error) {
95
-
const errorMessage = error instanceof Error
96
-
? error.message
97
-
: String(error);
98
-
setRetryCount((prev) => prev + 1);
99
-
if (retryCount >= 1) {
100
-
setShowContinueAnyway(true);
101
-
}
102
-
103
-
setError(errorMessage);
104
-
setStatus("error");
105
-
onStepError(errorMessage, true);
106
-
}
107
-
};
108
-
109
-
const retryVerification = async () => {
110
-
await verifyFinalization();
111
-
};
112
-
113
-
const continueAnyway = () => {
114
-
setStatus("completed");
115
-
setShowContinueAnyway(false);
116
-
onStepComplete();
117
-
};
118
-
119
-
return (
120
-
<MigrationStep
121
-
name="Finalize Migration"
122
-
status={status}
123
-
error={error}
124
-
isVerificationError={status === "error" &&
125
-
error?.includes("Verification failed")}
126
-
index={3}
127
-
onRetryVerification={retryVerification}
128
-
>
129
-
{status === "error" && showContinueAnyway && (
130
-
<div class="flex space-x-2 mt-2">
131
-
<button
132
-
type="button"
133
-
onClick={continueAnyway}
134
-
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
135
-
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
136
-
>
137
-
Continue Anyway
138
-
</button>
139
-
</div>
140
-
)}
141
-
</MigrationStep>
142
-
);
143
-
}
-294
islands/migration-steps/IdentityMigrationStep.tsx
-294
islands/migration-steps/IdentityMigrationStep.tsx
···
1
-
import { useEffect, useRef, useState } from "preact/hooks";
2
-
import { MigrationStep } from "../../components/MigrationStep.tsx";
3
-
import {
4
-
parseApiResponse,
5
-
StepCommonProps,
6
-
verifyMigrationStep,
7
-
} from "../../lib/migration-types.ts";
8
-
9
-
interface IdentityMigrationStepProps extends StepCommonProps {
10
-
isActive: boolean;
11
-
}
12
-
13
-
export default function IdentityMigrationStep({
14
-
credentials: _credentials,
15
-
onStepComplete,
16
-
onStepError,
17
-
isActive,
18
-
}: IdentityMigrationStepProps) {
19
-
const [status, setStatus] = useState<
20
-
"pending" | "in-progress" | "verifying" | "completed" | "error"
21
-
>("pending");
22
-
const [error, setError] = useState<string>();
23
-
const [retryCount, setRetryCount] = useState(0);
24
-
const [showContinueAnyway, setShowContinueAnyway] = useState(false);
25
-
const [token, setToken] = useState("");
26
-
const [identityRequestSent, setIdentityRequestSent] = useState(false);
27
-
const [identityRequestCooldown, setIdentityRequestCooldown] = useState(0);
28
-
const [cooldownInterval, setCooldownInterval] = useState<number | null>(null);
29
-
const [stepName, setStepName] = useState("Migrate Identity");
30
-
const identityRequestInProgressRef = useRef(false);
31
-
32
-
// Clean up interval on unmount
33
-
useEffect(() => {
34
-
return () => {
35
-
if (cooldownInterval !== null) {
36
-
clearInterval(cooldownInterval);
37
-
}
38
-
};
39
-
}, [cooldownInterval]);
40
-
41
-
useEffect(() => {
42
-
if (isActive && status === "pending") {
43
-
startIdentityMigration();
44
-
}
45
-
}, [isActive]);
46
-
47
-
const startIdentityMigration = async () => {
48
-
// Prevent multiple concurrent calls
49
-
if (identityRequestInProgressRef.current) {
50
-
return;
51
-
}
52
-
53
-
identityRequestInProgressRef.current = true;
54
-
setStatus("in-progress");
55
-
setError(undefined);
56
-
57
-
// Don't send duplicate requests
58
-
if (identityRequestSent) {
59
-
setStepName(
60
-
"Enter the token sent to your email to complete identity migration",
61
-
);
62
-
setTimeout(() => {
63
-
identityRequestInProgressRef.current = false;
64
-
}, 1000);
65
-
return;
66
-
}
67
-
68
-
try {
69
-
const requestRes = await fetch("/api/migrate/identity/request", {
70
-
method: "POST",
71
-
headers: { "Content-Type": "application/json" },
72
-
});
73
-
74
-
const requestText = await requestRes.text();
75
-
76
-
if (!requestRes.ok) {
77
-
const parsed = parseApiResponse(requestText);
78
-
throw new Error(
79
-
parsed.message || "Failed to request identity migration",
80
-
);
81
-
}
82
-
83
-
const parsed = parseApiResponse(requestText);
84
-
if (!parsed.success) {
85
-
throw new Error(parsed.message || "Identity migration request failed");
86
-
}
87
-
88
-
// Mark request as sent
89
-
setIdentityRequestSent(true);
90
-
91
-
// Handle rate limiting
92
-
const jsonData = JSON.parse(requestText);
93
-
if (jsonData.rateLimited && jsonData.cooldownRemaining) {
94
-
setIdentityRequestCooldown(jsonData.cooldownRemaining);
95
-
96
-
// Clear any existing interval
97
-
if (cooldownInterval !== null) {
98
-
clearInterval(cooldownInterval);
99
-
}
100
-
101
-
// Set up countdown timer
102
-
const intervalId = setInterval(() => {
103
-
setIdentityRequestCooldown((prev) => {
104
-
if (prev <= 1) {
105
-
clearInterval(intervalId);
106
-
setCooldownInterval(null);
107
-
return 0;
108
-
}
109
-
return prev - 1;
110
-
});
111
-
}, 1000);
112
-
113
-
setCooldownInterval(intervalId);
114
-
}
115
-
116
-
// Update step name to prompt for token
117
-
setStepName(
118
-
identityRequestCooldown > 0
119
-
? `Please wait ${identityRequestCooldown}s before requesting another code`
120
-
: "Enter the token sent to your email to complete identity migration",
121
-
);
122
-
} catch (error) {
123
-
const errorMessage = error instanceof Error
124
-
? error.message
125
-
: String(error);
126
-
// Don't mark as error if it was due to rate limiting
127
-
if (identityRequestCooldown > 0) {
128
-
setStatus("in-progress");
129
-
} else {
130
-
setError(errorMessage);
131
-
setStatus("error");
132
-
onStepError(errorMessage);
133
-
}
134
-
} finally {
135
-
setTimeout(() => {
136
-
identityRequestInProgressRef.current = false;
137
-
}, 1000);
138
-
}
139
-
};
140
-
141
-
const handleIdentityMigration = async () => {
142
-
if (!token) return;
143
-
144
-
try {
145
-
const identityRes = await fetch(
146
-
`/api/migrate/identity/sign?token=${encodeURIComponent(token)}`,
147
-
{
148
-
method: "POST",
149
-
headers: { "Content-Type": "application/json" },
150
-
},
151
-
);
152
-
153
-
const identityData = await identityRes.text();
154
-
if (!identityRes.ok) {
155
-
const parsed = parseApiResponse(identityData);
156
-
throw new Error(
157
-
parsed.message || "Failed to complete identity migration",
158
-
);
159
-
}
160
-
161
-
const parsed = parseApiResponse(identityData);
162
-
if (!parsed.success) {
163
-
throw new Error(parsed.message || "Identity migration failed");
164
-
}
165
-
166
-
// Verify the identity migration
167
-
await verifyIdentityMigration();
168
-
} catch (error) {
169
-
const errorMessage = error instanceof Error
170
-
? error.message
171
-
: String(error);
172
-
setError(errorMessage);
173
-
setStatus("error");
174
-
onStepError(errorMessage);
175
-
}
176
-
};
177
-
178
-
const verifyIdentityMigration = async () => {
179
-
setStatus("verifying");
180
-
181
-
try {
182
-
const result = await verifyMigrationStep(3);
183
-
184
-
if (result.ready) {
185
-
setStatus("completed");
186
-
setRetryCount(0);
187
-
setShowContinueAnyway(false);
188
-
onStepComplete();
189
-
} else {
190
-
const statusDetails = {
191
-
activated: result.activated,
192
-
validDid: result.validDid,
193
-
};
194
-
const errorMessage = `${
195
-
result.reason || "Verification failed"
196
-
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
197
-
198
-
setRetryCount((prev) => prev + 1);
199
-
if (retryCount >= 1) {
200
-
setShowContinueAnyway(true);
201
-
}
202
-
203
-
setError(errorMessage);
204
-
setStatus("error");
205
-
onStepError(errorMessage, true);
206
-
}
207
-
} catch (error) {
208
-
const errorMessage = error instanceof Error
209
-
? error.message
210
-
: String(error);
211
-
setRetryCount((prev) => prev + 1);
212
-
if (retryCount >= 1) {
213
-
setShowContinueAnyway(true);
214
-
}
215
-
216
-
setError(errorMessage);
217
-
setStatus("error");
218
-
onStepError(errorMessage, true);
219
-
}
220
-
};
221
-
222
-
const retryVerification = async () => {
223
-
await verifyIdentityMigration();
224
-
};
225
-
226
-
const continueAnyway = () => {
227
-
setStatus("completed");
228
-
setShowContinueAnyway(false);
229
-
onStepComplete();
230
-
};
231
-
232
-
return (
233
-
<MigrationStep
234
-
name={stepName}
235
-
status={status}
236
-
error={error}
237
-
isVerificationError={status === "error" &&
238
-
error?.includes("Verification failed")}
239
-
index={2}
240
-
onRetryVerification={retryVerification}
241
-
>
242
-
{status === "error" && showContinueAnyway && (
243
-
<div class="flex space-x-2 mt-2">
244
-
<button
245
-
type="button"
246
-
onClick={continueAnyway}
247
-
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
248
-
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
249
-
>
250
-
Continue Anyway
251
-
</button>
252
-
</div>
253
-
)}
254
-
255
-
{(status === "in-progress" || identityRequestSent) &&
256
-
stepName.includes("Enter the token sent to your email") &&
257
-
(identityRequestCooldown > 0
258
-
? (
259
-
<div class="mt-4">
260
-
<p class="text-sm text-amber-600 dark:text-amber-400">
261
-
<span class="font-medium">Rate limit:</span> Please wait{" "}
262
-
{identityRequestCooldown}{" "}
263
-
seconds before requesting another code. Check your email inbox
264
-
and spam folder for a previously sent code.
265
-
</p>
266
-
</div>
267
-
)
268
-
: (
269
-
<div class="mt-4 space-y-4">
270
-
<p class="text-sm text-blue-800 dark:text-blue-200">
271
-
Please check your email for the migration token and enter it
272
-
below:
273
-
</p>
274
-
<div class="flex space-x-2">
275
-
<input
276
-
type="text"
277
-
value={token}
278
-
onChange={(e) => setToken(e.currentTarget.value)}
279
-
placeholder="Enter token"
280
-
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400"
281
-
/>
282
-
<button
283
-
type="button"
284
-
onClick={handleIdentityMigration}
285
-
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
286
-
>
287
-
Submit Token
288
-
</button>
289
-
</div>
290
-
</div>
291
-
))}
292
-
</MigrationStep>
293
-
);
294
-
}
+5
-5
lib/cred/sessions.ts
+5
-5
lib/cred/sessions.ts
···
1
1
import { Agent } from "npm:@atproto/api";
2
2
import { getIronSession, SessionOptions } from "npm:iron-session";
3
-
import { createSessionOptions, CredentialSession } from "../types.ts";
3
+
import { CredentialSession, createSessionOptions } from "../types.ts";
4
4
5
5
let migrationSessionOptions: SessionOptions;
6
6
let credentialSessionOptions: SessionOptions;
···
34
34
export async function getCredentialSession(
35
35
req: Request,
36
36
res: Response = new Response(),
37
-
isMigration: boolean = false,
37
+
isMigration: boolean = false
38
38
) {
39
39
const options = await getOptions(isMigration);
40
40
return getIronSession<CredentialSession>(req, res, options);
···
50
50
export async function getCredentialAgent(
51
51
req: Request,
52
52
res: Response = new Response(),
53
-
isMigration: boolean = false,
53
+
isMigration: boolean = false
54
54
) {
55
55
const session = await getCredentialSession(req, res, isMigration);
56
56
if (
···
104
104
req: Request,
105
105
res: Response,
106
106
data: CredentialSession,
107
-
isMigration: boolean = false,
107
+
isMigration: boolean = false
108
108
) {
109
109
const session = await getCredentialSession(req, res, isMigration);
110
110
session.did = data.did;
···
125
125
export async function getCredentialSessionAgent(
126
126
req: Request,
127
127
res: Response = new Response(),
128
-
isMigration: boolean = false,
128
+
isMigration: boolean = false
129
129
) {
130
130
const session = await getCredentialSession(req, res, isMigration);
131
131
+3
-3
lib/id-resolver.ts
+3
-3
lib/id-resolver.ts
···
55
55
},
56
56
57
57
async resolveHandleToDid(handle: string) {
58
-
return await resolver.handle.resolve(handle) as Did;
58
+
return await resolver.handle.resolve(handle) as Did
59
59
},
60
60
61
61
async resolveDidToPdsUrl(did: string): Promise<string | undefined> {
···
68
68
return didDoc.pds;
69
69
} else {
70
70
const forcedDidDoc = await resolver.did.resolveAtprotoData(
71
-
did,
71
+
did,
72
72
true,
73
-
);
73
+
)
74
74
if (forcedDidDoc.pds) {
75
75
return forcedDidDoc.pds;
76
76
}
+3
-6
lib/migration-state.ts
+3
-6
lib/migration-state.ts
···
15
15
* @returns The migration state information
16
16
*/
17
17
export function getMigrationState(): MigrationStateInfo {
18
-
const state = (Deno.env.get("MIGRATION_STATE") || "up")
19
-
.toLowerCase() as MigrationState;
18
+
const state = (Deno.env.get("MIGRATION_STATE") || "up").toLowerCase() as MigrationState;
20
19
21
20
switch (state) {
22
21
case "issue":
23
22
return {
24
23
state: "issue",
25
-
message:
26
-
"Migration services are temporarily unavailable as we investigate an issue. Please try again later.",
24
+
message: "Migration services are temporarily unavailable as we investigate an issue. Please try again later.",
27
25
allowMigration: false,
28
26
};
29
27
30
28
case "maintenance":
31
29
return {
32
30
state: "maintenance",
33
-
message:
34
-
"Migration services are temporarily unavailable for maintenance. Please try again later.",
31
+
message: "Migration services are temporarily unavailable for maintenance. Please try again later.",
35
32
allowMigration: false,
36
33
};
37
34
-63
lib/migration-types.ts
-63
lib/migration-types.ts
···
1
-
/**
2
-
* Shared types for migration components
3
-
*/
4
-
5
-
export interface MigrationStateInfo {
6
-
state: "up" | "issue" | "maintenance";
7
-
message: string;
8
-
allowMigration: boolean;
9
-
}
10
-
11
-
export interface MigrationCredentials {
12
-
service: string;
13
-
handle: string;
14
-
email: string;
15
-
password: string;
16
-
invite?: string;
17
-
}
18
-
19
-
export interface StepCommonProps {
20
-
credentials: MigrationCredentials;
21
-
onStepComplete: () => void;
22
-
onStepError: (error: string, isVerificationError?: boolean) => void;
23
-
}
24
-
25
-
export interface VerificationResult {
26
-
ready: boolean;
27
-
reason?: string;
28
-
activated?: boolean;
29
-
validDid?: boolean;
30
-
repoCommit?: boolean;
31
-
repoRev?: boolean;
32
-
repoBlocks?: number;
33
-
expectedRecords?: number;
34
-
indexedRecords?: number;
35
-
privateStateValues?: number;
36
-
expectedBlobs?: number;
37
-
importedBlobs?: number;
38
-
}
39
-
40
-
/**
41
-
* Helper function to verify a migration step
42
-
*/
43
-
export async function verifyMigrationStep(
44
-
stepNum: number,
45
-
): Promise<VerificationResult> {
46
-
const res = await fetch(`/api/migrate/status?step=${stepNum}`);
47
-
const data = await res.json();
48
-
return data;
49
-
}
50
-
51
-
/**
52
-
* Helper function to handle API responses with proper error parsing
53
-
*/
54
-
export function parseApiResponse(
55
-
responseText: string,
56
-
): { success: boolean; message?: string } {
57
-
try {
58
-
const json = JSON.parse(responseText);
59
-
return { success: json.success !== false, message: json.message };
60
-
} catch {
61
-
return { success: responseText.trim() !== "", message: responseText };
62
-
}
63
-
}
+2
-2
lib/oauth/sessions.ts
+2
-2
lib/oauth/sessions.ts
···
1
1
import { Agent } from "npm:@atproto/api";
2
2
import { getIronSession, SessionOptions } from "npm:iron-session";
3
3
import { oauthClient } from "./client.ts";
4
-
import { createSessionOptions, OauthSession } from "../types.ts";
4
+
import { OauthSession, createSessionOptions } from "../types.ts";
5
5
6
6
let oauthSessionOptions: SessionOptions;
7
7
···
22
22
* @returns The OAuth session agent
23
23
*/
24
24
export async function getOauthSessionAgent(
25
-
req: Request,
25
+
req: Request
26
26
) {
27
27
try {
28
28
console.log("Getting OAuth session...");
+26
-32
lib/types.ts
+26
-32
lib/types.ts
···
15
15
* @param db - The Deno KV instance for the database
16
16
* @returns The unlock function
17
17
*/
18
-
async function createLock(
19
-
key: string,
20
-
db: Deno.Kv,
21
-
): Promise<() => Promise<void>> {
18
+
async function createLock(key: string, db: Deno.Kv): Promise<() => Promise<void>> {
22
19
const lockKey = ["session_lock", key];
23
20
const lockValue = Date.now();
24
-
21
+
25
22
// Try to acquire lock
26
23
const result = await db.atomic()
27
-
.check({ key: lockKey, versionstamp: null }) // Only if key doesn't exist
28
-
.set(lockKey, lockValue, { expireIn: 5000 }) // 5 second TTL
24
+
.check({ key: lockKey, versionstamp: null }) // Only if key doesn't exist
25
+
.set(lockKey, lockValue, { expireIn: 5000 }) // 5 second TTL
29
26
.commit();
30
27
31
28
if (!result.ok) {
···
43
40
* @type {OauthSession}
44
41
*/
45
42
export interface OauthSession {
46
-
did: string;
43
+
did: string
47
44
}
48
45
49
46
/**
···
71
68
* @param cookieName - The name of the iron session cookie
72
69
* @returns The session options for iron session
73
70
*/
74
-
export const createSessionOptions = async (
75
-
cookieName: string,
76
-
): Promise<SessionOptions> => {
77
-
const cookieSecret = Deno.env.get("COOKIE_SECRET");
78
-
if (!cookieSecret) {
79
-
throw new Error("COOKIE_SECRET is not set");
80
-
}
71
+
export const createSessionOptions = async (cookieName: string): Promise<SessionOptions> => {
72
+
const cookieSecret = Deno.env.get("COOKIE_SECRET");
73
+
if (!cookieSecret) {
74
+
throw new Error("COOKIE_SECRET is not set");
75
+
}
81
76
82
-
if (!db) {
83
-
db = await Deno.openKv();
84
-
}
77
+
if (!db) {
78
+
db = await Deno.openKv();
79
+
}
85
80
86
-
return {
87
-
cookieName: cookieName,
88
-
password: cookieSecret,
89
-
cookieOptions: {
90
-
secure: Deno.env.get("NODE_ENV") === "production" ||
91
-
Deno.env.get("NODE_ENV") === "staging",
92
-
httpOnly: true,
93
-
sameSite: "lax",
94
-
path: "/",
95
-
domain: undefined,
96
-
},
97
-
lockFn: (key: string) => createLock(key, db),
98
-
};
99
-
};
81
+
return {
82
+
cookieName: cookieName,
83
+
password: cookieSecret,
84
+
cookieOptions: {
85
+
secure: Deno.env.get("NODE_ENV") === "production" || Deno.env.get("NODE_ENV") === "staging",
86
+
httpOnly: true,
87
+
sameSite: "lax",
88
+
path: "/",
89
+
domain: undefined,
90
+
},
91
+
lockFn: (key: string) => createLock(key, db)
92
+
}
93
+
};
+5
-5
routes/_error.tsx
+5
-5
routes/_error.tsx
···
1
-
import { HttpError, PageProps } from "fresh";
1
+
import { PageProps, HttpError } from "fresh";
2
2
import posthog from "posthog-js";
3
3
4
4
export default function ErrorPage(props: PageProps) {
5
5
const error = props.error; // Contains the thrown Error or HTTPError
6
6
if (error instanceof HttpError) {
7
-
posthog.default.capture("error", {
7
+
posthog.default.capture('error', {
8
8
error: error.message,
9
9
status: error.status,
10
10
});
···
32
32
FLIGHT NOT FOUND
33
33
</p>
34
34
<p class="text-lg sm:text-xl text-slate-600 dark:text-white/70 max-w-2xl">
35
-
We couldn't locate the destination you're looking for.
36
-
Please check your flight number and try again.
35
+
We couldn't locate the destination you're looking for. Please
36
+
check your flight number and try again.
37
37
</p>
38
38
<div class="mt-8">
39
39
<a
···
48
48
</div>
49
49
</div>
50
50
</>
51
-
);
51
+
)
52
52
}
53
53
}
54
54
+38
-68
routes/about.tsx
+38
-68
routes/about.tsx
···
6
6
<div class="px-2 sm:px-4 py-4 sm:py-8 mx-auto">
7
7
<div class="max-w-screen-lg mx-auto flex flex-col items-center justify-center">
8
8
<div class="prose dark:prose-invert max-w-none w-full mb-0">
9
-
<h1 class="text-3xl font-bold text-center mb-8">
10
-
About AT Protocol
11
-
</h1>
9
+
<h1 class="text-3xl font-bold text-center mb-8">About AT Protocol</h1>
12
10
13
11
<div class="space-y-6">
14
12
<section>
15
-
<h2 class="text-2xl font-semibold mb-4">
16
-
What is AT Protocol?
17
-
</h2>
13
+
<h2 class="text-2xl font-semibold mb-4">What is AT Protocol?</h2>
18
14
<p class="text-gray-600 dark:text-gray-300">
19
15
AT Protocol (Authenticated Transfer Protocol) is the
20
16
foundation of Bluesky and other social apps like
21
17
<a href="https://tangled.sh">Tangled</a>,
22
-
<a href="https://spark.com">Spark</a>, and more. Unlike
23
-
traditional social platforms that lock your data and identity
24
-
to a single service, AT Protocol gives you complete control
25
-
over your digital presence. Think of it as an open standard
26
-
for social networking, similar to how email works across
27
-
different providers.
18
+
<a href="https://spark.com">Spark</a>, and more.
19
+
Unlike traditional social platforms that lock your
20
+
data and identity to a single service, AT Protocol
21
+
gives you complete control over your digital presence.
22
+
Think of it as an open standard for social networking,
23
+
similar to how email works across different providers.
28
24
</p>
29
25
</section>
30
26
···
32
28
<h2 class="text-2xl font-semibold mb-4">Key Features</h2>
33
29
<ul class="list-disc pl-6 space-y-4 text-gray-600 dark:text-gray-300">
34
30
<li>
35
-
<strong>PDS Servers:</strong>{" "}
36
-
PDS servers are where your data is stored. They can be run
37
-
by anyone, and they are very lightweight, allowing you to
38
-
choose which one to use or run your own. PDS servers just
39
-
store your data, meaning you don't have to switch PDS
40
-
servers to use a different app or service. You can have one
41
-
PDS while using many different apps and services with the
31
+
<strong>PDS Servers:</strong> PDS servers are where your data is stored.
32
+
They can be run by anyone, and they are very lightweight, allowing you to
33
+
choose which one to use or run your own. PDS servers just store your data,
34
+
meaning you don't have to switch PDS servers to use a different app or service.
35
+
You can have one PDS while using many different apps and services with the
42
36
same account.
43
37
</li>
44
38
<li>
45
-
<strong>Decentralized Identity:</strong>{" "}
46
-
Your account is tied to a DID (Decentralized Identifier)
47
-
rather than your handle/username. This means you can move
48
-
your entire account, including your followers and content,
49
-
to any PDS by changing where your DID points. It's also the
50
-
reason you can use any domain as your handle, because your
51
-
identity is not tied to your handle. Your handle can change,
39
+
<strong>Decentralized Identity:</strong> Your account is tied to a DID
40
+
(Decentralized Identifier) rather than your handle/username.
41
+
This means you can move your entire account, including your followers
42
+
and content, to any PDS by changing where your DID points.
43
+
It's also the reason you can use any domain as your handle, because
44
+
your identity is not tied to your handle. Your handle can change,
52
45
but your DID will always remain the same.
53
46
</li>
54
47
<li>
55
-
<strong>Portable Content:</strong>{" "}
56
-
All your posts, likes, and other social data are stored in
57
-
your Personal Data Server (PDS). You can switch PDS
58
-
providers without losing any content or connections.
48
+
<strong>Portable Content:</strong> All your posts, likes, and other social
49
+
data are stored in your Personal Data Server (PDS).
50
+
You can switch PDS providers without losing any content or connections.
59
51
</li>
60
52
<li>
61
-
<strong>Architecture:</strong>{" "}
62
-
The protocol uses a three-tier architecture: Personal Data
63
-
Servers (PDS) store your content, relays broadcast a stream
64
-
of all events on all PDSes, and AppViews process and serve
65
-
that stream into content for users. This means when you make
66
-
a post, the content is stored on your PDS, picked up by
67
-
relays, and AppViews listen to those relays to deliver that
68
-
post to all users.
53
+
<strong>Architecture:</strong> The protocol uses a three-tier architecture:
54
+
Personal Data Servers (PDS) store your content,
55
+
relays broadcast a stream of all events on all PDSes,
56
+
and AppViews process and serve that stream into content for users.
57
+
This means when you make a post, the content is stored on your PDS,
58
+
picked up by relays, and AppViews listen to those relays to deliver
59
+
that post to all users.
69
60
</li>
70
61
<li>
71
-
<strong>Algorithmic Choice:</strong>{" "}
72
-
You're not locked into a single algorithm for your feed.
73
-
Different services can offer different ways of curating
74
-
content, and you can choose which one you prefer. Bluesky
75
-
offers a way to make custom feeds, but even if it didn't,
76
-
different apps could still offer their own algorithms for
77
-
curating content.
62
+
<strong>Algorithmic Choice:</strong> You're not locked into a single algorithm
63
+
for your feed. Different services can offer different ways of curating content,
64
+
and you can choose which one you prefer. Bluesky offers a way to make custom
65
+
feeds, but even if it didn't, different apps could still offer their own
66
+
algorithms for curating content.
78
67
</li>
79
68
</ul>
80
69
</section>
···
83
72
<h2 class="text-2xl font-semibold mb-4">Learn More</h2>
84
73
<div class="space-y-4">
85
74
<p class="text-gray-600 dark:text-gray-300">
86
-
Want to dive deeper into AT Protocol? Check out these
87
-
resources:
75
+
Want to dive deeper into AT Protocol? Check out these resources:
88
76
</p>
89
77
<ul class="list-none space-y-2">
90
78
<li>
91
-
<a
92
-
href="https://atproto.com"
93
-
class="text-blue-500 hover:underline"
94
-
>
95
-
Official AT Protocol Docs
96
-
</a>{" "}
97
-
- The main source for protocol specs and information
79
+
<a href="https://atproto.com" class="text-blue-500 hover:underline">Official AT Protocol Docs</a> - The main source for protocol specs and information
98
80
</li>
99
81
<li>
100
-
<a
101
-
href="https://github.com/bluesky-social/atproto"
102
-
class="text-blue-500 hover:underline"
103
-
>
104
-
GitHub Repository
105
-
</a>{" "}
106
-
- View the protocol implementation
82
+
<a href="https://github.com/bluesky-social/atproto" class="text-blue-500 hover:underline">GitHub Repository</a> - View the protocol implementation
107
83
</li>
108
84
<li>
109
-
<a
110
-
href="https://atproto.wiki"
111
-
class="text-blue-500 hover:underline"
112
-
>
113
-
AT Protocol Wiki
114
-
</a>{" "}
115
-
- Community-driven documentation and resources
85
+
<a href="https://atproto.wiki" class="text-blue-500 hover:underline">AT Protocol Wiki</a> - Community-driven documentation and resources
116
86
</li>
117
87
</ul>
118
88
</div>
+41
-59
routes/api/cred/login.ts
+41
-59
routes/api/cred/login.ts
···
17
17
const { handle, password } = body;
18
18
19
19
if (!handle || !password) {
20
-
return new Response(
21
-
JSON.stringify({
22
-
success: false,
23
-
message: "Handle and password are required",
24
-
}),
25
-
{
26
-
status: 400,
27
-
headers: { "Content-Type": "application/json" },
28
-
}
29
-
);
20
+
return new Response(JSON.stringify({
21
+
success: false,
22
+
message: "Handle and password are required"
23
+
}), {
24
+
status: 400,
25
+
headers: { "Content-Type": "application/json" }
26
+
});
30
27
}
31
28
32
29
console.log("Resolving handle:", handle);
33
-
const did =
34
-
typeof handle == "string" && handle.startsWith("did:")
35
-
? handle
36
-
: await resolver.resolveHandleToDid(handle);
37
-
const service = await resolver.resolveDidToPdsUrl(did);
30
+
const did = await resolver.resolveHandleToDid(handle)
31
+
const service = await resolver.resolveDidToPdsUrl(did)
38
32
console.log("Resolved service:", service);
39
33
40
34
if (!service) {
41
-
return new Response(
42
-
JSON.stringify({
43
-
success: false,
44
-
message: "Invalid handle",
45
-
}),
46
-
{
47
-
status: 400,
48
-
}
49
-
);
35
+
return new Response(JSON.stringify({
36
+
success: false,
37
+
message: "Invalid handle"
38
+
}), {
39
+
status: 400,
40
+
})
50
41
}
51
42
52
43
try {
···
60
51
console.log("Created ATProto session:", {
61
52
did: sessionRes.data.did,
62
53
handle: sessionRes.data.handle,
63
-
hasAccessJwt: !!sessionRes.data.accessJwt,
54
+
hasAccessJwt: !!sessionRes.data.accessJwt
64
55
});
65
56
66
57
// Create response for setting cookies
67
-
const response = new Response(
68
-
JSON.stringify({
69
-
success: true,
70
-
did,
71
-
handle,
72
-
}),
73
-
{
74
-
status: 200,
75
-
headers: { "Content-Type": "application/json" },
76
-
}
77
-
);
58
+
const response = new Response(JSON.stringify({
59
+
success: true,
60
+
did,
61
+
handle
62
+
}), {
63
+
status: 200,
64
+
headers: { "Content-Type": "application/json" }
65
+
});
78
66
79
67
// Create and save our client session with tokens
80
68
await setCredentialSession(ctx.req, response, {
···
82
70
service,
83
71
password,
84
72
handle,
85
-
accessJwt: sessionRes.data.accessJwt,
73
+
accessJwt: sessionRes.data.accessJwt
86
74
});
87
75
88
76
// Log the response headers
89
77
console.log("Response headers:", {
90
78
cookies: response.headers.get("Set-Cookie"),
91
-
allHeaders: Object.fromEntries(response.headers.entries()),
79
+
allHeaders: Object.fromEntries(response.headers.entries())
92
80
});
93
81
94
82
return response;
95
83
} catch (err) {
96
84
const message = err instanceof Error ? err.message : String(err);
97
85
console.error("Login failed:", message);
98
-
return new Response(
99
-
JSON.stringify({
100
-
success: false,
101
-
message: "Invalid credentials",
102
-
}),
103
-
{
104
-
status: 401,
105
-
headers: { "Content-Type": "application/json" },
106
-
}
107
-
);
86
+
return new Response(JSON.stringify({
87
+
success: false,
88
+
message: "Invalid credentials"
89
+
}), {
90
+
status: 401,
91
+
headers: { "Content-Type": "application/json" }
92
+
});
108
93
}
109
94
} catch (error) {
110
95
const message = error instanceof Error ? error.message : String(error);
111
96
console.error("Login error:", message);
112
-
return new Response(
113
-
JSON.stringify({
114
-
success: false,
115
-
message: error instanceof Error ? error.message : "An error occurred",
116
-
}),
117
-
{
118
-
status: 500,
119
-
headers: { "Content-Type": "application/json" },
120
-
}
121
-
);
97
+
return new Response(JSON.stringify({
98
+
success: false,
99
+
message: error instanceof Error ? error.message : "An error occurred"
100
+
}), {
101
+
status: 500,
102
+
headers: { "Content-Type": "application/json" }
103
+
});
122
104
}
123
-
},
105
+
}
124
106
});
+9
-12
routes/api/me.ts
+9
-12
routes/api/me.ts
···
8
8
const res = new Response();
9
9
10
10
try {
11
-
console.log(
12
-
"[/api/me] Request headers:",
13
-
Object.fromEntries(req.headers.entries()),
14
-
);
11
+
console.log("[/api/me] Request headers:", Object.fromEntries(req.headers.entries()));
15
12
16
13
const agent = await getSessionAgent(req, res);
17
14
if (!agent) {
···
20
17
status: 200,
21
18
headers: {
22
19
"Content-Type": "application/json",
23
-
"X-Response-Type": "null",
24
-
},
20
+
"X-Response-Type": "null"
21
+
}
25
22
});
26
23
}
27
24
···
31
28
32
29
const responseData = {
33
30
did: session.data.did,
34
-
handle,
31
+
handle
35
32
};
36
33
37
34
return new Response(JSON.stringify(responseData), {
38
35
status: 200,
39
36
headers: {
40
37
"Content-Type": "application/json",
41
-
"X-Response-Type": "user",
42
-
},
38
+
"X-Response-Type": "user"
39
+
}
43
40
});
44
41
} catch (err) {
45
42
const message = err instanceof Error ? err.message : String(err);
···
48
45
stack: err instanceof Error ? err.stack : undefined,
49
46
url: req.url,
50
47
method: req.method,
51
-
headers: Object.fromEntries(req.headers.entries()),
48
+
headers: Object.fromEntries(req.headers.entries())
52
49
});
53
50
54
51
return new Response(JSON.stringify(null), {
···
56
53
headers: {
57
54
"Content-Type": "application/json",
58
55
"X-Response-Type": "error",
59
-
"X-Error-Message": encodeURIComponent(message),
60
-
},
56
+
"X-Error-Message": encodeURIComponent(message)
57
+
}
61
58
});
62
59
}
63
60
},
-52
routes/api/migrate/identity/request.ts
-52
routes/api/migrate/identity/request.ts
···
3
3
import { define } from "../../../../utils.ts";
4
4
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
5
5
6
-
// Simple in-memory cache for rate limiting
7
-
// In a production environment, you might want to use Redis or another shared cache
8
-
const requestCache = new Map<string, number>();
9
-
const COOLDOWN_PERIOD_MS = 60000; // 1 minute cooldown
10
-
11
6
/**
12
7
* Handle identity migration request
13
8
* Sends a PLC operation signature request to the old account's email
···
75
70
);
76
71
}
77
72
78
-
// Check if we've recently sent a request for this DID
79
-
const did = oldAgent.did || "";
80
-
const now = Date.now();
81
-
const lastRequestTime = requestCache.get(did);
82
-
83
-
if (lastRequestTime && now - lastRequestTime < COOLDOWN_PERIOD_MS) {
84
-
console.log(
85
-
`Rate limiting PLC request for ${did}, last request was ${
86
-
(now - lastRequestTime) / 1000
87
-
} seconds ago`,
88
-
);
89
-
return new Response(
90
-
JSON.stringify({
91
-
success: true,
92
-
message:
93
-
"A PLC code was already sent to your email. Please check your inbox and spam folder.",
94
-
rateLimited: true,
95
-
cooldownRemaining: Math.ceil(
96
-
(COOLDOWN_PERIOD_MS - (now - lastRequestTime)) / 1000,
97
-
),
98
-
}),
99
-
{
100
-
status: 200,
101
-
headers: {
102
-
"Content-Type": "application/json",
103
-
...Object.fromEntries(res.headers),
104
-
},
105
-
},
106
-
);
107
-
}
108
-
109
73
// Request the signature
110
74
console.log("Requesting PLC operation signature...");
111
75
try {
112
76
await oldAgent.com.atproto.identity.requestPlcOperationSignature();
113
77
console.log("Successfully requested PLC operation signature");
114
-
115
-
// Store the request time
116
-
if (did) {
117
-
requestCache.set(did, now);
118
-
119
-
// Optionally, set up cache cleanup for DIDs that haven't been used in a while
120
-
setTimeout(() => {
121
-
if (
122
-
did &&
123
-
requestCache.has(did) &&
124
-
Date.now() - requestCache.get(did)! > COOLDOWN_PERIOD_MS * 2
125
-
) {
126
-
requestCache.delete(did);
127
-
}
128
-
}, COOLDOWN_PERIOD_MS * 2);
129
-
}
130
78
} catch (error) {
131
79
console.error("Error requesting PLC operation signature:", {
132
80
name: error instanceof Error ? error.name : "Unknown",
+37
-44
routes/api/migrate/next-step.ts
+37
-44
routes/api/migrate/next-step.ts
···
2
2
import { define } from "../../../utils.ts";
3
3
4
4
export const handler = define.handlers({
5
-
async GET(ctx) {
6
-
let nextStep = null;
7
-
const oldAgent = await getSessionAgent(ctx.req);
8
-
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
5
+
async GET(ctx) {
6
+
let nextStep = null;
7
+
const oldAgent = await getSessionAgent(ctx.req);
8
+
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
9
+
10
+
if (!newAgent) return Response.json({ nextStep: 1, completed: false });
11
+
if (!oldAgent) return new Response("Unauthorized", { status: 401 });
9
12
10
-
if (!newAgent) return Response.json({ nextStep: 1, completed: false });
11
-
if (!oldAgent) return new Response("Unauthorized", { status: 401 });
13
+
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
14
+
const newStatus = await newAgent.com.atproto.server.checkAccountStatus();
15
+
if (!oldStatus.data || !newStatus.data) return new Response("Could not verify status", { status: 500 });
12
16
13
-
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
14
-
const newStatus = await newAgent.com.atproto.server.checkAccountStatus();
15
-
if (!oldStatus.data || !newStatus.data) {
16
-
return new Response("Could not verify status", { status: 500 });
17
-
}
17
+
// Check conditions in sequence to determine the next step
18
+
if (!newStatus.data) {
19
+
nextStep = 1;
20
+
} else if (!(newStatus.data.repoCommit &&
21
+
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
22
+
newStatus.data.privateStateValues === oldStatus.data.privateStateValues &&
23
+
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
24
+
newStatus.data.importedBlobs === oldStatus.data.importedBlobs)) {
25
+
nextStep = 2;
26
+
} else if (!newStatus.data.validDid) {
27
+
nextStep = 3;
28
+
} else if (!(newStatus.data.activated === true && oldStatus.data.activated === false)) {
29
+
nextStep = 4;
30
+
}
18
31
19
-
// Check conditions in sequence to determine the next step
20
-
if (!newStatus.data) {
21
-
nextStep = 1;
22
-
} else if (
23
-
!(newStatus.data.repoCommit &&
24
-
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
25
-
newStatus.data.privateStateValues ===
26
-
oldStatus.data.privateStateValues &&
27
-
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
28
-
newStatus.data.importedBlobs === oldStatus.data.importedBlobs)
29
-
) {
30
-
nextStep = 2;
31
-
} else if (!newStatus.data.validDid) {
32
-
nextStep = 3;
33
-
} else if (
34
-
!(newStatus.data.activated === true && oldStatus.data.activated === false)
35
-
) {
36
-
nextStep = 4;
32
+
return Response.json({
33
+
nextStep,
34
+
completed: nextStep === null,
35
+
currentStatus: {
36
+
activated: newStatus.data.activated,
37
+
validDid: newStatus.data.validDid,
38
+
repoCommit: newStatus.data.repoCommit,
39
+
indexedRecords: newStatus.data.indexedRecords,
40
+
privateStateValues: newStatus.data.privateStateValues,
41
+
importedBlobs: newStatus.data.importedBlobs
42
+
}
43
+
});
37
44
}
38
-
39
-
return Response.json({
40
-
nextStep,
41
-
completed: nextStep === null,
42
-
currentStatus: {
43
-
activated: newStatus.data.activated,
44
-
validDid: newStatus.data.validDid,
45
-
repoCommit: newStatus.data.repoCommit,
46
-
indexedRecords: newStatus.data.indexedRecords,
47
-
privateStateValues: newStatus.data.privateStateValues,
48
-
importedBlobs: newStatus.data.importedBlobs,
49
-
},
50
-
});
51
-
},
52
-
});
45
+
})
+3
-4
routes/api/migration-state.ts
+3
-4
routes/api/migration-state.ts
···
21
21
headers: {
22
22
"Content-Type": "application/json",
23
23
},
24
-
},
24
+
}
25
25
);
26
26
} catch (error) {
27
27
console.error("Error checking migration state:", error);
···
29
29
return new Response(
30
30
JSON.stringify({
31
31
state: "issue",
32
-
message:
33
-
"Unable to determine migration state. Please try again later.",
32
+
message: "Unable to determine migration state. Please try again later.",
34
33
allowMigration: false,
35
34
}),
36
35
{
···
38
37
headers: {
39
38
"Content-Type": "application/json",
40
39
},
41
-
},
40
+
}
42
41
);
43
42
}
44
43
},
+13
-13
routes/api/oauth/initiate.ts
+13
-13
routes/api/oauth/initiate.ts
···
1
-
import { isValidHandle } from "npm:@atproto/syntax";
1
+
import { isValidHandle } from 'npm:@atproto/syntax'
2
2
import { oauthClient } from "../../../lib/oauth/client.ts";
3
3
import { define } from "../../../utils.ts";
4
4
5
5
function isValidUrl(url: string): boolean {
6
6
try {
7
-
const urlp = new URL(url);
7
+
const urlp = new URL(url)
8
8
// http or https
9
-
return urlp.protocol === "http:" || urlp.protocol === "https:";
9
+
return urlp.protocol === 'http:' || urlp.protocol === 'https:'
10
10
} catch {
11
-
return false;
11
+
return false
12
12
}
13
13
}
14
14
15
15
export const handler = define.handlers({
16
16
async POST(ctx) {
17
-
const data = await ctx.req.json();
18
-
const handle = data.handle;
17
+
const data = await ctx.req.json()
18
+
const handle = data.handle
19
19
if (
20
-
typeof handle !== "string" ||
20
+
typeof handle !== 'string' ||
21
21
!(isValidHandle(handle) || isValidUrl(handle))
22
22
) {
23
-
return new Response("Invalid Handle", { status: 400 });
23
+
return new Response("Invalid Handle", {status: 400})
24
24
}
25
25
26
26
// Initiate the OAuth flow
27
27
try {
28
28
const url = await oauthClient.authorize(handle, {
29
-
scope: "atproto transition:generic transition:chat.bsky",
30
-
});
31
-
return Response.json({ redirectUrl: url.toString() });
29
+
scope: 'atproto transition:generic transition:chat.bsky',
30
+
})
31
+
return Response.json({ redirectUrl: url.toString() })
32
32
} catch (err) {
33
-
console.error({ err }, "oauth authorize failed");
34
-
return new Response("Couldn't initiate login", { status: 500 });
33
+
console.error({ err }, 'oauth authorize failed')
34
+
return new Response("Couldn't initiate login", {status: 500})
35
35
}
36
36
},
37
37
});
+1
-1
routes/api/plc/keys.ts
+1
-1
routes/api/plc/keys.ts
+8
-5
routes/api/plc/token.ts
+8
-5
routes/api/plc/token.ts
···
1
1
import { getSessionAgent } from "../../../lib/sessions.ts";
2
+
import { setCredentialSession } from "../../../lib/cred/sessions.ts";
3
+
import { Agent } from "@atproto/api";
2
4
import { define } from "../../../utils.ts";
3
5
4
6
/**
···
40
42
"Content-Type": "application/json",
41
43
...Object.fromEntries(res.headers), // Include session cookie headers
42
44
},
43
-
},
45
+
}
44
46
);
45
47
} catch (error) {
46
48
console.error("PLC signature request error:", error);
47
49
return new Response(
48
50
JSON.stringify({
49
51
success: false,
50
-
message: error instanceof Error
51
-
? error.message
52
-
: "Failed to get PLC operation signature (sending confirmation email)",
52
+
message:
53
+
error instanceof Error
54
+
? error.message
55
+
: "Failed to get PLC operation signature (sending confirmation email)",
53
56
}),
54
57
{
55
58
status: 400,
56
59
headers: { "Content-Type": "application/json" },
57
-
},
60
+
}
58
61
);
59
62
}
60
63
},
+8
-8
routes/api/plc/update/complete.ts
+8
-8
routes/api/plc/update/complete.ts
···
1
+
import { Agent } from "@atproto/api";
1
2
import { getSessionAgent } from "../../../../lib/sessions.ts";
2
3
import { define } from "../../../../utils.ts";
3
4
···
20
21
{
21
22
status: 400,
22
23
headers: { "Content-Type": "application/json" },
23
-
},
24
+
}
24
25
);
25
26
}
26
27
···
34
35
{
35
36
status: 401,
36
37
headers: { "Content-Type": "application/json" },
37
-
},
38
+
}
38
39
);
39
40
}
40
41
···
48
49
{
49
50
status: 400,
50
51
headers: { "Content-Type": "application/json" },
51
-
},
52
+
}
52
53
);
53
54
}
54
55
···
69
70
"Content-Type": "application/json",
70
71
...Object.fromEntries(res.headers), // Include session cookie headers
71
72
},
72
-
},
73
+
}
73
74
);
74
75
} catch (error) {
75
76
console.error("PLC update completion error:", error);
76
-
const message = error instanceof Error
77
-
? error.message
78
-
: "Unknown error occurred";
77
+
const message =
78
+
error instanceof Error ? error.message : "Unknown error occurred";
79
79
80
80
return new Response(
81
81
JSON.stringify({
···
85
85
{
86
86
status: 500,
87
87
headers: { "Content-Type": "application/json" },
88
-
},
88
+
}
89
89
);
90
90
}
91
91
},
+15
-15
routes/api/plc/update.ts
+15
-15
routes/api/plc/update.ts
···
51
51
{
52
52
status: 400,
53
53
headers: { "Content-Type": "application/json" },
54
-
},
54
+
}
55
55
);
56
56
}
57
57
console.log("Using agent DID:", did);
···
70
70
{
71
71
status: 400,
72
72
headers: { "Content-Type": "application/json" },
73
-
},
73
+
}
74
74
);
75
75
}
76
76
console.log("Got DID document:", didDoc);
···
92
92
{
93
93
status: 400,
94
94
headers: { "Content-Type": "application/json" },
95
-
},
95
+
}
96
96
);
97
97
}
98
98
···
124
124
"Content-Type": "application/json",
125
125
...Object.fromEntries(res.headers), // Include session cookie headers
126
126
},
127
-
},
127
+
}
128
128
);
129
129
} catch (error) {
130
130
console.error("PLC update error:", error);
131
-
const errorMessage = error instanceof Error
132
-
? error.message
133
-
: "Failed to update your PLC";
131
+
const errorMessage =
132
+
error instanceof Error ? error.message : "Failed to update your PLC";
134
133
console.log("Sending error response:", errorMessage);
135
134
136
135
return new Response(
137
136
JSON.stringify({
138
137
success: false,
139
138
message: errorMessage,
140
-
error: error instanceof Error
141
-
? {
142
-
name: error.name,
143
-
message: error.message,
144
-
stack: error.stack,
145
-
}
146
-
: String(error),
139
+
error:
140
+
error instanceof Error
141
+
? {
142
+
name: error.name,
143
+
message: error.message,
144
+
stack: error.stack,
145
+
}
146
+
: String(error),
147
147
}),
148
148
{
149
149
status: 400,
150
150
headers: { "Content-Type": "application/json" },
151
-
},
151
+
}
152
152
);
153
153
}
154
154
},
+17
-15
routes/api/plc/verify.ts
+17
-15
routes/api/plc/verify.ts
···
42
42
{
43
43
status: 400,
44
44
headers: { "Content-Type": "application/json" },
45
-
},
45
+
}
46
46
);
47
47
}
48
48
console.log("Using agent DID:", did);
···
61
61
{
62
62
status: 400,
63
63
headers: { "Content-Type": "application/json" },
64
-
},
64
+
}
65
65
);
66
66
}
67
67
console.log("Got DID document:", didDoc);
···
85
85
"Content-Type": "application/json",
86
86
...Object.fromEntries(res.headers), // Include session cookie headers
87
87
},
88
-
},
88
+
}
89
89
);
90
90
}
91
91
···
98
98
{
99
99
status: 404,
100
100
headers: { "Content-Type": "application/json" },
101
-
},
101
+
}
102
102
);
103
103
} catch (error) {
104
104
console.error("PLC verification error:", error);
105
-
const errorMessage = error instanceof Error
106
-
? error.message
107
-
: "Failed to verify rotation key";
105
+
const errorMessage =
106
+
error instanceof Error
107
+
? error.message
108
+
: "Failed to verify rotation key";
108
109
console.log("Sending error response:", errorMessage);
109
110
110
111
return new Response(
111
112
JSON.stringify({
112
113
success: false,
113
114
message: errorMessage,
114
-
error: error instanceof Error
115
-
? {
116
-
name: error.name,
117
-
message: error.message,
118
-
stack: error.stack,
119
-
}
120
-
: String(error),
115
+
error:
116
+
error instanceof Error
117
+
? {
118
+
name: error.name,
119
+
message: error.message,
120
+
stack: error.stack,
121
+
}
122
+
: String(error),
121
123
}),
122
124
{
123
125
status: 400,
124
126
headers: { "Content-Type": "application/json" },
125
-
},
127
+
}
126
128
);
127
129
}
128
130
},
+8
-11
routes/api/resolve-pds.ts
+8
-11
routes/api/resolve-pds.ts
···
7
7
const did = url.searchParams.get("did");
8
8
9
9
if (!did) {
10
-
return new Response(
11
-
JSON.stringify({ error: "DID parameter is required" }),
12
-
{
13
-
status: 400,
14
-
headers: { "Content-Type": "application/json" },
15
-
},
16
-
);
10
+
return new Response(JSON.stringify({ error: "DID parameter is required" }), {
11
+
status: 400,
12
+
headers: { "Content-Type": "application/json" }
13
+
});
17
14
}
18
15
19
16
try {
20
17
const pds = await resolver.resolveDidToPdsUrl(did);
21
18
return new Response(JSON.stringify({ pds }), {
22
19
status: 200,
23
-
headers: { "Content-Type": "application/json" },
20
+
headers: { "Content-Type": "application/json" }
24
21
});
25
22
} catch (error) {
26
23
console.error("Failed to resolve PDS:", error);
27
24
return new Response(JSON.stringify({ error: "Failed to resolve PDS" }), {
28
25
status: 500,
29
-
headers: { "Content-Type": "application/json" },
26
+
headers: { "Content-Type": "application/json" }
30
27
});
31
28
}
32
-
},
33
-
});
29
+
}
30
+
});
+2
-1
routes/api/server/describe.ts
+2
-1
routes/api/server/describe.ts
···
1
+
1
2
import { Agent } from "@atproto/api";
2
3
import { getSessionAgent } from "../../../lib/sessions.ts";
3
4
import { define } from "../../../utils.ts";
···
20
21
}
21
22
const result = await agent.com.atproto.server.describeServer();
22
23
return Response.json(result);
23
-
},
24
+
}
24
25
});
+3
-20
routes/index.tsx
+3
-20
routes/index.tsx
···
19
19
20
20
<LoginButton />
21
21
<p class="font-mono text-lg sm:text-xl mb-4 mt-4 sm:mb-6 text-center text-gray-600 dark:text-gray-300">
22
-
Airport is made with love by{" "}
23
-
<a
24
-
class="text-blue-500 hover:underline"
25
-
href="https://bsky.app/profile/knotbin.com"
26
-
>
27
-
Roscoe
28
-
</a>{" "}
29
-
for{" "}
30
-
<a class="text-blue-500 hover:underline" href="https://sprk.so">
31
-
Spark
32
-
</a>, a new short-video platform for AT Protocol.
22
+
Airport is made with love by <a class="text-blue-500 hover:underline" href="https://bsky.app/profile/knotbin.com">Roscoe</a> for <a class="text-blue-500 hover:underline" href="https://sprk.so">Spark</a>, a new short-video platform for AT Protocol.
33
23
</p>
34
24
<div class="text-center mb-4">
35
-
<a
36
-
href="/about"
37
-
class="inline-flex items-center text-blue-500 hover:text-blue-600 transition-colors"
38
-
>
39
-
<img
40
-
src="/icons/info_bold.svg"
41
-
alt="Info"
42
-
class="w-5 h-5 mr-2"
43
-
/>
25
+
<a href="/about" class="inline-flex items-center text-blue-500 hover:text-blue-600 transition-colors">
26
+
<img src="/icons/info_bold.svg" alt="Info" class="w-5 h-5 mr-2" />
44
27
<span class="font-mono">Learn more about AT Protocol</span>
45
28
</a>
46
29
</div>
+1
-1
routes/login/index.tsx
+1
-1
routes/login/index.tsx
+6
-1
routes/ticket-booth/index.tsx
+6
-1
routes/ticket-booth/index.tsx
···
1
+
import { PageProps } from "fresh";
2
+
import MigrationSetup from "../../islands/MigrationSetup.tsx";
1
3
import DidPlcProgress from "../../islands/DidPlcProgress.tsx";
2
4
3
-
export default function TicketBooth() {
5
+
export default function TicketBooth(props: PageProps) {
6
+
const service = props.url.searchParams.get("service");
7
+
const handle = props.url.searchParams.get("handle");
8
+
4
9
return (
5
10
<div class=" bg-gray-50 dark:bg-gray-900 p-4">
6
11
<div class="max-w-2xl mx-auto">
+9
-52
static/favicon.svg
+9
-52
static/favicon.svg
···
1
-
<svg
2
-
xmlns="http://www.w3.org/2000/svg"
3
-
version="1.1"
4
-
xmlns:xlink="http://www.w3.org/1999/xlink"
5
-
width="768px"
6
-
height="768px"
7
-
>
8
-
<svg
9
-
width="768px"
10
-
height="768px"
11
-
viewBox="0 0 768 768"
12
-
version="1.1"
13
-
xmlns="http://www.w3.org/2000/svg"
14
-
xmlns:xlink="http://www.w3.org/1999/xlink"
15
-
>
1
+
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="768px" height="768px"><svg width="768px" height="768px" viewBox="0 0 768 768" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
16
2
<title>Artboard</title>
17
-
<g
18
-
id="SvgjsG1018"
19
-
stroke="none"
20
-
stroke-width="1"
21
-
fill="none"
22
-
fill-rule="evenodd"
23
-
>
24
-
<path
25
-
d="M258.744161,673.532662 C220.757598,658.556745 187.761971,637.340862 159.757278,609.885015 C131.731315,582.675001 109.640415,549.95668 94.87512,513.789548 C79.62504,476.904419 72,436.829975 72,393.566215 C72,341.982502 79.9023142,296.222756 95.7069425,256.286977 C111.788845,216.351199 133.97078,182.794052 162.252746,155.615536 C190.638366,128.268343 224.327386,107.031355 261.239629,93.2158823 C298.948918,79.0719608 339.292311,72 382.269809,72 C434.951904,72 481.118055,80.1812879 520.768263,96.5438638 C560.418471,112.90644 593.414098,135.092983 619.755146,163.103494 C645.823967,190.667688 665.804365,223.408333 678.398635,259.198961 C691.153247,294.974763 696.976005,332.414555 695.866908,371.518338 C694.480537,425.320706 674.410807,486.092796 651.875912,513.807986 C627.109489,544.267686 603.766423,555.08515 561.350365,555.08515 C539.879106,555.08515 518.908303,544.647246 498.437956,523.771439 L520.642336,488.757018 L553.379562,469.399451 C566.854015,479.647575 578.145985,482.494276 587.255474,477.939554 C600.919708,471.107472 612.130106,436.413978 615.180122,422.270056 C618.73393,406.280102 620.684502,389.975506 621.002879,373.598326 C621.834702,330.611898 615.457396,294.420099 601.870961,265.022929 C588.284526,235.625759 569.845793,211.91389 546.554762,193.887324 C524.028964,175.836615 498.164782,162.407064 470.442999,154.367543 C442.715581,146.047589 415.1268,141.887612 387.676656,141.887612 C348.85827,141.887612 314.337635,148.127577 284.114749,160.607508 C253.891863,172.810107 228.382638,190.143344 207.587075,212.60722 C187.068785,234.793763 171.541431,261.140284 161.005012,291.646781 C150.745868,321.875947 145.893569,355.155762 146.448118,391.486227 C147.557214,427.53936 154.073158,459.98718 165.995948,488.829687 C177.28032,516.727243 194.28181,541.951359 215.9053,562.877276 C237.610095,583.620856 263.386636,599.628298 291.601152,609.885015 C320.714941,620.700955 352.601472,626.108925 387.260744,626.108925 C406.669937,626.108925 425.940493,623.89027 445.072411,619.452962 C464.481604,615.292985 482.227152,609.330351 498.309054,601.565061 L522.015997,666.460701 C500.665885,676.444645 478.206676,683.793938 454.63837,688.508578 C431.262297,693.509483 407.422332,696.019474 383.517543,696 C338.321851,696 296.730724,688.508578 258.744161,673.532662 Z"
26
-
id="SvgjsPath1017"
27
-
fill="#0083FF"
28
-
fill-rule="nonzero"
29
-
></path>
30
-
<g
31
-
id="SvgjsG1016"
32
-
transform="translate(115.0733, 79.4049)"
33
-
fill="#0083FF"
34
-
>
35
-
<path
36
-
d="M329.828907,231.383849 L260.699401,110.602459 C251.63529,94.765511 234.795945,84.9961472 216.561535,84.9961472 L202.32856,84.9961472 C197.759942,84.9961472 194.291354,89.1141864 195.064554,93.6217527 L218.683383,231.383849 L146.044594,231.383849 L117.317307,191.126992 C112.543308,184.436292 104.83484,180.465281 96.6208666,180.465281 L77.9170684,180.465281 C74.6441326,180.465281 72.2041032,183.486025 72.8889188,186.689439 L90.5192033,269.169882 C90.542094,269.250079 90.5821529,269.322638 90.6349289,269.382467 C90.634293,269.446115 90.634293,269.509764 90.634293,269.572775 L90.6349289,269.760538 C90.5821529,269.821003 90.542094,269.892926 90.5192033,269.973759 L72.8889188,352.453566 C72.2041032,355.657617 74.6441326,358.677724 77.9170684,358.677724 L96.6208666,358.677724 C104.83484,358.677724 112.543308,354.706712 117.317307,348.016012 L146.042051,307.761702 L218.684019,307.761702 L195.064554,445.52889 C194.291354,450.036456 197.759942,454.154495 202.32856,454.154495 L216.561535,454.154495 C234.795945,454.154495 251.63529,444.385132 260.699401,428.548184 L329.83145,307.761702 L399.512242,307.761702 C415.470292,307.761702 431.243943,304.348885 445.777042,297.751748 L448.41584,296.553888 C458.994558,291.751631 465.788667,281.20003 465.788667,269.572775 C465.788667,257.946157 458.994558,247.394556 448.41584,242.592299 L445.777042,241.39444 C431.243943,234.797303 415.470292,231.383849 399.512242,231.383849 L329.828907,231.383849 Z"
37
-
id="SvgjsPath1015"
38
-
transform="translate(269.2809, 269.5753) rotate(-134) translate(-269.2809, -269.5753)"
39
-
></path>
40
-
</g>
3
+
<g id="SvgjsG1018" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
4
+
<path d="M258.744161,673.532662 C220.757598,658.556745 187.761971,637.340862 159.757278,609.885015 C131.731315,582.675001 109.640415,549.95668 94.87512,513.789548 C79.62504,476.904419 72,436.829975 72,393.566215 C72,341.982502 79.9023142,296.222756 95.7069425,256.286977 C111.788845,216.351199 133.97078,182.794052 162.252746,155.615536 C190.638366,128.268343 224.327386,107.031355 261.239629,93.2158823 C298.948918,79.0719608 339.292311,72 382.269809,72 C434.951904,72 481.118055,80.1812879 520.768263,96.5438638 C560.418471,112.90644 593.414098,135.092983 619.755146,163.103494 C645.823967,190.667688 665.804365,223.408333 678.398635,259.198961 C691.153247,294.974763 696.976005,332.414555 695.866908,371.518338 C694.480537,425.320706 674.410807,486.092796 651.875912,513.807986 C627.109489,544.267686 603.766423,555.08515 561.350365,555.08515 C539.879106,555.08515 518.908303,544.647246 498.437956,523.771439 L520.642336,488.757018 L553.379562,469.399451 C566.854015,479.647575 578.145985,482.494276 587.255474,477.939554 C600.919708,471.107472 612.130106,436.413978 615.180122,422.270056 C618.73393,406.280102 620.684502,389.975506 621.002879,373.598326 C621.834702,330.611898 615.457396,294.420099 601.870961,265.022929 C588.284526,235.625759 569.845793,211.91389 546.554762,193.887324 C524.028964,175.836615 498.164782,162.407064 470.442999,154.367543 C442.715581,146.047589 415.1268,141.887612 387.676656,141.887612 C348.85827,141.887612 314.337635,148.127577 284.114749,160.607508 C253.891863,172.810107 228.382638,190.143344 207.587075,212.60722 C187.068785,234.793763 171.541431,261.140284 161.005012,291.646781 C150.745868,321.875947 145.893569,355.155762 146.448118,391.486227 C147.557214,427.53936 154.073158,459.98718 165.995948,488.829687 C177.28032,516.727243 194.28181,541.951359 215.9053,562.877276 C237.610095,583.620856 263.386636,599.628298 291.601152,609.885015 C320.714941,620.700955 352.601472,626.108925 387.260744,626.108925 C406.669937,626.108925 425.940493,623.89027 445.072411,619.452962 C464.481604,615.292985 482.227152,609.330351 498.309054,601.565061 L522.015997,666.460701 C500.665885,676.444645 478.206676,683.793938 454.63837,688.508578 C431.262297,693.509483 407.422332,696.019474 383.517543,696 C338.321851,696 296.730724,688.508578 258.744161,673.532662 Z" id="SvgjsPath1017" fill="#0083FF" fill-rule="nonzero"></path>
5
+
<g id="SvgjsG1016" transform="translate(115.0733, 79.4049)" fill="#0083FF">
6
+
<path d="M329.828907,231.383849 L260.699401,110.602459 C251.63529,94.765511 234.795945,84.9961472 216.561535,84.9961472 L202.32856,84.9961472 C197.759942,84.9961472 194.291354,89.1141864 195.064554,93.6217527 L218.683383,231.383849 L146.044594,231.383849 L117.317307,191.126992 C112.543308,184.436292 104.83484,180.465281 96.6208666,180.465281 L77.9170684,180.465281 C74.6441326,180.465281 72.2041032,183.486025 72.8889188,186.689439 L90.5192033,269.169882 C90.542094,269.250079 90.5821529,269.322638 90.6349289,269.382467 C90.634293,269.446115 90.634293,269.509764 90.634293,269.572775 L90.6349289,269.760538 C90.5821529,269.821003 90.542094,269.892926 90.5192033,269.973759 L72.8889188,352.453566 C72.2041032,355.657617 74.6441326,358.677724 77.9170684,358.677724 L96.6208666,358.677724 C104.83484,358.677724 112.543308,354.706712 117.317307,348.016012 L146.042051,307.761702 L218.684019,307.761702 L195.064554,445.52889 C194.291354,450.036456 197.759942,454.154495 202.32856,454.154495 L216.561535,454.154495 C234.795945,454.154495 251.63529,444.385132 260.699401,428.548184 L329.83145,307.761702 L399.512242,307.761702 C415.470292,307.761702 431.243943,304.348885 445.777042,297.751748 L448.41584,296.553888 C458.994558,291.751631 465.788667,281.20003 465.788667,269.572775 C465.788667,257.946157 458.994558,247.394556 448.41584,242.592299 L445.777042,241.39444 C431.243943,234.797303 415.470292,231.383849 399.512242,231.383849 L329.828907,231.383849 Z" id="SvgjsPath1015" transform="translate(269.2809, 269.5753) rotate(-134) translate(-269.2809, -269.5753)"></path>
7
+
</g>
41
8
</g>
42
-
</svg><style>
43
-
@media (prefers-color-scheme: light) {
44
-
:root {
45
-
filter: none;
46
-
}
47
-
}
48
-
@media (prefers-color-scheme: dark) {
49
-
:root {
50
-
filter: none;
51
-
}
52
-
}
53
-
</style>
54
-
</svg>
9
+
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
10
+
@media (prefers-color-scheme: dark) { :root { filter: none; } }
11
+
</style></svg>
+4
-12
static/icons/account.svg
+4
-12
static/icons/account.svg
···
1
-
<svg
2
-
xmlns="http://www.w3.org/2000/svg"
3
-
height="24px"
4
-
viewBox="0 0 24 24"
5
-
width="24px"
6
-
fill="#e3e3e3"
7
-
>
8
-
<path d="M0 0h24v24H0z" fill="none" />
9
-
<path
10
-
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
11
-
/>
12
-
</svg>
1
+
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3">
2
+
<path d="M0 0h24v24H0z" fill="none"/>
3
+
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
4
+
</svg>
+3
-9
static/icons/bluesky.svg
+3
-9
static/icons/bluesky.svg
···
1
-
<svg
2
-
xmlns="http://www.w3.org/2000/svg"
3
-
width="24"
4
-
height="24"
5
-
viewBox="-20 -20 296 266"
6
-
fill="none"
7
-
>
8
-
<path
1
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="-20 -20 296 266" fill="none">
2
+
<path
9
3
d="M55.491 15.172c29.35 22.035 60.917 66.712 72.509 90.686 11.592-23.974 43.159-68.651 72.509-90.686C221.686-.727 256-13.028 256 26.116c0 7.818-4.482 65.674-7.111 75.068-9.138 32.654-42.436 40.983-72.057 35.942 51.775 8.812 64.946 38 36.501 67.187-54.021 55.433-77.644-13.908-83.696-31.676-1.11-3.257-1.63-4.78-1.637-3.485-.008-1.296-.527.228-1.637 3.485-6.052 17.768-29.675 87.11-83.696 31.676-28.445-29.187-15.274-58.375 36.5-67.187-29.62 5.041-62.918-3.288-72.056-35.942C4.482 91.79 0 33.934 0 26.116 0-13.028 34.314-.727 55.491 15.172Z"
10
4
stroke="currentColor"
11
5
stroke-width="25"
12
6
fill="none"
13
7
stroke-linejoin="round"
14
8
/>
15
-
</svg>
9
+
</svg>
+5
-29
static/icons/info_bold.svg
+5
-29
static/icons/info_bold.svg
···
1
1
<?xml version="1.0" encoding="UTF-8"?>
2
-
<svg
3
-
width="24"
4
-
height="24"
5
-
viewBox="0 0 24 24"
6
-
fill="none"
7
-
xmlns="http://www.w3.org/2000/svg"
8
-
>
9
-
<path
10
-
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
11
-
stroke="currentColor"
12
-
stroke-width="2"
13
-
stroke-linecap="round"
14
-
stroke-linejoin="round"
15
-
/>
16
-
<path
17
-
d="M12 16V12"
18
-
stroke="currentColor"
19
-
stroke-width="2"
20
-
stroke-linecap="round"
21
-
stroke-linejoin="round"
22
-
/>
23
-
<path
24
-
d="M12 8H12.01"
25
-
stroke="currentColor"
26
-
stroke-width="2"
27
-
stroke-linecap="round"
28
-
stroke-linejoin="round"
29
-
/>
30
-
</svg>
2
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
3
+
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
4
+
<path d="M12 16V12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
5
+
<path d="M12 8H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
6
+
</svg>
+4
-21
static/icons/plane-departure_bold.svg
+4
-21
static/icons/plane-departure_bold.svg
···
1
-
<svg
2
-
width="80"
3
-
height="80"
4
-
viewBox="4 20 70 36"
5
-
fill="none"
6
-
xmlns="http://www.w3.org/2000/svg"
7
-
>
8
-
<path
9
-
fill-rule="evenodd"
10
-
clip-rule="evenodd"
11
-
d="M50.8224 33.2841L31.008 22.743C29.2561 21.8109 27.1909 21.6663 25.3262 22.345L19.7985 24.3569C19.3667 24.5141 19.2513 25.0706 19.5849 25.3865L34.2839 39.3037L18.5107 45.0447L13.6805 42.0448C12.6378 41.3972 11.3555 41.2642 10.202 41.684L5.45108 43.4133C4.99314 43.5799 4.83855 44.15 5.14954 44.5252L13.9345 55.124C13.9589 55.1535 13.9934 55.1711 14.0299 55.1746C14.0582 55.1928 14.0913 55.2035 14.1264 55.2046L22.9607 55.4824C27.707 55.6317 32.4381 54.874 36.9004 53.2498L63.8001 43.4591C66.6965 42.405 69.3245 40.7247 71.4968 38.5381L72.708 37.3189C73.9986 36.0199 74.4226 34.0923 73.7963 32.3716C73.1701 30.6509 71.6062 29.4468 69.7826 29.2812L68.071 29.1259C65.0014 28.8472 61.9082 29.2493 59.0119 30.3034L50.8224 33.2841Z"
12
-
fill="#FFFFFF"
13
-
/>
14
-
<path
15
-
d="M7 64H75"
16
-
stroke="#C2CCDE"
17
-
stroke-width="6"
18
-
stroke-linecap="round"
19
-
stroke-linejoin="round"
20
-
/>
21
-
</svg>
1
+
<svg width="80" height="80" viewBox="4 20 70 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.8224 33.2841L31.008 22.743C29.2561 21.8109 27.1909 21.6663 25.3262 22.345L19.7985 24.3569C19.3667 24.5141 19.2513 25.0706 19.5849 25.3865L34.2839 39.3037L18.5107 45.0447L13.6805 42.0448C12.6378 41.3972 11.3555 41.2642 10.202 41.684L5.45108 43.4133C4.99314 43.5799 4.83855 44.15 5.14954 44.5252L13.9345 55.124C13.9589 55.1535 13.9934 55.1711 14.0299 55.1746C14.0582 55.1928 14.0913 55.2035 14.1264 55.2046L22.9607 55.4824C27.707 55.6317 32.4381 54.874 36.9004 53.2498L63.8001 43.4591C66.6965 42.405 69.3245 40.7247 71.4968 38.5381L72.708 37.3189C73.9986 36.0199 74.4226 34.0923 73.7963 32.3716C73.1701 30.6509 71.6062 29.4468 69.7826 29.2812L68.071 29.1259C65.0014 28.8472 61.9082 29.2493 59.0119 30.3034L50.8224 33.2841Z" fill="#FFFFFF" />
3
+
<path d="M7 64H75" stroke="#C2CCDE" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />
4
+
</svg>
+3
-14
static/icons/plane_bold.svg
+3
-14
static/icons/plane_bold.svg
···
1
-
<svg
2
-
width="80"
3
-
height="80"
4
-
viewBox="8 10 64 60"
5
-
fill="none"
6
-
xmlns="http://www.w3.org/2000/svg"
7
-
>
8
-
<path
9
-
fill-rule="evenodd"
10
-
clip-rule="evenodd"
11
-
d="M49.4268 34L38.5549 15.0236C37.1294 12.5354 34.4811 11.0005 31.6134 11.0005H29.375C28.6565 11.0005 28.111 11.6475 28.2326 12.3557L31.9471 34H20.5233L16.0054 27.6751C15.2546 26.6239 14.0423 26 12.7505 26H9.80898C9.29425 26 8.91051 26.4746 9.01821 26.9779L11.7909 39.9367C11.7945 39.9493 11.8008 39.9607 11.8091 39.9701C11.809 39.9801 11.809 39.9901 11.809 40L11.8091 40.0295C11.8008 40.039 11.7945 40.0503 11.7909 40.063L9.01821 53.0217C8.91051 53.5251 9.29425 53.9996 9.80898 53.9996H12.7505C14.0423 53.9996 15.2546 53.3757 16.0054 52.3245L20.5229 46H31.9472L28.2326 67.6451C28.111 68.3533 28.6565 69.0003 29.375 69.0003H31.6134C34.4811 69.0003 37.1294 67.4654 38.5549 64.9772L49.4272 46H60.3858C62.8955 46 65.3762 45.4638 67.6618 44.4273L68.0768 44.2391C69.7405 43.4846 70.809 41.8268 70.809 40C70.809 38.1733 69.7405 36.5155 68.0768 35.761L67.6618 35.5728C65.3762 34.5363 62.8955 34 60.3858 34H49.4268Z"
12
-
fill="#C2CCDE"
13
-
/>
14
-
</svg>
1
+
<svg width="80" height="80" viewBox="8 10 64 60" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.4268 34L38.5549 15.0236C37.1294 12.5354 34.4811 11.0005 31.6134 11.0005H29.375C28.6565 11.0005 28.111 11.6475 28.2326 12.3557L31.9471 34H20.5233L16.0054 27.6751C15.2546 26.6239 14.0423 26 12.7505 26H9.80898C9.29425 26 8.91051 26.4746 9.01821 26.9779L11.7909 39.9367C11.7945 39.9493 11.8008 39.9607 11.8091 39.9701C11.809 39.9801 11.809 39.9901 11.809 40L11.8091 40.0295C11.8008 40.039 11.7945 40.0503 11.7909 40.063L9.01821 53.0217C8.91051 53.5251 9.29425 53.9996 9.80898 53.9996H12.7505C14.0423 53.9996 15.2546 53.3757 16.0054 52.3245L20.5229 46H31.9472L28.2326 67.6451C28.111 68.3533 28.6565 69.0003 29.375 69.0003H31.6134C34.4811 69.0003 37.1294 67.4654 38.5549 64.9772L49.4272 46H60.3858C62.8955 46 65.3762 45.4638 67.6618 44.4273L68.0768 44.2391C69.7405 43.4846 70.809 41.8268 70.809 40C70.809 38.1733 69.7405 36.5155 68.0768 35.761L67.6618 35.5728C65.3762 34.5363 62.8955 34 60.3858 34H49.4268Z" fill="#C2CCDE" />
3
+
</svg>
+4
-18
static/icons/ticket_bold.svg
+4
-18
static/icons/ticket_bold.svg
···
1
-
<svg
2
-
width="80"
3
-
height="80"
4
-
viewBox="6 18 70 44"
5
-
fill="none"
6
-
xmlns="http://www.w3.org/2000/svg"
7
-
>
8
-
<path
9
-
fill-rule="evenodd"
10
-
clip-rule="evenodd"
11
-
d="M8 24C8 21.7909 9.79086 20 12 20H68C70.2091 20 72 21.7909 72 24V33.6052C71.6647 33.6578 71.3304 33.7373 71 33.8446C68.3333 34.7111 66.5279 37.1961 66.5279 40C66.5279 42.8039 68.3333 45.2889 71 46.1554C71.3304 46.2627 71.6647 46.3422 72 46.3948V56C72 58.2091 70.2091 60 68 60H12C9.79086 60 8 58.2091 8 56V46.4726C8.66685 46.4727 9.3412 46.3694 10 46.1554C12.6667 45.2889 14.4721 42.8039 14.4721 40C14.4721 37.1961 12.6667 34.7111 10 33.8446C9.3412 33.6306 8.66685 33.5273 8 33.5274V24Z"
12
-
fill="#FFFFFF"
13
-
/>
14
-
<path
15
-
d="M72 33.6052L72.3097 35.5811C73.2828 35.4286 74 34.5903 74 33.6052H72ZM71 33.8446L70.382 31.9425L70.382 31.9425L71 33.8446ZM71 46.1554L70.382 48.0575H70.382L71 46.1554ZM72 46.3948H74C74 45.4097 73.2828 44.5714 72.3097 44.4189L72 46.3948ZM8 46.4726L8.00027 44.4726C7.46979 44.4725 6.96101 44.6832 6.58588 45.0583C6.21075 45.4333 6 45.9421 6 46.4726H8ZM10 46.1554L10.618 48.0575H10.618L10 46.1554ZM14.4721 40H12.4721H14.4721ZM10 33.8446L10.618 31.9425H10.618L10 33.8446ZM8 33.5274H6C6 34.0579 6.21075 34.5667 6.58588 34.9417C6.96101 35.3168 7.46979 35.5275 8.00027 35.5274L8 33.5274ZM12 18C8.68629 18 6 20.6863 6 24H10C10 22.8954 10.8954 22 12 22V18ZM68 18H12V22H68V18ZM74 24C74 20.6863 71.3137 18 68 18V22C69.1046 22 70 22.8954 70 24H74ZM74 33.6052V24H70V33.6052H74ZM71.6903 31.6294C71.2511 31.6982 70.8136 31.8023 70.382 31.9425L71.618 35.7467C71.8472 35.6723 72.0783 35.6174 72.3097 35.5811L71.6903 31.6294ZM70.382 31.9425C66.8913 33.0767 64.5279 36.3296 64.5279 40H68.5279C68.5279 38.0626 69.7754 36.3454 71.618 35.7467L70.382 31.9425ZM64.5279 40C64.5279 43.6704 66.8913 46.9233 70.382 48.0575L71.618 44.2533C69.7754 43.6546 68.5279 41.9374 68.5279 40H64.5279ZM70.382 48.0575C70.8136 48.1977 71.2511 48.3018 71.6903 48.3706L72.3097 44.4189C72.0783 44.3826 71.8472 44.3277 71.618 44.2533L70.382 48.0575ZM74 56V46.3948H70V56H74ZM68 62C71.3137 62 74 59.3137 74 56H70C70 57.1046 69.1046 58 68 58V62ZM12 62H68V58H12V62ZM6 56C6 59.3137 8.68629 62 12 62V58C10.8954 58 10 57.1046 10 56H6ZM6 46.4726V56H10V46.4726H6ZM9.38197 44.2533C8.92559 44.4015 8.46006 44.4726 8.00027 44.4726L7.99973 48.4726C8.87364 48.4727 9.7568 48.3373 10.618 48.0575L9.38197 44.2533ZM12.4721 40C12.4721 41.9374 11.2246 43.6545 9.38197 44.2533L10.618 48.0575C14.1087 46.9233 16.4721 43.6704 16.4721 40H12.4721ZM9.38197 35.7467C11.2246 36.3454 12.4721 38.0626 12.4721 40H16.4721C16.4721 36.3296 14.1087 33.0767 10.618 31.9425L9.38197 35.7467ZM8.00027 35.5274C8.46006 35.5274 8.92559 35.5985 9.38197 35.7467L10.618 31.9425C9.7568 31.6627 8.87364 31.5273 7.99973 31.5274L8.00027 35.5274ZM6 24V33.5274H10V24H6Z"
16
-
fill="#FFFFFF"
17
-
/>
18
-
</svg>
1
+
<svg width="80" height="80" viewBox="6 18 70 44" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 24C8 21.7909 9.79086 20 12 20H68C70.2091 20 72 21.7909 72 24V33.6052C71.6647 33.6578 71.3304 33.7373 71 33.8446C68.3333 34.7111 66.5279 37.1961 66.5279 40C66.5279 42.8039 68.3333 45.2889 71 46.1554C71.3304 46.2627 71.6647 46.3422 72 46.3948V56C72 58.2091 70.2091 60 68 60H12C9.79086 60 8 58.2091 8 56V46.4726C8.66685 46.4727 9.3412 46.3694 10 46.1554C12.6667 45.2889 14.4721 42.8039 14.4721 40C14.4721 37.1961 12.6667 34.7111 10 33.8446C9.3412 33.6306 8.66685 33.5273 8 33.5274V24Z" fill="#FFFFFF" />
3
+
<path d="M72 33.6052L72.3097 35.5811C73.2828 35.4286 74 34.5903 74 33.6052H72ZM71 33.8446L70.382 31.9425L70.382 31.9425L71 33.8446ZM71 46.1554L70.382 48.0575H70.382L71 46.1554ZM72 46.3948H74C74 45.4097 73.2828 44.5714 72.3097 44.4189L72 46.3948ZM8 46.4726L8.00027 44.4726C7.46979 44.4725 6.96101 44.6832 6.58588 45.0583C6.21075 45.4333 6 45.9421 6 46.4726H8ZM10 46.1554L10.618 48.0575H10.618L10 46.1554ZM14.4721 40H12.4721H14.4721ZM10 33.8446L10.618 31.9425H10.618L10 33.8446ZM8 33.5274H6C6 34.0579 6.21075 34.5667 6.58588 34.9417C6.96101 35.3168 7.46979 35.5275 8.00027 35.5274L8 33.5274ZM12 18C8.68629 18 6 20.6863 6 24H10C10 22.8954 10.8954 22 12 22V18ZM68 18H12V22H68V18ZM74 24C74 20.6863 71.3137 18 68 18V22C69.1046 22 70 22.8954 70 24H74ZM74 33.6052V24H70V33.6052H74ZM71.6903 31.6294C71.2511 31.6982 70.8136 31.8023 70.382 31.9425L71.618 35.7467C71.8472 35.6723 72.0783 35.6174 72.3097 35.5811L71.6903 31.6294ZM70.382 31.9425C66.8913 33.0767 64.5279 36.3296 64.5279 40H68.5279C68.5279 38.0626 69.7754 36.3454 71.618 35.7467L70.382 31.9425ZM64.5279 40C64.5279 43.6704 66.8913 46.9233 70.382 48.0575L71.618 44.2533C69.7754 43.6546 68.5279 41.9374 68.5279 40H64.5279ZM70.382 48.0575C70.8136 48.1977 71.2511 48.3018 71.6903 48.3706L72.3097 44.4189C72.0783 44.3826 71.8472 44.3277 71.618 44.2533L70.382 48.0575ZM74 56V46.3948H70V56H74ZM68 62C71.3137 62 74 59.3137 74 56H70C70 57.1046 69.1046 58 68 58V62ZM12 62H68V58H12V62ZM6 56C6 59.3137 8.68629 62 12 62V58C10.8954 58 10 57.1046 10 56H6ZM6 46.4726V56H10V46.4726H6ZM9.38197 44.2533C8.92559 44.4015 8.46006 44.4726 8.00027 44.4726L7.99973 48.4726C8.87364 48.4727 9.7568 48.3373 10.618 48.0575L9.38197 44.2533ZM12.4721 40C12.4721 41.9374 11.2246 43.6545 9.38197 44.2533L10.618 48.0575C14.1087 46.9233 16.4721 43.6704 16.4721 40H12.4721ZM9.38197 35.7467C11.2246 36.3454 12.4721 38.0626 12.4721 40H16.4721C16.4721 36.3296 14.1087 33.0767 10.618 31.9425L9.38197 35.7467ZM8.00027 35.5274C8.46006 35.5274 8.92559 35.5985 9.38197 35.7467L10.618 31.9425C9.7568 31.6627 8.87364 31.5273 7.99973 31.5274L8.00027 35.5274ZM6 24V33.5274H10V24H6Z" fill="#FFFFFF" />
4
+
</svg>
+116
-125
static/styles.css
+116
-125
static/styles.css
···
2
2
@import url("https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap");
3
3
4
4
@font-face {
5
-
font-family: "Skyfont";
6
-
src: url("fonts/skyfont.regular.otf") format("opentype");
7
-
font-weight: normal;
8
-
font-style: normal;
5
+
font-family: "Skyfont";
6
+
src: url("fonts/skyfont.regular.otf") format("opentype");
7
+
font-weight: normal;
8
+
font-style: normal;
9
9
}
10
10
11
11
@font-face {
12
-
font-family: "F25_Bank_Printer";
13
-
src: url("fonts/F25_Bank_Printer.ttf") format("truetype");
14
-
font-weight: normal;
15
-
font-style: normal;
12
+
font-family: "F25_Bank_Printer";
13
+
src: url("fonts/F25_Bank_Printer.ttf") format("truetype");
14
+
font-weight: normal;
15
+
font-style: normal;
16
16
}
17
17
18
18
@tailwind base;
···
20
20
@tailwind utilities;
21
21
22
22
@keyframes fadeOut {
23
-
0% {
24
-
opacity: 1;
25
-
}
26
-
75% {
27
-
opacity: 1;
28
-
} /* Hold full opacity for most of the animation */
29
-
100% {
30
-
opacity: 0;
31
-
}
23
+
0% {
24
+
opacity: 1;
25
+
}
26
+
75% {
27
+
opacity: 1;
28
+
} /* Hold full opacity for most of the animation */
29
+
100% {
30
+
opacity: 0;
31
+
}
32
32
}
33
33
34
34
.status-message-fade {
35
-
animation: fadeOut 2s forwards;
35
+
animation: fadeOut 2s forwards;
36
36
}
37
37
38
38
.font-spectral {
39
-
font-family: "Spectral", serif;
39
+
font-family: "Spectral", serif;
40
40
}
41
41
42
42
.grow-wrap {
43
-
/* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */
44
-
display: grid;
43
+
/* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */
44
+
display: grid;
45
45
}
46
46
.grow-wrap::after {
47
-
/* Note the weird space! Needed to preventy jumpy behavior */
48
-
content: attr(data-replicated-value) " ";
47
+
/* Note the weird space! Needed to preventy jumpy behavior */
48
+
content: attr(data-replicated-value) " ";
49
49
50
-
/* This is how textarea text behaves */
51
-
white-space: pre-wrap;
50
+
/* This is how textarea text behaves */
51
+
white-space: pre-wrap;
52
52
53
-
/* Hidden from view, clicks, and screen readers */
54
-
visibility: hidden;
53
+
/* Hidden from view, clicks, and screen readers */
54
+
visibility: hidden;
55
55
}
56
56
.grow-wrap > textarea {
57
-
/* You could leave this, but after a user resizes, then it ruins the auto sizing */
58
-
resize: none;
57
+
/* You could leave this, but after a user resizes, then it ruins the auto sizing */
58
+
resize: none;
59
59
60
-
/* Firefox shows scrollbar on growth, you can hide like this. */
61
-
overflow: hidden;
60
+
/* Firefox shows scrollbar on growth, you can hide like this. */
61
+
overflow: hidden;
62
62
}
63
63
.grow-wrap > textarea,
64
64
.grow-wrap::after {
65
-
/* Identical styling required!! */
66
-
font: inherit;
65
+
/* Identical styling required!! */
66
+
font: inherit;
67
67
68
-
/* Place on top of each other */
69
-
grid-area: 1 / 1 / 2 / 2;
68
+
/* Place on top of each other */
69
+
grid-area: 1 / 1 / 2 / 2;
70
70
}
71
71
72
72
/* Base styling */
73
73
@layer base {
74
-
body {
75
-
@apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
76
-
font-family: Space Mono;
77
-
}
78
-
button {
79
-
@apply rounded-xl;
80
-
}
74
+
body {
75
+
@apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
76
+
font-family: Space Mono;
77
+
}
78
+
button {
79
+
@apply rounded-xl;
80
+
}
81
81
82
-
input {
83
-
@apply px-4 py-2;
84
-
}
82
+
input {
83
+
@apply px-4 py-2;
84
+
}
85
85
86
-
h1,
87
-
h2,
88
-
h3,
89
-
h4,
90
-
h5 {
91
-
font-family: Share Tech Mono, monospace;
92
-
}
86
+
h1,
87
+
h2,
88
+
h3,
89
+
h4,
90
+
h5 {
91
+
font-family:
92
+
Share Tech Mono,
93
+
monospace;
94
+
}
93
95
}
94
96
95
97
.ticket {
96
-
font-family: F25_Bank_Printer, monospace;
97
-
@apply bg-white dark:bg-gray-800 p-8 relative overflow-hidden;
98
-
position: relative;
99
-
/* Angled corners */
100
-
clip-path: polygon(
101
-
20px 0,
102
-
/* Top left corner */ calc(100% - 20px) 0,
103
-
/* Top right corner */ 100% 20px,
104
-
/* Top right */ 100% calc(100% - 20px),
105
-
/* Bottom right */ calc(100% - 20px) 100%,
106
-
/* Bottom right corner */ 20px 100%,
107
-
/* Bottom left corner */ 0 calc(100% - 20px),
108
-
/* Bottom left */ 0 20px /* Back to top left */
109
-
);
98
+
font-family: F25_Bank_Printer, monospace;
99
+
@apply bg-white dark:bg-gray-800 p-8 relative overflow-hidden;
100
+
position: relative;
101
+
/* Angled corners */
102
+
clip-path: polygon(
103
+
20px 0,
104
+
/* Top left corner */ calc(100% - 20px) 0,
105
+
/* Top right corner */ 100% 20px,
106
+
/* Top right */ 100% calc(100% - 20px),
107
+
/* Bottom right */ calc(100% - 20px) 100%,
108
+
/* Bottom right corner */ 20px 100%,
109
+
/* Bottom left corner */ 0 calc(100% - 20px),
110
+
/* Bottom left */ 0 20px /* Back to top left */
111
+
);
110
112
}
111
113
112
114
/* Create side perforations using pseudo-elements */
113
115
.ticket::before,
114
116
.ticket::after {
115
-
content: "";
116
-
position: absolute;
117
-
top: 30px;
118
-
bottom: 30px;
119
-
width: 1px;
120
-
background-image: linear-gradient(
121
-
to bottom,
122
-
transparent 0%,
123
-
transparent 40%,
124
-
currentColor 40%,
125
-
currentColor 60%,
126
-
transparent 60%,
127
-
transparent 100%
128
-
);
129
-
background-size: 100% 20px;
130
-
background-repeat: repeat-y;
131
-
opacity: 0.2;
117
+
content: "";
118
+
position: absolute;
119
+
top: 30px;
120
+
bottom: 30px;
121
+
width: 1px;
122
+
background-image: linear-gradient(
123
+
to bottom,
124
+
transparent 0%,
125
+
transparent 40%,
126
+
currentColor 40%,
127
+
currentColor 60%,
128
+
transparent 60%,
129
+
transparent 100%
130
+
);
131
+
background-size: 100% 20px;
132
+
background-repeat: repeat-y;
133
+
opacity: 0.2;
132
134
}
133
135
134
136
.ticket::before {
135
-
left: 8px;
137
+
left: 8px;
136
138
}
137
139
138
140
.ticket::after {
139
-
right: 8px;
141
+
right: 8px;
140
142
}
141
143
142
144
.dark .ticket {
143
-
background-image:
144
-
radial-gradient(
145
-
circle at 10px center,
146
-
rgb(17 24 39) 4px,
147
-
transparent 4px
148
-
),
149
-
radial-gradient(
150
-
circle at calc(100% - 10px) center,
151
-
rgb(17 24 39) 4px,
152
-
transparent 4px
153
-
);
145
+
background-image:
146
+
radial-gradient(
147
+
circle at 10px center,
148
+
rgb(17 24 39) 4px,
149
+
transparent 4px
150
+
),
151
+
radial-gradient(
152
+
circle at calc(100% - 10px) center,
153
+
rgb(17 24 39) 4px,
154
+
transparent 4px
155
+
);
154
156
}
155
157
156
158
/* Remove the previous background images and corner cuts */
157
159
.ticket::before,
158
160
.ticket::after {
159
-
display: none;
161
+
display: none;
160
162
}
161
163
162
164
.boarding-label {
163
-
@apply absolute top-2 right-2 bg-blue-100 dark:bg-blue-900 px-3 py-1
164
-
rounded-full text-xs font-bold uppercase tracking-wider;
165
-
transform: rotate(2deg);
165
+
@apply absolute top-2 right-2 bg-blue-100 dark:bg-blue-900 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider;
166
+
transform: rotate(2deg);
166
167
}
167
168
168
169
.flight-info {
169
-
@apply flex justify-between items-center mt-4 pt-4 border-t border-dashed;
170
+
@apply flex justify-between items-center mt-4 pt-4 border-t border-dashed;
170
171
}
171
172
172
173
.passenger-info {
173
-
@apply text-sm text-gray-600 dark:text-gray-400 mt-2;
174
+
@apply text-sm text-gray-600 dark:text-gray-400 mt-2;
174
175
}
175
176
176
177
/* Modern Airport Sign Styles */
177
178
.airport-sign {
178
-
position: relative;
179
-
transform-origin: top;
180
-
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
181
-
border-radius: 0.5rem;
182
-
backdrop-filter: blur(8px);
179
+
position: relative;
180
+
transform-origin: top;
181
+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
182
+
border-radius: 0.5rem;
183
+
backdrop-filter: blur(8px);
183
184
}
184
185
185
186
/* Dropdown panel styles */
186
187
.airport-sign + div {
187
-
border-radius: 0.5rem;
188
-
backdrop-filter: blur(8px);
188
+
border-radius: 0.5rem;
189
+
backdrop-filter: blur(8px);
189
190
}
190
191
191
192
/* Remove old texture styles */
192
193
.airport-sign,
193
194
.airport-sign + div {
194
-
background-blend-mode: overlay;
195
+
background-blend-mode: overlay;
195
196
}
196
197
197
198
@keyframes popin {
198
-
0% {
199
-
opacity: 0;
200
-
transform: scale(0.95);
201
-
}
202
-
100% {
203
-
opacity: 1;
204
-
transform: scale(1);
205
-
}
199
+
0% { opacity: 0; transform: scale(0.95); }
200
+
100% { opacity: 1; transform: scale(1); }
206
201
}
207
202
.animate-popin {
208
-
animation: popin 0.25s cubic-bezier(0.4, 0, 0.2, 1);
203
+
animation: popin 0.25s cubic-bezier(0.4,0,0.2,1);
209
204
}
210
205
@keyframes bounce-short {
211
-
0%, 100% {
212
-
transform: translateY(0);
213
-
}
214
-
50% {
215
-
transform: translateY(-8px);
216
-
}
206
+
0%, 100% { transform: translateY(0); }
207
+
50% { transform: translateY(-8px); }
217
208
}
218
209
.animate-bounce-short {
219
210
animation: bounce-short 0.5s;