Graphical PDS migrator for AT Protocol

socials

Changed files
+224 -79
components
islands
routes
static
+64 -6
components/Button.tsx
···
import { JSX } from "preact";
-
import { IS_BROWSER } from "fresh/runtime";
+
+
type ButtonBaseProps = {
+
color?: "blue" | "amber";
+
icon?: string;
+
iconAlt?: string;
+
label?: string;
+
className?: string;
+
condensed?: boolean;
+
};
+
+
type ButtonProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps>;
+
type AnchorProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps> & { href: string };
+
+
type Props = ButtonProps | AnchorProps;
+
+
export function Button(props: Props) {
+
const { color = "blue", icon, iconAlt, label, className = "", condensed = false, ...rest } = props;
+
const isAnchor = 'href' in props;
+
+
const baseStyles = "airport-sign flex items-center [transition:none]";
+
const paddingStyles = condensed ? 'px-2 py-1.5' : 'px-3 py-2 sm:px-6 sm:py-3';
+
const transformStyles = "translate-y-0 hover:translate-y-1 hover:transition-transform hover:duration-200 hover:ease-in-out";
+
const colorStyles = {
+
blue: "bg-gradient-to-r from-blue-400 to-blue-500 text-white hover:from-blue-500 hover:to-blue-600",
+
amber: "bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 hover:from-amber-500 hover:to-amber-600",
+
};
-
export function Button(props: JSX.HTMLAttributes<HTMLButtonElement>) {
+
const buttonContent = (
+
<>
+
{icon && (
+
<img
+
src={icon}
+
alt={iconAlt || ""}
+
className={`${condensed ? 'w-4 h-4' : 'w-6 h-6'} mr-2`}
+
style={{ filter: color === 'blue' ? "brightness(0) invert(1)" : "brightness(0)" }}
+
/>
+
)}
+
{label && (
+
<span className="font-mono font-bold tracking-wider">
+
{label}
+
</span>
+
)}
+
</>
+
);
+
+
const buttonStyles = `${baseStyles} ${paddingStyles} ${transformStyles} ${colorStyles[color]} ${className}`;
+
+
if (isAnchor) {
+
return (
+
<a
+
href={props.href}
+
className={buttonStyles}
+
{...rest as JSX.HTMLAttributes<HTMLAnchorElement>}
+
>
+
{buttonContent}
+
</a>
+
);
+
}
+
+
const buttonProps = rest as JSX.HTMLAttributes<HTMLButtonElement>;
return (
<button
-
{...props}
-
disabled={!IS_BROWSER || props.disabled}
-
class="px-2 py-1 border-gray-500 border-2 rounded bg-white hover:bg-gray-200 transition-colors"
-
/>
+
{...buttonProps}
+
className={buttonStyles}
+
>
+
{buttonContent}
+
</button>
);
}
+37 -4
deno.lock
···
"jsr:@std/path@^1.0.9": "1.0.9",
"jsr:@std/semver@1": "1.0.5",
"npm:@atproto-labs/handle-resolver-node@~0.1.14": "0.1.15",
+
"npm:@atproto-labs/simple-store@~0.1.2": "0.1.2",
"npm:@atproto/api@*": "0.15.6",
"npm:@atproto/api@~0.15.6": "0.15.6",
"npm:@atproto/crypto@*": "0.4.4",
"npm:@atproto/identity@*": "0.4.8",
"npm:@atproto/jwk@0.1.4": "0.1.4",
"npm:@atproto/oauth-client@~0.3.13": "0.3.16",
+
"npm:@atproto/oauth-types@~0.2.4": "0.2.7",
"npm:@atproto/syntax@*": "0.4.0",
"npm:@atproto/xrpc@*": "0.7.0",
+
"npm:@lucide/lab@*": "0.1.2",
"npm:@opentelemetry/api@^1.9.0": "1.9.0",
"npm:@preact/signals@^1.2.3": "1.3.2_preact@10.26.6",
"npm:@preact/signals@^2.0.4": "2.0.4_preact@10.26.6",
+
"npm:@types/node@*": "22.15.15",
"npm:autoprefixer@10.4.17": "10.4.17_postcss@8.4.35",
"npm:cssnano@6.0.3": "6.0.3_postcss@8.4.35",
"npm:esbuild-wasm@0.23.1": "0.23.1",
"npm:esbuild@0.23.1": "0.23.1",
"npm:iron-session@*": "8.0.4",
"npm:jose@5.9.6": "5.9.6",
+
"npm:lucide-preact@*": "0.511.0_preact@10.26.6",
"npm:postcss@8.4.35": "8.4.35",
+
"npm:preact-feather@*": "4.2.1_preact@10.26.6",
"npm:preact-render-to-string@^6.5.11": "6.5.13_preact@10.26.6",
"npm:preact@^10.25.1": "10.26.6",
"npm:preact@^10.26.6": "10.26.6",
···
"dependencies": [
"@atproto-labs/fetch",
"@atproto-labs/pipe",
-
"@atproto-labs/simple-store",
+
"@atproto-labs/simple-store@0.2.0",
"@atproto-labs/simple-store-memory",
"@atproto/did",
"zod"
···
"@atproto-labs/handle-resolver@0.1.8": {
"integrity": "sha512-Y0ckccoCGDo/3g4thPkgp9QcORmc+qqEaCBCYCZYtfLIQp4775u22wd+4fyEyJP4DqoReKacninkICgRGfs3dQ==",
"dependencies": [
-
"@atproto-labs/simple-store",
+
"@atproto-labs/simple-store@0.2.0",
"@atproto-labs/simple-store-memory",
"@atproto/did",
"zod"
···
"@atproto-labs/simple-store-memory@0.1.3": {
"integrity": "sha512-jkitT9+AtU+0b28DoN92iURLaCt/q/q4yX8q6V+9LSwYlUTqKoj/5NFKvF7x6EBuG+gpUdlcycbH7e60gjOhRQ==",
"dependencies": [
-
"@atproto-labs/simple-store",
+
"@atproto-labs/simple-store@0.2.0",
"lru-cache"
]
+
},
+
"@atproto-labs/simple-store@0.1.2": {
+
"integrity": "sha512-9vTNvyPPBs44tKVFht16wGlilW8u4wpEtKwLkWbuNEh3h9TTQ8zjVhEoGZh/v73G4Otr9JUOSIq+/5+8OZD2mQ=="
},
"@atproto-labs/simple-store@0.2.0": {
"integrity": "sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA=="
···
"@atproto-labs/fetch",
"@atproto-labs/handle-resolver",
"@atproto-labs/identity-resolver",
-
"@atproto-labs/simple-store",
+
"@atproto-labs/simple-store@0.2.0",
"@atproto-labs/simple-store-memory",
"@atproto/did",
"@atproto/jwk@0.1.5",
···
"@jridgewell/sourcemap-codec"
]
},
+
"@lucide/lab@0.1.2": {
+
"integrity": "sha512-VprF2BJa7ZuTGOhUd5cf8tHJXyL63wdxcGieAiVVoR9hO0YmPsnZO0AGqDiX2/br+/MC6n8BoJcmPilltOXIJA=="
+
},
"@noble/curves@1.9.1": {
"integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==",
"dependencies": [
···
"@trysound/sax@0.2.0": {
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="
},
+
"@types/node@22.15.15": {
+
"integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==",
+
"dependencies": [
+
"undici-types"
+
]
+
},
"ansi-regex@5.0.1": {
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
···
"lru-cache@10.4.3": {
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
+
"lucide-preact@0.511.0_preact@10.26.6": {
+
"integrity": "sha512-7MhxCepYkNOfXZTWahbDVODh/BkhLUeCTY5mh6WmIvWcCWssul7TeIM/SkNARifRWZ9KUwYcl9oeV6VTIlqJog==",
+
"dependencies": [
+
"preact"
+
]
+
},
"mdn-data@2.0.28": {
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="
},
···
"source-map-js"
},
+
"preact-feather@4.2.1_preact@10.26.6": {
+
"integrity": "sha512-yK5kYW64AoOkm+xTtUjwcFx0zNrqVTbwmtww8G2AmAB6f8wyQgwZgc6oRXllSYeg7q1I8VbkUpErJuKJ6Vq2eA==",
+
"dependencies": [
+
"preact"
+
]
+
},
"preact-render-to-string@6.5.13_preact@10.26.6": {
"integrity": "sha512-iGPd+hKPMFKsfpR2vL4kJ6ZPcFIoWZEcBf0Dpm3zOpdVvj77aY8RlLiQji5OMrngEyaxGogeakTb54uS2FvA6w==",
"dependencies": [
···
},
"uncrypto@0.1.3": {
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="
+
},
+
"undici-types@6.21.0": {
+
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"undici@6.21.2": {
"integrity": "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g=="
+37 -60
islands/Header.tsx
···
import { useEffect, useState } from "preact/hooks";
import { IS_BROWSER } from "fresh/runtime";
+
import { Button } from "../components/Button.tsx";
interface User {
did: string;
···
export default function Header() {
const [user, setUser] = useState<User | null>(null);
+
const [showDropdown, setShowDropdown] = useState(false);
useEffect(() => {
if (!IS_BROWSER) return;
···
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-between py-4">
{/* Home Link */}
-
<a
+
<Button
href="/"
-
className="airport-sign bg-gradient-to-r from-blue-500 to-blue-600 text-white flex items-center px-3 sm:px-6 py-2 sm:py-3 transform translate-y-0 transition-transform duration-200 ease-in-out hover:translate-y-1 hover:from-blue-600 hover:to-blue-700"
-
>
-
<img
-
src="/icons/plane_bold.svg"
-
alt="Plane"
-
className="w-6 h-6 mr-2"
-
style={{ filter: "brightness(0) invert(1)" }}
-
/>
-
<span className="font-mono font-bold tracking-wider">AIRPORT</span>
-
</a>
+
color="blue"
+
icon="/icons/plane_bold.svg"
+
iconAlt="Plane"
+
label="AIRPORT"
+
/>
<div className="flex items-center gap-3">
{/* Departures (Migration) */}
-
<div className="relative group">
-
<a
-
href="/migrate"
-
className="airport-sign bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 flex items-center px-3 sm:px-6 py-2 sm:py-3 transform translate-y-0 transition-transform duration-200 ease-in-out hover:translate-y-1 hover:from-amber-500 hover:to-amber-600"
-
>
-
<img
-
src="/icons/plane-departure_bold.svg"
-
alt="Departures"
-
className="w-6 h-6 mr-2"
-
style={{ filter: "brightness(0)" }}
-
/>
-
<span className="font-mono font-bold tracking-wider">
-
DEPARTURES
-
</span>
-
</a>
-
</div>
+
<Button
+
href="/migrate"
+
color="amber"
+
icon="/icons/plane-departure_bold.svg"
+
iconAlt="Departures"
+
label="DEPARTURES"
+
/>
{/* Check-in (Login/Profile) */}
<div className="relative">
-
{user?.did
-
? (
-
<div className="relative group">
-
<div className="airport-sign bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 flex items-center px-3 sm:px-6 py-2 sm:py-3 transform translate-y-0 transition-transform duration-200 ease-in-out hover:translate-y-1 hover:from-amber-500 hover:to-amber-600 cursor-pointer">
-
<img
-
src="/icons/ticket_bold.svg"
-
alt="Check-in"
-
className="w-6 h-6 mr-2"
-
style={{ filter: "brightness(0)" }}
-
/>
-
<span className="font-mono font-bold tracking-wider">
-
CHECKED IN
-
</span>
-
</div>
-
<div className="absolute opacity-0 translate-y-[-8px] pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto top-full right-0 w-56 bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 py-3 px-4 rounded-md transition-all duration-200">
+
{user?.did ? (
+
<div className="relative">
+
<Button
+
color="amber"
+
icon="/icons/ticket_bold.svg"
+
iconAlt="Check-in"
+
label="CHECKED IN"
+
onClick={() => setShowDropdown(!showDropdown)}
+
/>
+
{showDropdown && (
+
<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">
<div className="text-sm font-mono mb-2 pb-2 border-b border-slate-900/10">
<div title={user.handle || "Anonymous"}>
{truncateText(user.handle || "Anonymous", 20)}
···
Sign Out
</button>
</div>
-
</div>
-
)
-
: (
-
<a
-
href="/login"
-
className="airport-sign bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 flex items-center px-3 sm:px-6 py-2 sm:py-3 transform translate-y-0 transition-transform duration-200 ease-in-out hover:translate-y-1 hover:from-amber-500 hover:to-amber-600"
-
>
-
<img
-
src="/icons/ticket_bold.svg"
-
alt="Check-in"
-
className="w-6 h-6 mr-2"
-
style={{ filter: "brightness(0)" }}
-
/>
-
<span className="font-mono font-bold tracking-wider">
-
CHECK-IN
-
</span>
-
</a>
-
)}
+
)}
+
</div>
+
) : (
+
<Button
+
href="/login"
+
color="amber"
+
icon="/icons/ticket_bold.svg"
+
iconAlt="Check-in"
+
label="CHECK-IN"
+
/>
+
)}
</div>
</div>
</div>
+74
islands/SocialLinks.tsx
···
+
import { useEffect, useState } from "preact/hooks";
+
import * as Icon from 'npm:preact-feather';
+
+
interface GitHubRepo {
+
stargazers_count: number;
+
}
+
+
export default function SocialLinks() {
+
const [starCount, setStarCount] = useState<number | null>(null);
+
+
useEffect(() => {
+
const fetchRepoInfo = async () => {
+
try {
+
const response = await fetch("https://api.github.com/repos/knotbin/airport");
+
const data: GitHubRepo = await response.json();
+
setStarCount(data.stargazers_count);
+
} catch (error) {
+
console.error("Failed to fetch GitHub repo info:", error);
+
}
+
};
+
+
fetchRepoInfo();
+
}, []);
+
+
const formatStarCount = (count: number | null) => {
+
if (count === null) return "...";
+
if (count >= 1000) {
+
return `${(count / 1000).toFixed(1)}k`;
+
}
+
return count.toString();
+
};
+
+
return (
+
<div class="mt-8 flex justify-center items-center gap-6">
+
<a
+
href="https://bsky.app/profile/knotbin.com"
+
class="text-gray-600 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
+
target="_blank"
+
rel="noopener noreferrer"
+
>
+
<svg
+
class="w-6 h-6"
+
viewBox="-20 -20 296 266"
+
fill="none"
+
stroke="currentColor"
+
stroke-width="25"
+
stroke-linejoin="round"
+
xmlns="http://www.w3.org/2000/svg"
+
>
+
<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"
+
/>
+
</svg>
+
</a>
+
<a
+
href="https://ko-fi.com/knotbin"
+
class="text-gray-600 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
+
target="_blank"
+
rel="noopener noreferrer"
+
>
+
<Icon.Coffee class="w-6 h-6" />
+
</a>
+
<a
+
href="https://github.com/knotbin/airport"
+
class="text-gray-600 hover:text-purple-500 dark:text-gray-400 dark:hover:text-purple-400 transition-colors flex items-center gap-1"
+
target="_blank"
+
rel="noopener noreferrer"
+
>
+
<Icon.Github class="w-6 h-6" />
+
<span class="text-sm font-mono">{formatStarCount(starCount)}</span>
+
</a>
+
</div>
+
);
+
}
+2 -2
islands/Ticket.tsx
···
return (
<div class="max-w-4xl mx-auto">
-
<div class="ticket mb-8 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 p-6 relative before:absolute before:inset-[1px] before:bg-white dark:before:bg-slate-800 before:-z-10 after:absolute after:inset-0 after:bg-slate-200 dark:after:bg-slate-700 after:-z-20 [clip-path:polygon(0_0,20px_0,100%_0,100%_calc(100%-20px),calc(100%-20px)_100%,0_100%,0_calc(100%-20px),20px_100%)] after:[clip-path:polygon(0_0,20px_0,100%_0,100%_calc(100%-20px),calc(100%-20px)_100%,0_100%,0_calc(100%-20px),20px_100%)]">
+
<div class="ticket mb-8 bg-white dark:bg-slate-800 p-6 relative before:absolute before:bg-white dark:before:bg-slate-800 before:-z-10 after:absolute after:inset-0 after:bg-slate-200 dark:after:bg-slate-700 after:-z-20 [clip-path:polygon(0_0,20px_0,100%_0,100%_calc(100%-20px),calc(100%-20px)_100%,0_100%,0_calc(100%-20px),20px_100%)] after:[clip-path:polygon(0_0,20px_0,100%_0,100%_calc(100%-20px),calc(100%-20px)_100%,0_100%,0_calc(100%-20px),20px_100%)]">
<div class="boarding-label text-amber-500 dark:text-amber-400 font-mono font-bold tracking-wider text-sm mb-4">
BOARDING PASS
</div>
···
</p>
</div>
-
<div class="ticket bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 p-6 relative before:absolute before:inset-[1px] before:bg-white dark:before:bg-slate-800 before:-z-10 after:absolute after:inset-0 after:bg-slate-200 dark:after:bg-slate-700 after:-z-20 [clip-path:polygon(0_0,20px_0,100%_0,100%_calc(100%-20px),calc(100%-20px)_100%,0_100%,0_calc(100%-20px),20px_100%)] after:[clip-path:polygon(0_0,20px_0,100%_0,100%_calc(100%-20px),calc(100%-20px)_100%,0_100%,0_calc(100%-20px),20px_100%)]">
+
<div class="ticket mb-8 bg-white dark:bg-slate-800 p-6 relative before:absolute before:bg-white dark:before:bg-slate-800 before:-z-10 after:absolute after:inset-0 after:bg-slate-200 dark:after:bg-slate-700 after:-z-20 [clip-path:polygon(0_0,20px_0,100%_0,100%_calc(100%-20px),calc(100%-20px)_100%,0_100%,0_calc(100%-20px),20px_100%)] after:[clip-path:polygon(0_0,20px_0,100%_0,100%_calc(100%-20px),calc(100%-20px)_100%,0_100%,0_calc(100%-20px),20px_100%)]">
<div class="boarding-label text-amber-500 dark:text-amber-400 font-mono font-bold tracking-wider text-sm mb-4">
FLIGHT DETAILS
</div>
+9 -6
routes/index.tsx
···
import Ticket from "../islands/Ticket.tsx";
import AirportSign from "../islands/AirportSign.tsx";
+
import SocialLinks from "../islands/SocialLinks.tsx";
+
import { Button } from "../components/Button.tsx";
export default function Home() {
return (
···
<Ticket />
-
<div class="mt-6 sm:mt-8 text-center">
-
<a
+
<div class="mt-6 sm:mt-8 text-center w-fit mx-auto">
+
<Button
href="/login"
-
class="inline-flex items-center px-4 sm:px-6 py-2 sm:py-3 border border-transparent text-base sm:text-lg font-mono rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
-
>
-
Begin Your Journey
-
</a>
+
color="blue"
+
label="BEGIN YOUR JOURNEY"
+
/>
</div>
+
+
<SocialLinks />
</div>
</div>
</div>
+1 -1
static/styles.css
···
.airport-sign {
position: relative;
transform-origin: top;
-
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 0.5rem;
backdrop-filter: blur(8px);
}