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}