static site frontend for mapped.at mapped.at
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Move to vite and lit

+3542 -2120
+27 -2
.gitignore
··· 1 1 .superpowers 2 - docs/superpowers 3 - public/data/service.json 2 + docs 3 + public/data/service.json 4 + 5 + # Logs 6 + logs 7 + *.log 8 + npm-debug.log* 9 + yarn-debug.log* 10 + yarn-error.log* 11 + pnpm-debug.log* 12 + lerna-debug.log* 13 + 14 + node_modules 15 + dist 16 + dist-ssr 17 + *.local 18 + 19 + # Editor directories and files 20 + .vscode/* 21 + !.vscode/extensions.json 22 + .idea 23 + .DS_Store 24 + *.suo 25 + *.ntvs* 26 + *.njsproj 27 + *.sln 28 + *.sw?
+5 -4
.tangled/workflows/spindle.yaml
··· 9 9 nixpkgs: 10 10 - nodejs 11 11 - coreutils 12 + - gnused 12 13 - curl 13 14 - glibc 14 15 github:NixOS/nixpkgs/nixpkgs-unstable: 15 - - bun 16 + - pnpm_9 16 17 17 18 environment: 18 - SITE_PATH: "public" 19 + SITE_PATH: "dist" 19 20 SITE_NAME: "mapped.at" 20 21 WISP_HANDLE: "mapped.at" 21 22 ··· 23 24 - name: build site 24 25 command: | 25 26 export PATH="$HOME/.nix-profile/bin:$PATH" 26 - 27 - bun run scripts/fetch-service-data.mjs 27 + pnpm install 28 + pnpm run build 28 29 29 30 - name: deploy to wisp 30 31 command: |
+21
index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <link 7 + rel="stylesheet" 8 + href="https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css" 9 + /> 10 + <title>mapped.at</title> 11 + <link 12 + rel="icon" 13 + href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚵</text></svg>" 14 + /> 15 + </head> 16 + <body> 17 + <app-nav></app-nav> 18 + <app-root></app-root> 19 + <script type="module" src="/src/main.ts"></script> 20 + </body> 21 + </html>
+24
package.json
··· 1 + { 2 + "name": "mapped-at", 3 + "private": true, 4 + "version": "0.0.0", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite", 8 + "build": "tsc && vite build", 9 + "preview": "vite preview" 10 + }, 11 + "dependencies": { 12 + "@atcute/client": "^4.2.1", 13 + "@atcute/identity-resolver": "^1.2.2", 14 + "@atcute/oauth-browser-client": "^3.0.0", 15 + "leaflet": "^1.9.4", 16 + "lit": "^3.3.2" 17 + }, 18 + "devDependencies": { 19 + "@types/leaflet": "^1.9.21", 20 + "typescript": "~6.0.2", 21 + "vite": "^8.0.4", 22 + "vite-plugin-static-copy": "^4.0.1" 23 + } 24 + }
+851
pnpm-lock.yaml
··· 1 + lockfileVersion: '9.0' 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + 9 + .: 10 + dependencies: 11 + '@atcute/client': 12 + specifier: ^4.2.1 13 + version: 4.2.1 14 + '@atcute/identity-resolver': 15 + specifier: ^1.2.2 16 + version: 1.2.2(@atcute/identity@1.1.4) 17 + '@atcute/oauth-browser-client': 18 + specifier: ^3.0.0 19 + version: 3.0.0(@atcute/identity@1.1.4) 20 + leaflet: 21 + specifier: ^1.9.4 22 + version: 1.9.4 23 + lit: 24 + specifier: ^3.3.2 25 + version: 3.3.2 26 + devDependencies: 27 + '@types/leaflet': 28 + specifier: ^1.9.21 29 + version: 1.9.21 30 + typescript: 31 + specifier: ~6.0.2 32 + version: 6.0.2 33 + vite: 34 + specifier: ^8.0.4 35 + version: 8.0.8 36 + vite-plugin-static-copy: 37 + specifier: ^4.0.1 38 + version: 4.0.1(vite@8.0.8) 39 + 40 + packages: 41 + 42 + '@atcute/client@4.2.1': 43 + resolution: {integrity: sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw==} 44 + 45 + '@atcute/identity-resolver@1.2.2': 46 + resolution: {integrity: sha512-eUh/UH4bFvuXS0X7epYCeJC/kj4rbBXfSRumLEH4smMVwNOgTo7cL/0Srty+P/qVPoZEyXdfEbS0PHJyzoXmHw==} 47 + peerDependencies: 48 + '@atcute/identity': ^1.0.0 49 + 50 + '@atcute/identity@1.1.4': 51 + resolution: {integrity: sha512-RCw1IqflfuSYCxK5m0lZCm0UnvIzcUnuhngiBhJEJb9a9Mc2SEf1xP3H8N5r8pvEH1LoAYd6/zrvCNU+uy9esw==} 52 + 53 + '@atcute/lexicons@1.3.0': 54 + resolution: {integrity: sha512-Eq5y+9onnCXNVUlNiMf31beSXHKqptB7lUo/68YbhlmxdaR7ooywHmahya9goP5AsmlYEA1z+dRPXIDAa9O7cg==} 55 + 56 + '@atcute/multibase@1.2.0': 57 + resolution: {integrity: sha512-ZK2GRra+qIYq9nNuQB52m2ul0hOmCQEtPobGfTSUxm7pF0OGEkWGkWHugFhNEDVzHzTwPxHp6VGotdZFue4lYQ==} 58 + 59 + '@atcute/oauth-browser-client@3.0.0': 60 + resolution: {integrity: sha512-7AbKV8tTe7aRJNJV7gCcWHSVEADb2nr58O1p7dQsf73HSe9pvlBkj/Vk1yjjtH691uAVYkwhHSh0bC7D8XdwJw==} 61 + 62 + '@atcute/oauth-crypto@0.1.0': 63 + resolution: {integrity: sha512-qZYDCNLF/4B6AndYT1rsQelN8621AC5u/sL5PHvlr/qqAbmmUwCBGjEgRSyZtHE1AqD60VNiSMlOgAuEQTSl3w==} 64 + 65 + '@atcute/oauth-keyset@0.1.0': 66 + resolution: {integrity: sha512-+wqT/+I5Lg9VzKnKY3g88+N45xbq+wsdT6bHDGqCVa2u57gRvolFF4dY+weMfc/OX641BIZO6/o+zFtKBsMQnQ==} 67 + 68 + '@atcute/oauth-types@0.1.1': 69 + resolution: {integrity: sha512-u+3KMjse3Uc/9hDyilu1QVN7IpcnjVXgRzhddzBB8Uh6wePHNVBDdi9wQvFTVVA3zmxtMJVptXRyLLg6Ou9bqg==} 70 + 71 + '@atcute/uint8array@1.1.1': 72 + resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==} 73 + 74 + '@atcute/util-fetch@1.0.5': 75 + resolution: {integrity: sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==} 76 + 77 + '@atcute/util-text@1.2.0': 78 + resolution: {integrity: sha512-b8WSh+Z7K601eUFFmTFj8QPKDO8Ic0VDDj63sdKzpkm+ySQKsYT5nXekViGqFVKbyKj1V5FyvZvgXad6/aI4QQ==} 79 + 80 + '@badrap/valita@0.4.6': 81 + resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==} 82 + engines: {node: '>= 18'} 83 + 84 + '@emnapi/core@1.9.2': 85 + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} 86 + 87 + '@emnapi/runtime@1.9.2': 88 + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} 89 + 90 + '@emnapi/wasi-threads@1.2.1': 91 + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} 92 + 93 + '@lit-labs/ssr-dom-shim@1.5.1': 94 + resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} 95 + 96 + '@lit/reactive-element@2.1.2': 97 + resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} 98 + 99 + '@napi-rs/wasm-runtime@1.1.3': 100 + resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} 101 + peerDependencies: 102 + '@emnapi/core': ^1.7.1 103 + '@emnapi/runtime': ^1.7.1 104 + 105 + '@oxc-project/types@0.124.0': 106 + resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} 107 + 108 + '@rolldown/binding-android-arm64@1.0.0-rc.15': 109 + resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} 110 + engines: {node: ^20.19.0 || >=22.12.0} 111 + cpu: [arm64] 112 + os: [android] 113 + 114 + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': 115 + resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} 116 + engines: {node: ^20.19.0 || >=22.12.0} 117 + cpu: [arm64] 118 + os: [darwin] 119 + 120 + '@rolldown/binding-darwin-x64@1.0.0-rc.15': 121 + resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} 122 + engines: {node: ^20.19.0 || >=22.12.0} 123 + cpu: [x64] 124 + os: [darwin] 125 + 126 + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': 127 + resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} 128 + engines: {node: ^20.19.0 || >=22.12.0} 129 + cpu: [x64] 130 + os: [freebsd] 131 + 132 + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': 133 + resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} 134 + engines: {node: ^20.19.0 || >=22.12.0} 135 + cpu: [arm] 136 + os: [linux] 137 + 138 + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': 139 + resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} 140 + engines: {node: ^20.19.0 || >=22.12.0} 141 + cpu: [arm64] 142 + os: [linux] 143 + libc: [glibc] 144 + 145 + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': 146 + resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} 147 + engines: {node: ^20.19.0 || >=22.12.0} 148 + cpu: [arm64] 149 + os: [linux] 150 + libc: [musl] 151 + 152 + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': 153 + resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} 154 + engines: {node: ^20.19.0 || >=22.12.0} 155 + cpu: [ppc64] 156 + os: [linux] 157 + libc: [glibc] 158 + 159 + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': 160 + resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} 161 + engines: {node: ^20.19.0 || >=22.12.0} 162 + cpu: [s390x] 163 + os: [linux] 164 + libc: [glibc] 165 + 166 + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': 167 + resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} 168 + engines: {node: ^20.19.0 || >=22.12.0} 169 + cpu: [x64] 170 + os: [linux] 171 + libc: [glibc] 172 + 173 + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': 174 + resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} 175 + engines: {node: ^20.19.0 || >=22.12.0} 176 + cpu: [x64] 177 + os: [linux] 178 + libc: [musl] 179 + 180 + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': 181 + resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} 182 + engines: {node: ^20.19.0 || >=22.12.0} 183 + cpu: [arm64] 184 + os: [openharmony] 185 + 186 + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': 187 + resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} 188 + engines: {node: '>=14.0.0'} 189 + cpu: [wasm32] 190 + 191 + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': 192 + resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} 193 + engines: {node: ^20.19.0 || >=22.12.0} 194 + cpu: [arm64] 195 + os: [win32] 196 + 197 + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': 198 + resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} 199 + engines: {node: ^20.19.0 || >=22.12.0} 200 + cpu: [x64] 201 + os: [win32] 202 + 203 + '@rolldown/pluginutils@1.0.0-rc.15': 204 + resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} 205 + 206 + '@standard-schema/spec@1.1.0': 207 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 208 + 209 + '@tybys/wasm-util@0.10.1': 210 + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} 211 + 212 + '@types/geojson@7946.0.16': 213 + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} 214 + 215 + '@types/leaflet@1.9.21': 216 + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} 217 + 218 + '@types/trusted-types@2.0.7': 219 + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} 220 + 221 + anymatch@3.1.3: 222 + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} 223 + engines: {node: '>= 8'} 224 + 225 + binary-extensions@2.3.0: 226 + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} 227 + engines: {node: '>=8'} 228 + 229 + braces@3.0.3: 230 + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} 231 + engines: {node: '>=8'} 232 + 233 + chokidar@3.6.0: 234 + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} 235 + engines: {node: '>= 8.10.0'} 236 + 237 + detect-libc@2.1.2: 238 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} 239 + engines: {node: '>=8'} 240 + 241 + esm-env@1.2.2: 242 + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} 243 + 244 + fdir@6.5.0: 245 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 246 + engines: {node: '>=12.0.0'} 247 + peerDependencies: 248 + picomatch: ^3 || ^4 249 + peerDependenciesMeta: 250 + picomatch: 251 + optional: true 252 + 253 + fill-range@7.1.1: 254 + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} 255 + engines: {node: '>=8'} 256 + 257 + fsevents@2.3.3: 258 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 259 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 260 + os: [darwin] 261 + 262 + glob-parent@5.1.2: 263 + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} 264 + engines: {node: '>= 6'} 265 + 266 + is-binary-path@2.1.0: 267 + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} 268 + engines: {node: '>=8'} 269 + 270 + is-extglob@2.1.1: 271 + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} 272 + engines: {node: '>=0.10.0'} 273 + 274 + is-glob@4.0.3: 275 + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} 276 + engines: {node: '>=0.10.0'} 277 + 278 + is-number@7.0.0: 279 + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} 280 + engines: {node: '>=0.12.0'} 281 + 282 + leaflet@1.9.4: 283 + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} 284 + 285 + lightningcss-android-arm64@1.32.0: 286 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} 287 + engines: {node: '>= 12.0.0'} 288 + cpu: [arm64] 289 + os: [android] 290 + 291 + lightningcss-darwin-arm64@1.32.0: 292 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} 293 + engines: {node: '>= 12.0.0'} 294 + cpu: [arm64] 295 + os: [darwin] 296 + 297 + lightningcss-darwin-x64@1.32.0: 298 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} 299 + engines: {node: '>= 12.0.0'} 300 + cpu: [x64] 301 + os: [darwin] 302 + 303 + lightningcss-freebsd-x64@1.32.0: 304 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} 305 + engines: {node: '>= 12.0.0'} 306 + cpu: [x64] 307 + os: [freebsd] 308 + 309 + lightningcss-linux-arm-gnueabihf@1.32.0: 310 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} 311 + engines: {node: '>= 12.0.0'} 312 + cpu: [arm] 313 + os: [linux] 314 + 315 + lightningcss-linux-arm64-gnu@1.32.0: 316 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} 317 + engines: {node: '>= 12.0.0'} 318 + cpu: [arm64] 319 + os: [linux] 320 + libc: [glibc] 321 + 322 + lightningcss-linux-arm64-musl@1.32.0: 323 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} 324 + engines: {node: '>= 12.0.0'} 325 + cpu: [arm64] 326 + os: [linux] 327 + libc: [musl] 328 + 329 + lightningcss-linux-x64-gnu@1.32.0: 330 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} 331 + engines: {node: '>= 12.0.0'} 332 + cpu: [x64] 333 + os: [linux] 334 + libc: [glibc] 335 + 336 + lightningcss-linux-x64-musl@1.32.0: 337 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} 338 + engines: {node: '>= 12.0.0'} 339 + cpu: [x64] 340 + os: [linux] 341 + libc: [musl] 342 + 343 + lightningcss-win32-arm64-msvc@1.32.0: 344 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} 345 + engines: {node: '>= 12.0.0'} 346 + cpu: [arm64] 347 + os: [win32] 348 + 349 + lightningcss-win32-x64-msvc@1.32.0: 350 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} 351 + engines: {node: '>= 12.0.0'} 352 + cpu: [x64] 353 + os: [win32] 354 + 355 + lightningcss@1.32.0: 356 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} 357 + engines: {node: '>= 12.0.0'} 358 + 359 + lit-element@4.2.2: 360 + resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} 361 + 362 + lit-html@3.3.2: 363 + resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} 364 + 365 + lit@3.3.2: 366 + resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} 367 + 368 + nanoid@3.3.11: 369 + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 370 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 371 + hasBin: true 372 + 373 + nanoid@5.1.7: 374 + resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} 375 + engines: {node: ^18 || >=20} 376 + hasBin: true 377 + 378 + normalize-path@3.0.0: 379 + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} 380 + engines: {node: '>=0.10.0'} 381 + 382 + p-map@7.0.4: 383 + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} 384 + engines: {node: '>=18'} 385 + 386 + picocolors@1.1.1: 387 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 388 + 389 + picomatch@2.3.2: 390 + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} 391 + engines: {node: '>=8.6'} 392 + 393 + picomatch@4.0.4: 394 + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} 395 + engines: {node: '>=12'} 396 + 397 + postcss@8.5.9: 398 + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} 399 + engines: {node: ^10 || ^12 || >=14} 400 + 401 + readdirp@3.6.0: 402 + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} 403 + engines: {node: '>=8.10.0'} 404 + 405 + rolldown@1.0.0-rc.15: 406 + resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} 407 + engines: {node: ^20.19.0 || >=22.12.0} 408 + hasBin: true 409 + 410 + source-map-js@1.2.1: 411 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 412 + engines: {node: '>=0.10.0'} 413 + 414 + tinyglobby@0.2.16: 415 + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} 416 + engines: {node: '>=12.0.0'} 417 + 418 + to-regex-range@5.0.1: 419 + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 420 + engines: {node: '>=8.0'} 421 + 422 + tslib@2.8.1: 423 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 424 + 425 + typescript@6.0.2: 426 + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} 427 + engines: {node: '>=14.17'} 428 + hasBin: true 429 + 430 + unicode-segmenter@0.14.5: 431 + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} 432 + 433 + vite-plugin-static-copy@4.0.1: 434 + resolution: {integrity: sha512-r3kQUrrimduikhyRm58ayemoxsgB8lZdn/JULLL4wpXHAZlYejtyZx7E/id7dwRtIOSYWu/tWvFjdEOTzso2MA==} 435 + engines: {node: ^22.0.0 || >=24.0.0} 436 + peerDependencies: 437 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 438 + 439 + vite@8.0.8: 440 + resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} 441 + engines: {node: ^20.19.0 || >=22.12.0} 442 + hasBin: true 443 + peerDependencies: 444 + '@types/node': ^20.19.0 || >=22.12.0 445 + '@vitejs/devtools': ^0.1.0 446 + esbuild: ^0.27.0 || ^0.28.0 447 + jiti: '>=1.21.0' 448 + less: ^4.0.0 449 + sass: ^1.70.0 450 + sass-embedded: ^1.70.0 451 + stylus: '>=0.54.8' 452 + sugarss: ^5.0.0 453 + terser: ^5.16.0 454 + tsx: ^4.8.1 455 + yaml: ^2.4.2 456 + peerDependenciesMeta: 457 + '@types/node': 458 + optional: true 459 + '@vitejs/devtools': 460 + optional: true 461 + esbuild: 462 + optional: true 463 + jiti: 464 + optional: true 465 + less: 466 + optional: true 467 + sass: 468 + optional: true 469 + sass-embedded: 470 + optional: true 471 + stylus: 472 + optional: true 473 + sugarss: 474 + optional: true 475 + terser: 476 + optional: true 477 + tsx: 478 + optional: true 479 + yaml: 480 + optional: true 481 + 482 + snapshots: 483 + 484 + '@atcute/client@4.2.1': 485 + dependencies: 486 + '@atcute/identity': 1.1.4 487 + '@atcute/lexicons': 1.3.0 488 + 489 + '@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.4)': 490 + dependencies: 491 + '@atcute/identity': 1.1.4 492 + '@atcute/lexicons': 1.3.0 493 + '@atcute/util-fetch': 1.0.5 494 + '@badrap/valita': 0.4.6 495 + 496 + '@atcute/identity@1.1.4': 497 + dependencies: 498 + '@atcute/lexicons': 1.3.0 499 + '@badrap/valita': 0.4.6 500 + 501 + '@atcute/lexicons@1.3.0': 502 + dependencies: 503 + '@atcute/uint8array': 1.1.1 504 + '@atcute/util-text': 1.2.0 505 + '@standard-schema/spec': 1.1.0 506 + esm-env: 1.2.2 507 + 508 + '@atcute/multibase@1.2.0': 509 + dependencies: 510 + '@atcute/uint8array': 1.1.1 511 + 512 + '@atcute/oauth-browser-client@3.0.0(@atcute/identity@1.1.4)': 513 + dependencies: 514 + '@atcute/client': 4.2.1 515 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) 516 + '@atcute/lexicons': 1.3.0 517 + '@atcute/multibase': 1.2.0 518 + '@atcute/oauth-crypto': 0.1.0 519 + '@atcute/oauth-types': 0.1.1 520 + nanoid: 5.1.7 521 + transitivePeerDependencies: 522 + - '@atcute/identity' 523 + 524 + '@atcute/oauth-crypto@0.1.0': 525 + dependencies: 526 + '@atcute/multibase': 1.2.0 527 + '@atcute/uint8array': 1.1.1 528 + '@badrap/valita': 0.4.6 529 + nanoid: 5.1.7 530 + 531 + '@atcute/oauth-keyset@0.1.0': 532 + dependencies: 533 + '@atcute/oauth-crypto': 0.1.0 534 + 535 + '@atcute/oauth-types@0.1.1': 536 + dependencies: 537 + '@atcute/identity': 1.1.4 538 + '@atcute/lexicons': 1.3.0 539 + '@atcute/oauth-keyset': 0.1.0 540 + '@badrap/valita': 0.4.6 541 + 542 + '@atcute/uint8array@1.1.1': {} 543 + 544 + '@atcute/util-fetch@1.0.5': 545 + dependencies: 546 + '@badrap/valita': 0.4.6 547 + 548 + '@atcute/util-text@1.2.0': 549 + dependencies: 550 + unicode-segmenter: 0.14.5 551 + 552 + '@badrap/valita@0.4.6': {} 553 + 554 + '@emnapi/core@1.9.2': 555 + dependencies: 556 + '@emnapi/wasi-threads': 1.2.1 557 + tslib: 2.8.1 558 + optional: true 559 + 560 + '@emnapi/runtime@1.9.2': 561 + dependencies: 562 + tslib: 2.8.1 563 + optional: true 564 + 565 + '@emnapi/wasi-threads@1.2.1': 566 + dependencies: 567 + tslib: 2.8.1 568 + optional: true 569 + 570 + '@lit-labs/ssr-dom-shim@1.5.1': {} 571 + 572 + '@lit/reactive-element@2.1.2': 573 + dependencies: 574 + '@lit-labs/ssr-dom-shim': 1.5.1 575 + 576 + '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': 577 + dependencies: 578 + '@emnapi/core': 1.9.2 579 + '@emnapi/runtime': 1.9.2 580 + '@tybys/wasm-util': 0.10.1 581 + optional: true 582 + 583 + '@oxc-project/types@0.124.0': {} 584 + 585 + '@rolldown/binding-android-arm64@1.0.0-rc.15': 586 + optional: true 587 + 588 + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': 589 + optional: true 590 + 591 + '@rolldown/binding-darwin-x64@1.0.0-rc.15': 592 + optional: true 593 + 594 + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': 595 + optional: true 596 + 597 + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': 598 + optional: true 599 + 600 + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': 601 + optional: true 602 + 603 + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': 604 + optional: true 605 + 606 + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': 607 + optional: true 608 + 609 + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': 610 + optional: true 611 + 612 + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': 613 + optional: true 614 + 615 + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': 616 + optional: true 617 + 618 + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': 619 + optional: true 620 + 621 + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': 622 + dependencies: 623 + '@emnapi/core': 1.9.2 624 + '@emnapi/runtime': 1.9.2 625 + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) 626 + optional: true 627 + 628 + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': 629 + optional: true 630 + 631 + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': 632 + optional: true 633 + 634 + '@rolldown/pluginutils@1.0.0-rc.15': {} 635 + 636 + '@standard-schema/spec@1.1.0': {} 637 + 638 + '@tybys/wasm-util@0.10.1': 639 + dependencies: 640 + tslib: 2.8.1 641 + optional: true 642 + 643 + '@types/geojson@7946.0.16': {} 644 + 645 + '@types/leaflet@1.9.21': 646 + dependencies: 647 + '@types/geojson': 7946.0.16 648 + 649 + '@types/trusted-types@2.0.7': {} 650 + 651 + anymatch@3.1.3: 652 + dependencies: 653 + normalize-path: 3.0.0 654 + picomatch: 2.3.2 655 + 656 + binary-extensions@2.3.0: {} 657 + 658 + braces@3.0.3: 659 + dependencies: 660 + fill-range: 7.1.1 661 + 662 + chokidar@3.6.0: 663 + dependencies: 664 + anymatch: 3.1.3 665 + braces: 3.0.3 666 + glob-parent: 5.1.2 667 + is-binary-path: 2.1.0 668 + is-glob: 4.0.3 669 + normalize-path: 3.0.0 670 + readdirp: 3.6.0 671 + optionalDependencies: 672 + fsevents: 2.3.3 673 + 674 + detect-libc@2.1.2: {} 675 + 676 + esm-env@1.2.2: {} 677 + 678 + fdir@6.5.0(picomatch@4.0.4): 679 + optionalDependencies: 680 + picomatch: 4.0.4 681 + 682 + fill-range@7.1.1: 683 + dependencies: 684 + to-regex-range: 5.0.1 685 + 686 + fsevents@2.3.3: 687 + optional: true 688 + 689 + glob-parent@5.1.2: 690 + dependencies: 691 + is-glob: 4.0.3 692 + 693 + is-binary-path@2.1.0: 694 + dependencies: 695 + binary-extensions: 2.3.0 696 + 697 + is-extglob@2.1.1: {} 698 + 699 + is-glob@4.0.3: 700 + dependencies: 701 + is-extglob: 2.1.1 702 + 703 + is-number@7.0.0: {} 704 + 705 + leaflet@1.9.4: {} 706 + 707 + lightningcss-android-arm64@1.32.0: 708 + optional: true 709 + 710 + lightningcss-darwin-arm64@1.32.0: 711 + optional: true 712 + 713 + lightningcss-darwin-x64@1.32.0: 714 + optional: true 715 + 716 + lightningcss-freebsd-x64@1.32.0: 717 + optional: true 718 + 719 + lightningcss-linux-arm-gnueabihf@1.32.0: 720 + optional: true 721 + 722 + lightningcss-linux-arm64-gnu@1.32.0: 723 + optional: true 724 + 725 + lightningcss-linux-arm64-musl@1.32.0: 726 + optional: true 727 + 728 + lightningcss-linux-x64-gnu@1.32.0: 729 + optional: true 730 + 731 + lightningcss-linux-x64-musl@1.32.0: 732 + optional: true 733 + 734 + lightningcss-win32-arm64-msvc@1.32.0: 735 + optional: true 736 + 737 + lightningcss-win32-x64-msvc@1.32.0: 738 + optional: true 739 + 740 + lightningcss@1.32.0: 741 + dependencies: 742 + detect-libc: 2.1.2 743 + optionalDependencies: 744 + lightningcss-android-arm64: 1.32.0 745 + lightningcss-darwin-arm64: 1.32.0 746 + lightningcss-darwin-x64: 1.32.0 747 + lightningcss-freebsd-x64: 1.32.0 748 + lightningcss-linux-arm-gnueabihf: 1.32.0 749 + lightningcss-linux-arm64-gnu: 1.32.0 750 + lightningcss-linux-arm64-musl: 1.32.0 751 + lightningcss-linux-x64-gnu: 1.32.0 752 + lightningcss-linux-x64-musl: 1.32.0 753 + lightningcss-win32-arm64-msvc: 1.32.0 754 + lightningcss-win32-x64-msvc: 1.32.0 755 + 756 + lit-element@4.2.2: 757 + dependencies: 758 + '@lit-labs/ssr-dom-shim': 1.5.1 759 + '@lit/reactive-element': 2.1.2 760 + lit-html: 3.3.2 761 + 762 + lit-html@3.3.2: 763 + dependencies: 764 + '@types/trusted-types': 2.0.7 765 + 766 + lit@3.3.2: 767 + dependencies: 768 + '@lit/reactive-element': 2.1.2 769 + lit-element: 4.2.2 770 + lit-html: 3.3.2 771 + 772 + nanoid@3.3.11: {} 773 + 774 + nanoid@5.1.7: {} 775 + 776 + normalize-path@3.0.0: {} 777 + 778 + p-map@7.0.4: {} 779 + 780 + picocolors@1.1.1: {} 781 + 782 + picomatch@2.3.2: {} 783 + 784 + picomatch@4.0.4: {} 785 + 786 + postcss@8.5.9: 787 + dependencies: 788 + nanoid: 3.3.11 789 + picocolors: 1.1.1 790 + source-map-js: 1.2.1 791 + 792 + readdirp@3.6.0: 793 + dependencies: 794 + picomatch: 2.3.2 795 + 796 + rolldown@1.0.0-rc.15: 797 + dependencies: 798 + '@oxc-project/types': 0.124.0 799 + '@rolldown/pluginutils': 1.0.0-rc.15 800 + optionalDependencies: 801 + '@rolldown/binding-android-arm64': 1.0.0-rc.15 802 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 803 + '@rolldown/binding-darwin-x64': 1.0.0-rc.15 804 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 805 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 806 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 807 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 808 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 809 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 810 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 811 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 812 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 813 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 814 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 815 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 816 + 817 + source-map-js@1.2.1: {} 818 + 819 + tinyglobby@0.2.16: 820 + dependencies: 821 + fdir: 6.5.0(picomatch@4.0.4) 822 + picomatch: 4.0.4 823 + 824 + to-regex-range@5.0.1: 825 + dependencies: 826 + is-number: 7.0.0 827 + 828 + tslib@2.8.1: 829 + optional: true 830 + 831 + typescript@6.0.2: {} 832 + 833 + unicode-segmenter@0.14.5: {} 834 + 835 + vite-plugin-static-copy@4.0.1(vite@8.0.8): 836 + dependencies: 837 + chokidar: 3.6.0 838 + p-map: 7.0.4 839 + picocolors: 1.1.1 840 + tinyglobby: 0.2.16 841 + vite: 8.0.8 842 + 843 + vite@8.0.8: 844 + dependencies: 845 + lightningcss: 1.32.0 846 + picomatch: 4.0.4 847 + postcss: 8.5.9 848 + rolldown: 1.0.0-rc.15 849 + tinyglobby: 0.2.16 850 + optionalDependencies: 851 + fsevents: 2.3.3
-400
public/api.js
··· 1 - import { 2 - CompositeDidDocumentResolver, 3 - PlcDidDocumentResolver, 4 - WebDidDocumentResolver, 5 - } from '@atcute/identity-resolver'; 6 - 7 - // ── Constants ───────────────────────────────────────────────────── 8 - const MAPPED_AT_DID = 'did:plc:l5m5nuh5cvdatyn5fjxar2sh'; 9 - const MAPPED_AT_PDS = 'https://leccinum.us-west.host.bsky.network'; 10 - const MAPPED_AT_BASE_POST = 'at://did:plc:l5m5nuh5cvdatyn5fjxar2sh/at.mapped.post/3mitem4c3p727' 11 - const CONSTELLATION_URL = 'https://constellation.microcosm.blue'; 12 - 13 - // ── decodePolyline ───────────────────────────────────────────────── 14 - // Decodes a Google encoded polyline string into a GeoJSON Feature. 15 - // GeoJSON uses [longitude, latitude] order; Leaflet's GeoJSON layer expects this. 16 - function decodePolyline(encoded) { 17 - const coords = []; 18 - let index = 0, lat = 0, lng = 0; 19 - while (index < encoded.length) { 20 - let b, shift = 0, result = 0; 21 - do { 22 - b = encoded.charCodeAt(index++) - 63; 23 - result |= (b & 0x1f) << shift; 24 - shift += 5; 25 - } while (b >= 0x20); 26 - lat += (result & 1) ? ~(result >> 1) : (result >> 1); 27 - 28 - shift = 0; result = 0; 29 - do { 30 - b = encoded.charCodeAt(index++) - 63; 31 - result |= (b & 0x1f) << shift; 32 - shift += 5; 33 - } while (b >= 0x20); 34 - lng += (result & 1) ? ~(result >> 1) : (result >> 1); 35 - 36 - coords.push([lat / 1e5, lng / 1e5]); 37 - } 38 - return { 39 - type: 'Feature', 40 - geometry: { 41 - type: 'LineString', 42 - coordinates: coords.map(([la, lo]) => [lo, la]), 43 - }, 44 - }; 45 - } 46 - 47 - // ── normaliseStats ───────────────────────────────────────────────── 48 - // Converts wire units (metres/seconds) to display units (km/minutes). 49 - function normaliseStats(stats) { 50 - if (!stats) return null; 51 - return { 52 - distance: stats.distance != null ? Math.round(stats.distance / 100) / 10 : null, 53 - duration: stats.duration != null ? Math.round(stats.duration / 60) : null, 54 - elevation: stats.elevation ?? null, 55 - }; 56 - } 57 - 58 - // ── Author helpers ───────────────────────────────────────────────── 59 - function toInitials(str) { 60 - const parts = str.split(/[.\-]/); 61 - return parts.slice(0, 2).map(p => (p[0] ?? '').toUpperCase()).join(''); 62 - } 63 - 64 - // Extracts { handle, initials } from a resolved DID document. 65 - // Falls back to a truncated DID if alsoKnownAs is absent. 66 - function authorFromDidDoc(did, doc) { 67 - const raw = doc?.alsoKnownAs?.[0]; 68 - if (raw) { 69 - const handle = raw.replace(/^at:\/\//, ''); 70 - return { handle, initials: toInitials(handle) }; 71 - } 72 - // fallback: first 4 chars of the method-specific part 73 - const short = did.split(':').slice(2).join(':').slice(0, 4); 74 - return { handle: did, initials: short.slice(0, 2).toUpperCase() }; 75 - } 76 - 77 - // Extracts the PDS endpoint URL from a DID document. 78 - function pdsFromDidDoc(doc) { 79 - const svc = doc?.service?.find(s => s.type === 'AtprotoPersonalDataServer'); 80 - return svc?.serviceEndpoint ?? null; 81 - } 82 - 83 - // ── listRecords ──────────────────────────────────────────────────── 84 - // Fetches all records in a collection from the mapped.at service account PDS. 85 - async function listRecords(collection) { 86 - const url = `${MAPPED_AT_PDS}/xrpc/com.atproto.repo.listRecords` + 87 - `?repo=${MAPPED_AT_DID}&collection=${collection}&limit=100`; 88 - const res = await fetch(url); 89 - if (!res.ok) throw new Error(`listRecords(${collection}) HTTP ${res.status}`); 90 - return (await res.json()).records ?? []; 91 - } 92 - 93 - // ── fetchServiceData ─────────────────────────────────────────────── 94 - // Fetches trails, locations, activities from the service account and returns 95 - // URI→record lookup maps plus normalised arrays for public consumption. 96 - // Uses a build-time snapshot for instant load, then revalidates in background. 97 - let _cachedServiceData = null; 98 - let _serviceInflight = null; 99 - 100 - export function fetchServiceData() { 101 - if (_cachedServiceData) return Promise.resolve(_cachedServiceData); 102 - if (!_serviceInflight) _serviceInflight = _fetchServiceData(); 103 - return _serviceInflight; 104 - } 105 - 106 - // Builds the service data object from raw PDS record arrays. 107 - // Each record is { uri, value } as returned by com.atproto.repo.listRecords. 108 - function _buildFromRecs(trailRecs, locationRecs, activityRecs) { 109 - const activityMap = new Map(); 110 - for (const { uri, value } of activityRecs) { 111 - const rkey = uri.split('/').pop(); 112 - activityMap.set(uri, { uri, rkey, name: value.name }); 113 - } 114 - 115 - const locationMap = new Map(); 116 - for (const { uri, value } of locationRecs) { 117 - const rkey = uri.split('/').pop(); 118 - locationMap.set(uri, { 119 - uri, rkey, 120 - name: value.name ?? null, 121 - lat: parseFloat(value.latitude), 122 - lng: parseFloat(value.longitude), 123 - }); 124 - } 125 - 126 - const trailMap = new Map(); 127 - for (const { uri, value } of trailRecs) { 128 - const rkey = uri.split('/').pop(); 129 - const activityType = value.activityType?.uri 130 - ? activityMap.get(value.activityType.uri) ?? null 131 - : null; 132 - const locations = (value.locations ?? []) 133 - .map(ref => locationMap.get(ref.uri)) 134 - .filter(Boolean); 135 - trailMap.set(uri, { 136 - uri, rkey, 137 - name: value.name, 138 - activityType, 139 - locations, 140 - geo: value.polyline ? decodePolyline(value.polyline) : null, 141 - }); 142 - } 143 - 144 - const trails = [...trailMap.values()]; 145 - const locations = [...locationMap.values()]; 146 - const activities = [...activityMap.values()]; 147 - 148 - return { trailMap, locationMap, activityMap, trails, locations, activities }; 149 - } 150 - 151 - async function _fetchServiceData() { 152 - // Try bundled snapshot first (generated at build time) 153 - try { 154 - const res = await fetch('/data/service.json'); 155 - if (res.ok) { 156 - const { trails, locations, activities } = await res.json(); 157 - const data = _buildFromRecs(trails, locations, activities); 158 - _cachedServiceData = data; 159 - // Revalidate from live PDS in background 160 - _revalidateServiceData(); 161 - return data; 162 - } 163 - } catch (_) { 164 - // 404 on local dev or network error — fall through to live fetch 165 - } 166 - 167 - // Live fetch (local dev or first-ever deploy) 168 - return _fetchLiveServiceData(); 169 - } 170 - 171 - async function _fetchLiveServiceData() { 172 - const [trailRecs, locationRecs, activityRecs] = await Promise.all([ 173 - listRecords('at.mapped.trail'), 174 - listRecords('at.mapped.location'), 175 - listRecords('at.mapped.activity'), 176 - ]); 177 - const data = _buildFromRecs(trailRecs, locationRecs, activityRecs); 178 - _cachedServiceData = data; 179 - return data; 180 - } 181 - 182 - async function _revalidateServiceData() { 183 - try { 184 - const prev = _cachedServiceData; 185 - const fresh = await _fetchLiveServiceData(); 186 - _serviceInflight = null; 187 - if (_serviceDataChanged(prev, fresh)) { 188 - document.dispatchEvent(new CustomEvent('mapped:servicedata')); 189 - } 190 - } catch (_) { 191 - // Silently ignore — stale data is fine 192 - } 193 - } 194 - 195 - function _serviceDataChanged(prev, next) { 196 - if (!prev) return true; 197 - return prev.trails.map(t => t.uri).join() !== next.trails.map(t => t.uri).join(); 198 - } 199 - 200 - // ── collectPostRefs ──────────────────────────────────────────────── 201 - // Discovers all user posts that reference the mapped.at base post via basePost. 202 - // Returns an array of { did, rkey } unique post references. 203 - async function collectPostRefs() { 204 - const url = `${CONSTELLATION_URL}/xrpc/blue.microcosm.links.getBacklinks` + 205 - `?subject=${encodeURIComponent(MAPPED_AT_BASE_POST)}&source=at.mapped.post:basePost.uri&limit=100`; 206 - const res = await fetch(url); 207 - if (!res.ok) return []; 208 - const links = (await res.json()).records ?? []; 209 - 210 - const seen = new Set(); 211 - const refs = []; 212 - for (const { did, rkey } of links) { 213 - const uri = `at://${did}/at.mapped.post/${rkey}`; 214 - if (seen.has(uri)) continue; 215 - seen.add(uri); 216 - refs.push({ did, rkey }); 217 - } 218 - return refs; 219 - } 220 - 221 - // ── DID resolver ─────────────────────────────────────────────────── 222 - const _didResolver = new CompositeDidDocumentResolver({ 223 - methods: { 224 - plc: new PlcDidDocumentResolver(), 225 - web: new WebDidDocumentResolver(), 226 - }, 227 - }); 228 - 229 - // ── resolveDidInfo ───────────────────────────────────────────────── 230 - // Resolves a DID to { author, pds }. 231 - // Gets the PDS endpoint from the DID document, then fetches the handle 232 - // from com.atproto.repo.describeRepo (more reliable than alsoKnownAs). 233 - // Returns null if resolution fails (post will be skipped). 234 - async function resolveDidInfo(did) { 235 - try { 236 - const didDoc = await _didResolver.resolve(did); 237 - const doc = didDoc?.document ?? didDoc; 238 - const pds = pdsFromDidDoc(doc); 239 - if (!pds) return null; 240 - 241 - // describeRepo returns the account's current handle directly 242 - const repoRes = await fetch( 243 - `${pds}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}` 244 - ); 245 - const handle = repoRes.ok ? (await repoRes.json()).handle ?? null : null; 246 - 247 - const author = handle 248 - ? { handle, initials: toInitials(handle) } 249 - : authorFromDidDoc(did, doc); // fallback to alsoKnownAs parsing 250 - 251 - return { author, pds }; 252 - } catch (err) { 253 - console.warn(`DID resolution failed for ${did}:`, err); 254 - return null; 255 - } 256 - } 257 - 258 - // ── fetchPostRecord ──────────────────────────────────────────────── 259 - // Fetches a single at.mapped.post record from its author's PDS. 260 - // Returns the raw record value, or null on failure. 261 - async function fetchPostRecord(pds, did, rkey) { 262 - try { 263 - const url = `${pds}/xrpc/com.atproto.repo.getRecord` + 264 - `?repo=${encodeURIComponent(did)}&collection=at.mapped.post&rkey=${rkey}`; 265 - const res = await fetch(url); 266 - if (!res.ok) return null; 267 - return (await res.json()).value ?? null; 268 - } catch (err) { 269 - console.warn(`fetchPostRecord failed for at://${did}/at.mapped.post/${rkey}:`, err); 270 - return null; 271 - } 272 - } 273 - 274 - // ── _hydratePost ─────────────────────────────────────────────────── 275 - // Builds the normalised Post object from raw PDS value + lookup maps + author. 276 - function _hydratePost(did, rkey, value, author, { trailMap, locationMap, activityMap }) { 277 - const uri = `at://${did}/at.mapped.post/${rkey}`; 278 - const activityType = value.activity?.uri 279 - ? activityMap.get(value.activity.uri) ?? null 280 - : null; 281 - const location = value.location?.uri 282 - ? locationMap.get(value.location.uri) ?? null 283 - : null; 284 - const trail = value.trail?.uri 285 - ? trailMap.get(value.trail.uri) ?? null 286 - : null; 287 - 288 - return { 289 - uri, 290 - rkey, 291 - author, 292 - title: value.title ?? null, 293 - text: value.text ?? null, 294 - timestamp: new Date(value.timestamp), 295 - activityType, 296 - location, 297 - trail, 298 - stats: normaliseStats(value.stats ?? null), 299 - }; 300 - } 301 - 302 - // ── Post cache helpers ───────────────────────────────────────────── 303 - const POST_CACHE_KEY = 'mapped_cache'; 304 - const POST_CACHE_TTL_MS = 60_000; 305 - 306 - function _readPostsCache() { 307 - try { 308 - const raw = localStorage.getItem(POST_CACHE_KEY); 309 - if (!raw) return null; 310 - const { posts, cachedAt } = JSON.parse(raw); 311 - if (Date.now() - cachedAt > POST_CACHE_TTL_MS) return null; 312 - // Revive Date objects (JSON serialises them as strings) 313 - return posts.map(p => ({ ...p, timestamp: new Date(p.timestamp) })); 314 - } catch (_) { 315 - return null; 316 - } 317 - } 318 - 319 - function _writePostsCache(posts) { 320 - try { 321 - localStorage.setItem(POST_CACHE_KEY, JSON.stringify({ posts, cachedAt: Date.now() })); 322 - } catch (_) { 323 - // localStorage may be unavailable (private browsing, quota exceeded) — ignore 324 - } 325 - } 326 - 327 - // ── fetchAll ─────────────────────────────────────────────────────── 328 - // Main entry point. Returns cached data instantly when available. 329 - // Revalidates posts in background; dispatches 'mapped:posts' on update. 330 - let _cachedResult = null; 331 - let _fetchAllInflight = null; 332 - 333 - export function fetchAll() { 334 - if (_cachedResult) return Promise.resolve(_cachedResult); 335 - if (!_fetchAllInflight) _fetchAllInflight = _fetchAll(); 336 - return _fetchAllInflight; 337 - } 338 - 339 - async function _fetchAll() { 340 - const serviceData = await fetchServiceData(); 341 - const { trails, locations, activities } = serviceData; 342 - 343 - // Serve from localStorage cache if fresh 344 - const cached = _readPostsCache(); 345 - if (cached) { 346 - _cachedResult = { trails, locations, activities, posts: cached }; 347 - _fetchAllInflight = null; 348 - _revalidatePosts(serviceData); 349 - return _cachedResult; 350 - } 351 - 352 - // Cold fetch 353 - const posts = await _fetchPosts(serviceData); 354 - _writePostsCache(posts); 355 - _cachedResult = { trails, locations, activities, posts }; 356 - return _cachedResult; 357 - } 358 - 359 - // Extracted post-fetching logic (constellation + user PDSs). 360 - async function _fetchPosts(serviceData) { 361 - const postRefs = await collectPostRefs(); 362 - 363 - const uniqueDids = [...new Set(postRefs.map(r => r.did))]; 364 - const didInfoMap = new Map(); 365 - await Promise.all(uniqueDids.map(async did => { 366 - const info = await resolveDidInfo(did); 367 - if (info) didInfoMap.set(did, info); 368 - })); 369 - 370 - const posts = (await Promise.all(postRefs.map(async ({ did, rkey }) => { 371 - const info = didInfoMap.get(did); 372 - if (!info) return null; 373 - const value = await fetchPostRecord(info.pds, did, rkey); 374 - if (!value) return null; 375 - return _hydratePost(did, rkey, value, info.author, serviceData); 376 - }))).filter(Boolean); 377 - 378 - posts.sort((a, b) => b.timestamp - a.timestamp); 379 - return posts; 380 - } 381 - 382 - async function _revalidatePosts(serviceData) { 383 - try { 384 - const prevPosts = _cachedResult?.posts ?? []; 385 - const posts = await _fetchPosts(serviceData); 386 - _writePostsCache(posts); 387 - const { trails, locations, activities } = serviceData; 388 - _cachedResult = { trails, locations, activities, posts }; 389 - if (_postsChanged(prevPosts, posts)) { 390 - document.dispatchEvent(new CustomEvent('mapped:posts')); 391 - } 392 - } catch (_) { 393 - // Silently ignore — stale data is fine 394 - } 395 - } 396 - 397 - function _postsChanged(prev, next) { 398 - if (prev.length !== next.length) return true; 399 - return prev.some((p, i) => p.uri !== next[i].uri || p.timestamp.getTime() !== next[i].timestamp.getTime()); 400 - }
-32
public/index.html
··· 1 - <!doctype html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <link rel="stylesheet" href="styles.css" /> 7 - <link 8 - rel="stylesheet" 9 - href="https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css" 10 - /> 11 - <title>mapped.at</title> 12 - <script type="importmap"> 13 - { 14 - "imports": { 15 - "leaflet": "https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.js", 16 - "@atcute/client": "https://esm.sh/@atcute/client", 17 - "@atcute/oauth-browser-client": "https://esm.sh/@atcute/oauth-browser-client", 18 - "@atcute/identity-resolver": "https://esm.sh/@atcute/identity-resolver" 19 - } 20 - } 21 - </script> 22 - <link 23 - rel="icon" 24 - href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚵</text></svg>" 25 - /> 26 - </head> 27 - <body> 28 - <app-nav></app-nav> 29 - <app-root></app-root> 30 - <script type="module" src="script.js"></script> 31 - </body> 32 - </html>
public/oauth-client-metadata.json src/oauth-client-metadata.json
+23 -11
public/router.js src/router.ts
··· 3 3 // route object. Consumers call navigate() to push new routes and 4 4 // onRouteChange() to subscribe. 5 5 6 - const _listeners = new Set(); 6 + const _listeners = new Set<(route: ReturnType<typeof getRoute>) => void>(); 7 7 8 8 export function getRoute() { 9 9 const params = new URLSearchParams(location.search); 10 10 return { 11 - view: params.get('view') ?? 'about', 12 - filter: params.get('filter') ?? null, 13 - id: params.get('id') ?? null, 11 + view: params.get("view") ?? "about", 12 + filter: params.get("filter") ?? null, 13 + id: params.get("id") ?? null, 14 14 }; 15 15 } 16 16 17 - export function navigate(params) { 17 + export function navigate(params: { 18 + view?: string; 19 + filter?: string; 20 + id?: string; 21 + }) { 18 22 const qs = new URLSearchParams(); 19 23 for (const [k, v] of Object.entries(params)) { 20 24 if (v != null) qs.set(k, v); 21 25 } 22 26 const search = qs.toString(); 23 - history.pushState(null, '', search ? `?${search}` : location.pathname); 27 + history.pushState(null, "", search ? `?${search}` : location.pathname); 24 28 _notify(); 25 29 } 26 30 27 - export function onRouteChange(callback) { 31 + export function onRouteChange( 32 + callback: (route: ReturnType<typeof getRoute>) => void, 33 + ) { 28 34 _listeners.add(callback); 29 35 return () => _listeners.delete(callback); 30 36 } ··· 34 40 for (const fn of [..._listeners]) fn(route); 35 41 } 36 42 37 - window.addEventListener('popstate', _notify); 43 + window.addEventListener("popstate", _notify); 38 44 39 45 // Intercept same-page link clicks (including those inside shadow DOM) and 40 46 // route them through navigate() instead of triggering a full page reload. 41 - document.addEventListener('click', (e) => { 42 - const anchor = e.composedPath().find(el => el.tagName === 'A'); 47 + document.addEventListener("click", (e) => { 48 + const anchor = e 49 + .composedPath() 50 + .find( 51 + (el): el is HTMLAnchorElement => 52 + el instanceof HTMLAnchorElement && el.tagName === "A", 53 + ); 43 54 if (!anchor || anchor.target) return; 44 55 const url = new URL(anchor.href, location.href); 45 - if (url.origin !== location.origin || url.pathname !== location.pathname) return; 56 + if (url.origin !== location.origin || url.pathname !== location.pathname) 57 + return; 46 58 e.preventDefault(); 47 59 navigate(Object.fromEntries(url.searchParams)); 48 60 });
-864
public/script.js
··· 1 - import L, { GeoJSON } from 'leaflet'; 2 - import { 3 - configureOAuth, 4 - createAuthorizationUrl, 5 - finalizeAuthorization, 6 - getSession, 7 - deleteStoredSession, 8 - OAuthUserAgent, 9 - } from '@atcute/oauth-browser-client'; 10 - import { 11 - CompositeDidDocumentResolver, 12 - LocalActorResolver, 13 - PlcDidDocumentResolver, 14 - WebDidDocumentResolver, 15 - XrpcHandleResolver, 16 - } from '@atcute/identity-resolver'; 17 - import { getRoute, navigate, onRouteChange } from './router.js'; 18 - import metadata from './oauth-client-metadata.json' with { type: 'json' }; 19 - 20 - // ── ATProto OAuth configuration ─────────────────────────────────── 21 - configureOAuth({ 22 - metadata: { 23 - redirect_uri: metadata.redirect_uris[0], 24 - ...metadata, 25 - }, 26 - identityResolver: new LocalActorResolver({ 27 - handleResolver: new XrpcHandleResolver({ 28 - serviceUrl: 'https://public.api.bsky.app', 29 - }), 30 - didDocumentResolver: new CompositeDidDocumentResolver({ 31 - methods: { 32 - plc: new PlcDidDocumentResolver(), 33 - web: new WebDidDocumentResolver(), 34 - }, 35 - }), 36 - }), 37 - }); 38 - 39 - // ── relativeTime helper ─────────────────────────────────────────── 40 - // Uses Intl.RelativeTimeFormat to produce "5 hours ago", "yesterday", etc. 41 - function relativeTime(date) { 42 - const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); 43 - const diffSeconds = (date - Date.now()) / 1000; 44 - const units = [ 45 - ['year', 31536000], 46 - ['month', 2592000], 47 - ['week', 604800], 48 - ['day', 86400], 49 - ['hour', 3600], 50 - ['minute', 60], 51 - ]; 52 - for (const [unit, seconds] of units) { 53 - if (Math.abs(diffSeconds) >= seconds) { 54 - return rtf.format(Math.round(diffSeconds / seconds), unit); 55 - } 56 - } 57 - return 'just now'; 58 - } 59 - 60 - // ── Activity type pill configuration ───────────────────────────── 61 - const PILL_CONFIG = { 62 - 'Hiking': { cls: 'pill-green' }, 63 - 'Running': { cls: 'pill-green' }, 64 - 'Cycling': { cls: 'pill-brown' }, 65 - 'Kayaking': { cls: 'pill-teal' }, 66 - }; 67 - 68 - function getPillConfig(activityName) { 69 - return PILL_CONFIG[activityName] ?? { cls: 'pill-gray' }; 70 - } 71 - 72 - // Generate a consistent color based on a string (author name or activity type) 73 - function getColorForString(str) { 74 - if (!str) return '#999999'; 75 - const hash = str.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); 76 - const hue = hash % 360; 77 - return `hsl(${hue}, 65%, 50%)`; 78 - } 79 - 80 - // Format duration in minutes to a readable string (e.g., "3h 42m") 81 - function formatDuration(minutes) { 82 - if (!minutes || minutes <= 0) return '—'; 83 - const hours = Math.floor(minutes / 60); 84 - const mins = minutes % 60; 85 - if (hours > 0) { 86 - return `${hours}h ${mins.toString().padStart(2, '0')}m`; 87 - } 88 - return `${mins}m`; 89 - } 90 - 91 - // ── <atproto-login> ─────────────────────────────────────────────── 92 - class AtprotoLogin extends HTMLElement { 93 - _handle = null; // null = logged out; handle string = logged in 94 - 95 - connectedCallback() { 96 - this.attachShadow({ mode: 'open' }); 97 - this._render(); 98 - this._init(); 99 - } 100 - 101 - // ── Rendering ──────────────────────────────────────────────────── 102 - 103 - _render() { 104 - this.shadowRoot.innerHTML = /*html*/` 105 - <link rel="stylesheet" href="styles.css"> 106 - ${this._handle 107 - ? `<button class="login-handle">@${this._handle}</button>` 108 - : `<button class="login-btn">Sign in</button>` 109 - } 110 - <dialog class="login-dialog"> 111 - ${this._handle ? /*html*/` 112 - <div class="login-logout"> 113 - <p class="login-logout-handle">@${this._handle}</p> 114 - <button class="login-logout-btn">Sign out</button> 115 - </div> 116 - ` : /*html*/` 117 - <form> 118 - <h2>Sign in</h2> 119 - <label> 120 - Handle 121 - <input name="handle" type="text" placeholder="you.bsky.social" 122 - autocomplete="username" spellcheck="false" autofocus> 123 - </label> 124 - <button type="submit" class="login-dialog-submit">Continue</button> 125 - <p class="login-error"></p> 126 - </form> 127 - `} 128 - </dialog> 129 - `; 130 - this._attachListeners(); 131 - if (!this._handle) { 132 - this.shadowRoot.querySelector('input[name="handle"]')?.focus(); 133 - } 134 - } 135 - 136 - // ── Event wiring ───────────────────────────────────────────────── 137 - 138 - _attachListeners() { 139 - const dialog = this.shadowRoot.querySelector('dialog'); 140 - 141 - this.shadowRoot.querySelector(this._handle ? '.login-handle' : '.login-btn') 142 - .addEventListener('click', () => dialog.showModal()); 143 - 144 - dialog.addEventListener('click', (e) => { 145 - if (e.target === dialog) dialog.close(); 146 - }); 147 - 148 - if (this._handle) { 149 - this.shadowRoot.querySelector('.login-logout-btn') 150 - .addEventListener('click', () => this._logout()); 151 - } else { 152 - const form = this.shadowRoot.querySelector('form'); 153 - const errorEl = this.shadowRoot.querySelector('.login-error'); 154 - 155 - dialog.addEventListener('close', () => { 156 - errorEl.style.display = 'none'; 157 - errorEl.textContent = ''; 158 - form.reset(); 159 - }); 160 - 161 - form.addEventListener('submit', async (e) => { 162 - e.preventDefault(); 163 - const identifier = form.handle.value.trim().replace(/^@/, ''); 164 - const submitBtn = form.querySelector('.login-dialog-submit'); 165 - submitBtn.disabled = true; 166 - submitBtn.textContent = 'Redirecting…'; 167 - errorEl.style.display = 'none'; 168 - try { 169 - sessionStorage.setItem('atproto-pending-handle', identifier); 170 - const authUrl = await createAuthorizationUrl({ 171 - target: { type: 'account', identifier }, 172 - scope: metadata.scope, 173 - }); 174 - await new Promise(r => setTimeout(r, 200)); 175 - window.location.assign(authUrl); 176 - } catch (err) { 177 - sessionStorage.removeItem('atproto-pending-handle'); 178 - errorEl.textContent = err.message ?? 'Authorization failed'; 179 - errorEl.style.display = 'block'; 180 - submitBtn.disabled = false; 181 - submitBtn.textContent = 'Continue'; 182 - } 183 - }); 184 - } 185 - } 186 - 187 - // ── Auth ───────────────────────────────────────────────────────── 188 - 189 - async _init() { 190 - const params = new URLSearchParams(location.hash.slice(1)); 191 - 192 - if (params.has('code') && params.has('state')) { 193 - // Returning from OAuth authorization server 194 - history.replaceState(null, '', location.pathname + location.search); 195 - try { 196 - const { session } = await finalizeAuthorization(params); 197 - const did = session.info.sub; 198 - const handle = sessionStorage.getItem('atproto-pending-handle') ?? did; 199 - sessionStorage.removeItem('atproto-pending-handle'); 200 - localStorage.setItem('atproto-did', did); 201 - localStorage.setItem('atproto-handle', handle); 202 - this._handle = handle; 203 - this._render(); 204 - } catch (err) { 205 - // Finalization failed — show error in the login dialog 206 - this._render(); 207 - const errorEl = this.shadowRoot.querySelector('.login-error'); 208 - if (errorEl) { 209 - errorEl.textContent = err.message ?? 'Authorization failed'; 210 - errorEl.style.display = 'block'; 211 - this.shadowRoot.querySelector('dialog').showModal(); 212 - } 213 - } 214 - return; 215 - } 216 - 217 - // Resume existing session 218 - const did = localStorage.getItem('atproto-did'); 219 - if (!did) return; 220 - try { 221 - await getSession(did, { allowStale: true }); 222 - this._handle = localStorage.getItem('atproto-handle') ?? did; 223 - this._render(); 224 - } catch { 225 - localStorage.removeItem('atproto-did'); 226 - localStorage.removeItem('atproto-handle'); 227 - } 228 - } 229 - 230 - async _logout() { 231 - const did = localStorage.getItem('atproto-did'); 232 - const btn = this.shadowRoot.querySelector('.login-logout-btn'); 233 - if (btn) { btn.disabled = true; btn.textContent = 'Signing out…'; } 234 - try { 235 - const session = await getSession(did, { allowStale: true }); 236 - const agent = new OAuthUserAgent(session); 237 - await agent.signOut(); 238 - } catch { 239 - if (did) deleteStoredSession(did); 240 - } 241 - localStorage.removeItem('atproto-did'); 242 - localStorage.removeItem('atproto-handle'); 243 - this._handle = null; 244 - this._render(); 245 - } 246 - } 247 - 248 - customElements.define('atproto-login', AtprotoLogin); 249 - 250 - // ── <about-view> ───────────────────────────────────────────────── 251 - class AboutView extends HTMLElement { 252 - connectedCallback() { 253 - const shadow = this.attachShadow({ mode: 'open' }); 254 - shadow.innerHTML = /*html*/` 255 - <link rel="stylesheet" href="styles.css"> 256 - <div class="about-container"> 257 - <div class="about-hero"> 258 - <div class="about-hero-label">Welcome to</div> 259 - <div class="about-hero-title">mapped.at</div> 260 - <div class="about-hero-tagline">Trails and activities, built on the open AT protocol</div> 261 - </div> 262 - <div class="card"> 263 - <div class="card-body"> 264 - <p class="caption">Browse community trails, log your activities, and connect with other adventurers — all on a decentralised, open network.</p> 265 - <div class="about-ctas"> 266 - <button class="about-cta-primary">Browse Trails →</button> 267 - <button class="about-cta-secondary">View Activity</button> 268 - </div> 269 - </div> 270 - </div> 271 - </div> 272 - `; 273 - 274 - shadow.querySelector('.about-cta-primary').addEventListener('click', () => { 275 - navigate({ view: 'trails' }); 276 - }); 277 - shadow.querySelector('.about-cta-secondary').addEventListener('click', () => { 278 - navigate({ view: 'feed' }); 279 - }); 280 - } 281 - } 282 - 283 - customElements.define('about-view', AboutView); 284 - 285 - // ── <app-root> ──────────────────────────────────────────────────── 286 - // Reads the current route and renders the appropriate page component. 287 - // Subscribes to route changes and re-renders on each transition. 288 - class AppRoot extends HTMLElement { 289 - connectedCallback() { 290 - // Defer until all customElements.define calls in this module have run. 291 - // Without this, PostDetail/TrailDetail aren't defined yet when _render() 292 - // tries to create them, causing property setters to be missed on upgrade. 293 - Promise.resolve().then(() => { 294 - this._unsubscribe = onRouteChange(route => this._render(route)); 295 - this._render(getRoute()); 296 - }); 297 - } 298 - 299 - disconnectedCallback() { 300 - this._unsubscribe?.(); 301 - } 302 - 303 - _render(route) { 304 - this.innerHTML = ''; 305 - 306 - if (route.view === 'trail') { 307 - const el = document.createElement('trail-detail'); 308 - el.trailId = route.id; 309 - this.appendChild(el); 310 - return; 311 - } 312 - 313 - if (route.view === 'post') { 314 - const el = document.createElement('post-detail'); 315 - el.postId = route.id; 316 - this.appendChild(el); 317 - return; 318 - } 319 - 320 - // Default: tabs + feed or trails list 321 - const tabs = document.createElement('tab-switcher'); 322 - this.appendChild(tabs); 323 - 324 - if (route.view === 'about') { 325 - this.appendChild(document.createElement('about-view')); 326 - } else if (route.view === 'feed') { 327 - const feed = document.createElement('activity-feed'); 328 - if (route.filter) feed.setAttribute('data-filter', route.filter); 329 - this.appendChild(feed); 330 - } else { 331 - // view === 'trails' (default) 332 - const trailsList = document.createElement('trails-list'); 333 - if (route.filter) trailsList.setAttribute('data-filter', route.filter); 334 - this.appendChild(trailsList); 335 - } 336 - } 337 - } 338 - 339 - customElements.define('app-root', AppRoot); 340 - 341 - // ── <app-nav> ───────────────────────────────────────────────────── 342 - class AppNav extends HTMLElement { 343 - connectedCallback() { 344 - const shadow = this.attachShadow({ mode: 'open' }); 345 - shadow.innerHTML = /*html*/` 346 - <link rel="stylesheet" href="styles.css"> 347 - <nav class="nav"> 348 - <a class="nav-logo" href="."> 349 - <div class="nav-logo-icon">🚵</div> 350 - mapped.at 351 - </a> 352 - <atproto-login></atproto-login> 353 - </nav> 354 - `; 355 - } 356 - } 357 - 358 - customElements.define('app-nav', AppNav); 359 - 360 - // ── <post-detail> ──────────────────────────────────────────────── 361 - // Detail page for a single post. Receives postId as a property. 362 - class PostDetail extends HTMLElement { 363 - set postId(value) { this._postId = value; } 364 - 365 - async connectedCallback() { 366 - const shadow = this.attachShadow({ mode: 'open' }); 367 - shadow.innerHTML = /*html*/` 368 - <link rel="stylesheet" href="styles.css"> 369 - <div class="detail-page"> 370 - <button class="back-btn">← Back</button> 371 - <div class="skeleton-map" style="border-radius:12px;margin-top:12px"></div> 372 - </div> 373 - `; 374 - shadow.querySelector('.back-btn').addEventListener('click', () => history.back()); 375 - 376 - let data; 377 - try { 378 - data = await fetchAll(); 379 - } catch { 380 - shadow.querySelector('.skeleton-map').outerHTML = 381 - '<p class="not-found">Failed to load post.</p>'; 382 - return; 383 - } 384 - 385 - const post = data.posts.find(p => p.rkey === this._postId); 386 - shadow.querySelector('.skeleton-map')?.remove(); 387 - 388 - if (!post) { 389 - shadow.querySelector('.detail-page').insertAdjacentHTML( 390 - 'beforeend', '<p class="not-found">Post not found.</p>' 391 - ); 392 - return; 393 - } 394 - 395 - const card = document.createElement('activity-card'); 396 - card.post = post; 397 - card.hideLink = true; 398 - shadow.querySelector('.detail-page').appendChild(card); 399 - } 400 - } 401 - 402 - customElements.define('post-detail', PostDetail); 403 - 404 - // ── <activity-card> ─────────────────────────────────────────────── 405 - // Usage: const el = document.createElement('activity-card'); 406 - // el.post = postObject; // set before appending to DOM 407 - // container.appendChild(el); 408 - class ActivityCard extends HTMLElement { 409 - set post(value) { this._post = value; } 410 - set hideLink(value) { this._hideLink = value; } 411 - 412 - connectedCallback() { 413 - const shadow = this.attachShadow({ mode: 'open' }); 414 - const isActivity = this._post.activityType !== null; 415 - shadow.innerHTML = /*html*/` 416 - <link rel="stylesheet" href="styles.css"> 417 - ${isActivity ? '<link rel="stylesheet" href="https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css">' : ''} 418 - ${isActivity ? this._renderActivity(this._post) : this._renderTravel(this._post)} 419 - `; 420 - if (isActivity && this._post.trail) { 421 - const mapEl = shadow.querySelector('.activity-map'); 422 - requestAnimationFrame(() => { 423 - const lMap = new L.Map(mapEl, { 424 - zoomControl: false, 425 - scrollWheelZoom: false, 426 - dragging: false, 427 - doubleClickZoom: false, 428 - }); 429 - new L.TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { 430 - maxZoom: 19, 431 - attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', 432 - }).addTo(lMap); 433 - const layer = new GeoJSON(this._post.trail.geo, { 434 - style: { color: '#4a7c59', weight: 3, opacity: 0.85 }, 435 - }).addTo(lMap); 436 - lMap.fitBounds(layer.getBounds(), { maxZoom: 12, padding: [16, 16] }); 437 - }); 438 - } 439 - } 440 - 441 - _renderActivity(post) { 442 - const activityName = post.activityType?.name || 'Activity'; 443 - const pill = getPillConfig(activityName); 444 - const showElevation = post.stats?.elevation > 0; 445 - const avatarColor = getColorForString(post.author.handle); 446 - const title = post.title || `${activityName} Activity`; 447 - return /*html*/` 448 - <div class="card"> 449 - <div class="card-header"> 450 - <div class="avatar" style="background:${avatarColor}">${post.author.initials}</div> 451 - <div class="user-info"> 452 - <div class="user-name">${post.author.handle}</div> 453 - <div class="user-meta"> 454 - <span class="pill ${pill.cls}">${activityName}</span> 455 - <span>· ${relativeTime(post.timestamp)}</span> 456 - </div> 457 - </div> 458 - <div class="distance">${post.stats?.distance ?? '—'} <span>km</span></div> 459 - </div> 460 - <div class="activity-title">${title}</div> 461 - <div class="activity-map"></div> 462 - <div class="card-body"> 463 - ${post.text ? `<p class="caption">${post.text}</p>` : ''} 464 - <div class="stats"> 465 - <span>⏱ ${formatDuration(post.stats?.duration)}</span> 466 - ${showElevation ? `<span>📈 ${post.stats.elevation}m elev</span>` : ''} 467 - <span>📍 ${post.location?.name ?? '—'}</span> 468 - </div> 469 - ${!this._hideLink ? `<a class="view-link" href="?view=post&id=${post.rkey}">View post →</a>` : ''} 470 - </div> 471 - </div> 472 - `; 473 - } 474 - 475 - _renderTravel(post) { 476 - // Generate a color based on location name 477 - const locationName = post.location?.name ?? 'Other'; 478 - const bgColor = getColorForString(locationName); 479 - const bg = `linear-gradient(to bottom, ${bgColor}88, ${bgColor})`; 480 - const avatarColor = getColorForString(post.author.handle); 481 - 482 - return /*html*/` 483 - <div class="card"> 484 - <div class="travel-cover" style="background:${bg}"> 485 - <div> 486 - <div class="travel-cover-location">📍 ${post.location?.name ?? 'Travel'}</div> 487 - <div class="travel-cover-title">${post.title ?? 'Travel'}</div> 488 - </div> 489 - </div> 490 - <div class="card-body"> 491 - <div class="travel-author"> 492 - <div class="avatar" style="background:${avatarColor}">${post.author.initials}</div> 493 - <div> 494 - <div class="travel-author-name">${post.author.handle}</div> 495 - <div class="travel-author-meta">Travel · ${relativeTime(post.timestamp)}</div> 496 - </div> 497 - </div> 498 - <p class="caption">${post.text ?? 'No description'}</p> 499 - ${!this._hideLink ? `<a class="view-link" href="?view=post&id=${post.rkey}">View post →</a>` : ''} 500 - </div> 501 - </div> 502 - `; 503 - } 504 - } 505 - 506 - customElements.define('activity-card', ActivityCard); 507 - 508 - // ── Tab switcher ────────────────────────────────────────────────── 509 - class TabSwitcher extends HTMLElement { 510 - constructor() { 511 - super(); 512 - this.attachShadow({ mode: 'open' }); 513 - } 514 - 515 - connectedCallback() { 516 - this._unsubscribe = onRouteChange(route => this._update(route)); 517 - this._render(getRoute()); 518 - this._onServiceData = () => this._update(getRoute()); 519 - document.addEventListener('mapped:servicedata', this._onServiceData); 520 - } 521 - 522 - disconnectedCallback() { 523 - this._unsubscribe?.(); 524 - document.removeEventListener('mapped:servicedata', this._onServiceData); 525 - } 526 - 527 - _render(route) { 528 - this.shadowRoot.innerHTML = /*html*/` 529 - <link rel="stylesheet" href="styles.css"> 530 - <div class="tabs-container"> 531 - <div role="tablist" class="tabs"> 532 - <button role="tab" class="tab" data-view="about">About</button> 533 - <button role="tab" class="tab" data-view="trails">Trails</button> 534 - <button role="tab" class="tab" data-view="feed">All Activity</button> 535 - </div> 536 - </div> 537 - <div class="filter-row"> 538 - <select></select> 539 - </div> 540 - `; 541 - 542 - this.shadowRoot.querySelectorAll('[role="tab"]').forEach(btn => { 543 - btn.addEventListener('click', () => { 544 - navigate({ view: btn.dataset.view }); 545 - }); 546 - }); 547 - 548 - this.shadowRoot.querySelector('select').addEventListener('change', e => { 549 - const currentRoute = getRoute(); 550 - navigate({ view: currentRoute.view, filter: e.target.value || undefined }); 551 - }); 552 - 553 - this._update(route); 554 - } 555 - 556 - _update(route) { 557 - // Sync active tab 558 - this.shadowRoot.querySelectorAll('[role="tab"]').forEach(btn => { 559 - const isActive = btn.dataset.view === route.view; 560 - btn.setAttribute('aria-selected', String(isActive)); 561 - btn.classList.toggle('active', isActive); 562 - }); 563 - 564 - // Hide filter row on about view 565 - const filterRow = this.shadowRoot.querySelector('.filter-row'); 566 - filterRow.style.display = route.view === 'about' ? 'none' : ''; 567 - 568 - const select = this.shadowRoot.querySelector('select'); 569 - select.innerHTML = ''; 570 - select.disabled = true; 571 - 572 - if (route.view === 'trails') { 573 - fetchServiceData().then(({ locations }) => { 574 - select.disabled = false; 575 - const defaultOpt = document.createElement('option'); 576 - defaultOpt.value = ''; 577 - defaultOpt.textContent = 'All Locations'; 578 - select.appendChild(defaultOpt); 579 - 580 - const seen = new Set(); 581 - for (const loc of locations) { 582 - if (seen.has(loc.rkey)) continue; 583 - seen.add(loc.rkey); 584 - const opt = document.createElement('option'); 585 - opt.value = loc.rkey; 586 - opt.textContent = loc.name ?? loc.rkey; 587 - select.appendChild(opt); 588 - } 589 - select.value = route.filter ?? ''; 590 - }).catch(() => { select.disabled = false; }); 591 - } else { 592 - select.disabled = false; 593 - const defaultOpt = document.createElement('option'); 594 - defaultOpt.value = ''; 595 - defaultOpt.textContent = 'All Activity'; 596 - select.appendChild(defaultOpt); 597 - 598 - [['hiking', 'Hiking'], ['cycling', 'Cycling'], ['other', 'Other']].forEach(([val, label]) => { 599 - const opt = document.createElement('option'); 600 - opt.value = val; 601 - opt.textContent = label; 602 - select.appendChild(opt); 603 - }); 604 - select.value = route.filter ?? ''; 605 - } 606 - } 607 - } 608 - 609 - customElements.define('tab-switcher', TabSwitcher); 610 - 611 - // ── <trail-detail> ─────────────────────────────────────────────── 612 - // Detail page for a single trail. Receives trailId as a property. 613 - class TrailDetail extends HTMLElement { 614 - set trailId(value) { this._trailId = value; } 615 - 616 - async connectedCallback() { 617 - const shadow = this.attachShadow({ mode: 'open' }); 618 - shadow.innerHTML = /*html*/` 619 - <link rel="stylesheet" href="styles.css"> 620 - <div class="detail-page"> 621 - <button class="back-btn">← Back</button> 622 - <div class="skeleton-map" style="border-radius:12px;margin-top:12px"></div> 623 - </div> 624 - `; 625 - shadow.querySelector('.back-btn').addEventListener('click', () => history.back()); 626 - 627 - let data; 628 - try { 629 - data = await fetchAll(); 630 - } catch { 631 - shadow.querySelector('.skeleton-map').outerHTML = 632 - '<p class="not-found">Failed to load trail.</p>'; 633 - return; 634 - } 635 - 636 - const trail = data.trails.find(t => t.rkey === this._trailId); 637 - shadow.querySelector('.skeleton-map')?.remove(); 638 - 639 - if (!trail) { 640 - shadow.querySelector('.detail-page').insertAdjacentHTML( 641 - 'beforeend', '<p class="not-found">Trail not found.</p>' 642 - ); 643 - return; 644 - } 645 - 646 - const card = document.createElement('trail-card'); 647 - card.trail = trail; 648 - card.trailKey = trail.rkey; 649 - card.hideLink = true; 650 - shadow.querySelector('.detail-page').appendChild(card); 651 - 652 - const relatedPosts = data.posts.filter(p => p.trail?.rkey === this._trailId); 653 - if (relatedPosts.length > 0) { 654 - const heading = document.createElement('h3'); 655 - heading.className = 'detail-section-heading'; 656 - heading.textContent = 'Posts on this trail'; 657 - shadow.querySelector('.detail-page').appendChild(heading); 658 - 659 - for (const post of relatedPosts) { 660 - const postCard = document.createElement('activity-card'); 661 - postCard.post = post; 662 - shadow.querySelector('.detail-page').appendChild(postCard); 663 - } 664 - } 665 - } 666 - } 667 - 668 - customElements.define('trail-detail', TrailDetail); 669 - 670 - // ── <trail-card> ──────────────────────────────────────────────── 671 - class TrailCard extends HTMLElement { 672 - set trail(value) { 673 - this._trail = value; 674 - } 675 - set trailKey(value) { 676 - this._trailKey = value; 677 - } 678 - set hideLink(value) { this._hideLink = value; } 679 - 680 - connectedCallback() { 681 - this._render(); 682 - } 683 - 684 - _render() { 685 - if (!this._trail || !this._trailKey) return; 686 - 687 - // Only create shadow if it doesn't exist 688 - let shadow = this.shadowRoot; 689 - if (!shadow) { 690 - shadow = this.attachShadow({ mode: 'open' }); 691 - } 692 - 693 - shadow.innerHTML = /*html*/` 694 - <link rel="stylesheet" href="styles.css"> 695 - <link rel="stylesheet" href="https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css"> 696 - <div class="card"> 697 - <div class="card-header"> 698 - <div class="trail-header-content"> 699 - <div class="trail-title">${this._trail.name || 'Unknown Trail'}</div> 700 - ${this._trail.activityType ? `<span class="pill ${getPillConfig(this._trail.activityType.name).cls}">${this._trail.activityType.name}</span>` : ''} 701 - </div> 702 - </div> 703 - <div class="trail-map"></div> 704 - <div class="card-body"> 705 - <div class="trail-info"> 706 - ${!this._hideLink ? `<a class="view-link" href="?view=trail&id=${this._trailKey}">View trail →</a>` : ''} 707 - </div> 708 - </div> 709 - </div> 710 - `; 711 - 712 - // Initialize map 713 - requestAnimationFrame(() => { 714 - const mapEl = shadow.querySelector('.trail-map'); 715 - if (!mapEl) return; 716 - 717 - const lMap = new L.Map(mapEl, { 718 - zoomControl: false, 719 - scrollWheelZoom: false, 720 - dragging: false, 721 - doubleClickZoom: false, 722 - }); 723 - new L.TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { 724 - maxZoom: 19, 725 - attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', 726 - }).addTo(lMap); 727 - const layer = new GeoJSON(this._trail.geo, { 728 - style: { color: '#4a7c59', weight: 3, opacity: 0.85 }, 729 - pointToLayer: (feature, latlng) => { 730 - return L.circleMarker?.(latlng, { 731 - radius: 6, 732 - fillColor: '#4a7c59', 733 - color: '#fff', 734 - weight: 2, 735 - opacity: 1, 736 - fillOpacity: 0.8 737 - }); 738 - } 739 - }).addTo(lMap); 740 - lMap.fitBounds(layer.getBounds(), { maxZoom: 12, padding: [16, 16] }); 741 - }); 742 - } 743 - } 744 - 745 - customElements.define('trail-card', TrailCard); 746 - 747 - // ── <trails-list> ──────────────────────────────────────────────── 748 - class TrailsList extends HTMLElement { 749 - connectedCallback() { 750 - const shadow = this.attachShadow({ mode: 'open' }); 751 - shadow.innerHTML = /*html*/` 752 - <link rel="stylesheet" href="styles.css"> 753 - <link rel="stylesheet" href="https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css"> 754 - <div class="trails-container"> 755 - <div class="trails-list">${_skeletonCards(3)}</div> 756 - </div> 757 - `; 758 - this._load(shadow); 759 - 760 - const observer = new MutationObserver(() => this._load(shadow)); 761 - observer.observe(this, { attributes: true, attributeFilter: ['data-filter'] }); 762 - 763 - this._onServiceData = () => this._load(shadow); 764 - document.addEventListener('mapped:servicedata', this._onServiceData); 765 - } 766 - 767 - disconnectedCallback() { 768 - document.removeEventListener('mapped:servicedata', this._onServiceData); 769 - } 770 - 771 - async _load(shadow) { 772 - let data; 773 - try { 774 - data = await fetchServiceData(); 775 - } catch (err) { 776 - shadow.querySelector('.trails-list').innerHTML = 777 - `<p style="text-align:center;color:#999;padding:32px">Failed to load trails.</p>`; 778 - return; 779 - } 780 - const filter = this.getAttribute('data-filter'); 781 - const trails = filter 782 - ? data.trails.filter(t => t.locations.some(l => l.rkey === filter)) 783 - : data.trails; 784 - 785 - const container = shadow.querySelector('.trails-list'); 786 - container.innerHTML = ''; 787 - for (const trail of trails) { 788 - const card = document.createElement('trail-card'); 789 - card.trailKey = trail.rkey; 790 - card.trail = trail; 791 - container.appendChild(card); 792 - } 793 - } 794 - } 795 - 796 - customElements.define('trails-list', TrailsList); 797 - 798 - // ── <activity-feed> ─────────────────────────────────────────────── 799 - class ActivityFeed extends HTMLElement { 800 - connectedCallback() { 801 - const shadow = this.attachShadow({ mode: 'open' }); 802 - shadow.innerHTML = /*html*/` 803 - <link rel="stylesheet" href="styles.css"> 804 - <div class="feed">${_skeletonCards(3)}</div> 805 - `; 806 - this._load(shadow); 807 - 808 - const observer = new MutationObserver(() => this._load(shadow)); 809 - observer.observe(this, { attributes: true, attributeFilter: ['data-filter'] }); 810 - 811 - this._onPosts = () => this._load(shadow); 812 - document.addEventListener('mapped:posts', this._onPosts); 813 - } 814 - 815 - disconnectedCallback() { 816 - document.removeEventListener('mapped:posts', this._onPosts); 817 - } 818 - 819 - async _load(shadow) { 820 - let data; 821 - try { 822 - data = await fetchAll(); 823 - } catch (err) { 824 - shadow.querySelector('.feed').innerHTML = 825 - `<p style="text-align:center;color:#999;padding:32px">Failed to load posts.</p>`; 826 - return; 827 - } 828 - const feed = shadow.querySelector('.feed'); 829 - feed.innerHTML = ''; 830 - const filter = this.getAttribute('data-filter') || 'all'; 831 - for (const post of _filterPosts(data.posts, filter)) { 832 - const card = document.createElement('activity-card'); 833 - card.post = post; 834 - feed.appendChild(card); 835 - } 836 - } 837 - } 838 - 839 - customElements.define('activity-feed', ActivityFeed); 840 - 841 - function _filterPosts(posts, filter) { 842 - if (filter === 'all') return posts; 843 - if (filter === 'other') return posts.filter(p => p.activityType === null); 844 - return posts.filter(p => p.activityType?.name?.toLowerCase() === filter); 845 - } 846 - 847 - function _skeletonCards(n) { 848 - return Array.from({ length: n }, () => /*html*/` 849 - <div class="card"> 850 - <div class="card-header"> 851 - <div class="skeleton-circle"></div> 852 - <div class="skeleton-lines"> 853 - <div class="skeleton-line w60"></div> 854 - <div class="skeleton-line w40"></div> 855 - </div> 856 - </div> 857 - <div class="skeleton-map"></div> 858 - <div class="card-body"> 859 - <div class="skeleton-line w80" style="margin-bottom:8px"></div> 860 - <div class="skeleton-line w50"></div> 861 - </div> 862 - </div> 863 - `).join(''); 864 - }
-781
public/styles.css
··· 1 - /* ── Custom properties ──────────────────────────────────────────── */ 2 - :root { 3 - --color-bg: #f7f4ef; 4 - --color-card: #fff; 5 - --color-border: #e8e3da; 6 - --color-text-primary: #2d2d2d; 7 - --color-text-secondary: #999; 8 - --color-accent-green: #4a7c59; 9 - --color-accent-brown: #8b5e3c; 10 - --color-accent-teal: #2e7d8a; 11 - --shadow-card: 0 1px 4px rgba(0, 0, 0, 0.07); 12 - --radius-card: 12px; 13 - --feed-max-width: 600px; 14 - } 15 - 16 - @media (prefers-color-scheme: dark) { 17 - :root { 18 - --color-bg: #1a1917; 19 - --color-card: #252320; 20 - --color-border: #3a3631; 21 - --color-text-primary: #f0ece4; 22 - --color-text-secondary: #7a7470; 23 - --color-accent-green: #5a9c6e; 24 - --color-accent-brown: #b07848; 25 - --color-accent-teal: #3aaabb; 26 - --shadow-card: 0 1px 6px rgba(0, 0, 0, 0.4); 27 - } 28 - 29 - .caption { 30 - color: #a09890; 31 - } 32 - .pill-green { 33 - background: #1e3528; 34 - } 35 - .pill-brown { 36 - background: #2e1e10; 37 - } 38 - .pill-teal { 39 - background: #0e2428; 40 - } 41 - .pill-gray { 42 - background: #2a2826; 43 - color: #8a8480; 44 - } 45 - } 46 - 47 - /* ── Reset ──────────────────────────────────────────────────────── */ 48 - *, 49 - *::before, 50 - *::after { 51 - box-sizing: border-box; 52 - margin: 0; 53 - padding: 0; 54 - } 55 - 56 - body { 57 - background: var(--color-bg); 58 - color: var(--color-text-primary); 59 - font-family: 60 - system-ui, 61 - -apple-system, 62 - sans-serif; 63 - min-height: 100vh; 64 - } 65 - 66 - /* ── Nav ────────────────────────────────────────────────────────── */ 67 - .nav { 68 - position: sticky; 69 - top: 0; 70 - z-index: 1001; /* above leaflet */ 71 - background: var(--color-card); 72 - border-bottom: 1px solid var(--color-border); 73 - height: 56px; 74 - display: flex; 75 - align-items: center; 76 - padding: 0 24px; 77 - } 78 - 79 - .nav-logo { 80 - display: flex; 81 - align-items: center; 82 - gap: 8px; 83 - font-weight: 700; 84 - font-size: 16px; 85 - letter-spacing: -0.3px; 86 - color: var(--color-text-primary); 87 - text-decoration: none; 88 - } 89 - 90 - .nav-logo-icon { 91 - width: 28px; 92 - height: 28px; 93 - background: var(--color-accent-green); 94 - border-radius: 6px; 95 - display: flex; 96 - align-items: center; 97 - justify-content: center; 98 - font-size: 14px; 99 - } 100 - 101 - .nav-avatar { 102 - margin-left: auto; 103 - width: 32px; 104 - height: 32px; 105 - border-radius: 50%; 106 - background: linear-gradient( 107 - 135deg, 108 - var(--color-accent-green), 109 - var(--color-accent-brown) 110 - ); 111 - cursor: pointer; 112 - flex-shrink: 0; 113 - } 114 - 115 - /* ── Feed ───────────────────────────────────────────────────────── */ 116 - .feed { 117 - max-width: var(--feed-max-width); 118 - margin: 0 auto; 119 - padding: 24px 16px; 120 - display: flex; 121 - flex-direction: column; 122 - gap: 16px; 123 - } 124 - 125 - activity-feed { 126 - display: block; 127 - } 128 - 129 - activity-feed.hidden { 130 - display: none; 131 - } 132 - 133 - /* ── Trails container ───────────────────────────────────────────── */ 134 - .trails-container { 135 - max-width: var(--feed-max-width); 136 - margin: 0 auto; 137 - padding: 24px 16px; 138 - } 139 - 140 - .trails-list { 141 - display: flex; 142 - flex-direction: column; 143 - gap: 16px; 144 - } 145 - 146 - /* ── Tabs ───────────────────────────────────────────────────────── */ 147 - .tabs-container { 148 - background: var(--color-card); 149 - border-bottom: 1px solid var(--color-border); 150 - position: sticky; 151 - top: 56px; 152 - z-index: 1001; /* above leaflet */ 153 - } 154 - 155 - .tabs { 156 - max-width: var(--feed-max-width); 157 - margin: 0 auto; 158 - padding: 0 16px; 159 - display: flex; 160 - gap: 8px; 161 - align-items: center; 162 - } 163 - 164 - .tab { 165 - flex: 1; 166 - padding: 12px 16px; 167 - background: transparent; 168 - border: none; 169 - color: var(--color-text-secondary); 170 - font-size: 14px; 171 - font-weight: 500; 172 - cursor: pointer; 173 - position: relative; 174 - transition: color 0.2s ease; 175 - border-radius: 6px 6px 0 0; 176 - letter-spacing: -0.3px; 177 - } 178 - 179 - .tab:hover { 180 - color: var(--color-text-primary); 181 - background: rgba(45, 45, 45, 0.04); 182 - } 183 - 184 - .tab[aria-selected="true"], 185 - .tab.active { 186 - color: var(--color-text-primary); 187 - font-weight: 600; 188 - } 189 - 190 - .tab[aria-selected="true"]::after, 191 - .tab.active::after { 192 - content: ""; 193 - position: absolute; 194 - bottom: 0; 195 - left: 0; 196 - right: 0; 197 - height: 3px; 198 - background: var(--color-accent-green); 199 - border-radius: 2px 2px 0 0; 200 - } 201 - 202 - @media (prefers-color-scheme: dark) { 203 - .tab:hover { 204 - background: rgba(240, 236, 228, 0.06); 205 - } 206 - } 207 - 208 - /* ── Filter row ─────────────────────────────────────────────────── */ 209 - .filter-row { 210 - max-width: var(--feed-max-width); 211 - margin: 0 auto; 212 - padding: 12px 16px 4px; 213 - } 214 - 215 - .filter-row select { 216 - appearance: none; 217 - background: var(--color-card); 218 - border: 1px solid var(--color-border); 219 - border-radius: 999px; 220 - color: var(--color-text-secondary); 221 - font-size: 13px; 222 - font-weight: 500; 223 - padding: 6px 30px 6px 14px; 224 - cursor: pointer; 225 - box-shadow: var(--shadow-card); 226 - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); 227 - background-repeat: no-repeat; 228 - background-position: right 12px center; 229 - transition: color 0.2s ease, border-color 0.2s ease; 230 - } 231 - 232 - .filter-row select:focus { 233 - outline: none; 234 - border-color: var(--color-accent-green); 235 - color: var(--color-text-primary); 236 - } 237 - 238 - /* ── About view ─────────────────────────────────────────────────── */ 239 - .about-container { 240 - max-width: var(--feed-max-width); 241 - margin: 0 auto; 242 - padding: 16px 16px 0; 243 - display: flex; 244 - flex-direction: column; 245 - gap: 12px; 246 - } 247 - 248 - .about-hero { 249 - background: linear-gradient(135deg, var(--color-accent-green), var(--color-accent-teal)); 250 - border-radius: var(--radius-card); 251 - padding: 24px 20px; 252 - color: #fff; 253 - } 254 - 255 - .about-hero-label { 256 - font-size: 11px; 257 - font-weight: 600; 258 - text-transform: uppercase; 259 - letter-spacing: 0.5px; 260 - opacity: 0.75; 261 - margin-bottom: 6px; 262 - } 263 - 264 - .about-hero-title { 265 - font-size: 28px; 266 - font-weight: 800; 267 - margin-bottom: 6px; 268 - letter-spacing: -0.5px; 269 - } 270 - 271 - .about-hero-tagline { 272 - font-size: 13px; 273 - opacity: 0.85; 274 - line-height: 1.5; 275 - } 276 - 277 - .about-ctas { 278 - display: flex; 279 - gap: 8px; 280 - margin-top: 16px; 281 - } 282 - 283 - .about-cta-primary { 284 - background: var(--color-accent-green); 285 - color: #fff; 286 - border: none; 287 - border-radius: 6px; 288 - padding: 8px 16px; 289 - font-size: 13px; 290 - font-weight: 600; 291 - cursor: pointer; 292 - } 293 - 294 - .about-cta-secondary { 295 - background: var(--color-bg); 296 - color: var(--color-text-primary); 297 - border: 1px solid var(--color-border); 298 - border-radius: 6px; 299 - padding: 8px 16px; 300 - font-size: 13px; 301 - font-weight: 500; 302 - cursor: pointer; 303 - } 304 - 305 - .about-cta-primary:hover { 306 - opacity: 0.9; 307 - } 308 - 309 - .about-cta-secondary:hover { 310 - background: var(--color-card); 311 - } 312 - 313 - /* ── Card shell ─────────────────────────────────────────────────── */ 314 - .card { 315 - background: var(--color-card); 316 - border-radius: var(--radius-card); 317 - overflow: hidden; 318 - box-shadow: var(--shadow-card); 319 - } 320 - 321 - /* ── Card header (activity posts) ───────────────────────────────── */ 322 - .card-header { 323 - padding: 14px 16px 12px; 324 - display: flex; 325 - align-items: center; 326 - gap: 10px; 327 - } 328 - 329 - .avatar { 330 - width: 38px; 331 - height: 38px; 332 - border-radius: 50%; 333 - display: flex; 334 - align-items: center; 335 - justify-content: center; 336 - color: #fff; 337 - font-weight: 700; 338 - font-size: 14px; 339 - flex-shrink: 0; 340 - } 341 - 342 - .user-info { 343 - flex: 1; 344 - min-width: 0; 345 - } 346 - 347 - .user-name { 348 - font-weight: 600; 349 - font-size: 14px; 350 - color: var(--color-text-primary); 351 - } 352 - 353 - .user-meta { 354 - font-size: 12px; 355 - color: var(--color-text-secondary); 356 - display: flex; 357 - align-items: center; 358 - gap: 4px; 359 - margin-top: 2px; 360 - } 361 - 362 - .distance { 363 - margin-left: auto; 364 - font-size: 18px; 365 - font-weight: 800; 366 - color: var(--color-text-primary); 367 - white-space: nowrap; 368 - } 369 - 370 - .distance span { 371 - font-size: 12px; 372 - font-weight: 500; 373 - color: var(--color-text-secondary); 374 - } 375 - 376 - /* ── Activity type pills ────────────────────────────────────────── */ 377 - .pill { 378 - font-size: 11px; 379 - padding: 1px 7px; 380 - border-radius: 20px; 381 - font-weight: 600; 382 - white-space: nowrap; 383 - } 384 - .pill-green { 385 - background: #eef5f0; 386 - color: var(--color-accent-green); 387 - } 388 - .pill-brown { 389 - background: #faf3ec; 390 - color: var(--color-accent-brown); 391 - } 392 - .pill-teal { 393 - background: #ecf5f7; 394 - color: var(--color-accent-teal); 395 - } 396 - .pill-gray { 397 - background: #f0f0f0; 398 - color: #666; 399 - } 400 - 401 - /* ── Activity map ───────────────────────────────────────────────── */ 402 - .activity-map { 403 - height: 140px; 404 - width: 100%; 405 - } 406 - 407 - /* ── Trail map ──────────────────────────────────────────────────── */ 408 - .trail-map { 409 - height: 200px; 410 - width: 100%; 411 - } 412 - 413 - /* ── Trail title ────────────────────────────────────────────────── */ 414 - .trail-title { 415 - font-size: 16px; 416 - font-weight: 600; 417 - color: var(--color-text-primary); 418 - line-height: 1.3; 419 - } 420 - 421 - .trail-header-content { 422 - display: flex; 423 - align-items: center; 424 - gap: 10px; 425 - flex: 1; 426 - min-width: 0; 427 - } 428 - 429 - /* ── Trail info ────────────────────────────────────────────────── */ 430 - .trail-info { 431 - display: flex; 432 - gap: 16px; 433 - flex-wrap: wrap; 434 - } 435 - .trail-info span { 436 - font-size: 13px; 437 - color: var(--color-text-secondary); 438 - } 439 - 440 - /* ── Activity title ─────────────────────────────────────────────── */ 441 - .activity-title { 442 - padding: 0 16px 12px; 443 - font-size: 16px; 444 - font-weight: 600; 445 - color: var(--color-text-primary); 446 - line-height: 1.3; 447 - } 448 - 449 - /* ── Card body ──────────────────────────────────────────────────── */ 450 - .card-body { 451 - padding: 12px 16px 14px; 452 - } 453 - 454 - .caption { 455 - font-size: 13px; 456 - color: var(--color-text-primary); 457 - line-height: 1.5; 458 - margin-bottom: 10px; 459 - } 460 - 461 - .stats { 462 - display: flex; 463 - gap: 16px; 464 - flex-wrap: wrap; 465 - } 466 - .stats span { 467 - font-size: 12px; 468 - color: var(--color-text-secondary); 469 - } 470 - 471 - /* ── Travel card cover ──────────────────────────────────────────── */ 472 - .travel-cover { 473 - height: 160px; 474 - display: flex; 475 - align-items: flex-end; 476 - padding: 14px 16px; 477 - } 478 - 479 - .travel-cover-location { 480 - color: rgba(255, 255, 255, 0.8); 481 - font-size: 11px; 482 - margin-bottom: 2px; 483 - } 484 - 485 - .travel-cover-title { 486 - color: #fff; 487 - font-size: 17px; 488 - font-weight: 700; 489 - } 490 - 491 - /* ── Travel card author row ─────────────────────────────────────── */ 492 - .travel-author { 493 - display: flex; 494 - align-items: center; 495 - gap: 10px; 496 - margin-bottom: 10px; 497 - } 498 - 499 - .travel-author .avatar { 500 - width: 28px; 501 - height: 28px; 502 - font-size: 11px; 503 - } 504 - 505 - .travel-author-name { 506 - font-weight: 600; 507 - font-size: 13px; 508 - color: var(--color-text-primary); 509 - } 510 - 511 - .travel-author-meta { 512 - font-size: 11px; 513 - color: var(--color-text-secondary); 514 - } 515 - 516 - /* ── Responsive ─────────────────────────────────────────────────── */ 517 - @media (max-width: 640px) { 518 - .feed { 519 - padding: 16px 12px; 520 - } 521 - .nav { 522 - padding: 0 16px; 523 - } 524 - } 525 - 526 - /* ── Login / Auth ───────────────────────────────────────────────── */ 527 - 528 - /* Host element positioning */ 529 - atproto-login { 530 - margin-left: auto; 531 - flex-shrink: 0; 532 - display: flex; 533 - align-items: center; 534 - } 535 - 536 - /* trails-list custom element styling */ 537 - trails-list { 538 - display: block; 539 - } 540 - 541 - activity-feed.hidden { 542 - display: none; 543 - } 544 - 545 - /* "Sign in" button (logged-out state) */ 546 - .login-btn { 547 - background: none; 548 - border: none; 549 - font-size: 13px; 550 - font-weight: 500; 551 - color: var(--color-text-secondary); 552 - cursor: pointer; 553 - padding: 6px 10px; 554 - border-radius: 6px; 555 - font-family: inherit; 556 - } 557 - 558 - .login-btn:hover { 559 - color: var(--color-text-primary); 560 - background: var(--color-border); 561 - } 562 - 563 - /* Handle display (logged-in state) */ 564 - .login-handle { 565 - background: none; 566 - border: none; 567 - font-size: 13px; 568 - font-weight: 500; 569 - color: var(--color-text-secondary); 570 - cursor: pointer; 571 - padding: 4px 8px; 572 - border-radius: 6px; 573 - font-family: inherit; 574 - } 575 - 576 - .login-handle:hover { 577 - color: var(--color-text-primary); 578 - text-decoration: underline; 579 - } 580 - 581 - /* Modal dialog */ 582 - .login-dialog { 583 - border: none; 584 - border-radius: var(--radius-card); 585 - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18); 586 - padding: 24px; 587 - width: min(320px, 90vw); 588 - background: var(--color-card); 589 - color: var(--color-text-primary); 590 - font-family: inherit; 591 - } 592 - 593 - .login-dialog::backdrop { 594 - background: rgba(0, 0, 0, 0.35); 595 - } 596 - 597 - .login-dialog form { 598 - display: flex; 599 - flex-direction: column; 600 - gap: 14px; 601 - } 602 - 603 - .login-dialog h2 { 604 - font-size: 16px; 605 - font-weight: 700; 606 - margin-bottom: 4px; 607 - } 608 - 609 - .login-dialog label { 610 - display: flex; 611 - flex-direction: column; 612 - gap: 4px; 613 - font-size: 13px; 614 - font-weight: 500; 615 - color: var(--color-text-secondary); 616 - } 617 - 618 - .login-dialog input { 619 - padding: 8px 10px; 620 - border: 1px solid var(--color-border); 621 - border-radius: 6px; 622 - font-size: 14px; 623 - background: var(--color-bg); 624 - color: var(--color-text-primary); 625 - font-family: inherit; 626 - } 627 - 628 - .login-dialog input:focus { 629 - outline: 2px solid var(--color-accent-green); 630 - outline-offset: -1px; 631 - border-color: transparent; 632 - } 633 - 634 - .login-dialog-submit { 635 - padding: 9px 16px; 636 - background: var(--color-accent-green); 637 - color: #fff; 638 - border: none; 639 - border-radius: 6px; 640 - font-size: 14px; 641 - font-weight: 600; 642 - cursor: pointer; 643 - font-family: inherit; 644 - } 645 - 646 - .login-dialog-submit:hover:not(:disabled) { 647 - filter: brightness(1.08); 648 - } 649 - 650 - .login-dialog-submit:disabled { 651 - opacity: 0.6; 652 - cursor: not-allowed; 653 - } 654 - 655 - .login-error { 656 - font-size: 12px; 657 - color: #c0392b; 658 - display: none; 659 - } 660 - 661 - /* Logout dialog content */ 662 - .login-logout { 663 - display: flex; 664 - flex-direction: column; 665 - gap: 12px; 666 - } 667 - 668 - .login-logout-handle { 669 - font-size: 14px; 670 - font-weight: 600; 671 - color: var(--color-text-primary); 672 - } 673 - 674 - .login-logout-btn { 675 - padding: 8px 12px; 676 - background: none; 677 - border: 1px solid var(--color-border); 678 - border-radius: 6px; 679 - font-size: 13px; 680 - font-weight: 500; 681 - color: var(--color-text-secondary); 682 - cursor: pointer; 683 - font-family: inherit; 684 - text-align: left; 685 - } 686 - 687 - .login-logout-btn:hover { 688 - background: var(--color-border); 689 - color: var(--color-text-primary); 690 - } 691 - 692 - /* ── Detail pages ─────────────────────────────────────────────── */ 693 - .detail-page { 694 - max-width: 480px; 695 - margin: 0 auto; 696 - padding: 1rem; 697 - } 698 - 699 - .back-btn { 700 - background: none; 701 - border: none; 702 - color: var(--color-text-secondary, #666); 703 - cursor: pointer; 704 - font-size: 0.9rem; 705 - margin-bottom: 1rem; 706 - padding: 0; 707 - } 708 - 709 - .back-btn:hover { 710 - color: var(--color-text-primary, #222); 711 - } 712 - 713 - .not-found { 714 - color: var(--color-text-secondary, #666); 715 - text-align: center; 716 - margin-top: 2rem; 717 - } 718 - 719 - .detail-section-heading { 720 - font-size: 0.95rem; 721 - font-weight: 600; 722 - color: var(--color-text-secondary, #666); 723 - margin: 1.5rem 0 0.75rem; 724 - padding: 0; 725 - } 726 - 727 - /* ── Card view links ──────────────────────────────────────────── */ 728 - .view-link { 729 - display: inline-block; 730 - margin-top: 0.5rem; 731 - font-size: 0.85rem; 732 - color: var(--color-text-secondary, #666); 733 - text-decoration: none; 734 - } 735 - 736 - .view-link:hover { 737 - text-decoration: underline; 738 - } 739 - 740 - /* ── Skeleton loading ────────────────────────────────────────────── */ 741 - @keyframes shimmer { 742 - 0% { background-position: 200% 0; } 743 - 100% { background-position: -200% 0; } 744 - } 745 - 746 - .skeleton-circle { 747 - width: 38px; 748 - height: 38px; 749 - border-radius: 50%; 750 - flex-shrink: 0; 751 - background: linear-gradient(90deg, #ece8e2 25%, #f5f2ee 50%, #ece8e2 75%); 752 - background-size: 200% 100%; 753 - animation: shimmer 1.4s infinite; 754 - } 755 - 756 - .skeleton-lines { 757 - flex: 1; 758 - display: flex; 759 - flex-direction: column; 760 - gap: 6px; 761 - } 762 - 763 - .skeleton-line { 764 - height: 12px; 765 - border-radius: 4px; 766 - background: linear-gradient(90deg, #ece8e2 25%, #f5f2ee 50%, #ece8e2 75%); 767 - background-size: 200% 100%; 768 - animation: shimmer 1.4s infinite; 769 - } 770 - 771 - .skeleton-line.w80 { width: 80%; } 772 - .skeleton-line.w60 { width: 60%; } 773 - .skeleton-line.w50 { width: 50%; } 774 - .skeleton-line.w40 { width: 40%; } 775 - 776 - .skeleton-map { 777 - height: 140px; 778 - background: linear-gradient(90deg, #ece8e2 25%, #f5f2ee 50%, #ece8e2 75%); 779 - background-size: 200% 100%; 780 - animation: shimmer 1.4s infinite; 781 - }
-26
scripts/fetch-service-data.mjs
··· 1 - #!/usr/bin/env node 2 - import { writeFile, mkdir } from 'node:fs/promises'; 3 - import { join, dirname } from 'node:path'; 4 - import { fileURLToPath } from 'node:url'; 5 - 6 - const MAPPED_AT_DID = 'did:plc:l5m5nuh5cvdatyn5fjxar2sh'; 7 - const MAPPED_AT_PDS = 'https://leccinum.us-west.host.bsky.network'; 8 - const OUT_PATH = join(dirname(fileURLToPath(import.meta.url)), '../public/data/service.json'); 9 - 10 - async function listRecords(collection) { 11 - const url = `${MAPPED_AT_PDS}/xrpc/com.atproto.repo.listRecords` + 12 - `?repo=${MAPPED_AT_DID}&collection=${collection}&limit=100`; 13 - const res = await fetch(url); 14 - if (!res.ok) throw new Error(`listRecords(${collection}) HTTP ${res.status}`); 15 - return (await res.json()).records ?? []; 16 - } 17 - 18 - const [trails, locations, activities] = await Promise.all([ 19 - listRecords('at.mapped.trail'), 20 - listRecords('at.mapped.location'), 21 - listRecords('at.mapped.activity'), 22 - ]); 23 - 24 - await mkdir(dirname(OUT_PATH), { recursive: true }); 25 - await writeFile(OUT_PATH, JSON.stringify({ trails, locations, activities, fetchedAt: new Date().toISOString() })); 26 - console.log(`Wrote ${OUT_PATH} (${trails.length} trails, ${locations.length} locations, ${activities.length} activities)`);
+527
src/api.ts
··· 1 + import { Client, ok } from "@atcute/client"; 2 + import { 3 + CompositeDidDocumentResolver, 4 + PlcDidDocumentResolver, 5 + WebDidDocumentResolver, 6 + } from "@atcute/identity-resolver"; 7 + import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 8 + 9 + // ── Constants ───────────────────────────────────────────────────── 10 + const MAPPED_AT_DID = "did:plc:l5m5nuh5cvdatyn5fjxar2sh"; 11 + const MAPPED_AT_PDS = "https://leccinum.us-west.host.bsky.network"; 12 + const MAPPED_AT_BASE_POST_URI = 13 + "at://did:plc:l5m5nuh5cvdatyn5fjxar2sh/at.mapped.post/3mitem4c3p727"; 14 + const MAPPED_AT_BASE_POST_CID = 15 + "bafyreie4cz5gwb7ogcabmomr4wjkoici7eem5yfctzxdm342zkcq5r4hnq"; 16 + const CONSTELLATION_URL = "https://constellation.microcosm.blue"; 17 + 18 + // ── decodePolyline ───────────────────────────────────────────────── 19 + // Decodes a Google encoded polyline string into a GeoJSON Feature. 20 + // GeoJSON uses [longitude, latitude] order; Leaflet's GeoJSON layer expects this. 21 + function decodePolyline(encoded: string) { 22 + const coords = []; 23 + let index = 0, 24 + lat = 0, 25 + lng = 0; 26 + while (index < encoded.length) { 27 + let b, 28 + shift = 0, 29 + result = 0; 30 + do { 31 + b = encoded.charCodeAt(index++) - 63; 32 + result |= (b & 0x1f) << shift; 33 + shift += 5; 34 + } while (b >= 0x20); 35 + lat += result & 1 ? ~(result >> 1) : result >> 1; 36 + 37 + shift = 0; 38 + result = 0; 39 + do { 40 + b = encoded.charCodeAt(index++) - 63; 41 + result |= (b & 0x1f) << shift; 42 + shift += 5; 43 + } while (b >= 0x20); 44 + lng += result & 1 ? ~(result >> 1) : result >> 1; 45 + 46 + coords.push([lat / 1e5, lng / 1e5]); 47 + } 48 + return { 49 + type: "Feature", 50 + geometry: { 51 + type: "LineString", 52 + coordinates: coords.map(([la, lo]) => [lo, la]), 53 + }, 54 + }; 55 + } 56 + 57 + // ── normaliseStats ───────────────────────────────────────────────── 58 + // Converts wire units (metres/seconds) to display units (km/minutes). 59 + function normaliseStats( 60 + stats: { distance?: number; duration?: number; elevation?: number } | null, 61 + ) { 62 + if (!stats) return null; 63 + return { 64 + distance: 65 + stats.distance != null 66 + ? Math.round(stats.distance / 100) / 10 67 + : undefined, 68 + duration: 69 + stats.duration != null ? Math.round(stats.duration / 60) : undefined, 70 + elevation: stats.elevation ?? undefined, 71 + }; 72 + } 73 + 74 + // ── Author helpers ───────────────────────────────────────────────── 75 + function toInitials(str: string) { 76 + const parts = str.split(/[.\-]/); 77 + return parts 78 + .slice(0, 2) 79 + .map((p) => (p[0] ?? "").toUpperCase()) 80 + .join(""); 81 + } 82 + 83 + // Extracts { handle, initials } from a resolved DID document. 84 + // Falls back to a truncated DID if alsoKnownAs is absent. 85 + function authorFromDidDoc(did: string, doc: { alsoKnownAs?: string[] }) { 86 + const raw = doc?.alsoKnownAs?.[0]; 87 + if (raw) { 88 + const handle = raw.replace(/^at:\/\//, ""); 89 + return { handle, initials: toInitials(handle) }; 90 + } 91 + // fallback: first 4 chars of the method-specific part 92 + const short = did.split(":").slice(2).join(":").slice(0, 4); 93 + return { handle: did, initials: short.slice(0, 2).toUpperCase() }; 94 + } 95 + 96 + // Extracts the PDS endpoint URL from a DID document. 97 + function pdsFromDidDoc(doc: { 98 + service?: { type: string; serviceEndpoint: string }[]; 99 + }) { 100 + const svc = doc?.service?.find((s) => s.type === "AtprotoPersonalDataServer"); 101 + return svc?.serviceEndpoint ?? null; 102 + } 103 + 104 + // ── listRecords ──────────────────────────────────────────────────── 105 + // Fetches all records in a collection from the mapped.at service account PDS. 106 + async function listRecords(collection: string) { 107 + const url = 108 + `${MAPPED_AT_PDS}/xrpc/com.atproto.repo.listRecords` + 109 + `?repo=${MAPPED_AT_DID}&collection=${collection}&limit=100`; 110 + const res = await fetch(url); 111 + if (!res.ok) throw new Error(`listRecords(${collection}) HTTP ${res.status}`); 112 + return (await res.json()).records ?? []; 113 + } 114 + 115 + // ── fetchServiceData ─────────────────────────────────────────────── 116 + // Fetches trails, locations, activities from the service account and returns 117 + // URI→record lookup maps plus normalised arrays for public consumption. 118 + let _cachedServiceData: ReturnType<typeof _buildFromRecs> | null = null; 119 + let _serviceInflight: Promise<ReturnType<typeof _buildFromRecs>> | null = null; 120 + 121 + export function fetchServiceData() { 122 + if (_cachedServiceData) return Promise.resolve(_cachedServiceData); 123 + if (!_serviceInflight) _serviceInflight = _fetchLiveServiceData(); 124 + return _serviceInflight; 125 + } 126 + 127 + // Builds the service data object from raw PDS record arrays. 128 + // Each record is { uri, value } as returned by com.atproto.repo.listRecords. 129 + function _buildFromRecs( 130 + trailRecs: any[], 131 + locationRecs: any[], 132 + activityRecs: any[], 133 + ) { 134 + const activityMap = new Map(); 135 + for (const { uri, cid, value } of activityRecs) { 136 + const rkey = uri.split("/").pop(); 137 + activityMap.set(uri, { uri, cid, rkey, name: value.name }); 138 + } 139 + 140 + const locationMap = new Map(); 141 + for (const { uri, value } of locationRecs) { 142 + const rkey = uri.split("/").pop(); 143 + locationMap.set(uri, { 144 + uri, 145 + rkey, 146 + name: value.name ?? null, 147 + lat: parseFloat(value.latitude), 148 + lng: parseFloat(value.longitude), 149 + }); 150 + } 151 + 152 + const trailMap = new Map(); 153 + for (const { uri, cid, value } of trailRecs) { 154 + const rkey = uri.split("/").pop(); 155 + const activityType = value.activityType?.uri 156 + ? (activityMap.get(value.activityType.uri) ?? null) 157 + : null; 158 + const locations = (value.locations ?? []) 159 + .map((ref: { uri: string }) => locationMap.get(ref.uri)) 160 + .filter(Boolean); 161 + trailMap.set(uri, { 162 + uri, 163 + cid, 164 + rkey, 165 + name: value.name, 166 + activityType, 167 + locations, 168 + geo: value.polyline ? decodePolyline(value.polyline) : null, 169 + }); 170 + } 171 + 172 + const trails = [...trailMap.values()]; 173 + const locations = [...locationMap.values()]; 174 + const activities = [...activityMap.values()]; 175 + 176 + return { trailMap, locationMap, activityMap, trails, locations, activities }; 177 + } 178 + 179 + async function _fetchLiveServiceData() { 180 + const [trailRecs, locationRecs, activityRecs] = await Promise.all([ 181 + listRecords("at.mapped.trail"), 182 + listRecords("at.mapped.location"), 183 + listRecords("at.mapped.activity"), 184 + ]); 185 + const data = _buildFromRecs(trailRecs, locationRecs, activityRecs); 186 + _cachedServiceData = data; 187 + return data; 188 + } 189 + 190 + 191 + // ── collectPostRefs ──────────────────────────────────────────────── 192 + // Discovers all user posts that reference the mapped.at base post via basePost. 193 + // Returns an array of { did, rkey } unique post references. 194 + async function collectPostRefs() { 195 + const url = 196 + `${CONSTELLATION_URL}/xrpc/blue.microcosm.links.getBacklinks` + 197 + `?subject=${encodeURIComponent(MAPPED_AT_BASE_POST_URI)}&source=at.mapped.post:basePost.uri&limit=100`; 198 + const res = await fetch(url); 199 + if (!res.ok) return []; 200 + const links = (await res.json()).records ?? []; 201 + 202 + const seen = new Set(); 203 + const refs = []; 204 + for (const { did, rkey } of links) { 205 + const uri = `at://${did}/at.mapped.post/${rkey}`; 206 + if (seen.has(uri)) continue; 207 + seen.add(uri); 208 + refs.push({ did, rkey }); 209 + } 210 + return refs; 211 + } 212 + 213 + // ── DID resolver ─────────────────────────────────────────────────── 214 + const _didResolver = new CompositeDidDocumentResolver({ 215 + methods: { 216 + plc: new PlcDidDocumentResolver(), 217 + web: new WebDidDocumentResolver(), 218 + }, 219 + }); 220 + 221 + // ── resolveDidInfo ───────────────────────────────────────────────── 222 + // Resolves a DID to { author, pds }. 223 + // Gets the PDS endpoint from the DID document, then fetches the handle 224 + // from com.atproto.repo.describeRepo (more reliable than alsoKnownAs). 225 + // Returns null if resolution fails (post will be skipped). 226 + async function resolveDidInfo(did: Parameters<typeof _didResolver.resolve>[0]) { 227 + try { 228 + const didDoc = (await _didResolver.resolve(did)) as { document: any } | any; 229 + const doc = didDoc?.document ?? didDoc; 230 + const pds = pdsFromDidDoc(doc); 231 + if (!pds) return null; 232 + 233 + // describeRepo returns the account's current handle directly 234 + const repoRes = await fetch( 235 + `${pds}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`, 236 + ); 237 + const handle = repoRes.ok ? ((await repoRes.json()).handle ?? null) : null; 238 + 239 + const author = handle 240 + ? { handle, initials: toInitials(handle) } 241 + : authorFromDidDoc(did, doc); // fallback to alsoKnownAs parsing 242 + 243 + return { author, pds }; 244 + } catch (err) { 245 + console.warn(`DID resolution failed for ${did}:`, err); 246 + return null; 247 + } 248 + } 249 + 250 + // ── fetchPostRecord ──────────────────────────────────────────────── 251 + // Fetches a single at.mapped.post record from its author's PDS. 252 + // Returns the raw record value, or null on failure. 253 + async function fetchPostRecord(pds: string, did: string, rkey: string) { 254 + try { 255 + const url = 256 + `${pds}/xrpc/com.atproto.repo.getRecord` + 257 + `?repo=${encodeURIComponent(did)}&collection=at.mapped.post&rkey=${rkey}`; 258 + const res = await fetch(url); 259 + if (!res.ok) return null; 260 + return ((await res.json()).value as Post) ?? null; 261 + } catch (err) { 262 + console.warn( 263 + `fetchPostRecord failed for at://${did}/at.mapped.post/${rkey}:`, 264 + err, 265 + ); 266 + return null; 267 + } 268 + } 269 + 270 + export type Post = { 271 + uri: string; 272 + rkey: string; 273 + author: any; 274 + title: string | null; 275 + text: string | null; 276 + timestamp: number; 277 + activityType: any | null; 278 + location: any | null; 279 + trail: any | null; 280 + stats: { 281 + distance?: number; 282 + duration?: number; 283 + elevation?: number; 284 + } | null; 285 + }; 286 + 287 + // ── _hydratePost ─────────────────────────────────────────────────── 288 + // Builds the normalised Post object from raw PDS value + lookup maps + author. 289 + function _hydratePost( 290 + did: string, 291 + rkey: string, 292 + value: any, 293 + author: any, 294 + { 295 + trails, 296 + locations, 297 + activities, 298 + }: { trails: any; locations: any; activities: any }, 299 + ): Post { 300 + const uri = `at://${did}/at.mapped.post/${rkey}`; 301 + const activityType = value.activity?.uri 302 + ? (activities.find((a: any) => a.uri === value.activity.uri) ?? null) 303 + : null; 304 + const location = value.location?.uri 305 + ? (locations.find((a: any) => a.uri === value.location.uri) ?? null) 306 + : null; 307 + const trail = value.trail?.uri 308 + ? (trails.find((a: any) => a.uri === value.trail.uri) ?? null) 309 + : null; 310 + 311 + return { 312 + uri, 313 + rkey, 314 + author, 315 + title: value.title ?? null, 316 + text: value.text ?? null, 317 + timestamp: value.timestamp, 318 + activityType, 319 + location, 320 + trail, 321 + stats: normaliseStats(value.stats ?? null), 322 + }; 323 + } 324 + 325 + // ── Post cache helpers ───────────────────────────────────────────── 326 + const POST_CACHE_KEY = "mapped_cache"; 327 + const POST_CACHE_TTL_MS = 60_000; 328 + 329 + function _readPostsCache() { 330 + try { 331 + const raw = localStorage.getItem(POST_CACHE_KEY); 332 + if (!raw) return null; 333 + const { posts, cachedAt } = JSON.parse(raw) as { 334 + posts: Post[]; 335 + cachedAt: number; 336 + }; 337 + if (Date.now() - cachedAt > POST_CACHE_TTL_MS) return null; 338 + // Revive Date objects (JSON serialises them as strings) 339 + return posts; //posts.map((p) => ({ ...p, timestamp: new Date(p.timestamp) })); 340 + } catch (_) { 341 + return null; 342 + } 343 + } 344 + 345 + function _writePostsCache(posts: Post[]) { 346 + try { 347 + localStorage.setItem( 348 + POST_CACHE_KEY, 349 + JSON.stringify({ posts, cachedAt: Date.now() }), 350 + ); 351 + } catch (_) { 352 + // localStorage may be unavailable (private browsing, quota exceeded) — ignore 353 + } 354 + } 355 + 356 + // ── fetchAll ─────────────────────────────────────────────────────── 357 + // Main entry point. Returns cached data instantly when available. 358 + // Revalidates posts in background; dispatches 'mapped:posts' on update. 359 + let _cachedResult: { 360 + trails: any; 361 + locations: any; 362 + activities: any; 363 + posts: Post[]; 364 + } | null = null; 365 + let _fetchAllInflight: Promise<{ 366 + trails: any; 367 + locations: any; 368 + activities: any; 369 + posts: Post[]; 370 + }> | null = null; 371 + 372 + export function fetchAll() { 373 + if (_cachedResult) return Promise.resolve(_cachedResult); 374 + if (!_fetchAllInflight) _fetchAllInflight = _fetchAll(); 375 + return _fetchAllInflight; 376 + } 377 + 378 + async function _fetchAll() { 379 + const serviceData = await fetchServiceData(); 380 + const { trails, locations, activities } = serviceData; 381 + 382 + // Serve from localStorage cache if fresh 383 + const cached = _readPostsCache(); 384 + if (cached) { 385 + _cachedResult = { trails, locations, activities, posts: cached }; 386 + _fetchAllInflight = null; 387 + _revalidatePosts(serviceData); 388 + return _cachedResult; 389 + } 390 + 391 + // Cold fetch 392 + const posts = await _fetchPosts(serviceData); 393 + _writePostsCache(posts); 394 + _cachedResult = { trails, locations, activities, posts }; 395 + return _cachedResult; 396 + } 397 + 398 + // Extracted post-fetching logic (constellation + user PDSs). 399 + async function _fetchPosts(serviceData: ReturnType<typeof _buildFromRecs>) { 400 + const postRefs = await collectPostRefs(); 401 + 402 + const uniqueDids = [...new Set(postRefs.map((r) => r.did))]; 403 + const didInfoMap = new Map(); 404 + await Promise.all( 405 + uniqueDids.map(async (did) => { 406 + const info = await resolveDidInfo(did); 407 + if (info) didInfoMap.set(did, info); 408 + }), 409 + ); 410 + 411 + const posts = ( 412 + await Promise.all( 413 + postRefs.map(async ({ did, rkey }) => { 414 + const info = didInfoMap.get(did); 415 + if (!info) return null; 416 + const value = await fetchPostRecord(info.pds, did, rkey); 417 + if (!value) return null; 418 + return _hydratePost(did, rkey, value, info.author, serviceData); 419 + }), 420 + ) 421 + ).filter((p): p is Post => p !== null); 422 + 423 + posts.sort((a, b) => b!.timestamp - a!.timestamp); 424 + return posts; 425 + } 426 + 427 + async function _revalidatePosts( 428 + serviceData: ReturnType<typeof _buildFromRecs>, 429 + ) { 430 + try { 431 + const prevPosts = _cachedResult?.posts ?? []; 432 + const posts = await _fetchPosts(serviceData); 433 + _writePostsCache(posts); 434 + const { trails, locations, activities } = serviceData; 435 + _cachedResult = { trails, locations, activities, posts }; 436 + if (_postsChanged(prevPosts, posts)) { 437 + document.dispatchEvent(new CustomEvent("mapped:posts")); 438 + } 439 + } catch (_) { 440 + // Silently ignore — stale data is fine 441 + } 442 + } 443 + 444 + function _postsChanged(prev: Post[], next: Post[]) { 445 + if (prev.length !== next.length) return true; 446 + return prev.some( 447 + (p, i) => p.uri !== next[i].uri || p.timestamp !== next[i].timestamp, 448 + ); 449 + } 450 + 451 + // ── createPost ──────────────────────────────────────────────────── 452 + // Writes an at.mapped.post record to the user's PDS and optimistically 453 + // prepends it to the feed cache. 454 + // agent — OAuthUserAgent instance (from @atcute/oauth-browser-client) 455 + // did — the user's DID string 456 + // handle — the user's handle string (for display in the optimistic post) 457 + // activityEntry — entry from serviceData.activityMap with { uri, cid, name } 458 + // trailEntry — entry from serviceData.trailMap with { uri, cid, name, ... } 459 + // title — optional string (max 100 chars) 460 + // text — optional string (max 500 chars) 461 + export async function createPost( 462 + agent: OAuthUserAgent, 463 + { 464 + did, 465 + handle, 466 + activityEntry, 467 + trailEntry, 468 + title, 469 + text, 470 + }: { 471 + did: string; 472 + handle: string; 473 + activityEntry: { uri: string; cid: string; name: string }; 474 + trailEntry: { uri: string; cid: string; name: string }; 475 + title?: string; 476 + text?: string; 477 + }, 478 + ) { 479 + const rpc = new Client< 480 + {}, 481 + { 482 + "com.atproto.repo.createRecord": { 483 + input: { 484 + repo: string; 485 + collection: string; 486 + record: any; 487 + }; 488 + }; 489 + } 490 + >({ handler: agent }); 491 + 492 + const record: { 493 + title?: string; 494 + text?: string; 495 + $type: string; 496 + timestamp: string; 497 + activity: { uri: string; cid: string }; 498 + trail: { uri: string; cid: string }; 499 + basePost: { uri: string; cid: string }; 500 + } = { 501 + $type: "at.mapped.post", 502 + timestamp: new Date().toISOString(), 503 + activity: { uri: activityEntry.uri, cid: activityEntry.cid }, 504 + trail: { uri: trailEntry.uri, cid: trailEntry.cid }, 505 + basePost: { uri: MAPPED_AT_BASE_POST_URI, cid: MAPPED_AT_BASE_POST_CID }, 506 + }; 507 + if (title) record.title = title; 508 + if (text) record.text = text; 509 + 510 + const res = (await ok( 511 + rpc.post("com.atproto.repo.createRecord", { 512 + input: { repo: did, collection: "at.mapped.post", record }, 513 + as: "json", 514 + }), 515 + )) as { uri: string }; 516 + 517 + const rkey = res.uri.split("/").pop()!; 518 + const author = { handle, initials: toInitials(handle) }; 519 + 520 + // Optimistic update: prepend to in-memory cache 521 + if (_cachedResult) { 522 + const post = _hydratePost(did, rkey, record, author, _cachedResult); 523 + _cachedResult.posts.unshift(post); 524 + _writePostsCache(_cachedResult.posts); 525 + document.dispatchEvent(new CustomEvent("mapped:posts")); 526 + } 527 + }
+128
src/components/about-view.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement } from "lit/decorators.js"; 3 + import { sharedStyles } from "./shared-styles"; 4 + import { navigate } from "../router"; 5 + 6 + @customElement("about-view") 7 + export class AboutView extends LitElement { 8 + static styles = [ 9 + sharedStyles, 10 + css` 11 + .about-container { 12 + max-width: var(--feed-max-width); 13 + margin: 0 auto; 14 + padding: 16px 16px 0; 15 + display: flex; 16 + flex-direction: column; 17 + gap: 12px; 18 + } 19 + 20 + .about-hero { 21 + background: linear-gradient( 22 + 135deg, 23 + var(--color-accent-green), 24 + var(--color-accent-teal) 25 + ); 26 + border-radius: var(--radius-card); 27 + padding: 24px 20px; 28 + color: #fff; 29 + } 30 + 31 + .about-hero-label { 32 + font-size: 11px; 33 + font-weight: 600; 34 + text-transform: uppercase; 35 + letter-spacing: 0.5px; 36 + opacity: 0.75; 37 + margin-bottom: 6px; 38 + } 39 + 40 + .about-hero-title { 41 + font-size: 28px; 42 + font-weight: 800; 43 + margin-bottom: 6px; 44 + letter-spacing: -0.5px; 45 + } 46 + 47 + .about-hero-tagline { 48 + font-size: 13px; 49 + opacity: 0.85; 50 + line-height: 1.5; 51 + } 52 + 53 + .about-ctas { 54 + display: flex; 55 + gap: 8px; 56 + margin-top: 16px; 57 + } 58 + 59 + .about-cta-primary { 60 + background: var(--color-accent-green); 61 + color: #fff; 62 + border: none; 63 + border-radius: 6px; 64 + padding: 8px 16px; 65 + font-size: 13px; 66 + font-weight: 600; 67 + cursor: pointer; 68 + } 69 + 70 + .about-cta-secondary { 71 + background: var(--color-bg); 72 + color: var(--color-text-primary); 73 + border: 1px solid var(--color-border); 74 + border-radius: 6px; 75 + padding: 8px 16px; 76 + font-size: 13px; 77 + font-weight: 500; 78 + cursor: pointer; 79 + } 80 + 81 + .about-cta-primary:hover { 82 + opacity: 0.9; 83 + } 84 + 85 + .about-cta-secondary:hover { 86 + background: var(--color-card); 87 + } 88 + `, 89 + ]; 90 + 91 + private _goTrails() { 92 + navigate({ view: "trails" }); 93 + } 94 + 95 + private _goFeed() { 96 + navigate({ view: "feed" }); 97 + } 98 + 99 + render() { 100 + return html` 101 + <div class="about-container"> 102 + <div class="about-hero"> 103 + <div class="about-hero-label">Welcome to</div> 104 + <div class="about-hero-title">mapped.at</div> 105 + <div class="about-hero-tagline"> 106 + Trails and activities, built on the open AT protocol 107 + </div> 108 + </div> 109 + <div class="card"> 110 + <div class="card-body"> 111 + <p class="caption"> 112 + Browse community trails, log your activities, and connect with 113 + other adventurers — all on a decentralised, open network. 114 + </p> 115 + <div class="about-ctas"> 116 + <button class="about-cta-primary" @click=${this._goTrails}> 117 + Browse Trails → 118 + </button> 119 + <button class="about-cta-secondary" @click=${this._goFeed}> 120 + View Activity 121 + </button> 122 + </div> 123 + </div> 124 + </div> 125 + </div> 126 + `; 127 + } 128 + }
+191
src/components/activity-card.ts
··· 1 + import { LitElement, html, css, unsafeCSS } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + import * as L from "leaflet"; 4 + import { GeoJSON } from "leaflet"; 5 + import leafletCss from "leaflet/dist/leaflet.css?inline"; 6 + import type { Post } from "../api.ts"; 7 + import { relativeTime, formatDuration, getColorForString, getPillConfig } from "../utils.ts"; 8 + import { sharedStyles } from "./shared-styles.ts"; 9 + 10 + @customElement("activity-card") 11 + export class ActivityCard extends LitElement { 12 + static styles = [ 13 + sharedStyles, 14 + unsafeCSS(leafletCss), 15 + css` 16 + .user-info { 17 + flex: 1; 18 + min-width: 0; 19 + } 20 + .user-name { 21 + font-weight: 600; 22 + font-size: 14px; 23 + color: var(--color-text-primary); 24 + } 25 + .user-meta { 26 + font-size: 12px; 27 + color: var(--color-text-secondary); 28 + display: flex; 29 + align-items: center; 30 + gap: 4px; 31 + margin-top: 2px; 32 + } 33 + .distance { 34 + margin-left: auto; 35 + font-size: 18px; 36 + font-weight: 800; 37 + color: var(--color-text-primary); 38 + white-space: nowrap; 39 + } 40 + .distance span { 41 + font-size: 12px; 42 + font-weight: 500; 43 + color: var(--color-text-secondary); 44 + } 45 + .activity-map { 46 + height: 140px; 47 + width: 100%; 48 + } 49 + .activity-title { 50 + padding: 0 16px 12px; 51 + font-size: 16px; 52 + font-weight: 600; 53 + color: var(--color-text-primary); 54 + line-height: 1.3; 55 + } 56 + .travel-cover { 57 + height: 160px; 58 + display: flex; 59 + align-items: flex-end; 60 + padding: 14px 16px; 61 + } 62 + .travel-cover-location { 63 + color: rgba(255, 255, 255, 0.8); 64 + font-size: 11px; 65 + margin-bottom: 2px; 66 + } 67 + .travel-cover-title { 68 + color: #fff; 69 + font-size: 17px; 70 + font-weight: 700; 71 + } 72 + .travel-author { 73 + display: flex; 74 + align-items: center; 75 + gap: 10px; 76 + margin-bottom: 10px; 77 + } 78 + .travel-author .avatar { 79 + width: 28px; 80 + height: 28px; 81 + font-size: 11px; 82 + } 83 + .travel-author-name { 84 + font-weight: 600; 85 + font-size: 13px; 86 + color: var(--color-text-primary); 87 + } 88 + .travel-author-meta { 89 + font-size: 11px; 90 + color: var(--color-text-secondary); 91 + } 92 + `, 93 + ]; 94 + 95 + @property({ attribute: false }) post!: Post; 96 + @property({ type: Boolean }) hideLink = false; 97 + 98 + firstUpdated() { 99 + if (this.post?.activityType !== null && this.post?.trail) { 100 + const mapEl = this.renderRoot.querySelector(".activity-map") as HTMLDivElement | null; 101 + if (!mapEl) return; 102 + const lMap = new L.Map(mapEl, { 103 + zoomControl: false, 104 + scrollWheelZoom: false, 105 + dragging: false, 106 + doubleClickZoom: false, 107 + }); 108 + new L.TileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { 109 + maxZoom: 19, 110 + attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', 111 + }).addTo(lMap); 112 + const layer = new GeoJSON(this.post.trail.geo, { 113 + style: { color: "#4a7c59", weight: 3, opacity: 0.85 }, 114 + }).addTo(lMap); 115 + lMap.fitBounds(layer.getBounds(), { maxZoom: 12, padding: [16, 16] }); 116 + } 117 + } 118 + 119 + private _renderActivity() { 120 + const post = this.post; 121 + const activityName = post.activityType?.name || "Activity"; 122 + const pill = getPillConfig(activityName); 123 + const showElevation = (post.stats?.elevation || 0) > 0; 124 + const avatarColor = getColorForString(post.author.handle); 125 + const title = post.title || `${activityName} Activity`; 126 + return html` 127 + <div class="card"> 128 + <div class="card-header"> 129 + <div class="avatar" style="background:${avatarColor}">${post.author.initials}</div> 130 + <div class="user-info"> 131 + <div class="user-name">${post.author.handle}</div> 132 + <div class="user-meta"> 133 + <span class="pill ${pill.cls}">${activityName}</span> 134 + <span>· ${relativeTime(post.timestamp)}</span> 135 + </div> 136 + </div> 137 + <div class="distance">${post.stats?.distance ?? "—"} <span>km</span></div> 138 + </div> 139 + <div class="activity-title">${title}</div> 140 + <div class="activity-map"></div> 141 + <div class="card-body"> 142 + ${post.text ? html`<p class="caption">${post.text}</p>` : ""} 143 + <div class="stats"> 144 + <span>⏱ ${formatDuration(post.stats?.duration ?? null)}</span> 145 + ${showElevation ? html`<span>📈 ${post.stats?.elevation}m elev</span>` : ""} 146 + <span>📍 ${post.location?.name ?? "—"}</span> 147 + </div> 148 + ${!this.hideLink 149 + ? html`<a class="view-link" href="?view=post&id=${post.rkey}">View post →</a>` 150 + : ""} 151 + </div> 152 + </div> 153 + `; 154 + } 155 + 156 + private _renderTravel() { 157 + const post = this.post; 158 + const locationName = post.location?.name ?? "Other"; 159 + const bgColor = getColorForString(locationName); 160 + const bg = `linear-gradient(to bottom, ${bgColor}88, ${bgColor})`; 161 + const avatarColor = getColorForString(post.author.handle); 162 + return html` 163 + <div class="card"> 164 + <div class="travel-cover" style="background:${bg}"> 165 + <div> 166 + <div class="travel-cover-location">📍 ${post.location?.name ?? "Travel"}</div> 167 + <div class="travel-cover-title">${post.title ?? "Travel"}</div> 168 + </div> 169 + </div> 170 + <div class="card-body"> 171 + <div class="travel-author"> 172 + <div class="avatar" style="background:${avatarColor}">${post.author.initials}</div> 173 + <div> 174 + <div class="travel-author-name">${post.author.handle}</div> 175 + <div class="travel-author-meta">Travel · ${relativeTime(post.timestamp)}</div> 176 + </div> 177 + </div> 178 + <p class="caption">${post.text ?? "No description"}</p> 179 + ${!this.hideLink 180 + ? html`<a class="view-link" href="?view=post&id=${post.rkey}">View post →</a>` 181 + : ""} 182 + </div> 183 + </div> 184 + `; 185 + } 186 + 187 + render() { 188 + if (!this.post) return html``; 189 + return this.post.activityType !== null ? this._renderActivity() : this._renderTravel(); 190 + } 191 + }
+95
src/components/activity-feed.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + import { unsafeHTML } from "lit/directives/unsafe-html.js"; 4 + import { fetchAll, type Post } from "../api.ts"; 5 + import { filterPosts, skeletonCards } from "../utils.ts"; 6 + import { sharedStyles } from "./shared-styles.ts"; 7 + 8 + @customElement("activity-feed") 9 + export class ActivityFeed extends LitElement { 10 + static styles = [ 11 + sharedStyles, 12 + css` 13 + .feed { 14 + max-width: var(--feed-max-width); 15 + margin: 0 auto; 16 + padding: 24px 16px; 17 + display: flex; 18 + flex-direction: column; 19 + gap: 16px; 20 + } 21 + `, 22 + ]; 23 + 24 + @property({ attribute: "data-filter" }) filter = "all"; 25 + 26 + @state() private _posts: Post[] = []; 27 + @state() private _loading = true; 28 + @state() private _error = false; 29 + @state() private _loggedIn = false; 30 + 31 + private _onPosts = () => this._load(); 32 + private _onAuthChange = () => this._load(); 33 + 34 + connectedCallback() { 35 + super.connectedCallback(); 36 + this._loggedIn = !!localStorage.getItem("atproto-did"); 37 + document.addEventListener("mapped:posts", this._onPosts); 38 + document.addEventListener("mapped:authchange", this._onAuthChange); 39 + this._load(); 40 + } 41 + 42 + disconnectedCallback() { 43 + super.disconnectedCallback(); 44 + document.removeEventListener("mapped:posts", this._onPosts); 45 + document.removeEventListener("mapped:authchange", this._onAuthChange); 46 + } 47 + 48 + updated(changedProps: Map<string, unknown>) { 49 + if ( 50 + changedProps.has("filter") && 51 + changedProps.get("filter") !== undefined 52 + ) { 53 + this._load(); 54 + } 55 + } 56 + 57 + private async _load() { 58 + this._loading = true; 59 + this._error = false; 60 + this._loggedIn = !!localStorage.getItem("atproto-did"); 61 + let data; 62 + try { 63 + data = await fetchAll(); 64 + } catch { 65 + this._loading = false; 66 + this._error = true; 67 + return; 68 + } 69 + this._posts = filterPosts(data.posts, this.filter); 70 + this._loading = false; 71 + } 72 + 73 + render() { 74 + if (this._loading) { 75 + return html`<div class="feed">${unsafeHTML(skeletonCards(3))}</div>`; 76 + } 77 + if (this._error) { 78 + return html` 79 + <div class="feed"> 80 + <p style="text-align:center;color:#999;padding:32px"> 81 + Failed to load posts. 82 + </p> 83 + </div> 84 + `; 85 + } 86 + return html` 87 + <div class="feed"> 88 + ${this._loggedIn ? html`<compose-card></compose-card>` : ""} 89 + ${this._posts.map( 90 + (post) => html`<activity-card .post=${post}></activity-card>`, 91 + )} 92 + </div> 93 + `; 94 + } 95 + }
+54
src/components/app-nav.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement } from "lit/decorators.js"; 3 + 4 + @customElement("app-nav") 5 + export class AppNav extends LitElement { 6 + static styles = css` 7 + .nav { 8 + position: sticky; 9 + top: 0; 10 + z-index: 1001; 11 + background: var(--color-card); 12 + border-bottom: 1px solid var(--color-border); 13 + height: 56px; 14 + display: flex; 15 + align-items: center; 16 + padding: 0 24px; 17 + justify-content: space-between; 18 + } 19 + 20 + .nav-logo { 21 + display: flex; 22 + align-items: center; 23 + gap: 8px; 24 + font-weight: 700; 25 + font-size: 16px; 26 + letter-spacing: -0.3px; 27 + color: var(--color-text-primary); 28 + text-decoration: none; 29 + } 30 + 31 + .nav-logo-icon { 32 + width: 28px; 33 + height: 28px; 34 + background: var(--color-accent-green); 35 + border-radius: 6px; 36 + display: flex; 37 + align-items: center; 38 + justify-content: center; 39 + font-size: 14px; 40 + } 41 + `; 42 + 43 + render() { 44 + return html` 45 + <nav class="nav"> 46 + <a class="nav-logo" href="."> 47 + <div class="nav-logo-icon">🚵</div> 48 + mapped.at 49 + </a> 50 + <atproto-login></atproto-login> 51 + </nav> 52 + `; 53 + } 54 + }
+60
src/components/app-root.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + import { ifDefined } from "lit/directives/if-defined.js"; 4 + import { getRoute, onRouteChange } from "../router"; 5 + 6 + type Route = ReturnType<typeof getRoute>; 7 + 8 + @customElement("app-root") 9 + export class AppRoot extends LitElement { 10 + // Light DOM: no shadow root — renders directly into the element. 11 + override createRenderRoot() { 12 + return this; 13 + } 14 + 15 + @state() private _route: Route = getRoute(); 16 + 17 + private _unsubscribe: (() => void) | null = null; 18 + 19 + connectedCallback() { 20 + super.connectedCallback(); 21 + // Defer to ensure all custom elements defined in main.ts are registered 22 + // before the first render attempts to create trail-detail / post-detail. 23 + Promise.resolve().then(() => { 24 + this._unsubscribe = onRouteChange((route) => { 25 + this._route = route; 26 + }); 27 + this._route = getRoute(); 28 + }); 29 + } 30 + 31 + disconnectedCallback() { 32 + super.disconnectedCallback(); 33 + this._unsubscribe?.(); 34 + } 35 + 36 + render() { 37 + const route = this._route; 38 + 39 + if (route.view === "trail") { 40 + return html`<trail-detail .trailId=${route.id!}></trail-detail>`; 41 + } 42 + 43 + if (route.view === "post") { 44 + return html`<post-detail .postId=${route.id}></post-detail>`; 45 + } 46 + 47 + return html` 48 + <tab-switcher></tab-switcher> 49 + ${route.view === "about" 50 + ? html`<about-view></about-view>` 51 + : route.view === "feed" 52 + ? html`<activity-feed 53 + data-filter=${ifDefined(route.filter)} 54 + ></activity-feed>` 55 + : html`<trails-list 56 + data-filter=${ifDefined(route.filter)} 57 + ></trails-list>`} 58 + `; 59 + } 60 + }
+321
src/components/atproto-login.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement, state, query } from "lit/decorators.js"; 3 + import { 4 + createAuthorizationUrl, 5 + finalizeAuthorization, 6 + getSession, 7 + deleteStoredSession, 8 + OAuthUserAgent, 9 + } from "@atcute/oauth-browser-client"; 10 + import metadata from "../oauth-client-metadata.json" with { type: "json" }; 11 + 12 + @customElement("atproto-login") 13 + export class AtprotoLogin extends LitElement { 14 + static styles = css` 15 + :host { 16 + margin-left: auto; 17 + flex-shrink: 0; 18 + display: flex; 19 + align-items: center; 20 + } 21 + 22 + .login-btn { 23 + background: none; 24 + border: none; 25 + font-size: 13px; 26 + font-weight: 500; 27 + color: var(--color-text-secondary); 28 + cursor: pointer; 29 + padding: 6px 10px; 30 + border-radius: 6px; 31 + font-family: inherit; 32 + } 33 + 34 + .login-btn:hover { 35 + color: var(--color-text-primary); 36 + background: var(--color-border); 37 + } 38 + 39 + .login-handle { 40 + background: none; 41 + border: none; 42 + font-size: 13px; 43 + font-weight: 500; 44 + color: var(--color-text-secondary); 45 + cursor: pointer; 46 + padding: 4px 8px; 47 + border-radius: 6px; 48 + font-family: inherit; 49 + } 50 + 51 + .login-handle:hover { 52 + color: var(--color-text-primary); 53 + text-decoration: underline; 54 + } 55 + 56 + dialog { 57 + border: none; 58 + border-radius: var(--radius-card); 59 + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18); 60 + padding: 24px; 61 + width: min(320px, 90vw); 62 + background: var(--color-card); 63 + color: var(--color-text-primary); 64 + font-family: inherit; 65 + } 66 + 67 + dialog::backdrop { 68 + background: rgba(0, 0, 0, 0.35); 69 + } 70 + 71 + form { 72 + display: flex; 73 + flex-direction: column; 74 + gap: 14px; 75 + } 76 + 77 + h2 { 78 + font-size: 16px; 79 + font-weight: 700; 80 + margin-bottom: 4px; 81 + } 82 + 83 + label { 84 + display: flex; 85 + flex-direction: column; 86 + gap: 4px; 87 + font-size: 13px; 88 + font-weight: 500; 89 + color: var(--color-text-secondary); 90 + } 91 + 92 + input { 93 + padding: 8px 10px; 94 + border: 1px solid var(--color-border); 95 + border-radius: 6px; 96 + font-size: 14px; 97 + background: var(--color-bg); 98 + color: var(--color-text-primary); 99 + font-family: inherit; 100 + } 101 + 102 + input:focus { 103 + outline: 2px solid var(--color-accent-green); 104 + outline-offset: -1px; 105 + border-color: transparent; 106 + } 107 + 108 + .login-dialog-submit { 109 + padding: 9px 16px; 110 + background: var(--color-accent-green); 111 + color: #fff; 112 + border: none; 113 + border-radius: 6px; 114 + font-size: 14px; 115 + font-weight: 600; 116 + cursor: pointer; 117 + font-family: inherit; 118 + } 119 + 120 + .login-dialog-submit:hover:not(:disabled) { 121 + filter: brightness(1.08); 122 + } 123 + 124 + .login-dialog-submit:disabled { 125 + opacity: 0.6; 126 + cursor: not-allowed; 127 + } 128 + 129 + .login-error { 130 + font-size: 12px; 131 + color: #c0392b; 132 + } 133 + 134 + .login-logout { 135 + display: flex; 136 + flex-direction: column; 137 + gap: 12px; 138 + } 139 + 140 + .login-logout-handle { 141 + font-size: 14px; 142 + font-weight: 600; 143 + color: var(--color-text-primary); 144 + } 145 + 146 + .login-logout-btn { 147 + padding: 8px 12px; 148 + background: none; 149 + border: 1px solid var(--color-border); 150 + border-radius: 6px; 151 + font-size: 13px; 152 + font-weight: 500; 153 + color: var(--color-text-secondary); 154 + cursor: pointer; 155 + font-family: inherit; 156 + text-align: left; 157 + } 158 + 159 + .login-logout-btn:hover { 160 + background: var(--color-border); 161 + color: var(--color-text-primary); 162 + } 163 + `; 164 + 165 + @state() private _handle: string | null = null; 166 + @state() private _errorMessage = ""; 167 + @state() private _submitting = false; 168 + 169 + @query("dialog") private _dialog!: HTMLDialogElement; 170 + 171 + connectedCallback() { 172 + super.connectedCallback(); 173 + this._init(); 174 + } 175 + 176 + private _openDialog() { 177 + this._dialog.showModal(); 178 + } 179 + 180 + private _onDialogClick(e: MouseEvent) { 181 + if (e.target === this._dialog) this._dialog.close(); 182 + } 183 + 184 + private _onDialogClose() { 185 + this._errorMessage = ""; 186 + } 187 + 188 + private async _onSubmit(e: SubmitEvent) { 189 + e.preventDefault(); 190 + const form = e.target as HTMLFormElement; 191 + const identifier = (form.handle as HTMLInputElement).value 192 + .trim() 193 + .replace(/^@/, "") as `${string}.${string}`; 194 + this._submitting = true; 195 + this._errorMessage = ""; 196 + try { 197 + sessionStorage.setItem("atproto-pending-handle", identifier); 198 + const authUrl = await createAuthorizationUrl({ 199 + target: { type: "account", identifier }, 200 + scope: metadata.scope, 201 + }); 202 + await new Promise((r) => setTimeout(r, 200)); 203 + window.location.assign(authUrl); 204 + } catch (err: any) { 205 + sessionStorage.removeItem("atproto-pending-handle"); 206 + this._errorMessage = err.message ?? "Authorization failed"; 207 + this._submitting = false; 208 + } 209 + } 210 + 211 + private async _logout() { 212 + const did = localStorage.getItem("atproto-did") as 213 + | `did:${string}:${string}` 214 + | null; 215 + try { 216 + if (!did) return; 217 + const session = await getSession(did, { allowStale: true }); 218 + const agent = new OAuthUserAgent(session); 219 + await agent.signOut(); 220 + } catch { 221 + if (did) deleteStoredSession(did); 222 + } 223 + localStorage.removeItem("atproto-did"); 224 + localStorage.removeItem("atproto-handle"); 225 + this._handle = null; 226 + this._dispatchAuthChange(); 227 + } 228 + 229 + private async _init() { 230 + const params = new URLSearchParams(location.hash.slice(1)); 231 + 232 + if (params.has("code") && params.has("state")) { 233 + history.replaceState(null, "", location.pathname + location.search); 234 + try { 235 + const { session } = await finalizeAuthorization(params); 236 + const did = session.info.sub; 237 + const handle = sessionStorage.getItem("atproto-pending-handle") ?? did; 238 + sessionStorage.removeItem("atproto-pending-handle"); 239 + localStorage.setItem("atproto-did", did); 240 + localStorage.setItem("atproto-handle", handle); 241 + this._handle = handle; 242 + this._dispatchAuthChange(); 243 + } catch (err: any) { 244 + await this.updateComplete; 245 + this._errorMessage = err.message ?? "Authorization failed"; 246 + this._dialog.showModal(); 247 + } 248 + return; 249 + } 250 + 251 + const did = localStorage.getItem("atproto-did") as 252 + | `did:${string}:${string}` 253 + | null; 254 + if (!did) return; 255 + try { 256 + await getSession(did, { allowStale: true }); 257 + this._handle = localStorage.getItem("atproto-handle") ?? did; 258 + this._dispatchAuthChange(); 259 + } catch { 260 + localStorage.removeItem("atproto-did"); 261 + localStorage.removeItem("atproto-handle"); 262 + } 263 + } 264 + 265 + private _dispatchAuthChange() { 266 + document.dispatchEvent( 267 + new CustomEvent("mapped:authchange", { 268 + detail: { handle: this._handle }, 269 + }), 270 + ); 271 + } 272 + 273 + render() { 274 + return html` 275 + ${this._handle 276 + ? html`<button class="login-handle" @click=${this._openDialog}> 277 + @${this._handle} 278 + </button>` 279 + : html`<button class="login-btn" @click=${this._openDialog}> 280 + Sign in 281 + </button>`} 282 + <dialog @click=${this._onDialogClick} @close=${this._onDialogClose}> 283 + ${this._handle 284 + ? html` 285 + <div class="login-logout"> 286 + <p class="login-logout-handle">@${this._handle}</p> 287 + <button class="login-logout-btn" @click=${this._logout}> 288 + Sign out 289 + </button> 290 + </div> 291 + ` 292 + : html` 293 + <form @submit=${this._onSubmit}> 294 + <h2>Sign in</h2> 295 + <label> 296 + Handle 297 + <input 298 + name="handle" 299 + type="text" 300 + placeholder="you.bsky.social" 301 + autocomplete="username" 302 + spellcheck="false" 303 + autofocus 304 + /> 305 + </label> 306 + <button 307 + type="submit" 308 + class="login-dialog-submit" 309 + ?disabled=${this._submitting} 310 + > 311 + ${this._submitting ? "Redirecting…" : "Continue"} 312 + </button> 313 + ${this._errorMessage 314 + ? html`<p class="login-error">${this._errorMessage}</p>` 315 + : ""} 316 + </form> 317 + `} 318 + </dialog> 319 + `; 320 + } 321 + }
+233
src/components/compose-card.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 4 + import { fetchServiceData, createPost } from "../api.ts"; 5 + import { getColorForString } from "../utils.ts"; 6 + import { sharedStyles } from "./shared-styles.ts"; 7 + 8 + @customElement("compose-card") 9 + export class ComposeCard extends LitElement { 10 + static styles = [ 11 + sharedStyles, 12 + css` 13 + .compose-form { 14 + display: flex; 15 + flex-direction: column; 16 + gap: 12px; 17 + padding: 14px 16px; 18 + } 19 + .compose-top-row { 20 + display: flex; 21 + align-items: flex-start; 22 + gap: 10px; 23 + } 24 + .compose-selects { 25 + display: flex; 26 + flex-direction: column; 27 + gap: 6px; 28 + flex: 1; 29 + } 30 + .compose-selects select { 31 + appearance: none; 32 + background: var(--color-card); 33 + border: 1px solid var(--color-border); 34 + border-radius: 6px; 35 + color: var(--color-text-primary); 36 + font-size: 13px; 37 + padding: 6px 10px; 38 + cursor: pointer; 39 + width: 100%; 40 + } 41 + .compose-selects select:focus { 42 + outline: none; 43 + border-color: var(--color-accent-green); 44 + } 45 + input[type="text"], 46 + textarea { 47 + background: var(--color-card); 48 + border: 1px solid var(--color-border); 49 + border-radius: 6px; 50 + color: var(--color-text-primary); 51 + font-size: 13px; 52 + padding: 8px 10px; 53 + font-family: inherit; 54 + resize: vertical; 55 + } 56 + input[type="text"]:focus, 57 + textarea:focus { 58 + outline: none; 59 + border-color: var(--color-accent-green); 60 + } 61 + .compose-footer { 62 + display: flex; 63 + align-items: center; 64 + justify-content: flex-end; 65 + gap: 10px; 66 + } 67 + .compose-error-msg { 68 + font-size: 12px; 69 + color: #c0392b; 70 + flex: 1; 71 + } 72 + .compose-submit { 73 + padding: 8px 16px; 74 + background: var(--color-accent-green); 75 + color: #fff; 76 + border: none; 77 + border-radius: 6px; 78 + font-size: 13px; 79 + font-weight: 600; 80 + cursor: pointer; 81 + } 82 + .compose-submit:disabled { 83 + opacity: 0.5; 84 + cursor: not-allowed; 85 + } 86 + .compose-loading { 87 + padding: 16px; 88 + color: var(--color-text-secondary); 89 + font-size: 13px; 90 + } 91 + `, 92 + ]; 93 + 94 + @state() private _serviceData: Awaited<ReturnType<typeof fetchServiceData>> | null = null; 95 + @state() private _submitting = false; 96 + @state() private _errorMessage = ""; 97 + @state() private _activityType = ""; 98 + @state() private _trail = ""; 99 + 100 + connectedCallback() { 101 + super.connectedCallback(); 102 + fetchServiceData() 103 + .then((data) => { 104 + this._serviceData = data; 105 + }) 106 + .catch(() => { 107 + this._errorMessage = "Failed to load activities."; 108 + }); 109 + } 110 + 111 + private async _handleSubmit(e: Event) { 112 + e.preventDefault(); 113 + if (this._submitting || !this._serviceData) return; 114 + 115 + const form = e.target as HTMLFormElement; 116 + const titleInput = form.querySelector<HTMLInputElement>('[name="title"]'); 117 + const textInput = form.querySelector<HTMLTextAreaElement>('[name="text"]'); 118 + 119 + this._submitting = true; 120 + this._errorMessage = ""; 121 + 122 + try { 123 + const did = localStorage.getItem("atproto-did")! as `did:${string}:${string}`; 124 + const handle = localStorage.getItem("atproto-handle") ?? did; 125 + const session = await getSession(did, { allowStale: false }); 126 + const agent = new OAuthUserAgent(session); 127 + 128 + const activityEntry = this._serviceData.activityMap.get(this._activityType); 129 + const trailEntry = this._serviceData.trailMap.get(this._trail); 130 + 131 + await createPost(agent, { 132 + did, 133 + handle, 134 + activityEntry, 135 + trailEntry, 136 + title: titleInput?.value.trim() || undefined, 137 + text: textInput?.value.trim() || undefined, 138 + }); 139 + 140 + form.reset(); 141 + this._activityType = ""; 142 + this._trail = ""; 143 + } catch (err: any) { 144 + this._errorMessage = err.description ?? err.message ?? "Failed to post. Please try again."; 145 + } finally { 146 + this._submitting = false; 147 + } 148 + } 149 + 150 + render() { 151 + if (!this._serviceData && !this._errorMessage) { 152 + return html` 153 + <div class="card"> 154 + <div class="compose-loading">Loading…</div> 155 + </div> 156 + `; 157 + } 158 + 159 + if (!this._serviceData) { 160 + return html` 161 + <div class="card"> 162 + <p class="compose-error-msg">${this._errorMessage}</p> 163 + </div> 164 + `; 165 + } 166 + 167 + const handle = localStorage.getItem("atproto-handle") ?? ""; 168 + const avatarColor = getColorForString(handle); 169 + const initials = handle 170 + .split(/[.\-]/) 171 + .slice(0, 2) 172 + .map((p) => (p[0] ?? "").toUpperCase()) 173 + .join(""); 174 + const submitDisabled = !this._activityType || !this._trail || this._submitting; 175 + 176 + return html` 177 + <div class="card"> 178 + <form class="compose-form" @submit=${this._handleSubmit}> 179 + <div class="compose-top-row"> 180 + <div class="avatar" style="background:${avatarColor}">${initials}</div> 181 + <div class="compose-selects"> 182 + <select 183 + name="activityType" 184 + required 185 + .value=${this._activityType} 186 + @change=${(e: Event) => { this._activityType = (e.target as HTMLSelectElement).value; }} 187 + > 188 + <option value="">Select type…</option> 189 + ${this._serviceData.activities.map( 190 + (a) => html`<option value="${a.uri}">${a.name}</option>`, 191 + )} 192 + </select> 193 + <select 194 + name="trail" 195 + required 196 + .value=${this._trail} 197 + @change=${(e: Event) => { this._trail = (e.target as HTMLSelectElement).value; }} 198 + > 199 + <option value="">Select trail…</option> 200 + ${this._serviceData.trails.map( 201 + (t) => html`<option value="${t.uri}">${t.name}</option>`, 202 + )} 203 + </select> 204 + </div> 205 + </div> 206 + <input 207 + name="title" 208 + type="text" 209 + placeholder="Title (optional)" 210 + maxlength="100" 211 + autocomplete="off" 212 + ?disabled=${this._submitting} 213 + /> 214 + <textarea 215 + name="text" 216 + placeholder="What happened? (optional)" 217 + maxlength="500" 218 + rows="3" 219 + ?disabled=${this._submitting} 220 + ></textarea> 221 + <div class="compose-footer"> 222 + ${this._errorMessage 223 + ? html`<p class="compose-error-msg">${this._errorMessage}</p>` 224 + : ""} 225 + <button type="submit" class="compose-submit" ?disabled=${submitDisabled}> 226 + ${this._submitting ? "Posting…" : "Post activity"} 227 + </button> 228 + </div> 229 + </form> 230 + </div> 231 + `; 232 + } 233 + }
+50
src/components/post-detail.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + import { fetchAll, type Post } from "../api.ts"; 4 + import { sharedStyles } from "./shared-styles.ts"; 5 + 6 + @customElement("post-detail") 7 + export class PostDetail extends LitElement { 8 + static styles = [sharedStyles]; 9 + 10 + @property() postId: string | null = null; 11 + 12 + @state() private _post: Post | null = null; 13 + @state() private _loading = true; 14 + @state() private _error = false; 15 + 16 + connectedCallback() { 17 + super.connectedCallback(); 18 + this._load(); 19 + } 20 + 21 + private async _load() { 22 + this._loading = true; 23 + this._error = false; 24 + let data; 25 + try { 26 + data = await fetchAll(); 27 + } catch { 28 + this._loading = false; 29 + this._error = true; 30 + return; 31 + } 32 + this._post = data.posts.find((p) => p.rkey === this.postId) ?? null; 33 + this._loading = false; 34 + } 35 + 36 + render() { 37 + return html` 38 + <div class="detail-page"> 39 + <button class="back-btn" @click=${() => history.back()}>← Back</button> 40 + ${this._loading 41 + ? html`<div class="skeleton-map" style="border-radius:12px;margin-top:12px"></div>` 42 + : this._error 43 + ? html`<p class="not-found">Failed to load post.</p>` 44 + : this._post 45 + ? html`<activity-card .post=${this._post} .hideLink=${true}></activity-card>` 46 + : html`<p class="not-found">Post not found.</p>`} 47 + </div> 48 + `; 49 + } 50 + }
+202
src/components/shared-styles.ts
··· 1 + import { css } from "lit"; 2 + 3 + export const sharedStyles = css` 4 + /* ── Card shell ──────────────────────────────────────────────── */ 5 + .card { 6 + background: var(--color-card); 7 + border-radius: var(--radius-card); 8 + overflow: hidden; 9 + box-shadow: var(--shadow-card); 10 + } 11 + 12 + .card-header { 13 + padding: 14px 16px 12px; 14 + display: flex; 15 + align-items: center; 16 + gap: 10px; 17 + } 18 + 19 + .card-body { 20 + padding: 12px 16px 14px; 21 + } 22 + 23 + /* ── Avatar ──────────────────────────────────────────────────── */ 24 + .avatar { 25 + width: 38px; 26 + height: 38px; 27 + border-radius: 50%; 28 + display: flex; 29 + align-items: center; 30 + justify-content: center; 31 + color: #fff; 32 + font-weight: 700; 33 + font-size: 14px; 34 + flex-shrink: 0; 35 + } 36 + 37 + /* ── Activity type pills ─────────────────────────────────────── */ 38 + .pill { 39 + font-size: 11px; 40 + padding: 1px 7px; 41 + border-radius: 20px; 42 + font-weight: 600; 43 + white-space: nowrap; 44 + } 45 + .pill-green { 46 + background: #eef5f0; 47 + color: var(--color-accent-green); 48 + } 49 + .pill-brown { 50 + background: #faf3ec; 51 + color: var(--color-accent-brown); 52 + } 53 + .pill-teal { 54 + background: #ecf5f7; 55 + color: var(--color-accent-teal); 56 + } 57 + .pill-gray { 58 + background: #f0f0f0; 59 + color: #666; 60 + } 61 + 62 + @media (prefers-color-scheme: dark) { 63 + .pill-green { 64 + background: #1e3528; 65 + } 66 + .pill-brown { 67 + background: #2e1e10; 68 + } 69 + .pill-teal { 70 + background: #0e2428; 71 + } 72 + .pill-gray { 73 + background: #2a2826; 74 + color: #8a8480; 75 + } 76 + } 77 + 78 + /* ── Text utilities ──────────────────────────────────────────── */ 79 + .caption { 80 + font-size: 13px; 81 + color: var(--color-text-primary); 82 + line-height: 1.5; 83 + margin-bottom: 10px; 84 + } 85 + 86 + @media (prefers-color-scheme: dark) { 87 + .caption { 88 + color: #a09890; 89 + } 90 + } 91 + 92 + .stats { 93 + display: flex; 94 + gap: 16px; 95 + flex-wrap: wrap; 96 + } 97 + .stats span { 98 + font-size: 12px; 99 + color: var(--color-text-secondary); 100 + } 101 + 102 + .view-link { 103 + display: inline-block; 104 + margin-top: 0.5rem; 105 + font-size: 0.85rem; 106 + color: var(--color-text-secondary); 107 + text-decoration: none; 108 + } 109 + .view-link:hover { 110 + text-decoration: underline; 111 + } 112 + 113 + /* ── Skeleton loading ────────────────────────────────────────── */ 114 + @keyframes shimmer { 115 + 0% { 116 + background-position: 200% 0; 117 + } 118 + 100% { 119 + background-position: -200% 0; 120 + } 121 + } 122 + 123 + .skeleton-circle { 124 + width: 38px; 125 + height: 38px; 126 + border-radius: 50%; 127 + flex-shrink: 0; 128 + background: linear-gradient(90deg, #ece8e2 25%, #f5f2ee 50%, #ece8e2 75%); 129 + background-size: 200% 100%; 130 + animation: shimmer 1.4s infinite; 131 + } 132 + 133 + .skeleton-lines { 134 + flex: 1; 135 + display: flex; 136 + flex-direction: column; 137 + gap: 6px; 138 + } 139 + 140 + .skeleton-line { 141 + height: 12px; 142 + border-radius: 4px; 143 + background: linear-gradient(90deg, #ece8e2 25%, #f5f2ee 50%, #ece8e2 75%); 144 + background-size: 200% 100%; 145 + animation: shimmer 1.4s infinite; 146 + } 147 + 148 + .skeleton-line.w80 { 149 + width: 80%; 150 + } 151 + .skeleton-line.w60 { 152 + width: 60%; 153 + } 154 + .skeleton-line.w50 { 155 + width: 50%; 156 + } 157 + .skeleton-line.w40 { 158 + width: 40%; 159 + } 160 + 161 + .skeleton-map { 162 + height: 140px; 163 + background: linear-gradient(90deg, #ece8e2 25%, #f5f2ee 50%, #ece8e2 75%); 164 + background-size: 200% 100%; 165 + animation: shimmer 1.4s infinite; 166 + } 167 + 168 + /* ── Detail pages ────────────────────────────────────────────── */ 169 + .detail-page { 170 + max-width: 480px; 171 + margin: 0 auto; 172 + padding: 1rem; 173 + } 174 + 175 + .back-btn { 176 + background: none; 177 + border: none; 178 + color: var(--color-text-secondary); 179 + cursor: pointer; 180 + font-size: 0.9rem; 181 + margin-bottom: 1rem; 182 + padding: 0; 183 + } 184 + 185 + .back-btn:hover { 186 + color: var(--color-text-primary); 187 + } 188 + 189 + .not-found { 190 + color: var(--color-text-secondary); 191 + text-align: center; 192 + margin-top: 2rem; 193 + } 194 + 195 + .detail-section-heading { 196 + font-size: 0.95rem; 197 + font-weight: 600; 198 + color: var(--color-text-secondary); 199 + margin: 1.5rem 0 0.75rem; 200 + padding: 0; 201 + } 202 + `;
+232
src/components/tab-switcher.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + import { sharedStyles } from "./shared-styles"; 4 + import { navigate, getRoute, onRouteChange } from "../router"; 5 + import { fetchServiceData } from "../api"; 6 + 7 + type Route = ReturnType<typeof getRoute>; 8 + 9 + @customElement("tab-switcher") 10 + export class TabSwitcher extends LitElement { 11 + static styles = [ 12 + sharedStyles, 13 + css` 14 + .tabs-container { 15 + background: var(--color-card); 16 + border-bottom: 1px solid var(--color-border); 17 + position: sticky; 18 + top: 56px; 19 + z-index: 1001; 20 + } 21 + 22 + .tabs { 23 + max-width: var(--feed-max-width); 24 + margin: 0 auto; 25 + padding: 0 16px; 26 + display: flex; 27 + gap: 8px; 28 + align-items: center; 29 + } 30 + 31 + .tab { 32 + flex: 1; 33 + padding: 12px 16px; 34 + background: transparent; 35 + border: none; 36 + color: var(--color-text-secondary); 37 + font-size: 14px; 38 + font-weight: 500; 39 + cursor: pointer; 40 + position: relative; 41 + transition: color 0.2s ease; 42 + border-radius: 6px 6px 0 0; 43 + letter-spacing: -0.3px; 44 + } 45 + 46 + .tab:hover { 47 + color: var(--color-text-primary); 48 + background: rgba(45, 45, 45, 0.04); 49 + } 50 + 51 + .tab[aria-selected="true"], 52 + .tab.active { 53 + color: var(--color-text-primary); 54 + font-weight: 600; 55 + } 56 + 57 + .tab[aria-selected="true"]::after, 58 + .tab.active::after { 59 + content: ""; 60 + position: absolute; 61 + bottom: 0; 62 + left: 0; 63 + right: 0; 64 + height: 3px; 65 + background: var(--color-accent-green); 66 + border-radius: 2px 2px 0 0; 67 + } 68 + 69 + @media (prefers-color-scheme: dark) { 70 + .tab:hover { 71 + background: rgba(240, 236, 228, 0.06); 72 + } 73 + } 74 + 75 + .filter-row { 76 + max-width: var(--feed-max-width); 77 + margin: 0 auto; 78 + padding: 12px 16px 4px; 79 + } 80 + 81 + .filter-row select { 82 + appearance: none; 83 + background: var(--color-card); 84 + border: 1px solid var(--color-border); 85 + border-radius: 999px; 86 + color: var(--color-text-secondary); 87 + font-size: 13px; 88 + font-weight: 500; 89 + padding: 6px 30px 6px 14px; 90 + cursor: pointer; 91 + box-shadow: var(--shadow-card); 92 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); 93 + background-repeat: no-repeat; 94 + background-position: right 12px center; 95 + transition: color 0.2s ease, border-color 0.2s ease; 96 + } 97 + 98 + .filter-row select:focus { 99 + outline: none; 100 + border-color: var(--color-accent-green); 101 + color: var(--color-text-primary); 102 + } 103 + `, 104 + ]; 105 + 106 + @state() private _route: Route = getRoute(); 107 + @state() private _locations: Array<{ rkey: string; name: string | null }> = []; 108 + @state() private _filterOptions: Array<{ value: string; label: string }> = []; 109 + 110 + private _unsubscribe: (() => void) | null = null; 111 + private _onServiceData: () => void = () => {}; 112 + 113 + connectedCallback() { 114 + super.connectedCallback(); 115 + this._unsubscribe = onRouteChange((route) => { 116 + this._route = route; 117 + this._loadFilterOptions(route); 118 + }); 119 + this._loadFilterOptions(this._route); 120 + this._onServiceData = () => this._loadFilterOptions(this._route); 121 + document.addEventListener("mapped:servicedata", this._onServiceData); 122 + } 123 + 124 + disconnectedCallback() { 125 + super.disconnectedCallback(); 126 + this._unsubscribe?.(); 127 + document.removeEventListener("mapped:servicedata", this._onServiceData); 128 + } 129 + 130 + private _loadFilterOptions(route: Route) { 131 + if (route.view === "trails") { 132 + fetchServiceData() 133 + .then(({ locations }) => { 134 + const seen = new Set<string>(); 135 + const unique: Array<{ rkey: string; name: string | null }> = []; 136 + for (const loc of locations) { 137 + if (seen.has(loc.rkey)) continue; 138 + seen.add(loc.rkey); 139 + unique.push({ rkey: loc.rkey, name: loc.name }); 140 + } 141 + this._locations = unique; 142 + this._filterOptions = []; 143 + }) 144 + .catch(() => { 145 + this._locations = []; 146 + this._filterOptions = []; 147 + }); 148 + } else { 149 + this._locations = []; 150 + this._filterOptions = [ 151 + { value: "hiking", label: "Hiking" }, 152 + { value: "cycling", label: "Cycling" }, 153 + { value: "other", label: "Other" }, 154 + ]; 155 + } 156 + } 157 + 158 + private _onSelectChange(e: Event) { 159 + const value = (e.target as HTMLSelectElement).value; 160 + navigate({ view: this._route.view, filter: value || undefined }); 161 + } 162 + 163 + render() { 164 + const route = this._route; 165 + const isTrails = route.view === "trails"; 166 + const showFilter = route.view !== "about"; 167 + 168 + const selectOptions = isTrails 169 + ? html` 170 + <option value="">All Locations</option> 171 + ${this._locations.map( 172 + (loc) => 173 + html`<option 174 + value=${loc.rkey} 175 + ?selected=${route.filter === loc.rkey} 176 + > 177 + ${loc.name ?? loc.rkey} 178 + </option>`, 179 + )} 180 + ` 181 + : html` 182 + <option value="" ?selected=${!route.filter}>All Activity</option> 183 + ${this._filterOptions.map( 184 + (opt) => 185 + html`<option 186 + value=${opt.value} 187 + ?selected=${route.filter === opt.value} 188 + > 189 + ${opt.label} 190 + </option>`, 191 + )} 192 + `; 193 + 194 + return html` 195 + <div class="tabs-container"> 196 + <div role="tablist" class="tabs"> 197 + ${( 198 + [ 199 + ["about", "About"], 200 + ["trails", "Trails"], 201 + ["feed", "All Activity"], 202 + ] as const 203 + ).map( 204 + ([view, label]) => html` 205 + <button 206 + role="tab" 207 + class="tab" 208 + aria-selected=${String(route.view === view)} 209 + data-view=${view} 210 + @click=${() => navigate({ view })} 211 + > 212 + ${label} 213 + </button> 214 + `, 215 + )} 216 + </div> 217 + </div> 218 + ${showFilter 219 + ? html` 220 + <div class="filter-row"> 221 + <select 222 + ?disabled=${isTrails && this._locations.length === 0} 223 + @change=${this._onSelectChange} 224 + > 225 + ${selectOptions} 226 + </select> 227 + </div> 228 + ` 229 + : ""} 230 + `; 231 + } 232 + }
+101
src/components/trail-card.ts
··· 1 + import { LitElement, html, css, unsafeCSS } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + import * as L from "leaflet"; 4 + import { GeoJSON } from "leaflet"; 5 + import leafletCss from "leaflet/dist/leaflet.css?inline"; 6 + import { getPillConfig } from "../utils.ts"; 7 + import { sharedStyles } from "./shared-styles.ts"; 8 + 9 + @customElement("trail-card") 10 + export class TrailCard extends LitElement { 11 + static styles = [ 12 + sharedStyles, 13 + unsafeCSS(leafletCss), 14 + css` 15 + .trail-map { 16 + height: 200px; 17 + width: 100%; 18 + } 19 + .trail-title { 20 + font-size: 16px; 21 + font-weight: 600; 22 + color: var(--color-text-primary); 23 + line-height: 1.3; 24 + } 25 + .trail-header-content { 26 + display: flex; 27 + align-items: center; 28 + gap: 10px; 29 + flex: 1; 30 + min-width: 0; 31 + } 32 + .trail-info { 33 + display: flex; 34 + gap: 16px; 35 + flex-wrap: wrap; 36 + } 37 + .trail-info span { 38 + font-size: 13px; 39 + color: var(--color-text-secondary); 40 + } 41 + `, 42 + ]; 43 + 44 + @property({ attribute: false }) trail: any; 45 + @property() trailKey: string = ""; 46 + @property({ type: Boolean }) hideLink = false; 47 + 48 + firstUpdated() { 49 + if (!this.trail) return; 50 + const mapEl = this.renderRoot.querySelector(".trail-map") as HTMLDivElement | null; 51 + if (!mapEl) return; 52 + const lMap = new L.Map(mapEl, { 53 + zoomControl: false, 54 + scrollWheelZoom: false, 55 + dragging: false, 56 + doubleClickZoom: false, 57 + }); 58 + new L.TileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { 59 + maxZoom: 19, 60 + attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', 61 + }).addTo(lMap); 62 + const layer = new GeoJSON(this.trail.geo, { 63 + style: { color: "#4a7c59", weight: 3, opacity: 0.85 }, 64 + pointToLayer: (_feature, latlng) => 65 + L.circleMarker(latlng, { 66 + radius: 6, 67 + fillColor: "#4a7c59", 68 + color: "#fff", 69 + weight: 2, 70 + opacity: 1, 71 + fillOpacity: 0.8, 72 + }), 73 + }).addTo(lMap); 74 + lMap.fitBounds(layer.getBounds(), { maxZoom: 12, padding: [16, 16] }); 75 + } 76 + 77 + render() { 78 + if (!this.trail || !this.trailKey) return html``; 79 + const pill = this.trail.activityType ? getPillConfig(this.trail.activityType.name) : null; 80 + return html` 81 + <div class="card"> 82 + <div class="card-header"> 83 + <div class="trail-header-content"> 84 + <div class="trail-title">${this.trail.name || "Unknown Trail"}</div> 85 + ${pill 86 + ? html`<span class="pill ${pill.cls}">${this.trail.activityType.name}</span>` 87 + : ""} 88 + </div> 89 + </div> 90 + <div class="trail-map"></div> 91 + <div class="card-body"> 92 + <div class="trail-info"> 93 + ${!this.hideLink 94 + ? html`<a class="view-link" href="?view=trail&id=${this.trailKey}">View trail →</a>` 95 + : ""} 96 + </div> 97 + </div> 98 + </div> 99 + `; 100 + } 101 + }
+66
src/components/trail-detail.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + import { fetchAll, type Post } from "../api.ts"; 4 + import { sharedStyles } from "./shared-styles.ts"; 5 + 6 + @customElement("trail-detail") 7 + export class TrailDetail extends LitElement { 8 + static styles = [sharedStyles]; 9 + 10 + @property() trailId: string | null = null; 11 + 12 + @state() private _trail: any = null; 13 + @state() private _relatedPosts: Post[] = []; 14 + @state() private _loading = true; 15 + @state() private _error = false; 16 + 17 + connectedCallback() { 18 + super.connectedCallback(); 19 + this._load(); 20 + } 21 + 22 + private async _load() { 23 + this._loading = true; 24 + this._error = false; 25 + let data; 26 + try { 27 + data = await fetchAll(); 28 + } catch { 29 + this._loading = false; 30 + this._error = true; 31 + return; 32 + } 33 + this._trail = data.trails.find((t: any) => t.rkey === this.trailId) ?? null; 34 + this._relatedPosts = data.posts.filter((p) => p.trail?.rkey === this.trailId); 35 + this._loading = false; 36 + } 37 + 38 + render() { 39 + return html` 40 + <div class="detail-page"> 41 + <button class="back-btn" @click=${() => history.back()}>← Back</button> 42 + ${this._loading 43 + ? html`<div class="skeleton-map" style="border-radius:12px;margin-top:12px"></div>` 44 + : this._error 45 + ? html`<p class="not-found">Failed to load trail.</p>` 46 + : this._trail 47 + ? html` 48 + <trail-card 49 + .trail=${this._trail} 50 + .trailKey=${this._trail.rkey} 51 + .hideLink=${true} 52 + ></trail-card> 53 + ${this._relatedPosts.length > 0 54 + ? html` 55 + <h3 class="detail-section-heading">Posts on this trail</h3> 56 + ${this._relatedPosts.map( 57 + (post) => html`<activity-card .post=${post}></activity-card>`, 58 + )} 59 + ` 60 + : ""} 61 + ` 62 + : html`<p class="not-found">Trail not found.</p>`} 63 + </div> 64 + `; 65 + } 66 + }
+94
src/components/trails-list.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + import { unsafeHTML } from "lit/directives/unsafe-html.js"; 4 + import { sharedStyles } from "./shared-styles"; 5 + import { fetchServiceData } from "../api"; 6 + import { skeletonCards } from "../utils"; 7 + 8 + type Trail = Awaited<ReturnType<typeof fetchServiceData>>["trails"][number]; 9 + 10 + @customElement("trails-list") 11 + export class TrailsList extends LitElement { 12 + static styles = [ 13 + sharedStyles, 14 + css` 15 + .trails-container { 16 + max-width: var(--feed-max-width); 17 + margin: 0 auto; 18 + padding: 24px 16px; 19 + } 20 + 21 + .trails-list { 22 + display: flex; 23 + flex-direction: column; 24 + gap: 16px; 25 + } 26 + `, 27 + ]; 28 + 29 + @property({ attribute: "data-filter" }) filter = ""; 30 + @state() private _trails: Trail[] | null = null; 31 + @state() private _error = false; 32 + 33 + private _onServiceData: () => void = () => {}; 34 + 35 + connectedCallback() { 36 + super.connectedCallback(); 37 + this._onServiceData = () => this._load(); 38 + document.addEventListener("mapped:servicedata", this._onServiceData); 39 + this._load(); 40 + } 41 + 42 + disconnectedCallback() { 43 + super.disconnectedCallback(); 44 + document.removeEventListener("mapped:servicedata", this._onServiceData); 45 + } 46 + 47 + updated(changedProperties: Map<string, unknown>) { 48 + if (changedProperties.has("filter") && changedProperties.get("filter") !== undefined) { 49 + this._load(); 50 + } 51 + } 52 + 53 + private async _load() { 54 + this._error = false; 55 + let data: Awaited<ReturnType<typeof fetchServiceData>>; 56 + try { 57 + data = await fetchServiceData(); 58 + } catch { 59 + this._error = true; 60 + return; 61 + } 62 + const filter = this.filter; 63 + this._trails = filter 64 + ? data.trails.filter((t) => 65 + t.locations.some((l: { rkey: string }) => l.rkey === filter), 66 + ) 67 + : data.trails; 68 + } 69 + 70 + render() { 71 + let content; 72 + if (this._error) { 73 + content = html`<p style="text-align:center;color:#999;padding:32px"> 74 + Failed to load trails. 75 + </p>`; 76 + } else if (this._trails === null) { 77 + content = html`${unsafeHTML(skeletonCards(3))}`; 78 + } else { 79 + content = this._trails.map( 80 + (trail) => 81 + html`<trail-card 82 + .trailKey=${trail.rkey} 83 + .trail=${trail} 84 + ></trail-card>`, 85 + ); 86 + } 87 + 88 + return html` 89 + <div class="trails-container"> 90 + <div class="trails-list">${content}</div> 91 + </div> 92 + `; 93 + } 94 + }
+9
src/counter.ts
··· 1 + export function setupCounter(element: HTMLButtonElement) { 2 + let counter = 0 3 + const setCounter = (count: number) => { 4 + counter = count 5 + element.innerHTML = `Count is ${counter}` 6 + } 7 + element.addEventListener('click', () => setCounter(counter + 1)) 8 + setCounter(0) 9 + }
+43
src/main.ts
··· 1 + import "./style.css"; 2 + 3 + import { configureOAuth } from "@atcute/oauth-browser-client"; 4 + import { 5 + CompositeDidDocumentResolver, 6 + LocalActorResolver, 7 + PlcDidDocumentResolver, 8 + WebDidDocumentResolver, 9 + XrpcHandleResolver, 10 + } from "@atcute/identity-resolver"; 11 + import metadata from "./oauth-client-metadata.json" with { type: "json" }; 12 + 13 + import "./components/atproto-login.ts"; 14 + import "./components/about-view.ts"; 15 + import "./components/app-nav.ts"; 16 + import "./components/app-root.ts"; 17 + import "./components/activity-card.ts"; 18 + import "./components/trail-card.ts"; 19 + import "./components/tab-switcher.ts"; 20 + import "./components/post-detail.ts"; 21 + import "./components/trail-detail.ts"; 22 + import "./components/trails-list.ts"; 23 + import "./components/compose-card.ts"; 24 + import "./components/activity-feed.ts"; 25 + 26 + // ── ATProto OAuth configuration ─────────────────────────────────── 27 + configureOAuth({ 28 + metadata: { 29 + redirect_uri: metadata.redirect_uris[0], 30 + ...metadata, 31 + }, 32 + identityResolver: new LocalActorResolver({ 33 + handleResolver: new XrpcHandleResolver({ 34 + serviceUrl: "https://public.api.bsky.app", 35 + }), 36 + didDocumentResolver: new CompositeDidDocumentResolver({ 37 + methods: { 38 + plc: new PlcDidDocumentResolver(), 39 + web: new WebDidDocumentResolver(), 40 + }, 41 + }), 42 + }), 43 + });
+57
src/style.css
··· 1 + /* ── Custom properties ──────────────────────────────────────────── */ 2 + :root { 3 + --color-bg: #f7f4ef; 4 + --color-card: #fff; 5 + --color-border: #e8e3da; 6 + --color-text-primary: #2d2d2d; 7 + --color-text-secondary: #999; 8 + --color-accent-green: #4a7c59; 9 + --color-accent-brown: #8b5e3c; 10 + --color-accent-teal: #2e7d8a; 11 + --shadow-card: 0 1px 4px rgba(0, 0, 0, 0.07); 12 + --radius-card: 12px; 13 + --feed-max-width: 600px; 14 + } 15 + 16 + @media (prefers-color-scheme: dark) { 17 + :root { 18 + --color-bg: #1a1917; 19 + --color-card: #252320; 20 + --color-border: #3a3631; 21 + --color-text-primary: #f0ece4; 22 + --color-text-secondary: #7a7470; 23 + --color-accent-green: #5a9c6e; 24 + --color-accent-brown: #b07848; 25 + --color-accent-teal: #3aaabb; 26 + --shadow-card: 0 1px 6px rgba(0, 0, 0, 0.4); 27 + } 28 + } 29 + 30 + /* ── Reset ──────────────────────────────────────────────────────── */ 31 + *, 32 + *::before, 33 + *::after { 34 + box-sizing: border-box; 35 + margin: 0; 36 + padding: 0; 37 + } 38 + 39 + body { 40 + background: var(--color-bg); 41 + color: var(--color-text-primary); 42 + font-family: 43 + system-ui, 44 + -apple-system, 45 + sans-serif; 46 + min-height: 100vh; 47 + } 48 + 49 + /* ── Custom element display defaults ────────────────────────────── */ 50 + activity-feed, 51 + trails-list { 52 + display: block; 53 + } 54 + 55 + activity-feed.hidden { 56 + display: none; 57 + }
+86
src/utils.ts
··· 1 + import type { Post } from "./api.ts"; 2 + 3 + // ── Time formatting ─────────────────────────────────────────────── 4 + export function relativeTime(date: number) { 5 + const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); 6 + const diffSeconds = (date - Date.now()) / 1000; 7 + const units: [Intl.RelativeTimeFormatUnit, number][] = [ 8 + ["year", 31536000], 9 + ["month", 2592000], 10 + ["week", 604800], 11 + ["day", 86400], 12 + ["hour", 3600], 13 + ["minute", 60], 14 + ]; 15 + for (const [unit, seconds] of units) { 16 + if (Math.abs(diffSeconds) >= seconds) { 17 + return rtf.format(Math.round(diffSeconds / seconds), unit); 18 + } 19 + } 20 + return "just now"; 21 + } 22 + 23 + // ── Duration formatting ─────────────────────────────────────────── 24 + export function formatDuration(minutes: number | null) { 25 + if (!minutes || minutes <= 0) return "—"; 26 + const hours = Math.floor(minutes / 60); 27 + const mins = minutes % 60; 28 + if (hours > 0) { 29 + return `${hours}h ${mins.toString().padStart(2, "0")}m`; 30 + } 31 + return `${mins}m`; 32 + } 33 + 34 + // ── Color from string ───────────────────────────────────────────── 35 + export function getColorForString(str: string | null) { 36 + if (!str) return "#999999"; 37 + const hash = str.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0); 38 + const hue = hash % 360; 39 + return `hsl(${hue}, 65%, 50%)`; 40 + } 41 + 42 + // ── Activity pill config ────────────────────────────────────────── 43 + const PILL_CONFIG = { 44 + Hiking: { cls: "pill-green" }, 45 + Running: { cls: "pill-green" }, 46 + Cycling: { cls: "pill-brown" }, 47 + Kayaking: { cls: "pill-teal" }, 48 + } as const; 49 + 50 + export function getPillConfig(activityName: string) { 51 + return ( 52 + PILL_CONFIG[activityName as keyof typeof PILL_CONFIG] ?? { 53 + cls: "pill-gray", 54 + } 55 + ); 56 + } 57 + 58 + // ── Post filtering ──────────────────────────────────────────────── 59 + export function filterPosts(posts: Post[], filter: string) { 60 + if (filter === "all") return posts; 61 + if (filter === "other") return posts.filter((p) => p.activityType === null); 62 + return posts.filter((p) => p.activityType?.name?.toLowerCase() === filter); 63 + } 64 + 65 + // ── Skeleton card HTML ──────────────────────────────────────────── 66 + export function skeletonCards(n: number) { 67 + return Array.from( 68 + { length: n }, 69 + () => /*html*/ ` 70 + <div class="card"> 71 + <div class="card-header"> 72 + <div class="skeleton-circle"></div> 73 + <div class="skeleton-lines"> 74 + <div class="skeleton-line w60"></div> 75 + <div class="skeleton-line w40"></div> 76 + </div> 77 + </div> 78 + <div class="skeleton-map"></div> 79 + <div class="card-body"> 80 + <div class="skeleton-line w80" style="margin-bottom:8px"></div> 81 + <div class="skeleton-line w50"></div> 82 + </div> 83 + </div> 84 + `, 85 + ).join(""); 86 + }
+27
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "es2023", 4 + "module": "esnext", 5 + "lib": ["ES2023", "DOM", "DOM.Iterable"], 6 + "types": ["vite/client"], 7 + "skipLibCheck": true, 8 + 9 + /* Bundler mode */ 10 + "moduleResolution": "bundler", 11 + "allowImportingTsExtensions": true, 12 + "verbatimModuleSyntax": true, 13 + "moduleDetection": "force", 14 + "noEmit": true, 15 + 16 + /* Linting */ 17 + "noUnusedLocals": true, 18 + "noUnusedParameters": true, 19 + "erasableSyntaxOnly": true, 20 + "noFallthroughCasesInSwitch": true, 21 + 22 + /* lit decorators */ 23 + "experimentalDecorators": true, 24 + "useDefineForClassFields": false 25 + }, 26 + "include": ["src"] 27 + }
+15
vite.config.ts
··· 1 + import { viteStaticCopy } from "vite-plugin-static-copy"; 2 + 3 + export default { 4 + plugins: [ 5 + viteStaticCopy({ 6 + targets: [ 7 + { 8 + src: "src/oauth-client-metadata.json", 9 + dest: ".", 10 + rename: { stripBase: 1 }, // strips `bin/` 11 + }, 12 + ], 13 + }), 14 + ], 15 + };