A website for the ATmosphereConf

chore: Initial profile infrastructure setup

Changed files
+1635 -11
lexicons
src
components
lexicon
types
com
atproto
label
repo
org
atmosphereconf
pages
+156
lexicons/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "type": "object", 7 + "description": "Metadata tag on an atproto resource (eg, repo or record).", 8 + "required": ["src", "uri", "val", "cts"], 9 + "properties": { 10 + "ver": { 11 + "type": "integer", 12 + "description": "The AT Protocol version of the label object." 13 + }, 14 + "src": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the actor who created this label." 18 + }, 19 + "uri": { 20 + "type": "string", 21 + "format": "uri", 22 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 23 + }, 24 + "cid": { 25 + "type": "string", 26 + "format": "cid", 27 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 28 + }, 29 + "val": { 30 + "type": "string", 31 + "maxLength": 128, 32 + "description": "The short string name of the value or type of this label." 33 + }, 34 + "neg": { 35 + "type": "boolean", 36 + "description": "If true, this is a negation label, overwriting a previous label." 37 + }, 38 + "cts": { 39 + "type": "string", 40 + "format": "datetime", 41 + "description": "Timestamp when this label was created." 42 + }, 43 + "exp": { 44 + "type": "string", 45 + "format": "datetime", 46 + "description": "Timestamp at which this label expires (no longer applies)." 47 + }, 48 + "sig": { 49 + "type": "bytes", 50 + "description": "Signature of dag-cbor encoded label." 51 + } 52 + } 53 + }, 54 + "selfLabels": { 55 + "type": "object", 56 + "description": "Metadata tags on an atproto record, published by the author within the record.", 57 + "required": ["values"], 58 + "properties": { 59 + "values": { 60 + "type": "array", 61 + "items": { "type": "ref", "ref": "#selfLabel" }, 62 + "maxLength": 10 63 + } 64 + } 65 + }, 66 + "selfLabel": { 67 + "type": "object", 68 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", 69 + "required": ["val"], 70 + "properties": { 71 + "val": { 72 + "type": "string", 73 + "maxLength": 128, 74 + "description": "The short string name of the value or type of this label." 75 + } 76 + } 77 + }, 78 + "labelValueDefinition": { 79 + "type": "object", 80 + "description": "Declares a label value and its expected interpretations and behaviors.", 81 + "required": ["identifier", "severity", "blurs", "locales"], 82 + "properties": { 83 + "identifier": { 84 + "type": "string", 85 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 86 + "maxLength": 100, 87 + "maxGraphemes": 100 88 + }, 89 + "severity": { 90 + "type": "string", 91 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 92 + "knownValues": ["inform", "alert", "none"] 93 + }, 94 + "blurs": { 95 + "type": "string", 96 + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 97 + "knownValues": ["content", "media", "none"] 98 + }, 99 + "defaultSetting": { 100 + "type": "string", 101 + "description": "The default setting for this label.", 102 + "knownValues": ["ignore", "warn", "hide"], 103 + "default": "warn" 104 + }, 105 + "adultOnly": { 106 + "type": "boolean", 107 + "description": "Does the user need to have adult content enabled in order to configure this label?" 108 + }, 109 + "locales": { 110 + "type": "array", 111 + "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } 112 + } 113 + } 114 + }, 115 + "labelValueDefinitionStrings": { 116 + "type": "object", 117 + "description": "Strings which describe the label in the UI, localized into a specific language.", 118 + "required": ["lang", "name", "description"], 119 + "properties": { 120 + "lang": { 121 + "type": "string", 122 + "description": "The code of the language these strings are written in.", 123 + "format": "language" 124 + }, 125 + "name": { 126 + "type": "string", 127 + "description": "A short human-readable name for the label.", 128 + "maxGraphemes": 64, 129 + "maxLength": 640 130 + }, 131 + "description": { 132 + "type": "string", 133 + "description": "A longer description of what the label means and why it might be applied.", 134 + "maxGraphemes": 10000, 135 + "maxLength": 100000 136 + } 137 + } 138 + }, 139 + "labelValue": { 140 + "type": "string", 141 + "knownValues": [ 142 + "!hide", 143 + "!no-promote", 144 + "!warn", 145 + "!no-unauthenticated", 146 + "dmca-violation", 147 + "doxxing", 148 + "porn", 149 + "sexual", 150 + "nudity", 151 + "nsfl", 152 + "gore" 153 + ] 154 + } 155 + } 156 + }
+45
lexicons/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "org.atmosphereconf.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A conference attendee profile.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "displayName": { 13 + "type": "string", 14 + "maxGraphemes": 64, 15 + "maxLength": 640 16 + }, 17 + "description": { 18 + "type": "string", 19 + "description": "Free-form profile description text.", 20 + "maxGraphemes": 256, 21 + "maxLength": 2560 22 + }, 23 + "avatar": { 24 + "type": "blob", 25 + "description": "Profile picture for conference attendee", 26 + "accept": ["image/png", "image/jpeg"], 27 + "maxSize": 1000000 28 + }, 29 + "banner": { 30 + "type": "blob", 31 + "description": "Larger horizontal image to display behind profile view.", 32 + "accept": ["image/png", "image/jpeg"], 33 + "maxSize": 1000000 34 + }, 35 + "labels": { 36 + "type": "union", 37 + "description": "Self-label values for the conference profile.", 38 + "refs": ["com.atproto.label.defs#selfLabels"] 39 + }, 40 + "createdAt": { "type": "string", "format": "datetime" } 41 + } 42 + } 43 + } 44 + } 45 + }
+15
lexicons/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["uri", "cid"], 9 + "properties": { 10 + "uri": { "type": "string", "format": "at-uri" }, 11 + "cid": { "type": "string", "format": "cid" } 12 + } 13 + } 14 + } 15 + }
+205
package-lock.json
··· 16 16 "daisyui": "^5.3.9", 17 17 "dotenv": "^17.2.3", 18 18 "tailwindcss": "^4.1.16" 19 + }, 20 + "devDependencies": { 21 + "@atproto/lex-cli": "^0.9.6" 19 22 } 20 23 }, 21 24 "node_modules/@astrojs/compiler": { ··· 265 268 "@atproto/jwk": "0.6.0", 266 269 "@atproto/jwk-jose": "0.1.11", 267 270 "zod": "^3.23.8" 271 + } 272 + }, 273 + "node_modules/@atproto/lex-cli": { 274 + "version": "0.9.6", 275 + "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.6.tgz", 276 + "integrity": "sha512-EedEKmURoSP735YwSDHsFrLOhZ4P2it8goCHv5ApWi/R9DFpOKOpmYfIXJ9MAprK8cw+yBnjDJbzpLJy7UXlTg==", 277 + "dev": true, 278 + "license": "MIT", 279 + "dependencies": { 280 + "@atproto/lexicon": "^0.5.1", 281 + "@atproto/syntax": "^0.4.1", 282 + "chalk": "^4.1.2", 283 + "commander": "^9.4.0", 284 + "prettier": "^3.2.5", 285 + "ts-morph": "^24.0.0", 286 + "yesno": "^0.4.0", 287 + "zod": "^3.23.8" 288 + }, 289 + "bin": { 290 + "lex": "dist/index.js" 291 + }, 292 + "engines": { 293 + "node": ">=18.7.0" 294 + } 295 + }, 296 + "node_modules/@atproto/lex-cli/node_modules/ansi-styles": { 297 + "version": "4.3.0", 298 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 299 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 300 + "dev": true, 301 + "license": "MIT", 302 + "dependencies": { 303 + "color-convert": "^2.0.1" 304 + }, 305 + "engines": { 306 + "node": ">=8" 307 + }, 308 + "funding": { 309 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 310 + } 311 + }, 312 + "node_modules/@atproto/lex-cli/node_modules/chalk": { 313 + "version": "4.1.2", 314 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 315 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 316 + "dev": true, 317 + "license": "MIT", 318 + "dependencies": { 319 + "ansi-styles": "^4.1.0", 320 + "supports-color": "^7.1.0" 321 + }, 322 + "engines": { 323 + "node": ">=10" 324 + }, 325 + "funding": { 326 + "url": "https://github.com/chalk/chalk?sponsor=1" 268 327 } 269 328 }, 270 329 "node_modules/@atproto/lexicon": { ··· 1958 2017 "vite": "^5.2.0 || ^6 || ^7" 1959 2018 } 1960 2019 }, 2020 + "node_modules/@ts-morph/common": { 2021 + "version": "0.25.0", 2022 + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", 2023 + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", 2024 + "dev": true, 2025 + "license": "MIT", 2026 + "dependencies": { 2027 + "minimatch": "^9.0.4", 2028 + "path-browserify": "^1.0.1", 2029 + "tinyglobby": "^0.2.9" 2030 + } 2031 + }, 1961 2032 "node_modules/@types/debug": { 1962 2033 "version": "4.1.12", 1963 2034 "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", ··· 2356 2427 "url": "https://github.com/sponsors/wooorm" 2357 2428 } 2358 2429 }, 2430 + "node_modules/balanced-match": { 2431 + "version": "1.0.2", 2432 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 2433 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 2434 + "dev": true, 2435 + "license": "MIT" 2436 + }, 2359 2437 "node_modules/base-64": { 2360 2438 "version": "1.0.0", 2361 2439 "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", ··· 2402 2480 }, 2403 2481 "funding": { 2404 2482 "url": "https://github.com/sponsors/sindresorhus" 2483 + } 2484 + }, 2485 + "node_modules/brace-expansion": { 2486 + "version": "2.0.2", 2487 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", 2488 + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", 2489 + "dev": true, 2490 + "license": "MIT", 2491 + "dependencies": { 2492 + "balanced-match": "^1.0.0" 2405 2493 } 2406 2494 }, 2407 2495 "node_modules/brotli": { ··· 2537 2625 "node": ">=6" 2538 2626 } 2539 2627 }, 2628 + "node_modules/code-block-writer": { 2629 + "version": "13.0.3", 2630 + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", 2631 + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", 2632 + "dev": true, 2633 + "license": "MIT" 2634 + }, 2635 + "node_modules/color-convert": { 2636 + "version": "2.0.1", 2637 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 2638 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 2639 + "dev": true, 2640 + "license": "MIT", 2641 + "dependencies": { 2642 + "color-name": "~1.1.4" 2643 + }, 2644 + "engines": { 2645 + "node": ">=7.0.0" 2646 + } 2647 + }, 2648 + "node_modules/color-name": { 2649 + "version": "1.1.4", 2650 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 2651 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 2652 + "dev": true, 2653 + "license": "MIT" 2654 + }, 2540 2655 "node_modules/comma-separated-tokens": { 2541 2656 "version": "2.0.3", 2542 2657 "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", ··· 2545 2660 "funding": { 2546 2661 "type": "github", 2547 2662 "url": "https://github.com/sponsors/wooorm" 2663 + } 2664 + }, 2665 + "node_modules/commander": { 2666 + "version": "9.5.0", 2667 + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", 2668 + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", 2669 + "dev": true, 2670 + "license": "MIT", 2671 + "engines": { 2672 + "node": "^12.20.0 || >=14" 2548 2673 } 2549 2674 }, 2550 2675 "node_modules/common-ancestor-path": { ··· 3032 3157 "radix3": "^1.1.2", 3033 3158 "ufo": "^1.6.1", 3034 3159 "uncrypto": "^0.1.3" 3160 + } 3161 + }, 3162 + "node_modules/has-flag": { 3163 + "version": "4.0.0", 3164 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 3165 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 3166 + "dev": true, 3167 + "license": "MIT", 3168 + "engines": { 3169 + "node": ">=8" 3035 3170 } 3036 3171 }, 3037 3172 "node_modules/hast-util-from-html": { ··· 4526 4661 "node": ">= 0.6" 4527 4662 } 4528 4663 }, 4664 + "node_modules/minimatch": { 4665 + "version": "9.0.5", 4666 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", 4667 + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 4668 + "dev": true, 4669 + "license": "ISC", 4670 + "dependencies": { 4671 + "brace-expansion": "^2.0.1" 4672 + }, 4673 + "engines": { 4674 + "node": ">=16 || 14 >=14.17" 4675 + }, 4676 + "funding": { 4677 + "url": "https://github.com/sponsors/isaacs" 4678 + } 4679 + }, 4529 4680 "node_modules/mrmime": { 4530 4681 "version": "2.0.1", 4531 4682 "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", ··· 4739 4890 "url": "https://github.com/inikulin/parse5?sponsor=1" 4740 4891 } 4741 4892 }, 4893 + "node_modules/path-browserify": { 4894 + "version": "1.0.1", 4895 + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", 4896 + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", 4897 + "dev": true, 4898 + "license": "MIT" 4899 + }, 4742 4900 "node_modules/picocolors": { 4743 4901 "version": "1.1.1", 4744 4902 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", ··· 4783 4941 }, 4784 4942 "engines": { 4785 4943 "node": "^10 || ^12 || >=14" 4944 + } 4945 + }, 4946 + "node_modules/prettier": { 4947 + "version": "3.6.2", 4948 + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", 4949 + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", 4950 + "dev": true, 4951 + "license": "MIT", 4952 + "bin": { 4953 + "prettier": "bin/prettier.cjs" 4954 + }, 4955 + "engines": { 4956 + "node": ">=14" 4957 + }, 4958 + "funding": { 4959 + "url": "https://github.com/prettier/prettier?sponsor=1" 4786 4960 } 4787 4961 }, 4788 4962 "node_modules/prismjs": { ··· 5316 5490 "url": "https://github.com/chalk/strip-ansi?sponsor=1" 5317 5491 } 5318 5492 }, 5493 + "node_modules/supports-color": { 5494 + "version": "7.2.0", 5495 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 5496 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 5497 + "dev": true, 5498 + "license": "MIT", 5499 + "dependencies": { 5500 + "has-flag": "^4.0.0" 5501 + }, 5502 + "engines": { 5503 + "node": ">=8" 5504 + } 5505 + }, 5319 5506 "node_modules/tailwindcss": { 5320 5507 "version": "4.1.16", 5321 5508 "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", ··· 5399 5586 "funding": { 5400 5587 "type": "github", 5401 5588 "url": "https://github.com/sponsors/wooorm" 5589 + } 5590 + }, 5591 + "node_modules/ts-morph": { 5592 + "version": "24.0.0", 5593 + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", 5594 + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", 5595 + "dev": true, 5596 + "license": "MIT", 5597 + "dependencies": { 5598 + "@ts-morph/common": "~0.25.0", 5599 + "code-block-writer": "^13.0.3" 5402 5600 } 5403 5601 }, 5404 5602 "node_modules/tsconfck": { ··· 5965 6163 "engines": { 5966 6164 "node": ">=12" 5967 6165 } 6166 + }, 6167 + "node_modules/yesno": { 6168 + "version": "0.4.0", 6169 + "resolved": "https://registry.npmjs.org/yesno/-/yesno-0.4.0.tgz", 6170 + "integrity": "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==", 6171 + "dev": true, 6172 + "license": "BSD" 5968 6173 }, 5969 6174 "node_modules/yocto-queue": { 5970 6175 "version": "1.2.1",
+5 -1
package.json
··· 6 6 "dev": "astro dev", 7 7 "build": "astro build", 8 8 "preview": "astro preview", 9 - "astro": "astro" 9 + "astro": "astro", 10 + "lexgen": "lex gen-server ./src/lexicon ./lexicons/*" 10 11 }, 11 12 "dependencies": { 12 13 "@astrojs/node": "^9.5.0", ··· 17 18 "daisyui": "^5.3.9", 18 19 "dotenv": "^17.2.3", 19 20 "tailwindcss": "^4.1.16" 21 + }, 22 + "devDependencies": { 23 + "@atproto/lex-cli": "^0.9.6" 20 24 } 21 25 }
+124
src/components/ProfileForm.astro
··· 1 + --- 2 + interface Props { 3 + displayName?: string 4 + description?: string 5 + avatar?: string 6 + banner?: string 7 + submitLabel?: string 8 + action?: string 9 + } 10 + 11 + const { 12 + displayName = '', 13 + description = '', 14 + avatar = '', 15 + banner = '', 16 + submitLabel = 'Create Profile', 17 + action = '/api/profile/create' 18 + } = Astro.props 19 + --- 20 + 21 + <form 22 + method="POST" 23 + action={action} 24 + enctype="multipart/form-data" 25 + class="space-y-4" 26 + > 27 + <div class="form-control"> 28 + <label class="label"> 29 + <span class="label-text">Display Name</span> 30 + <span class="label-text-alt">Max 64 characters</span> 31 + </label> 32 + <input 33 + type="text" 34 + name="displayName" 35 + placeholder="Enter your display name" 36 + class="input input-bordered w-full" 37 + value={displayName} 38 + maxlength="64" 39 + required 40 + /> 41 + </div> 42 + 43 + <div class="form-control"> 44 + <label class="label"> 45 + <span class="label-text">Description</span> 46 + <span class="label-text-alt">Max 256 characters</span> 47 + </label> 48 + <textarea 49 + name="description" 50 + placeholder="Tell us about yourself" 51 + class="textarea textarea-bordered h-24" 52 + maxlength="256" 53 + >{description}</textarea> 54 + </div> 55 + 56 + <div class="form-control"> 57 + <label class="label"> 58 + <span class="label-text">Avatar</span> 59 + <span class="label-text-alt">PNG or JPEG, max 1MB</span> 60 + </label> 61 + {avatar && ( 62 + <div class="avatar mb-2"> 63 + <div class="w-24 rounded-full"> 64 + <img src={avatar} alt="Current avatar" /> 65 + </div> 66 + </div> 67 + )} 68 + <input 69 + type="file" 70 + name="avatar" 71 + accept="image/png,image/jpeg" 72 + class="file-input file-input-bordered w-full" 73 + /> 74 + </div> 75 + 76 + <div class="form-control"> 77 + <label class="label"> 78 + <span class="label-text">Banner</span> 79 + <span class="label-text-alt">PNG or JPEG, max 1MB</span> 80 + </label> 81 + {banner && ( 82 + <div class="mb-2"> 83 + <img src={banner} alt="Current banner" class="w-full h-32 object-cover rounded-lg" /> 84 + </div> 85 + )} 86 + <input 87 + type="file" 88 + name="banner" 89 + accept="image/png,image/jpeg" 90 + class="file-input file-input-bordered w-full" 91 + /> 92 + </div> 93 + 94 + <div class="form-control mt-6"> 95 + <button type="submit" class="btn btn-primary w-full"> 96 + {submitLabel} 97 + </button> 98 + </div> 99 + </form> 100 + 101 + <script> 102 + // Client-side validation for file sizes 103 + const form = document.querySelector('form') 104 + if (form) { 105 + form.addEventListener('submit', (e) => { 106 + const avatarInput = form.querySelector('input[name="avatar"]') as HTMLInputElement 107 + const bannerInput = form.querySelector('input[name="banner"]') as HTMLInputElement 108 + 109 + const maxSize = 1000000 // 1MB 110 + 111 + if (avatarInput?.files?.[0] && avatarInput.files[0].size > maxSize) { 112 + e.preventDefault() 113 + alert('Avatar file size must be less than 1MB') 114 + return 115 + } 116 + 117 + if (bannerInput?.files?.[0] && bannerInput.files[0].size > maxSize) { 118 + e.preventDefault() 119 + alert('Banner file size must be less than 1MB') 120 + return 121 + } 122 + }) 123 + } 124 + </script>
+74
src/lexicon/index.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type Auth, 6 + type Options as XrpcOptions, 7 + Server as XrpcServer, 8 + type StreamConfigOrHandler, 9 + type MethodConfigOrHandler, 10 + createServer as createXrpcServer, 11 + } from '@atproto/xrpc-server' 12 + import { schemas } from './lexicons.js' 13 + 14 + export function createServer(options?: XrpcOptions): Server { 15 + return new Server(options) 16 + } 17 + 18 + export class Server { 19 + xrpc: XrpcServer 20 + org: OrgNS 21 + com: ComNS 22 + 23 + constructor(options?: XrpcOptions) { 24 + this.xrpc = createXrpcServer(schemas, options) 25 + this.org = new OrgNS(this) 26 + this.com = new ComNS(this) 27 + } 28 + } 29 + 30 + export class OrgNS { 31 + _server: Server 32 + atmosphereconf: OrgAtmosphereconfNS 33 + 34 + constructor(server: Server) { 35 + this._server = server 36 + this.atmosphereconf = new OrgAtmosphereconfNS(server) 37 + } 38 + } 39 + 40 + export class OrgAtmosphereconfNS { 41 + _server: Server 42 + 43 + constructor(server: Server) { 44 + this._server = server 45 + } 46 + } 47 + 48 + export class ComNS { 49 + _server: Server 50 + atproto: ComAtprotoNS 51 + 52 + constructor(server: Server) { 53 + this._server = server 54 + this.atproto = new ComAtprotoNS(server) 55 + } 56 + } 57 + 58 + export class ComAtprotoNS { 59 + _server: Server 60 + repo: ComAtprotoRepoNS 61 + 62 + constructor(server: Server) { 63 + this._server = server 64 + this.repo = new ComAtprotoRepoNS(server) 65 + } 66 + } 67 + 68 + export class ComAtprotoRepoNS { 69 + _server: Server 70 + 71 + constructor(server: Server) { 72 + this._server = server 73 + } 74 + }
+298
src/lexicon/lexicons.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type LexiconDoc, 6 + Lexicons, 7 + ValidationError, 8 + type ValidationResult, 9 + } from '@atproto/lexicon' 10 + import { type $Typed, is$typed, maybe$typed } from './util.js' 11 + 12 + export const schemaDict = { 13 + ComAtprotoLabelDefs: { 14 + lexicon: 1, 15 + id: 'com.atproto.label.defs', 16 + defs: { 17 + label: { 18 + type: 'object', 19 + description: 20 + 'Metadata tag on an atproto resource (eg, repo or record).', 21 + required: ['src', 'uri', 'val', 'cts'], 22 + properties: { 23 + ver: { 24 + type: 'integer', 25 + description: 'The AT Protocol version of the label object.', 26 + }, 27 + src: { 28 + type: 'string', 29 + format: 'did', 30 + description: 'DID of the actor who created this label.', 31 + }, 32 + uri: { 33 + type: 'string', 34 + format: 'uri', 35 + description: 36 + 'AT URI of the record, repository (account), or other resource that this label applies to.', 37 + }, 38 + cid: { 39 + type: 'string', 40 + format: 'cid', 41 + description: 42 + "Optionally, CID specifying the specific version of 'uri' resource this label applies to.", 43 + }, 44 + val: { 45 + type: 'string', 46 + maxLength: 128, 47 + description: 48 + 'The short string name of the value or type of this label.', 49 + }, 50 + neg: { 51 + type: 'boolean', 52 + description: 53 + 'If true, this is a negation label, overwriting a previous label.', 54 + }, 55 + cts: { 56 + type: 'string', 57 + format: 'datetime', 58 + description: 'Timestamp when this label was created.', 59 + }, 60 + exp: { 61 + type: 'string', 62 + format: 'datetime', 63 + description: 64 + 'Timestamp at which this label expires (no longer applies).', 65 + }, 66 + sig: { 67 + type: 'bytes', 68 + description: 'Signature of dag-cbor encoded label.', 69 + }, 70 + }, 71 + }, 72 + selfLabels: { 73 + type: 'object', 74 + description: 75 + 'Metadata tags on an atproto record, published by the author within the record.', 76 + required: ['values'], 77 + properties: { 78 + values: { 79 + type: 'array', 80 + items: { 81 + type: 'ref', 82 + ref: 'lex:com.atproto.label.defs#selfLabel', 83 + }, 84 + maxLength: 10, 85 + }, 86 + }, 87 + }, 88 + selfLabel: { 89 + type: 'object', 90 + description: 91 + 'Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.', 92 + required: ['val'], 93 + properties: { 94 + val: { 95 + type: 'string', 96 + maxLength: 128, 97 + description: 98 + 'The short string name of the value or type of this label.', 99 + }, 100 + }, 101 + }, 102 + labelValueDefinition: { 103 + type: 'object', 104 + description: 105 + 'Declares a label value and its expected interpretations and behaviors.', 106 + required: ['identifier', 'severity', 'blurs', 'locales'], 107 + properties: { 108 + identifier: { 109 + type: 'string', 110 + description: 111 + "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 112 + maxLength: 100, 113 + maxGraphemes: 100, 114 + }, 115 + severity: { 116 + type: 'string', 117 + description: 118 + "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 119 + knownValues: ['inform', 'alert', 'none'], 120 + }, 121 + blurs: { 122 + type: 'string', 123 + description: 124 + "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 125 + knownValues: ['content', 'media', 'none'], 126 + }, 127 + defaultSetting: { 128 + type: 'string', 129 + description: 'The default setting for this label.', 130 + knownValues: ['ignore', 'warn', 'hide'], 131 + default: 'warn', 132 + }, 133 + adultOnly: { 134 + type: 'boolean', 135 + description: 136 + 'Does the user need to have adult content enabled in order to configure this label?', 137 + }, 138 + locales: { 139 + type: 'array', 140 + items: { 141 + type: 'ref', 142 + ref: 'lex:com.atproto.label.defs#labelValueDefinitionStrings', 143 + }, 144 + }, 145 + }, 146 + }, 147 + labelValueDefinitionStrings: { 148 + type: 'object', 149 + description: 150 + 'Strings which describe the label in the UI, localized into a specific language.', 151 + required: ['lang', 'name', 'description'], 152 + properties: { 153 + lang: { 154 + type: 'string', 155 + description: 156 + 'The code of the language these strings are written in.', 157 + format: 'language', 158 + }, 159 + name: { 160 + type: 'string', 161 + description: 'A short human-readable name for the label.', 162 + maxGraphemes: 64, 163 + maxLength: 640, 164 + }, 165 + description: { 166 + type: 'string', 167 + description: 168 + 'A longer description of what the label means and why it might be applied.', 169 + maxGraphemes: 10000, 170 + maxLength: 100000, 171 + }, 172 + }, 173 + }, 174 + labelValue: { 175 + type: 'string', 176 + knownValues: [ 177 + '!hide', 178 + '!no-promote', 179 + '!warn', 180 + '!no-unauthenticated', 181 + 'dmca-violation', 182 + 'doxxing', 183 + 'porn', 184 + 'sexual', 185 + 'nudity', 186 + 'nsfl', 187 + 'gore', 188 + ], 189 + }, 190 + }, 191 + }, 192 + OrgAtmosphereconfProfile: { 193 + lexicon: 1, 194 + id: 'org.atmosphereconf.profile', 195 + defs: { 196 + main: { 197 + type: 'record', 198 + description: 'A conference attendee profile.', 199 + key: 'literal:self', 200 + record: { 201 + type: 'object', 202 + properties: { 203 + displayName: { 204 + type: 'string', 205 + maxGraphemes: 64, 206 + maxLength: 640, 207 + }, 208 + description: { 209 + type: 'string', 210 + description: 'Free-form profile description text.', 211 + maxGraphemes: 256, 212 + maxLength: 2560, 213 + }, 214 + avatar: { 215 + type: 'blob', 216 + description: 'Profile picture for conference attendee', 217 + accept: ['image/png', 'image/jpeg'], 218 + maxSize: 1000000, 219 + }, 220 + banner: { 221 + type: 'blob', 222 + description: 223 + 'Larger horizontal image to display behind profile view.', 224 + accept: ['image/png', 'image/jpeg'], 225 + maxSize: 1000000, 226 + }, 227 + labels: { 228 + type: 'union', 229 + description: 'Self-label values for the conference profile.', 230 + refs: ['lex:com.atproto.label.defs#selfLabels'], 231 + }, 232 + createdAt: { 233 + type: 'string', 234 + format: 'datetime', 235 + }, 236 + }, 237 + }, 238 + }, 239 + }, 240 + }, 241 + ComAtprotoRepoStrongRef: { 242 + lexicon: 1, 243 + id: 'com.atproto.repo.strongRef', 244 + description: 'A URI with a content-hash fingerprint.', 245 + defs: { 246 + main: { 247 + type: 'object', 248 + required: ['uri', 'cid'], 249 + properties: { 250 + uri: { 251 + type: 'string', 252 + format: 'at-uri', 253 + }, 254 + cid: { 255 + type: 'string', 256 + format: 'cid', 257 + }, 258 + }, 259 + }, 260 + }, 261 + }, 262 + } as const satisfies Record<string, LexiconDoc> 263 + export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 264 + export const lexicons: Lexicons = new Lexicons(schemas) 265 + 266 + export function validate<T extends { $type: string }>( 267 + v: unknown, 268 + id: string, 269 + hash: string, 270 + requiredType: true, 271 + ): ValidationResult<T> 272 + export function validate<T extends { $type?: string }>( 273 + v: unknown, 274 + id: string, 275 + hash: string, 276 + requiredType?: false, 277 + ): ValidationResult<T> 278 + export function validate( 279 + v: unknown, 280 + id: string, 281 + hash: string, 282 + requiredType?: boolean, 283 + ): ValidationResult { 284 + return (requiredType ? is$typed : maybe$typed)(v, id, hash) 285 + ? lexicons.validate(`${id}#${hash}`, v) 286 + : { 287 + success: false, 288 + error: new ValidationError( 289 + `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, 290 + ), 291 + } 292 + } 293 + 294 + export const ids = { 295 + ComAtprotoLabelDefs: 'com.atproto.label.defs', 296 + OrgAtmosphereconfProfile: 'org.atmosphereconf.profile', 297 + ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', 298 + } as const
+146
src/lexicon/types/com/atproto/label/defs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'com.atproto.label.defs' 16 + 17 + /** Metadata tag on an atproto resource (eg, repo or record). */ 18 + export interface Label { 19 + $type?: 'com.atproto.label.defs#label' 20 + /** The AT Protocol version of the label object. */ 21 + ver?: number 22 + /** DID of the actor who created this label. */ 23 + src: string 24 + /** AT URI of the record, repository (account), or other resource that this label applies to. */ 25 + uri: string 26 + /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */ 27 + cid?: string 28 + /** The short string name of the value or type of this label. */ 29 + val: string 30 + /** If true, this is a negation label, overwriting a previous label. */ 31 + neg?: boolean 32 + /** Timestamp when this label was created. */ 33 + cts: string 34 + /** Timestamp at which this label expires (no longer applies). */ 35 + exp?: string 36 + /** Signature of dag-cbor encoded label. */ 37 + sig?: Uint8Array 38 + } 39 + 40 + const hashLabel = 'label' 41 + 42 + export function isLabel<V>(v: V) { 43 + return is$typed(v, id, hashLabel) 44 + } 45 + 46 + export function validateLabel<V>(v: V) { 47 + return validate<Label & V>(v, id, hashLabel) 48 + } 49 + 50 + /** Metadata tags on an atproto record, published by the author within the record. */ 51 + export interface SelfLabels { 52 + $type?: 'com.atproto.label.defs#selfLabels' 53 + values: SelfLabel[] 54 + } 55 + 56 + const hashSelfLabels = 'selfLabels' 57 + 58 + export function isSelfLabels<V>(v: V) { 59 + return is$typed(v, id, hashSelfLabels) 60 + } 61 + 62 + export function validateSelfLabels<V>(v: V) { 63 + return validate<SelfLabels & V>(v, id, hashSelfLabels) 64 + } 65 + 66 + /** Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel. */ 67 + export interface SelfLabel { 68 + $type?: 'com.atproto.label.defs#selfLabel' 69 + /** The short string name of the value or type of this label. */ 70 + val: string 71 + } 72 + 73 + const hashSelfLabel = 'selfLabel' 74 + 75 + export function isSelfLabel<V>(v: V) { 76 + return is$typed(v, id, hashSelfLabel) 77 + } 78 + 79 + export function validateSelfLabel<V>(v: V) { 80 + return validate<SelfLabel & V>(v, id, hashSelfLabel) 81 + } 82 + 83 + /** Declares a label value and its expected interpretations and behaviors. */ 84 + export interface LabelValueDefinition { 85 + $type?: 'com.atproto.label.defs#labelValueDefinition' 86 + /** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */ 87 + identifier: string 88 + /** How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing. */ 89 + severity: 'inform' | 'alert' | 'none' | (string & {}) 90 + /** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */ 91 + blurs: 'content' | 'media' | 'none' | (string & {}) 92 + /** The default setting for this label. */ 93 + defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {}) 94 + /** Does the user need to have adult content enabled in order to configure this label? */ 95 + adultOnly?: boolean 96 + locales: LabelValueDefinitionStrings[] 97 + } 98 + 99 + const hashLabelValueDefinition = 'labelValueDefinition' 100 + 101 + export function isLabelValueDefinition<V>(v: V) { 102 + return is$typed(v, id, hashLabelValueDefinition) 103 + } 104 + 105 + export function validateLabelValueDefinition<V>(v: V) { 106 + return validate<LabelValueDefinition & V>(v, id, hashLabelValueDefinition) 107 + } 108 + 109 + /** Strings which describe the label in the UI, localized into a specific language. */ 110 + export interface LabelValueDefinitionStrings { 111 + $type?: 'com.atproto.label.defs#labelValueDefinitionStrings' 112 + /** The code of the language these strings are written in. */ 113 + lang: string 114 + /** A short human-readable name for the label. */ 115 + name: string 116 + /** A longer description of what the label means and why it might be applied. */ 117 + description: string 118 + } 119 + 120 + const hashLabelValueDefinitionStrings = 'labelValueDefinitionStrings' 121 + 122 + export function isLabelValueDefinitionStrings<V>(v: V) { 123 + return is$typed(v, id, hashLabelValueDefinitionStrings) 124 + } 125 + 126 + export function validateLabelValueDefinitionStrings<V>(v: V) { 127 + return validate<LabelValueDefinitionStrings & V>( 128 + v, 129 + id, 130 + hashLabelValueDefinitionStrings, 131 + ) 132 + } 133 + 134 + export type LabelValue = 135 + | '!hide' 136 + | '!no-promote' 137 + | '!warn' 138 + | '!no-unauthenticated' 139 + | 'dmca-violation' 140 + | 'doxxing' 141 + | 'porn' 142 + | 'sexual' 143 + | 'nudity' 144 + | 'nsfl' 145 + | 'gore' 146 + | (string & {})
+31
src/lexicon/types/com/atproto/repo/strongRef.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'com.atproto.repo.strongRef' 16 + 17 + export interface Main { 18 + $type?: 'com.atproto.repo.strongRef' 19 + uri: string 20 + cid: string 21 + } 22 + 23 + const hashMain = 'main' 24 + 25 + export function isMain<V>(v: V) { 26 + return is$typed(v, id, hashMain) 27 + } 28 + 29 + export function validateMain<V>(v: V) { 30 + return validate<Main & V>(v, id, hashMain) 31 + }
+42
src/lexicon/types/org/atmosphereconf/profile.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + import type * as ComAtprotoLabelDefs from '../../com/atproto/label/defs.js' 9 + 10 + const is$typed = _is$typed, 11 + validate = _validate 12 + const id = 'org.atmosphereconf.profile' 13 + 14 + export interface Main { 15 + $type: 'org.atmosphereconf.profile' 16 + displayName?: string 17 + /** Free-form profile description text. */ 18 + description?: string 19 + /** Profile picture for conference attendee */ 20 + avatar?: BlobRef 21 + /** Larger horizontal image to display behind profile view. */ 22 + banner?: BlobRef 23 + labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string } 24 + createdAt?: string 25 + [k: string]: unknown 26 + } 27 + 28 + const hashMain = 'main' 29 + 30 + export function isMain<V>(v: V) { 31 + return is$typed(v, id, hashMain) 32 + } 33 + 34 + export function validateMain<V>(v: V) { 35 + return validate<Main & V>(v, id, hashMain, true) 36 + } 37 + 38 + export { 39 + type Main as Record, 40 + isMain as isRecord, 41 + validateMain as validateRecord, 42 + }
+82
src/lexicon/util.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + 5 + import { type ValidationResult } from '@atproto/lexicon' 6 + 7 + export type OmitKey<T, K extends keyof T> = { 8 + [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 9 + } 10 + 11 + export type $Typed<V, T extends string = string> = V & { $type: T } 12 + export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'> 13 + 14 + export type $Type<Id extends string, Hash extends string> = Hash extends 'main' 15 + ? Id 16 + : `${Id}#${Hash}` 17 + 18 + function isObject<V>(v: V): v is V & object { 19 + return v != null && typeof v === 'object' 20 + } 21 + 22 + function is$type<Id extends string, Hash extends string>( 23 + $type: unknown, 24 + id: Id, 25 + hash: Hash, 26 + ): $type is $Type<Id, Hash> { 27 + return hash === 'main' 28 + ? $type === id 29 + : // $type === `${id}#${hash}` 30 + typeof $type === 'string' && 31 + $type.length === id.length + 1 + hash.length && 32 + $type.charCodeAt(id.length) === 35 /* '#' */ && 33 + $type.startsWith(id) && 34 + $type.endsWith(hash) 35 + } 36 + 37 + export type $TypedObject< 38 + V, 39 + Id extends string, 40 + Hash extends string, 41 + > = V extends { 42 + $type: $Type<Id, Hash> 43 + } 44 + ? V 45 + : V extends { $type?: string } 46 + ? V extends { $type?: infer T extends $Type<Id, Hash> } 47 + ? V & { $type: T } 48 + : never 49 + : V & { $type: $Type<Id, Hash> } 50 + 51 + export function is$typed<V, Id extends string, Hash extends string>( 52 + v: V, 53 + id: Id, 54 + hash: Hash, 55 + ): v is $TypedObject<V, Id, Hash> { 56 + return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 57 + } 58 + 59 + export function maybe$typed<V, Id extends string, Hash extends string>( 60 + v: V, 61 + id: Id, 62 + hash: Hash, 63 + ): v is V & object & { $type?: $Type<Id, Hash> } { 64 + return ( 65 + isObject(v) && 66 + ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 67 + ) 68 + } 69 + 70 + export type Validator<R = unknown> = (v: unknown) => ValidationResult<R> 71 + export type ValidatorParam<V extends Validator> = 72 + V extends Validator<infer R> ? R : never 73 + 74 + /** 75 + * Utility function that allows to convert a "validate*" utility function into a 76 + * type predicate. 77 + */ 78 + export function asPredicate<V extends Validator>(validate: V) { 79 + return function <T>(v: T): v is T & ValidatorParam<V> { 80 + return validate(v).success 81 + } 82 + }
+104
src/pages/api/profile/create.ts
··· 1 + import type { APIRoute } from 'astro' 2 + import { getOAuthClient } from '../../../lib/context' 3 + import { getSession } from '../../../lib/session' 4 + import { Agent, BlobRef } from '@atproto/api' 5 + import type { Main as ProfileRecord } from '../../../lexicon/types/org/atmosphereconf/profile' 6 + 7 + async function fileToBlob(agent: Agent, file: File): Promise<BlobRef> { 8 + const arrayBuffer = await file.arrayBuffer() 9 + const uint8Array = new Uint8Array(arrayBuffer) 10 + 11 + const response = await agent.com.atproto.repo.uploadBlob(uint8Array, { 12 + encoding: file.type, 13 + }) 14 + 15 + return response.data.blob 16 + } 17 + 18 + export const POST: APIRoute = async ({ request, cookies, redirect }) => { 19 + try { 20 + const session = getSession(cookies) 21 + const oauthClient = getOAuthClient(cookies) 22 + 23 + if (!session.did) { 24 + return new Response('Unauthorized', { status: 401 }) 25 + } 26 + 27 + const oauthSession = await oauthClient.restore(session.did) 28 + if (!oauthSession) { 29 + return new Response('Session expired', { status: 401 }) 30 + } 31 + 32 + const agent = new Agent(oauthSession) 33 + const formData = await request.formData() 34 + 35 + // Extract form data 36 + const displayName = formData.get('displayName') 37 + const description = formData.get('description') 38 + const avatarFile = formData.get('avatar') 39 + const bannerFile = formData.get('banner') 40 + 41 + if (!displayName || typeof displayName !== 'string') { 42 + return new Response('Display name is required', { status: 400 }) 43 + } 44 + 45 + // Validate file sizes 46 + if (avatarFile instanceof File && avatarFile.size > 0 && avatarFile.size > 1000000) { 47 + return new Response('Avatar file size must be less than 1MB', { status: 400 }) 48 + } 49 + 50 + if (bannerFile instanceof File && bannerFile.size > 0 && bannerFile.size > 1000000) { 51 + return new Response('Banner file size must be less than 1MB', { status: 400 }) 52 + } 53 + 54 + // Build the profile record 55 + const record: Omit<ProfileRecord, '$type'> = { 56 + displayName: displayName.slice(0, 64), 57 + description: typeof description === 'string' ? description.slice(0, 256) : undefined, 58 + createdAt: new Date().toISOString(), 59 + } 60 + 61 + // Upload avatar if provided 62 + if (avatarFile instanceof File && avatarFile.size > 0) { 63 + try { 64 + record.avatar = await fileToBlob(agent, avatarFile) 65 + } catch (err) { 66 + console.error('Failed to upload avatar:', err) 67 + return new Response('Failed to upload avatar', { status: 500 }) 68 + } 69 + } 70 + 71 + // Upload banner if provided 72 + if (bannerFile instanceof File && bannerFile.size > 0) { 73 + try { 74 + record.banner = await fileToBlob(agent, bannerFile) 75 + } catch (err) { 76 + console.error('Failed to upload banner:', err) 77 + return new Response('Failed to upload banner', { status: 500 }) 78 + } 79 + } 80 + 81 + // Create or update the profile record 82 + try { 83 + await agent.com.atproto.repo.putRecord({ 84 + repo: agent.assertDid, 85 + collection: 'org.atmosphereconf.profile', 86 + rkey: 'self', 87 + record: { 88 + $type: 'org.atmosphereconf.profile', 89 + ...record, 90 + }, 91 + }) 92 + 93 + return redirect('/') 94 + } catch (err) { 95 + console.error('Failed to create profile:', err) 96 + const error = err instanceof Error ? err.message : 'unexpected error' 97 + return new Response(`Failed to create profile: ${error}`, { status: 500 }) 98 + } 99 + } catch (err) { 100 + console.error('Profile creation failed:', err) 101 + const error = err instanceof Error ? err.message : 'unexpected error' 102 + return new Response(`Profile creation failed: ${error}`, { status: 500 }) 103 + } 104 + }
+13 -10
src/pages/index.astro
··· 43 43 <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 44 44 <meta name="viewport" content="width=device-width" /> 45 45 <meta name="generator" content={Astro.generator} /> 46 - <title>ATProto Login</title> 46 + <title>ATmosphere Login</title> 47 47 </head> 48 48 <body> 49 49 <div class="min-h-screen flex items-center justify-center"> ··· 64 64 )} 65 65 <p class="text-lg font-semibold">{displayName}</p> 66 66 <p class="text-sm opacity-70">{handle}</p> 67 - {description && ( 68 - <p class="text-sm mt-2 opacity-80">{description}</p> 69 - )} 67 + {description && <p class="text-sm mt-2 opacity-80">{description}</p>} 68 + </div> 69 + <div class="space-y-2 w-full"> 70 + <a href={`/profile/${handle}`} class="btn btn-primary w-full"> 71 + View Profile 72 + </a> 73 + <form method="POST" action="/api/logout" class="w-full"> 74 + <button type="submit" class="btn btn-error w-full"> 75 + Logout 76 + </button> 77 + </form> 70 78 </div> 71 - <form method="POST" action="/api/logout" class="w-full"> 72 - <button type="submit" class="btn btn-error w-full"> 73 - Logout 74 - </button> 75 - </form> 76 79 </div> 77 80 </> 78 81 ) : ( 79 82 <> 80 - <h2 class="card-title justify-center mb-6">ATProto Login</h2> 83 + <h2 class="card-title justify-center mb-6">ATmosphere Login</h2> 81 84 <form method="POST" action="/api/login"> 82 85 <div class="join join-vertical w-full"> 83 86 <input
+208
src/pages/profile/[handle].astro
··· 1 + --- 2 + import '../../styles.css' 3 + import { getSession } from '../../lib/session' 4 + import { getOAuthClient } from '../../lib/context' 5 + import { Agent } from '@atproto/api' 6 + 7 + const { handle } = Astro.params 8 + 9 + if (!handle) { 10 + return Astro.redirect('/') 11 + } 12 + 13 + const session = getSession(Astro.cookies) 14 + const oauthClient = getOAuthClient(Astro.cookies) 15 + 16 + let agent: Agent | null = null 17 + let profile: any = null 18 + let conferenceProfile: any = null 19 + let did: string | null = null 20 + let isOwnProfile = false 21 + 22 + // Get agent if authenticated 23 + if (session.did) { 24 + try { 25 + const oauthSession = await oauthClient.restore(session.did) 26 + if (oauthSession) { 27 + agent = new Agent(oauthSession) 28 + isOwnProfile = agent.assertDid === session.did 29 + } 30 + } catch (err) { 31 + console.warn('OAuth restore failed:', err) 32 + } 33 + } 34 + 35 + // Create a public agent to resolve the profile if we don't have an authenticated one 36 + const publicAgent = agent || new Agent({ service: 'https://public.api.bsky.app' }) 37 + 38 + // Resolve handle to DID and get profile 39 + try { 40 + const resolveResponse = await publicAgent.resolveHandle({ handle }) 41 + did = resolveResponse.data.did 42 + 43 + // Get Bluesky profile for basic info 44 + try { 45 + const profileResponse = await publicAgent.app.bsky.actor.getProfile({ 46 + actor: did, 47 + }) 48 + profile = profileResponse.data 49 + } catch (err) { 50 + console.warn('Failed to fetch Bluesky profile:', err) 51 + } 52 + 53 + // Get conference profile 54 + try { 55 + const response = await publicAgent.com.atproto.repo.getRecord({ 56 + repo: did, 57 + collection: 'org.atmosphereconf.profile', 58 + rkey: 'self' 59 + }) 60 + conferenceProfile = response.data.value 61 + } catch (err) { 62 + console.log('No conference profile found for this user') 63 + } 64 + } catch (err) { 65 + console.error('Failed to resolve handle:', err) 66 + return new Response('Profile not found', { status: 404 }) 67 + } 68 + 69 + // Helper function to convert blob refs to URLs 70 + function blobRefToUrl(blobRef: any, did: string): string { 71 + if (!blobRef || typeof blobRef !== 'object') return '' 72 + 73 + // Handle BlobRef object with CID 74 + if (blobRef.ref) { 75 + const cid = blobRef.ref.toString() 76 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@jpeg` 77 + } 78 + 79 + return '' 80 + } 81 + 82 + const displayName = conferenceProfile?.displayName || profile?.displayName || handle 83 + const description = conferenceProfile?.description || profile?.description || '' 84 + 85 + // Handle both blob refs and direct URLs 86 + let avatar = '' 87 + if (conferenceProfile?.avatar) { 88 + avatar = blobRefToUrl(conferenceProfile.avatar, did) 89 + } else if (profile?.avatar) { 90 + avatar = profile.avatar 91 + } 92 + 93 + let banner = '' 94 + if (conferenceProfile?.banner) { 95 + banner = blobRefToUrl(conferenceProfile.banner, did) 96 + } else if (profile?.banner) { 97 + banner = profile.banner 98 + } 99 + 100 + const hasConferenceProfile = !!conferenceProfile 101 + --- 102 + 103 + <html lang="en" data-theme="dracula"> 104 + <head> 105 + <meta charset="utf-8" /> 106 + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 107 + <meta name="viewport" content="width=device-width" /> 108 + <meta name="generator" content={Astro.generator} /> 109 + <title>{displayName} - ATmosphere</title> 110 + </head> 111 + <body> 112 + <div class="min-h-screen bg-base-300"> 113 + {banner && ( 114 + <div class="w-full h-48 md:h-64 bg-base-200"> 115 + <img 116 + src={banner} 117 + alt="Profile banner" 118 + class="w-full h-full object-cover" 119 + /> 120 + </div> 121 + )} 122 + 123 + <div class="container mx-auto px-4 -mt-16 relative z-10 max-w-4xl"> 124 + <div class="card bg-base-200 shadow-xl"> 125 + <div class="card-body"> 126 + <div class="flex flex-col md:flex-row gap-6"> 127 + <div class="flex-shrink-0"> 128 + {avatar ? ( 129 + <div class="avatar"> 130 + <div class="w-32 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2"> 131 + <img src={avatar} alt={displayName} /> 132 + </div> 133 + </div> 134 + ) : ( 135 + <div class="avatar placeholder"> 136 + <div class="bg-neutral text-neutral-content rounded-full w-32"> 137 + <span class="text-3xl">{displayName[0]?.toUpperCase()}</span> 138 + </div> 139 + </div> 140 + )} 141 + </div> 142 + 143 + <div class="flex-grow"> 144 + <div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"> 145 + <div> 146 + <h1 class="text-3xl font-bold">{displayName}</h1> 147 + <p class="text-sm opacity-70">@{handle}</p> 148 + {did && ( 149 + <p class="text-xs opacity-50 mt-1 font-mono break-all"> 150 + {did} 151 + </p> 152 + )} 153 + </div> 154 + 155 + {isOwnProfile && ( 156 + <a href="/profile/create" class="btn btn-primary btn-sm"> 157 + Edit Profile 158 + </a> 159 + )} 160 + </div> 161 + 162 + {description && ( 163 + <p class="mt-4 text-base whitespace-pre-wrap">{description}</p> 164 + )} 165 + 166 + <div class="mt-4"> 167 + {hasConferenceProfile ? ( 168 + <div class="badge badge-success gap-2"> 169 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current"> 170 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> 171 + </svg> 172 + Conference Attendee 173 + </div> 174 + ) : ( 175 + <div class="badge badge-ghost gap-2"> 176 + No Conference Profile 177 + </div> 178 + )} 179 + </div> 180 + </div> 181 + </div> 182 + 183 + {!hasConferenceProfile && isOwnProfile && ( 184 + <div class="alert alert-warning mt-6"> 185 + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> 186 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> 187 + </svg> 188 + <span>You haven't set up your conference profile yet.</span> 189 + <div> 190 + <a href="/profile/create" class="btn btn-sm btn-primary">Create Now</a> 191 + </div> 192 + </div> 193 + )} 194 + </div> 195 + </div> 196 + 197 + <div class="mt-6 mb-12"> 198 + <a href="/" class="btn btn-ghost"> 199 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 200 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> 201 + </svg> 202 + Back to Home 203 + </a> 204 + </div> 205 + </div> 206 + </div> 207 + </body> 208 + </html>
+87
src/pages/profile/create.astro
··· 1 + --- 2 + import '../../styles.css' 3 + import ProfileForm from '../../components/ProfileForm.astro' 4 + import { getSession } from '../../lib/session' 5 + import { getOAuthClient } from '../../lib/context' 6 + import { Agent } from '@atproto/api' 7 + 8 + const session = getSession(Astro.cookies) 9 + const oauthClient = getOAuthClient(Astro.cookies) 10 + 11 + // Redirect to login if not authenticated 12 + if (!session.did) { 13 + return Astro.redirect('/') 14 + } 15 + 16 + let agent: Agent | null = null 17 + let existingProfile: any = null 18 + 19 + try { 20 + const oauthSession = await oauthClient.restore(session.did) 21 + if (oauthSession) { 22 + agent = new Agent(oauthSession) 23 + 24 + // Check if profile already exists 25 + try { 26 + // Try to get the profile record from the repo 27 + const did = agent.assertDid 28 + const response = await agent.com.atproto.repo.getRecord({ 29 + repo: did, 30 + collection: 'org.atmosphereconf.profile', 31 + rkey: 'self' 32 + }) 33 + existingProfile = response.data.value 34 + } catch (err) { 35 + // Profile doesn't exist yet, which is fine 36 + console.log('No existing profile found') 37 + } 38 + } 39 + } catch (err) { 40 + console.warn('OAuth restore failed:', err) 41 + session.destroy() 42 + return Astro.redirect('/') 43 + } 44 + 45 + const displayName = existingProfile?.displayName || '' 46 + const description = existingProfile?.description || '' 47 + --- 48 + 49 + <html lang="en" data-theme="dracula"> 50 + <head> 51 + <meta charset="utf-8" /> 52 + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 53 + <meta name="viewport" content="width=device-width" /> 54 + <meta name="generator" content={Astro.generator} /> 55 + <title>Create Profile - ATmosphere</title> 56 + </head> 57 + <body> 58 + <div class="min-h-screen flex items-center justify-center p-4"> 59 + <div class="card w-full max-w-2xl bg-base-200 shadow-xl"> 60 + <div class="card-body"> 61 + <h2 class="card-title justify-center text-2xl mb-6"> 62 + {existingProfile ? 'Update Your Profile' : 'Create Your Profile'} 63 + </h2> 64 + 65 + <div class="alert alert-info mb-4"> 66 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"> 67 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> 68 + </svg> 69 + <span>Set up your conference attendee profile</span> 70 + </div> 71 + 72 + <ProfileForm 73 + displayName={displayName} 74 + description={description} 75 + submitLabel={existingProfile ? 'Update Profile' : 'Create Profile'} 76 + /> 77 + 78 + <div class="divider">OR</div> 79 + 80 + <a href="/" class="btn btn-ghost w-full"> 81 + Back to Home 82 + </a> 83 + </div> 84 + </div> 85 + </div> 86 + </body> 87 + </html>