Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

SPA/Domain Routes work

+85
bun.lock
···
"@elysiajs/eden": "^1.4.3",
"@elysiajs/openapi": "^1.4.11",
"@elysiajs/static": "^1.4.2",
+
"@radix-ui/react-dialog": "^1.1.15",
+
"@radix-ui/react-label": "^2.1.7",
+
"@radix-ui/react-radio-group": "^1.3.8",
+
"@radix-ui/react-slot": "^1.2.3",
+
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.2",
+
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"elysia": "latest",
"iron-session": "^8.0.4",
+
"lucide-react": "^0.546.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+
"tailwind-merge": "^3.3.1",
"tailwindcss": "4",
+
"tw-animate-css": "^1.4.0",
},
"devDependencies": {
"@types/react": "^19.2.2",
···
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="],
+
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
+
+
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
+
+
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
+
+
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
+
+
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
+
+
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
+
+
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
+
+
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
+
+
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
+
+
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
+
+
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
+
+
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
+
+
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
+
+
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+
+
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
+
+
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
+
+
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
+
+
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
+
+
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
+
+
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
+
+
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
+
+
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+
+
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
+
+
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
+
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
···
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
···
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
···
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
···
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
+
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
···
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
+
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
···
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
+
+
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
+
+
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+
+
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
···
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
+
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
"thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw=="],
···
"ts-morph": ["ts-morph@24.0.0", "", { "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" } }, "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw=="],
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
+
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
···
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
+
+
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+
+
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
+21
components.json
···
+
{
+
"$schema": "https://ui.shadcn.com/schema.json",
+
"style": "new-york",
+
"rsc": false,
+
"tsx": true,
+
"tailwind": {
+
"config": "",
+
"css": "src/styles/globals.css",
+
"baseColor": "neutral",
+
"cssVariables": true,
+
"prefix": ""
+
},
+
"aliases": {
+
"components": "@public/components",
+
"utils": "@public/lib/utils",
+
"ui": "@public/components/ui",
+
"lib": "@public/libs",
+
"hooks": "@public/hooks"
+
},
+
"iconLibrary": "lucide"
+
}
+10 -1
package.json
···
"@elysiajs/eden": "^1.4.3",
"@elysiajs/openapi": "^1.4.11",
"@elysiajs/static": "^1.4.2",
+
"@radix-ui/react-dialog": "^1.1.15",
+
"@radix-ui/react-label": "^2.1.7",
+
"@radix-ui/react-radio-group": "^1.3.8",
+
"@radix-ui/react-slot": "^1.2.3",
+
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.2",
+
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"elysia": "latest",
"iron-session": "^8.0.4",
+
"lucide-react": "^0.546.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
-
"tailwindcss": "4"
+
"tailwind-merge": "^3.3.1",
+
"tailwindcss": "4",
+
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@types/react": "^19.2.2",
+46
public/components/ui/badge.tsx
···
+
import * as React from "react"
+
import { Slot } from "@radix-ui/react-slot"
+
import { cva, type VariantProps } from "class-variance-authority"
+
+
import { cn } from "@public/lib/utils"
+
+
const badgeVariants = cva(
+
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+
{
+
variants: {
+
variant: {
+
default:
+
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+
secondary:
+
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+
destructive:
+
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+
outline:
+
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+
},
+
},
+
defaultVariants: {
+
variant: "default",
+
},
+
}
+
)
+
+
function Badge({
+
className,
+
variant,
+
asChild = false,
+
...props
+
}: React.ComponentProps<"span"> &
+
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+
const Comp = asChild ? Slot : "span"
+
+
return (
+
<Comp
+
data-slot="badge"
+
className={cn(badgeVariants({ variant }), className)}
+
{...props}
+
/>
+
)
+
}
+
+
export { Badge, badgeVariants }
+60
public/components/ui/button.tsx
···
+
import * as React from "react"
+
import { Slot } from "@radix-ui/react-slot"
+
import { cva, type VariantProps } from "class-variance-authority"
+
+
import { cn } from "@public/lib/utils"
+
+
const buttonVariants = cva(
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+
{
+
variants: {
+
variant: {
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
+
destructive:
+
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+
outline:
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+
secondary:
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
+
ghost:
+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+
link: "text-primary underline-offset-4 hover:underline",
+
},
+
size: {
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
+
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+
icon: "size-9",
+
"icon-sm": "size-8",
+
"icon-lg": "size-10",
+
},
+
},
+
defaultVariants: {
+
variant: "default",
+
size: "default",
+
},
+
}
+
)
+
+
function Button({
+
className,
+
variant,
+
size,
+
asChild = false,
+
...props
+
}: React.ComponentProps<"button"> &
+
VariantProps<typeof buttonVariants> & {
+
asChild?: boolean
+
}) {
+
const Comp = asChild ? Slot : "button"
+
+
return (
+
<Comp
+
data-slot="button"
+
className={cn(buttonVariants({ variant, size, className }))}
+
{...props}
+
/>
+
)
+
}
+
+
export { Button, buttonVariants }
+92
public/components/ui/card.tsx
···
+
import * as React from "react"
+
+
import { cn } from "@public/lib/utils"
+
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
+
return (
+
<div
+
data-slot="card"
+
className={cn(
+
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
+
className
+
)}
+
{...props}
+
/>
+
)
+
}
+
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+
return (
+
<div
+
data-slot="card-header"
+
className={cn(
+
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
+
className
+
)}
+
{...props}
+
/>
+
)
+
}
+
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+
return (
+
<div
+
data-slot="card-title"
+
className={cn("leading-none font-semibold", className)}
+
{...props}
+
/>
+
)
+
}
+
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+
return (
+
<div
+
data-slot="card-description"
+
className={cn("text-muted-foreground text-sm", className)}
+
{...props}
+
/>
+
)
+
}
+
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+
return (
+
<div
+
data-slot="card-action"
+
className={cn(
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+
className
+
)}
+
{...props}
+
/>
+
)
+
}
+
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+
return (
+
<div
+
data-slot="card-content"
+
className={cn("px-6", className)}
+
{...props}
+
/>
+
)
+
}
+
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+
return (
+
<div
+
data-slot="card-footer"
+
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
+
{...props}
+
/>
+
)
+
}
+
+
export {
+
Card,
+
CardHeader,
+
CardFooter,
+
CardTitle,
+
CardAction,
+
CardDescription,
+
CardContent,
+
}
+141
public/components/ui/dialog.tsx
···
+
import * as React from "react"
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
+
import { XIcon } from "lucide-react"
+
+
import { cn } from "@public/lib/utils"
+
+
function Dialog({
+
...props
+
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+
return <DialogPrimitive.Root data-slot="dialog" {...props} />
+
}
+
+
function DialogTrigger({
+
...props
+
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
+
}
+
+
function DialogPortal({
+
...props
+
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
+
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
+
}
+
+
function DialogClose({
+
...props
+
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
+
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
+
}
+
+
function DialogOverlay({
+
className,
+
...props
+
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
+
return (
+
<DialogPrimitive.Overlay
+
data-slot="dialog-overlay"
+
className={cn(
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+
className
+
)}
+
{...props}
+
/>
+
)
+
}
+
+
function DialogContent({
+
className,
+
children,
+
showCloseButton = true,
+
...props
+
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
+
showCloseButton?: boolean
+
}) {
+
return (
+
<DialogPortal data-slot="dialog-portal">
+
<DialogOverlay />
+
<DialogPrimitive.Content
+
data-slot="dialog-content"
+
className={cn(
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+
className
+
)}
+
{...props}
+
>
+
{children}
+
{showCloseButton && (
+
<DialogPrimitive.Close
+
data-slot="dialog-close"
+
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
+
>
+
<XIcon />
+
<span className="sr-only">Close</span>
+
</DialogPrimitive.Close>
+
)}
+
</DialogPrimitive.Content>
+
</DialogPortal>
+
)
+
}
+
+
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+
return (
+
<div
+
data-slot="dialog-header"
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+
{...props}
+
/>
+
)
+
}
+
+
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+
return (
+
<div
+
data-slot="dialog-footer"
+
className={cn(
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+
className
+
)}
+
{...props}
+
/>
+
)
+
}
+
+
function DialogTitle({
+
className,
+
...props
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
+
return (
+
<DialogPrimitive.Title
+
data-slot="dialog-title"
+
className={cn("text-lg leading-none font-semibold", className)}
+
{...props}
+
/>
+
)
+
}
+
+
function DialogDescription({
+
className,
+
...props
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
+
return (
+
<DialogPrimitive.Description
+
data-slot="dialog-description"
+
className={cn("text-muted-foreground text-sm", className)}
+
{...props}
+
/>
+
)
+
}
+
+
export {
+
Dialog,
+
DialogClose,
+
DialogContent,
+
DialogDescription,
+
DialogFooter,
+
DialogHeader,
+
DialogOverlay,
+
DialogPortal,
+
DialogTitle,
+
DialogTrigger,
+
}
+21
public/components/ui/input.tsx
···
+
import * as React from "react"
+
+
import { cn } from "@public/lib/utils"
+
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+
return (
+
<input
+
type={type}
+
data-slot="input"
+
className={cn(
+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+
className
+
)}
+
{...props}
+
/>
+
)
+
}
+
+
export { Input }
+22
public/components/ui/label.tsx
···
+
import * as React from "react"
+
import * as LabelPrimitive from "@radix-ui/react-label"
+
+
import { cn } from "@public/lib/utils"
+
+
function Label({
+
className,
+
...props
+
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+
return (
+
<LabelPrimitive.Root
+
data-slot="label"
+
className={cn(
+
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+
className
+
)}
+
{...props}
+
/>
+
)
+
}
+
+
export { Label }
+45
public/components/ui/radio-group.tsx
···
+
"use client"
+
+
import * as React from "react"
+
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
+
import { CircleIcon } from "lucide-react"
+
+
import { cn } from "@public/lib/utils"
+
+
function RadioGroup({
+
className,
+
...props
+
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
+
return (
+
<RadioGroupPrimitive.Root
+
data-slot="radio-group"
+
className={cn("grid gap-3", className)}
+
{...props}
+
/>
+
)
+
}
+
+
function RadioGroupItem({
+
className,
+
...props
+
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
+
return (
+
<RadioGroupPrimitive.Item
+
data-slot="radio-group-item"
+
className={cn(
+
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+
className
+
)}
+
{...props}
+
>
+
<RadioGroupPrimitive.Indicator
+
data-slot="radio-group-indicator"
+
className="relative flex items-center justify-center"
+
>
+
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
+
</RadioGroupPrimitive.Indicator>
+
</RadioGroupPrimitive.Item>
+
)
+
}
+
+
export { RadioGroup, RadioGroupItem }
+64
public/components/ui/tabs.tsx
···
+
import * as React from "react"
+
import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+
import { cn } from "@public/lib/utils"
+
+
function Tabs({
+
className,
+
...props
+
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+
return (
+
<TabsPrimitive.Root
+
data-slot="tabs"
+
className={cn("flex flex-col gap-2", className)}
+
{...props}
+
/>
+
)
+
}
+
+
function TabsList({
+
className,
+
...props
+
}: React.ComponentProps<typeof TabsPrimitive.List>) {
+
return (
+
<TabsPrimitive.List
+
data-slot="tabs-list"
+
className={cn(
+
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
+
className
+
)}
+
{...props}
+
/>
+
)
+
}
+
+
function TabsTrigger({
+
className,
+
...props
+
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+
return (
+
<TabsPrimitive.Trigger
+
data-slot="tabs-trigger"
+
className={cn(
+
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+
className
+
)}
+
{...props}
+
/>
+
)
+
}
+
+
function TabsContent({
+
className,
+
...props
+
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+
return (
+
<TabsPrimitive.Content
+
data-slot="tabs-content"
+
className={cn("outline-none", className)}
+
{...props}
+
/>
+
)
+
}
+
+
export { Tabs, TabsList, TabsTrigger, TabsContent }
+503 -92
public/editor/editor.tsx
···
-
import { useState, useRef } from 'react'
+
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
+
import { Button } from '@public/components/ui/button'
+
import {
+
Card,
+
CardContent,
+
CardDescription,
+
CardHeader,
+
CardTitle
+
} from '@public/components/ui/card'
+
import { Input } from '@public/components/ui/input'
+
import { Label } from '@public/components/ui/label'
+
import {
+
Tabs,
+
TabsContent,
+
TabsList,
+
TabsTrigger
+
} from '@public/components/ui/tabs'
+
import { Badge } from '@public/components/ui/badge'
+
import {
+
Dialog,
+
DialogContent,
+
DialogDescription,
+
DialogHeader,
+
DialogTitle,
+
DialogFooter
+
} from '@public/components/ui/dialog'
+
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
+
import {
+
Globe,
+
Upload,
+
Settings,
+
ExternalLink,
+
CheckCircle2,
+
XCircle,
+
AlertCircle
+
} from 'lucide-react'
import Layout from '@public/layouts'
-
function Editor() {
-
const [uploading, setUploading] = useState(false)
-
const [result, setResult] = useState<any>(null)
-
const [error, setError] = useState<string | null>(null)
-
const folderInputRef = useRef<HTMLInputElement>(null)
-
const siteNameRef = useRef<HTMLInputElement>(null)
+
// Mock user data - replace with actual auth
+
const mockUser = {
+
did: 'did:plc:abc123xyz',
+
handle: 'alice.bsky.social',
+
wispSubdomain: 'alice'
+
}
-
const handleFileUpload = async (e: React.FormEvent) => {
-
e.preventDefault()
-
setError(null)
-
setResult(null)
+
function Dashboard() {
+
const [customDomain, setCustomDomain] = useState('')
+
const [verificationStatus, setVerificationStatus] = useState<
+
'idle' | 'verifying' | 'success' | 'error'
+
>('idle')
+
const [selectedSite, setSelectedSite] = useState('')
-
const files = folderInputRef.current?.files
-
const siteName = siteNameRef.current?.value
+
const [configureModalOpen, setConfigureModalOpen] = useState(false)
+
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
+
const [currentSite, setCurrentSite] = useState<{
+
id: string
+
name: string
+
domain: string | null
+
} | null>(null)
+
const [selectedDomain, setSelectedDomain] = useState<string>('')
-
if (!files || files.length === 0) {
-
setError('Please select a folder to upload')
-
return
+
// Mock sites data
+
const [sites] = useState([
+
{
+
id: '1',
+
name: 'my-blog',
+
domain: 'alice.wisp.place',
+
status: 'active'
+
},
+
{ id: '2', name: 'portfolio', domain: null, status: 'active' },
+
{
+
id: '3',
+
name: 'docs-site',
+
domain: 'docs.example.com',
+
status: 'active'
}
+
])
-
if (!siteName) {
-
setError('Please enter a site name')
-
return
-
}
+
const availableDomains = [
+
{ value: 'alice.wisp.place', label: 'alice.wisp.place', type: 'wisp' },
+
{
+
value: 'docs.example.com',
+
label: 'docs.example.com',
+
type: 'custom'
+
},
+
{ value: 'none', label: 'No domain (use default URL)', type: 'none' }
+
]
-
setUploading(true)
-
-
try {
-
const formData = new FormData()
-
formData.append('siteName', siteName)
-
-
for (let i = 0; i < files.length; i++) {
-
formData.append('files', files[i])
-
}
+
const handleVerifyDNS = async () => {
+
setVerificationStatus('verifying')
+
// Simulate DNS verification
+
setTimeout(() => {
+
setVerificationStatus('success')
+
}, 2000)
+
}
-
const response = await fetch('/wisp/upload-files', {
-
method: 'POST',
-
body: formData
-
})
+
const handleConfigureSite = (site: {
+
id: string
+
name: string
+
domain: string | null
+
}) => {
+
setCurrentSite(site)
+
setSelectedDomain(site.domain || 'none')
+
setConfigureModalOpen(true)
+
}
-
if (!response.ok) {
-
throw new Error(`Upload failed: ${response.statusText}`)
-
}
+
const handleSaveConfiguration = () => {
+
console.log(
+
'[v0] Saving configuration for site:',
+
currentSite?.name,
+
'with domain:',
+
selectedDomain
+
)
+
// TODO: Implement actual save logic
+
setConfigureModalOpen(false)
+
}
-
const data = await response.json()
-
setResult(data)
-
} catch (err) {
-
setError(err instanceof Error ? err.message : 'Upload failed')
-
} finally {
-
setUploading(false)
+
const getSiteUrl = (site: { name: string; domain: string | null }) => {
+
if (site.domain) {
+
return `https://${site.domain}`
}
+
return `https://sites.wisp.place/${mockUser.did}/${site.name}`
}
return (
-
<div className="w-full max-w-2xl mx-auto p-6">
-
<h1 className="text-3xl font-bold mb-6 text-center">Upload Folder</h1>
-
-
<form onSubmit={handleFileUpload} className="space-y-4">
-
<div>
-
<label htmlFor="siteName" className="block text-sm font-medium mb-2">
-
Site Name
-
</label>
-
<input
-
ref={siteNameRef}
-
type="text"
-
id="siteName"
-
placeholder="Enter site name"
-
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
-
/>
+
<div className="w-full min-h-screen bg-background">
+
{/* Header */}
+
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
+
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
+
<div className="flex items-center gap-2">
+
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
+
<Globe className="w-5 h-5 text-primary-foreground" />
+
</div>
+
<span className="text-xl font-semibold text-foreground">
+
wisp.place
+
</span>
+
</div>
+
<div className="flex items-center gap-3">
+
<span className="text-sm text-muted-foreground">
+
{mockUser.handle}
+
</span>
+
</div>
</div>
+
</header>
-
<div>
-
<label htmlFor="folder" className="block text-sm font-medium mb-2">
-
Select Folder
-
</label>
-
<input
-
ref={folderInputRef}
-
type="file"
-
id="folder"
-
{...({ webkitdirectory: '', directory: '' } as any)}
-
multiple
-
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
-
/>
+
<div className="container mx-auto px-4 py-8 max-w-6xl w-full">
+
<div className="mb-8">
+
<h1 className="text-3xl font-bold mb-2">Dashboard</h1>
+
<p className="text-muted-foreground">
+
Manage your sites and domains
+
</p>
</div>
-
<button
-
type="submit"
-
disabled={uploading}
-
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-semibold py-2 px-4 rounded-md transition-colors"
-
>
-
{uploading ? 'Uploading...' : 'Upload Folder'}
-
</button>
-
</form>
+
<Tabs defaultValue="sites" className="space-y-6 w-full">
+
<TabsList className="grid w-full grid-cols-3 max-w-md">
+
<TabsTrigger value="sites">Sites</TabsTrigger>
+
<TabsTrigger value="domains">Domains</TabsTrigger>
+
<TabsTrigger value="upload">Upload</TabsTrigger>
+
</TabsList>
+
+
{/* Sites Tab */}
+
<TabsContent value="sites" className="space-y-4 min-h-[400px]">
+
<Card>
+
<CardHeader>
+
<CardTitle>Your Sites</CardTitle>
+
<CardDescription>
+
View and manage all your deployed sites
+
</CardDescription>
+
</CardHeader>
+
<CardContent className="space-y-4">
+
{sites.map((site) => (
+
<div
+
key={site.id}
+
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
+
>
+
<div className="flex-1">
+
<div className="flex items-center gap-3 mb-2">
+
<h3 className="font-semibold text-lg">
+
{site.name}
+
</h3>
+
<Badge
+
variant="secondary"
+
className="text-xs"
+
>
+
{site.status}
+
</Badge>
+
</div>
+
<a
+
href={getSiteUrl(site)}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
+
>
+
{site.domain ||
+
`sites.wisp.place/${mockUser.did}/${site.name}`}
+
<ExternalLink className="w-3 h-3" />
+
</a>
+
</div>
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={() =>
+
handleConfigureSite(site)
+
}
+
>
+
<Settings className="w-4 h-4 mr-2" />
+
Configure
+
</Button>
+
</div>
+
))}
+
</CardContent>
+
</Card>
+
</TabsContent>
+
+
{/* Domains Tab */}
+
<TabsContent value="domains" className="space-y-4 min-h-[400px]">
+
<Card>
+
<CardHeader>
+
<CardTitle>wisp.place Subdomain</CardTitle>
+
<CardDescription>
+
Your free subdomain on the wisp.place
+
network
+
</CardDescription>
+
</CardHeader>
+
<CardContent>
+
<div className="flex items-center gap-2 p-4 bg-muted/50 rounded-lg">
+
<CheckCircle2 className="w-5 h-5 text-green-500" />
+
<span className="font-mono text-lg">
+
{mockUser.wispSubdomain}.wisp.place
+
</span>
+
</div>
+
<p className="text-sm text-muted-foreground mt-3">
+
Configure which site uses this domain in the
+
Sites tab
+
</p>
+
</CardContent>
+
</Card>
+
+
<Card>
+
<CardHeader>
+
<CardTitle>Custom Domains</CardTitle>
+
<CardDescription>
+
Bring your own domain with DNS verification
+
</CardDescription>
+
</CardHeader>
+
<CardContent className="space-y-4">
+
<Button
+
onClick={() => setAddDomainModalOpen(true)}
+
className="w-full"
+
>
+
Add Custom Domain
+
</Button>
+
+
<div className="space-y-2">
+
<div className="flex items-center justify-between p-3 border border-border rounded-lg">
+
<div className="flex items-center gap-2">
+
<CheckCircle2 className="w-4 h-4 text-green-500" />
+
<span className="font-mono">
+
docs.example.com
+
</span>
+
</div>
+
<Badge variant="secondary">
+
Verified
+
</Badge>
+
</div>
+
</div>
+
</CardContent>
+
</Card>
+
</TabsContent>
+
+
{/* Upload Tab */}
+
<TabsContent value="upload" className="space-y-4 min-h-[400px]">
+
<Card>
+
<CardHeader>
+
<CardTitle>Upload Site</CardTitle>
+
<CardDescription>
+
Deploy a new site from a folder or Git
+
repository
+
</CardDescription>
+
</CardHeader>
+
<CardContent className="space-y-6">
+
<div className="space-y-2">
+
<Label htmlFor="site-name">Site Name</Label>
+
<Input
+
id="site-name"
+
placeholder="my-awesome-site"
+
/>
+
</div>
+
+
<div className="grid md:grid-cols-2 gap-4">
+
<Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
+
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
+
<Upload className="w-12 h-12 text-muted-foreground mb-4" />
+
<h3 className="font-semibold mb-2">
+
Upload Folder
+
</h3>
+
<p className="text-sm text-muted-foreground mb-4">
+
Drag and drop or click to upload
+
your static site files
+
</p>
+
<Button variant="outline">
+
Choose Folder
+
</Button>
+
</CardContent>
+
</Card>
+
+
<Card className="border-2 border-dashed hover:border-accent transition-colors">
+
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
+
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
+
<h3 className="font-semibold mb-2">
+
Connect Git Repository
+
</h3>
+
<p className="text-sm text-muted-foreground mb-4">
+
Link your GitHub, GitLab, or any
+
Git repository
+
</p>
+
<Button variant="outline">
+
Connect Git
+
</Button>
+
</CardContent>
+
</Card>
+
</div>
+
</CardContent>
+
</Card>
+
</TabsContent>
+
</Tabs>
+
</div>
+
+
<Dialog
+
open={configureModalOpen}
+
onOpenChange={setConfigureModalOpen}
+
>
+
<DialogContent className="sm:max-w-md">
+
<DialogHeader>
+
<DialogTitle>Configure Site Domain</DialogTitle>
+
<DialogDescription>
+
Choose which domain {currentSite?.name} should use
+
</DialogDescription>
+
</DialogHeader>
+
<div className="space-y-4 py-4">
+
<RadioGroup
+
value={selectedDomain}
+
onValueChange={setSelectedDomain}
+
>
+
{availableDomains.map((domain) => (
+
<div
+
key={domain.value}
+
className="flex items-center space-x-2"
+
>
+
<RadioGroupItem
+
value={domain.value}
+
id={domain.value}
+
/>
+
<Label
+
htmlFor={domain.value}
+
className="flex-1 cursor-pointer"
+
>
+
<div className="flex items-center justify-between">
+
<span className="font-mono text-sm">
+
{domain.label}
+
</span>
+
{domain.type === 'wisp' && (
+
<Badge
+
variant="secondary"
+
className="text-xs"
+
>
+
Free
+
</Badge>
+
)}
+
{domain.type === 'custom' && (
+
<Badge
+
variant="outline"
+
className="text-xs"
+
>
+
Custom
+
</Badge>
+
)}
+
</div>
+
</Label>
+
</div>
+
))}
+
</RadioGroup>
+
</div>
+
<DialogFooter>
+
<Button
+
variant="outline"
+
onClick={() => setConfigureModalOpen(false)}
+
>
+
Cancel
+
</Button>
+
<Button onClick={handleSaveConfiguration}>
+
Save Configuration
+
</Button>
+
</DialogFooter>
+
</DialogContent>
+
</Dialog>
+
+
<Dialog
+
open={addDomainModalOpen}
+
onOpenChange={setAddDomainModalOpen}
+
>
+
<DialogContent className="sm:max-w-lg">
+
<DialogHeader>
+
<DialogTitle>Add Custom Domain</DialogTitle>
+
<DialogDescription>
+
Configure DNS records to verify your domain
+
ownership
+
</DialogDescription>
+
</DialogHeader>
+
<div className="space-y-4 py-4">
+
<div className="space-y-2">
+
<Label htmlFor="new-domain">Domain Name</Label>
+
<Input
+
id="new-domain"
+
placeholder="example.com"
+
value={customDomain}
+
onChange={(e) =>
+
setCustomDomain(e.target.value)
+
}
+
/>
+
</div>
-
{error && (
-
<div className="mt-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-md">
-
{error}
-
</div>
-
)}
+
{customDomain && (
+
<div className="space-y-4 p-4 bg-muted/30 rounded-lg border border-border">
+
<div>
+
<h4 className="font-semibold mb-2 flex items-center gap-2">
+
<AlertCircle className="w-4 h-4 text-accent" />
+
DNS Configuration Required
+
</h4>
+
<p className="text-sm text-muted-foreground mb-4">
+
Add these DNS records to your domain
+
provider:
+
</p>
+
</div>
-
{result && (
-
<div className="mt-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded-md">
-
<h3 className="font-semibold mb-2">Upload Successful!</h3>
-
<p>Files uploaded: {result.fileCount}</p>
-
<p>Site name: {result.siteName}</p>
-
<p>URI: {result.uri}</p>
-
</div>
-
)}
+
<div className="space-y-3">
+
<div className="p-3 bg-background rounded border border-border">
+
<div className="flex justify-between items-start mb-1">
+
<span className="text-xs font-semibold text-muted-foreground">
+
TXT Record
+
</span>
+
</div>
+
<div className="font-mono text-sm space-y-1">
+
<div>
+
<span className="text-muted-foreground">
+
Name:
+
</span>{' '}
+
_wisp
+
</div>
+
<div>
+
<span className="text-muted-foreground">
+
Value:
+
</span>{' '}
+
{mockUser.did}
+
</div>
+
</div>
+
</div>
+
+
<div className="p-3 bg-background rounded border border-border">
+
<div className="flex justify-between items-start mb-1">
+
<span className="text-xs font-semibold text-muted-foreground">
+
CNAME Record
+
</span>
+
</div>
+
<div className="font-mono text-sm space-y-1">
+
<div>
+
<span className="text-muted-foreground">
+
Name:
+
</span>{' '}
+
@ or {customDomain}
+
</div>
+
<div>
+
<span className="text-muted-foreground">
+
Value:
+
</span>{' '}
+
abc123.dns.wisp.place
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
)}
+
</div>
+
<DialogFooter className="flex-col sm:flex-row gap-2">
+
<Button
+
variant="outline"
+
onClick={() => {
+
setAddDomainModalOpen(false)
+
setCustomDomain('')
+
setVerificationStatus('idle')
+
}}
+
className="w-full sm:w-auto"
+
>
+
Cancel
+
</Button>
+
<Button
+
onClick={handleVerifyDNS}
+
disabled={
+
!customDomain ||
+
verificationStatus === 'verifying'
+
}
+
className="w-full sm:w-auto"
+
>
+
{verificationStatus === 'verifying' ? (
+
<>Verifying DNS...</>
+
) : verificationStatus === 'success' ? (
+
<>
+
<CheckCircle2 className="w-4 h-4 mr-2" />
+
Verified
+
</>
+
) : verificationStatus === 'error' ? (
+
<>
+
<XCircle className="w-4 h-4 mr-2" />
+
Verification Failed
+
</>
+
) : (
+
<>Verify DNS Records</>
+
)}
+
</Button>
+
</DialogFooter>
+
</DialogContent>
+
</Dialog>
</div>
)
}
···
const root = createRoot(document.getElementById('elysia')!)
root.render(
<Layout className="gap-6">
-
<Editor />
+
<Dashboard />
</Layout>
-
)
+
)
public/images/maddelena-1.webp

This is a binary file and will not be displayed.

public/images/maddelena-2.webp

This is a binary file and will not be displayed.

+281 -127
public/index.tsx
···
import { useState, useRef, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
+
import {
+
ArrowRight,
+
Shield,
+
Zap,
+
Globe,
+
Lock,
+
Code,
+
Server
+
} from 'lucide-react'
import Layout from '@public/layouts'
+
import { Button } from '@public/components/ui/button'
+
import { Card } from '@public/components/ui/card'
function App() {
const [showForm, setShowForm] = useState(false)
···
}, [showForm])
return (
-
<>
-
<section id="header" className="py-24 px-6">
-
<div className="text-center space-y-8">
-
<div className="space-y-4">
-
<h1 className="text-6xl md:text-8xl font-bold text-balance leading-tight">
-
The complete platform to{' '}
-
<span className="gradient-text">
-
publish the web.
-
</span>
-
</h1>
-
<p className="text-xl md:text-2xl text-muted-foreground max-w-3xl mx-auto text-balance">
-
Your decentralized toolkit to stop configuring and
-
start publishing. Securely build, deploy, and own
-
your web presence with AT Protocol.
-
</p>
+
<div className="min-h-screen">
+
{/* Header */}
+
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
+
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
+
<div className="flex items-center gap-2">
+
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
+
<Globe className="w-5 h-5 text-primary-foreground" />
+
</div>
+
<span className="text-xl font-semibold text-foreground">
+
wisp.place
+
</span>
</div>
-
-
<div className="inline-flex items-center gap-2 bg-accent/10 border border-accent/20 rounded-full px-4 py-2">
-
<svg
-
xmlns="http://www.w3.org/2000/svg"
-
className="h-5 w-5 text-accent"
-
viewBox="0 0 20 20"
-
fill="currentColor"
+
<div className="flex items-center gap-3">
+
<Button
+
variant="ghost"
+
size="sm"
+
onClick={() => setShowForm(true)}
>
-
<path
-
fillRule="evenodd"
-
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.414-1.414L11 9.586V6z"
-
clipRule="evenodd"
-
/>
-
</svg>
-
<span className="text-sm font-medium text-accent">
-
Publish once, own forever
+
Sign In
+
</Button>
+
<Button
+
size="sm"
+
className="bg-accent text-accent-foreground hover:bg-accent/90"
+
>
+
Get Started
+
</Button>
+
</div>
+
</div>
+
</header>
+
+
{/* Hero Section */}
+
<section className="container mx-auto px-4 py-20 md:py-32">
+
<div className="max-w-4xl mx-auto text-center">
+
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8">
+
<span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span>
+
<span className="text-sm text-accent-foreground">
+
Built on AT Protocol
</span>
</div>
-
<div className="max-w-md mx-auto space-y-4 mt-8">
-
<div className="relative h-16">
-
<div
-
className={`transition-all duration-500 ease-in-out absolute inset-0 ${
-
showForm
-
? 'opacity-0 -translate-y-5 pointer-events-none'
-
: 'opacity-100 translate-y-0'
-
}`}
-
>
-
<button
-
onClick={() => setShowForm(true)}
-
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors"
-
>
-
Log in with AT Proto
-
<svg
-
xmlns="http://www.w3.org/2000/svg"
-
className="ml-2 w-5 h-5"
-
viewBox="0 0 24 24"
-
fill="none"
-
stroke="currentColor"
-
strokeWidth="2"
-
strokeLinecap="round"
-
strokeLinejoin="round"
-
>
-
<path d="M5 12h14M12 5l7 7-7 7" />
-
</svg>
-
</button>
-
</div>
+
<h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight">
+
Host your sites on the{' '}
+
<span className="text-primary">decentralized</span> web
+
</h1>
-
<div
-
className={`transition-all duration-500 ease-in-out absolute inset-0 ${
-
showForm
-
? 'opacity-100 translate-y-0'
-
: 'opacity-0 translate-y-5 pointer-events-none'
-
}`}
+
<p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto">
+
Deploy static sites to a truly open network. Your
+
content, your control, your identity. No platform
+
lock-in, ever.
+
</p>
+
+
<div className="max-w-md mx-auto relative">
+
<div
+
className={`transition-all duration-500 ease-in-out ${
+
showForm
+
? 'opacity-0 -translate-y-5 pointer-events-none'
+
: 'opacity-100 translate-y-0'
+
}`}
+
>
+
<Button
+
size="lg"
+
className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full"
+
onClick={() => setShowForm(true)}
>
-
<form
-
onSubmit={async (e) => {
-
e.preventDefault()
-
try {
-
const handle =
-
inputRef.current?.value
-
const res = await fetch(
-
'/api/auth/signin',
-
{
-
method: 'POST',
-
headers: {
-
'Content-Type':
-
'application/json'
-
},
-
body: JSON.stringify({
-
handle
-
})
-
}
-
)
-
if (!res.ok)
-
throw new Error(
-
'Request failed'
-
)
-
const data = await res.json()
-
if (data.url) {
-
window.location.href = data.url
-
} else {
-
alert('Unexpected response')
+
Log in with AT Proto
+
<ArrowRight className="ml-2 w-5 h-5" />
+
</Button>
+
</div>
+
+
<div
+
className={`transition-all duration-500 ease-in-out absolute inset-0 ${
+
showForm
+
? 'opacity-100 translate-y-0'
+
: 'opacity-0 translate-y-5 pointer-events-none'
+
}`}
+
>
+
<form
+
onSubmit={async (e) => {
+
e.preventDefault()
+
try {
+
const handle = inputRef.current?.value
+
const res = await fetch(
+
'/api/auth/signin',
+
{
+
method: 'POST',
+
headers: {
+
'Content-Type':
+
'application/json'
+
},
+
body: JSON.stringify({ handle })
}
-
} catch (error) {
-
console.error(
-
'Login failed:',
-
error
-
)
-
alert('Authentication failed')
+
)
+
if (!res.ok)
+
throw new Error('Request failed')
+
const data = await res.json()
+
if (data.url) {
+
window.location.href = data.url
+
} else {
+
alert('Unexpected response')
}
-
}}
-
className="space-y-3"
+
} catch (error) {
+
console.error('Login failed:', error)
+
alert('Authentication failed')
+
}
+
}}
+
className="space-y-3"
+
>
+
<input
+
ref={inputRef}
+
type="text"
+
name="handle"
+
placeholder="Enter your handle (e.g., alice.bsky.social)"
+
className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
+
/>
+
<button
+
type="submit"
+
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors"
>
-
<input
-
ref={inputRef}
-
type="text"
-
name="handle"
-
placeholder="Enter your handle (e.g., alice.bsky.social)"
-
className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
-
/>
-
<button
-
type="submit"
-
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors"
-
>
-
Continue
-
<svg
-
xmlns="http://www.w3.org/2000/svg"
-
className="ml-2 w-5 h-5"
-
viewBox="0 0 24 24"
-
fill="none"
-
stroke="currentColor"
-
strokeWidth="2"
-
strokeLinecap="round"
-
strokeLinejoin="round"
-
>
-
<path d="M5 12h14M12 5l7 7-7 7" />
-
</svg>
-
</button>
-
</form>
+
Continue
+
<ArrowRight className="ml-2 w-5 h-5" />
+
</button>
+
</form>
+
</div>
+
</div>
+
</div>
+
</section>
+
+
{/* Stats Section */}
+
<section className="container mx-auto px-4 py-16">
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-5xl mx-auto">
+
{[
+
{ value: '100%', label: 'Decentralized' },
+
{ value: '0ms', label: 'Cold Start' },
+
{ value: '∞', label: 'Scalability' },
+
{ value: 'You', label: 'Own Your Data' }
+
].map((stat, i) => (
+
<div key={i} className="text-center">
+
<div className="text-4xl md:text-5xl font-bold text-primary mb-2">
+
{stat.value}
+
</div>
+
<div className="text-sm text-muted-foreground">
+
{stat.label}
</div>
</div>
+
))}
+
</div>
+
</section>
+
+
{/* Features Grid */}
+
<section id="features" className="container mx-auto px-4 py-20">
+
<div className="text-center mb-16">
+
<h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance">
+
Built for the open web
+
</h2>
+
<p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto">
+
Everything you need to deploy and manage static sites on
+
a decentralized network
+
</p>
+
</div>
+
+
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
+
{[
+
{
+
icon: Shield,
+
title: 'True Ownership',
+
description:
+
'Your content lives on the AT Protocol network. No single company can take it down or lock you out.'
+
},
+
{
+
icon: Zap,
+
title: 'Lightning Fast',
+
description:
+
'Distributed edge network ensures your sites load instantly from anywhere in the world.'
+
},
+
{
+
icon: Lock,
+
title: 'Cryptographic Security',
+
description:
+
'Content-addressed storage and cryptographic verification ensure integrity and authenticity.'
+
},
+
{
+
icon: Code,
+
title: 'Developer Friendly',
+
description:
+
'Simple CLI, Git integration, and familiar workflows. Deploy with a single command.'
+
},
+
{
+
icon: Server,
+
title: 'Zero Vendor Lock-in',
+
description:
+
'Built on open protocols. Migrate your sites anywhere, anytime. Your data is portable.'
+
},
+
{
+
icon: Globe,
+
title: 'Global Network',
+
description:
+
'Leverage the power of decentralized infrastructure for unmatched reliability and uptime.'
+
}
+
].map((feature, i) => (
+
<Card
+
key={i}
+
className="p-6 hover:shadow-lg transition-shadow border-2 bg-card"
+
>
+
<div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4">
+
<feature.icon className="w-6 h-6 text-accent" />
+
</div>
+
<h3 className="text-xl font-semibold mb-2 text-card-foreground">
+
{feature.title}
+
</h3>
+
<p className="text-muted-foreground leading-relaxed">
+
{feature.description}
+
</p>
+
</Card>
+
))}
+
</div>
+
</section>
+
+
{/* How It Works */}
+
<section
+
id="how-it-works"
+
className="container mx-auto px-4 py-20 bg-muted/30"
+
>
+
<div className="max-w-4xl mx-auto">
+
<h2 className="text-4xl md:text-5xl font-bold text-center mb-16 text-balance">
+
Deploy in three steps
+
</h2>
+
+
<div className="space-y-12">
+
{[
+
{
+
step: '01',
+
title: 'Upload your site',
+
description:
+
'Link your Git repository or upload a folder containing your static site directly.'
+
},
+
{
+
step: '02',
+
title: 'Name and set domain',
+
description:
+
'Name your site and set domain routing to it. You can bring your own domain too.'
+
},
+
{
+
step: '03',
+
title: 'Deploy to AT Protocol',
+
description:
+
'Your site is published to the decentralized network with a permanent, verifiable identity.'
+
}
+
].map((step, i) => (
+
<div key={i} className="flex gap-6 items-start">
+
<div className="text-6xl font-bold text-accent/20 min-w-[80px]">
+
{step.step}
+
</div>
+
<div className="flex-1 pt-2">
+
<h3 className="text-2xl font-semibold mb-3">
+
{step.title}
+
</h3>
+
<p className="text-lg text-muted-foreground leading-relaxed">
+
{step.description}
+
</p>
+
</div>
+
</div>
+
))}
</div>
</div>
</section>
-
</>
+
+
{/* Footer */}
+
<footer className="border-t border-border/40 bg-muted/20">
+
<div className="container mx-auto px-4 py-8">
+
<div className="text-center text-sm text-muted-foreground">
+
<p>
+
Built by{' '}
+
<a
+
href="https://bsky.app/profile/nekomimi.pet"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
+
>
+
@nekomimi.pet
+
</a>
+
</p>
+
</div>
+
</div>
+
</footer>
+
</div>
)
}
+1 -1
public/layouts/index.tsx
···
<QueryClientProvider client={client}>
<div
className={clsx(
-
'flex flex-col justify-center items-center w-full min-h-screen',
+
'flex flex-col items-center w-full min-h-screen',
className
)}
>
+6
public/lib/utils.ts
···
+
import { clsx, type ClassValue } from "clsx"
+
import { twMerge } from "tailwind-merge"
+
+
export function cn(...inputs: ClassValue[]) {
+
return twMerge(clsx(inputs))
+
}
public/libs/api.ts public/lib/api.ts
-13
public/other/index.html
···
-
<!doctype html>
-
<html lang="en">
-
<head>
-
<meta charset="UTF-8" />
-
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
-
<title>Elysia Static</title>
-
</head>
-
<body>
-
<div id="elysia"></div>
-
<script type="module" src="./index.tsx"></script>
-
</body>
-
</html>
-27
public/other/index.tsx
···
-
import { createRoot } from 'react-dom/client'
-
import { useQuery } from '@tanstack/react-query'
-
-
import Layout from '../layouts'
-
import { api } from '../libs/api'
-
-
function App() {
-
const { data: response, isLoading } = useQuery({
-
queryKey: ['version'],
-
queryFn: () => api.message.get()
-
})
-
-
return (
-
<>
-
<img src="/images/maddelena-2.webp" className="max-w-40" />
-
<h1 className="text-3xl">API call!</h1>
-
<h2 className="text-6xl">{response?.data?.message}</h2>
-
</>
-
)
-
}
-
-
const root = createRoot(document.getElementById('elysia')!)
-
root.render(
-
<Layout className="gap-6">
-
<App />
-
</Layout>
-
)
+90 -145
public/styles/global.css
···
@import "tailwindcss";
+
@import "tw-animate-css";
-
.gradient-text {
-
background: linear-gradient(135deg,
-
#FFAAD2 0%, /* lavender pink */
-
#348AA7 25%, /* blue munsell */
-
#413C58 50%, /* english violet */
-
#CCD7C5 75%, /* ash gray */
-
#F2E7C9 100% /* parchment */
-
);
-
background-size: 200% 200%;
-
-webkit-background-clip: text;
-
-webkit-text-fill-color: transparent;
-
background-clip: text;
-
animation: gradient-shift 4s ease-in-out infinite;
-
}
+
@custom-variant dark (&:is(.dark *));
+
+
:root {
+
/* #F2E7C9 - parchment background */
+
--background: oklch(0.93 0.03 85);
+
/* #413C58 - violet for text */
+
--foreground: oklch(0.32 0.04 285);
+
+
--card: oklch(0.98 0.01 85);
+
--card-foreground: oklch(0.32 0.04 285);
+
+
--popover: oklch(0.98 0.01 85);
+
--popover-foreground: oklch(0.32 0.04 285);
+
+
/* #413C58 - violet primary */
+
--primary: oklch(0.32 0.04 285);
+
--primary-foreground: oklch(0.98 0.01 85);
+
+
/* #FFAAD2 - pink accent */
+
--accent: oklch(0.78 0.15 345);
+
--accent-foreground: oklch(0.32 0.04 285);
+
+
/* #348AA7 - blue secondary */
+
--secondary: oklch(0.56 0.08 220);
+
--secondary-foreground: oklch(0.98 0.01 85);
+
+
/* #CCD7C5 - ash muted */
+
--muted: oklch(0.85 0.02 130);
+
--muted-foreground: oklch(0.45 0.03 285);
+
+
--border: oklch(0.75 0.02 285);
+
--input: oklch(0.75 0.02 285);
+
--ring: oklch(0.78 0.15 345);
+
+
--destructive: oklch(0.577 0.245 27.325);
+
--destructive-foreground: oklch(0.985 0 0);
-
@keyframes gradient-shift {
-
0%,
-
100% {
-
background-position: 0% 50%;
-
}
-
50% {
-
background-position: 100% 50%;
-
}
+
--chart-1: oklch(0.78 0.15 345);
+
--chart-2: oklch(0.32 0.04 285);
+
--chart-3: oklch(0.56 0.08 220);
+
--chart-4: oklch(0.85 0.02 130);
+
--chart-5: oklch(0.93 0.03 85);
+
+
--radius: 0.75rem;
+
--sidebar: oklch(0.985 0 0);
+
--sidebar-foreground: oklch(0.145 0 0);
+
--sidebar-primary: oklch(0.205 0 0);
+
--sidebar-primary-foreground: oklch(0.985 0 0);
+
--sidebar-accent: oklch(0.97 0 0);
+
--sidebar-accent-foreground: oklch(0.205 0 0);
+
--sidebar-border: oklch(0.922 0 0);
+
--sidebar-ring: oklch(0.708 0 0);
}
-
/*
-
WISPY / GHOSTY THEME (Dark baseline)
-
Philosophy: elegant violet depths with pink accents
-
Palette: #FFAAD2 (pink), #413C58 (violet), #348AA7 (blue), #CCD7C5 (ash), #F2E7C9 (parchment)
-
*/
-
:root {
-
/* Core surfaces - english violet base */
-
--background: #413C58; /* english violet */
-
--foreground: #F2E7C9; /* parchment */
-
--card: #4d4763; /* slightly lighter violet */
-
--card-foreground: var(--foreground);
-
--popover: #48445f;
-
--popover-foreground: var(--foreground);
+
.dark {
+
/* #413C58 - violet background for dark mode */
+
--background: oklch(0.28 0.04 285);
+
/* #F2E7C9 - parchment text */
+
--foreground: oklch(0.93 0.03 85);
+
+
--card: oklch(0.32 0.04 285);
+
--card-foreground: oklch(0.93 0.03 85);
-
/* Brand spectral axis (pink accent!) */
-
--primary: #FFAAD2; /* lavender pink - main accent */
-
--primary-foreground: #413C58; /* violet */
-
--secondary: #348AA7; /* blue munsell */
-
--secondary-foreground: #F2E7C9; /* parchment */
-
--accent: #FFAAD2; /* lavender pink - keeping the pink! */
-
--accent-foreground: #413C58; /* violet */
-
--muted: #5a5570; /* muted violet */
-
--muted-foreground: #CCD7C5;
+
--popover: oklch(0.32 0.04 285);
+
--popover-foreground: oklch(0.93 0.03 85);
-
/* Feedback / semantic */
-
--destructive: #ff5588; /* brighter pink for warnings */
-
--destructive-foreground: #fff;
+
/* #FFAAD2 - pink primary in dark mode */
+
--primary: oklch(0.78 0.15 345);
+
--primary-foreground: oklch(0.32 0.04 285);
-
/* Interaction frame tokens */
-
--border: #5a5570; /* muted violet */
-
--input: #4d4763;
-
--ring: #FFAAD2; /* pink focus ring */
+
--accent: oklch(0.78 0.15 345);
+
--accent-foreground: oklch(0.32 0.04 285);
-
/* Data viz (ordered spectral gentle ramp) */
-
--chart-1: #FFAAD2; /* lavender pink */
-
--chart-2: #348AA7; /* blue munsell */
-
--chart-3: #CCD7C5; /* ash gray */
-
--chart-4: #F2E7C9; /* parchment */
-
--chart-5: #413C58; /* english violet */
+
--secondary: oklch(0.56 0.08 220);
+
--secondary-foreground: oklch(0.93 0.03 85);
-
--radius: 0.75rem;
+
--muted: oklch(0.38 0.03 285);
+
--muted-foreground: oklch(0.75 0.02 85);
-
/* Sidebar palette reuses base tokens for cohesion */
-
--sidebar: #38344a; /* darker violet */
-
--sidebar-foreground: var(--foreground);
-
--sidebar-primary: var(--primary);
-
--sidebar-primary-foreground: var(--primary-foreground);
-
--sidebar-accent: var(--accent);
-
--sidebar-accent-foreground: var(--accent-foreground);
-
--sidebar-border: var(--border);
-
--sidebar-ring: var(--ring);
-
}
+
--border: oklch(0.42 0.03 285);
+
--input: oklch(0.42 0.03 285);
+
--ring: oklch(0.78 0.15 345);
-
/* Light (parchment) variant */
-
[data-theme="light"] {
-
--background: #F2E7C9; /* parchment */
-
--foreground: #413C58; /* english violet */
-
--card: #faf5e6; /* lighter parchment */
-
--card-foreground: var(--foreground);
-
--popover: #fff;
-
--popover-foreground: var(--foreground);
-
--primary: #FFAAD2; /* lavender pink - keep the pink! */
-
--primary-foreground: #413C58;
-
--secondary: #348AA7; /* blue munsell */
-
--secondary-foreground: #fff;
-
--accent: #FFAAD2; /* lavender pink accent */
-
--accent-foreground: #413C58;
-
--muted: #e8dfc0;
-
--muted-foreground: #5a5570;
-
--destructive: #d8006d;
-
--destructive-foreground: #fff;
-
--border: #CCD7C5; /* ash gray */
-
--input: #faf5e6;
-
--ring: #FFAAD2; /* pink ring */
-
--chart-1: #FFAAD2;
-
--chart-2: #348AA7;
-
--chart-3: #413C58;
-
--chart-4: #CCD7C5;
-
--chart-5: #5a5570;
-
--sidebar: #f7f0da;
-
--sidebar-foreground: var(--foreground);
-
--sidebar-primary: var(--primary);
-
--sidebar-primary-foreground: var(--primary-foreground);
-
--sidebar-accent: var(--accent);
-
--sidebar-accent-foreground: var(--accent-foreground);
-
--sidebar-border: var(--border);
-
--sidebar-ring: var(--ring);
-
}
+
--destructive: oklch(0.577 0.245 27.325);
+
--destructive-foreground: oklch(0.985 0 0);
-
@media (prefers-color-scheme: light) {
-
:root:not([data-theme="dark"]) {
-
--background: #F2E7C9; /* parchment */
-
--foreground: #413C58; /* english violet */
-
--card: #faf5e6; /* lighter parchment */
-
--card-foreground: var(--foreground);
-
--popover: #fff;
-
--popover-foreground: var(--foreground);
-
--primary: #FFAAD2; /* lavender pink */
-
--primary-foreground: #413C58;
-
--secondary: #348AA7; /* blue munsell */
-
--secondary-foreground: #fff;
-
--accent: #FFAAD2; /* lavender pink */
-
--accent-foreground: #413C58;
-
--muted: #e8dfc0;
-
--muted-foreground: #5a5570;
-
--destructive: #d8006d;
-
--destructive-foreground: #fff;
-
--border: #CCD7C5; /* ash gray */
-
--input: #faf5e6;
-
--ring: #FFAAD2;
-
--chart-1: #FFAAD2;
-
--chart-2: #348AA7;
-
--chart-3: #413C58;
-
--chart-4: #CCD7C5;
-
--chart-5: #5a5570;
-
--sidebar: #f7f0da;
-
--sidebar-foreground: var(--foreground);
-
--sidebar-primary: var(--primary);
-
--sidebar-primary-foreground: var(--primary-foreground);
-
--sidebar-accent: var(--accent);
-
--sidebar-accent-foreground: var(--accent-foreground);
-
--sidebar-border: var(--border);
-
--sidebar-ring: var(--ring);
-
}
+
--chart-1: oklch(0.78 0.15 345);
+
--chart-2: oklch(0.93 0.03 85);
+
--chart-3: oklch(0.56 0.08 220);
+
--chart-4: oklch(0.85 0.02 130);
+
--chart-5: oklch(0.32 0.04 285);
+
--sidebar: oklch(0.205 0 0);
+
--sidebar-foreground: oklch(0.985 0 0);
+
--sidebar-primary: oklch(0.488 0.243 264.376);
+
--sidebar-primary-foreground: oklch(0.985 0 0);
+
--sidebar-accent: oklch(0.269 0 0);
+
--sidebar-accent-foreground: oklch(0.985 0 0);
+
--sidebar-border: oklch(0.269 0 0);
+
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
-
--font-sans: var(--font-geist-sans);
-
--font-mono: var(--font-geist-mono);
+
/* optional: --font-sans, --font-serif, --font-mono if they are applied in the layout.tsx */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
···
@apply bg-background text-foreground;
}
}
-
-
/* Reduced motion respect: disable animated shimmer if user prefers */
-
@media (prefers-reduced-motion: reduce) {
-
.gradient-text { animation: none; }
-
}
+2
src/index.ts
···
} from './lib/oauth-client'
import { authRoutes } from './routes/auth'
import { wispRoutes } from './routes/wisp'
+
import { domainRoutes } from './routes/domain'
const config: Config = {
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
···
)
.use(authRoutes(client))
.use(wispRoutes(client))
+
.use(domainRoutes(client))
.get('/client-metadata.json', (c) => {
return createClientMetadata(config)
})
+129
src/routes/domain.ts
···
+
import { Elysia } from 'elysia'
+
import { requireAuth, type AuthenticatedContext } from '../lib/wisp-auth'
+
import { NodeOAuthClient } from '@atproto/oauth-client-node'
+
import { Agent } from '@atproto/api'
+
import {
+
claimDomain,
+
getDomainByDid,
+
isDomainAvailable,
+
isValidHandle,
+
toDomain,
+
updateDomain,
+
} from '../lib/db'
+
+
export const domainRoutes = (client: NodeOAuthClient) =>
+
new Elysia({ prefix: '/api/domain' })
+
.derive(async ({ cookie }) => {
+
const auth = await requireAuth(client, cookie)
+
return { auth }
+
})
+
.get('/check', async ({ query }) => {
+
try {
+
const handle = (query.handle || "")
+
.trim()
+
.toLowerCase();
+
+
if (!isValidHandle(handle)) {
+
return {
+
available: false,
+
reason: "invalid"
+
};
+
}
+
+
const available = await isDomainAvailable(handle);
+
return {
+
available,
+
domain: toDomain(handle)
+
};
+
} catch (err) {
+
console.error("domain/check error", err);
+
return {
+
available: false
+
};
+
}
+
})
+
.post('/claim', async ({ body, auth }) => {
+
try {
+
const { handle } = body as { handle?: string };
+
const normalizedHandle = (handle || "").trim().toLowerCase();
+
+
if (!isValidHandle(normalizedHandle)) {
+
throw new Error("Invalid handle");
+
}
+
+
// ensure user hasn't already claimed
+
const existing = await getDomainByDid(auth.did);
+
if (existing) {
+
throw new Error("Already claimed");
+
}
+
+
// claim in DB
+
let domain: string;
+
try {
+
domain = await claimDomain(auth.did, normalizedHandle);
+
} catch (err) {
+
throw new Error("Handle taken");
+
}
+
+
// write place.wisp.domain record rkey = self
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init));
+
await agent.com.atproto.repo.putRecord({
+
repo: auth.did,
+
collection: "place.wisp.domain",
+
rkey: "self",
+
record: {
+
$type: "place.wisp.domain",
+
domain,
+
createdAt: new Date().toISOString(),
+
} as any,
+
validate: false,
+
});
+
+
return { success: true, domain };
+
} catch (err) {
+
console.error("domain/claim error", err);
+
throw new Error(`Failed to claim: ${err instanceof Error ? err.message : 'Unknown error'}`);
+
}
+
})
+
.post('/update', async ({ body, auth }) => {
+
try {
+
const { handle } = body as { handle?: string };
+
const normalizedHandle = (handle || "").trim().toLowerCase();
+
+
if (!isValidHandle(normalizedHandle)) {
+
throw new Error("Invalid handle");
+
}
+
+
const desiredDomain = toDomain(normalizedHandle);
+
const current = await getDomainByDid(auth.did);
+
+
if (current === desiredDomain) {
+
return { success: true, domain: current };
+
}
+
+
let domain: string;
+
try {
+
domain = await updateDomain(auth.did, normalizedHandle);
+
} catch (err) {
+
throw new Error("Handle taken");
+
}
+
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init));
+
await agent.com.atproto.repo.putRecord({
+
repo: auth.did,
+
collection: "place.wisp.domain",
+
rkey: "self",
+
record: {
+
$type: "place.wisp.domain",
+
domain,
+
createdAt: new Date().toISOString(),
+
} as any,
+
validate: false,
+
});
+
+
return { success: true, domain };
+
} catch (err) {
+
console.error("domain/update error", err);
+
throw new Error(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`);
+
}
+
});
+1 -1
tsconfig.json
···
"paths": {
"@server": ["./src/index.ts"],
"@server/*": ["./src/*"],
-
"@public/*": ["./public/*"]
+
"@public/*": ["./public/*"],
}
}
}