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 <div className="relative">
122 <Button
123 color="amber"
124 icon="/icons/account.svg"
125 iconAlt="Check-in"
126 label="CHECKED IN"
127 onClick={() => setShowDropdown(!showDropdown)}
128 />
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 )}
158 </div>
159 </div>
160 </div>
161 </div>
162 </header>
163 );
164}