Graphical PDS migrator for AT Protocol
1import { useEffect, useState } from "preact/hooks";
2import { IS_BROWSER } from "fresh/runtime";
3import { Button } from "../components/Button.tsx";
4
5/**
6 * The user interface.
7 * @type {User}
8 */
9interface User {
10 did: string;
11 handle?: string;
12}
13
14/**
15 * Truncate text to a maximum length.
16 * @param text - The text to truncate
17 * @param maxLength - The maximum length
18 * @returns The truncated text
19 */
20function truncateText(text: string, maxLength: number) {
21 if (text.length <= maxLength) return text;
22 let truncated = text.slice(0, maxLength);
23 // Remove trailing dots before adding ellipsis
24 while (truncated.endsWith(".")) {
25 truncated = truncated.slice(0, -1);
26 }
27 return truncated + "...";
28}
29
30/**
31 * The header component.
32 * @returns The header component
33 * @component
34 */
35export default function Header() {
36 const [user, setUser] = useState<User | null>(null);
37 const [showDropdown, setShowDropdown] = useState(false);
38
39 useEffect(() => {
40 if (!IS_BROWSER) return;
41
42 const fetchUser = async () => {
43 try {
44 const response = await fetch("/api/me", {
45 credentials: "include",
46 });
47 if (!response.ok) {
48 throw new Error("Failed to fetch user profile");
49 }
50 const userData = await response.json();
51 setUser(
52 userData
53 ? {
54 did: userData.did,
55 handle: userData.handle,
56 }
57 : null,
58 );
59 } catch (error) {
60 console.error("Failed to fetch user:", error);
61 setUser(null);
62 }
63 };
64
65 fetchUser();
66 }, []);
67
68 const handleLogout = async () => {
69 try {
70 const response = await fetch("/api/logout", {
71 method: "POST",
72 credentials: "include",
73 });
74
75 if (!response.ok) {
76 throw new Error("Logout failed");
77 }
78
79 setUser(null);
80 globalThis.location.href = "/";
81 } catch (error) {
82 console.error("Failed to logout:", error);
83 }
84 };
85
86 return (
87 <header className="hidden sm:block bg-white dark:bg-slate-900 text-slate-900 dark:text-white relative z-10 border-b border-slate-200 dark:border-slate-800">
88 <div className="max-w-7xl mx-auto px-4">
89 <div className="flex items-center justify-between py-4">
90 {/* Home Link */}
91 <Button
92 href="/"
93 color="blue"
94 icon="/icons/plane_bold.svg"
95 iconAlt="Plane"
96 label="AIRPORT"
97 />
98
99 <div className="flex items-center gap-3">
100 {/* Ticket booth (did:plc update) */}
101 <Button
102 href="/ticket-booth"
103 color="amber"
104 icon="/icons/ticket_bold.svg"
105 iconAlt="Ticket"
106 label="TICKET BOOTH"
107 />
108
109 {/* Departures (Migration) */}
110 <Button
111 href="/migrate"
112 color="amber"
113 icon="/icons/plane-departure_bold.svg"
114 iconAlt="Departures"
115 label="DEPARTURES"
116 />
117
118 {/* Check-in (Login/Profile) */}
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 : (
152 <Button
153 href="/login"
154 color="amber"
155 icon="/icons/account.svg"
156 iconAlt="Check-in"
157 label="CHECK-IN"
158 />
159 )}
160 </div>
161 </div>
162 </div>
163 </div>
164 </header>
165 );
166}