source for getorbyt.com getorbyt.com/
client bsky orbytapp app orbyt bluesky getorbyt orbytvideo atproto video
0
fork

Configure Feed

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

first commit

+6106
.DS_Store

This is a binary file and will not be displayed.

+2
.gitignore
··· 1 + /README_LOCAL_TESTING.md 2 + /test-server.py
+288
404.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 + <title>Loading... | Orbyt</title> 7 + 8 + <!-- Favicon setup --> 9 + <link rel="apple-touch-icon" sizes="180x180" href="favicon_io/apple-touch-icon.png"> 10 + <link rel="icon" type="image/png" sizes="32x32" href="favicon_io/favicon-32x32.png"> 11 + <link rel="icon" type="image/png" sizes="16x16" href="favicon_io/favicon-16x16.png"> 12 + <link rel="icon" href="favicon_io/favicon.ico"> 13 + <link rel="manifest" href="favicon_io/site.webmanifest"> 14 + 15 + <!-- Theme colors for mobile browsers --> 16 + <meta name="theme-color" content="#000000"> 17 + <meta name="msapplication-TileColor" content="#000000"> 18 + <link rel="preconnect" href="https://fonts.googleapis.com"> 19 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 20 + <link href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;700;900&display=swap" rel="stylesheet"> 21 + <style> 22 + 23 + * { 24 + margin: 0; 25 + padding: 0; 26 + box-sizing: border-box; 27 + } 28 + 29 + body { 30 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 31 + background: #000000; 32 + min-height: 100vh; 33 + color: #f3f5fe; 34 + position: relative; 35 + overflow-x: hidden; 36 + overflow-y: auto; 37 + margin: 0; 38 + padding: 0; 39 + } 40 + 41 + /* Router content container */ 42 + #router-content { 43 + width: 100%; 44 + min-height: 100vh; 45 + } 46 + 47 + /* Loading state */ 48 + #router-loading { 49 + position: fixed; 50 + top: 0; 51 + left: 0; 52 + right: 0; 53 + bottom: 0; 54 + background: #000; 55 + display: flex; 56 + align-items: center; 57 + justify-content: center; 58 + color: #fff; 59 + font-family: -apple-system, BlinkMacSystemFont, sans-serif; 60 + z-index: 9999; 61 + } 62 + 63 + /* Error state */ 64 + #router-error, 65 + #router-404 { 66 + position: fixed; 67 + top: 0; 68 + left: 0; 69 + right: 0; 70 + bottom: 0; 71 + background: #000; 72 + display: flex; 73 + align-items: center; 74 + justify-content: center; 75 + color: #fff; 76 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 77 + flex-direction: column; 78 + gap: 20px; 79 + z-index: 9999; 80 + } 81 + 82 + 83 + 84 + .container { 85 + max-width: 800px; 86 + margin: 0 auto; 87 + padding: 2rem; 88 + min-height: 100vh; 89 + } 90 + 91 + .error-content { 92 + max-width: 600px; 93 + width: 100%; 94 + text-align: center; 95 + margin: 0 auto; 96 + padding-top: 20vh; 97 + } 98 + 99 + .error-number { 100 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 101 + font-size: 8rem; 102 + font-weight: 900; 103 + color: #f3f5fe; 104 + margin-bottom: 1rem; 105 + line-height: 1; 106 + text-shadow: 0 0 20px rgba(255, 255, 255, 0.3); 107 + } 108 + 109 + .error-title { 110 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 111 + font-size: 2.5rem; 112 + font-weight: 900; 113 + color: #f3f5fe; 114 + margin-bottom: 1rem; 115 + } 116 + 117 + .error-message { 118 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 119 + font-size: 1.25rem; 120 + color: #cfd6e8; 121 + opacity: 0.9; 122 + margin-bottom: 2rem; 123 + line-height: 1.6; 124 + } 125 + 126 + .navigation-buttons { 127 + display: flex; 128 + gap: 1rem; 129 + justify-content: center; 130 + flex-wrap: wrap; 131 + margin-bottom: 3rem; 132 + } 133 + 134 + .nav-button { 135 + display: inline-flex; 136 + background: #FFFFFF; 137 + color: #000000; 138 + text-decoration: none; 139 + padding: 12px 20px; 140 + border-radius: 999px; 141 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 142 + font-size: 16px; 143 + font-weight: 700; 144 + transition: all 0.3s ease; 145 + border: 1px solid #FFFFFF; 146 + align-items: center; 147 + justify-content: center; 148 + min-width: 140px; 149 + } 150 + 151 + .nav-button:hover { 152 + background: #F8F8F8; 153 + border-color: #E8E8E8; 154 + transform: translateY(-1px); 155 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); 156 + } 157 + 158 + .nav-button.secondary { 159 + background: transparent; 160 + color: #f3f5fe; 161 + border: 1px solid rgba(255, 255, 255, 0.3); 162 + } 163 + 164 + .nav-button.secondary:hover { 165 + background: rgba(255, 255, 255, 0.1); 166 + border-color: rgba(255, 255, 255, 0.5); 167 + } 168 + 169 + .outlink-icon { 170 + width: 16px; 171 + height: 16px; 172 + margin-left: 6px; 173 + opacity: 1; 174 + vertical-align: baseline; 175 + fill: currentColor; 176 + transition: all 0.3s ease; 177 + } 178 + 179 + .footer { 180 + text-align: left; 181 + white-space: nowrap; 182 + display: block; 183 + margin-bottom: 40px; 184 + } 185 + 186 + .footer a { 187 + margin: 0 20px 0 0; 188 + display: inline-block; 189 + font-style: normal; 190 + font-weight: bold; 191 + font-size: 13px; 192 + text-align: left; 193 + letter-spacing: 0.2em; 194 + text-transform: uppercase; 195 + text-decoration: none; 196 + color: #ccc; 197 + } 198 + 199 + .footer a:hover { 200 + color: #f3f5fe; 201 + } 202 + 203 + .separator { 204 + height: 20px; 205 + margin-top: 80px; 206 + margin-bottom: 40px; 207 + position: relative; 208 + overflow: hidden; 209 + display: flex; 210 + align-items: center; 211 + justify-content: center; 212 + } 213 + 214 + .separator svg { 215 + width: 100%; 216 + min-width: 200px; 217 + height: 20px; 218 + overflow: visible; 219 + } 220 + 221 + @media (max-width: 768px) { 222 + .container { 223 + padding: 1rem; 224 + } 225 + 226 + .error-number { 227 + font-size: 6rem; 228 + } 229 + 230 + .error-title { 231 + font-size: 2rem; 232 + } 233 + 234 + .error-message { 235 + font-size: 1.1rem; 236 + } 237 + 238 + .navigation-buttons { 239 + flex-direction: column; 240 + align-items: center; 241 + } 242 + 243 + .nav-button { 244 + width: 100%; 245 + max-width: 280px; 246 + } 247 + } 248 + 249 + @media (max-width: 480px) { 250 + .error-number { 251 + font-size: 4rem; 252 + } 253 + 254 + .error-title { 255 + font-size: 1.5rem; 256 + } 257 + 258 + .error-message { 259 + font-size: 1rem; 260 + } 261 + 262 + .separator { 263 + margin-top: 80px; 264 + margin-bottom: 40px; 265 + } 266 + 267 + .footer { 268 + white-space: normal; 269 + margin-bottom: 40px; 270 + text-align: center; 271 + } 272 + 273 + .footer a { 274 + display: block; 275 + margin: 8px 0 8px 0; 276 + text-align: center; 277 + } 278 + } 279 + </style> 280 + </head> 281 + <body> 282 + <!-- Router content will be loaded here --> 283 + <div id="router-content"></div> 284 + 285 + <!-- Router script --> 286 + <script type="module" src="/js/router.js"></script> 287 + </body> 288 + </html>
+1
CNAME
··· 1 + getorbyt.com
TV-Raw.png

This is a binary file and will not be displayed.

api/banners/3053370.png

This is a binary file and will not be displayed.

api/banners/andromeda-23596_512.gif

This is a binary file and will not be displayed.

api/banners/header_3.2.jpeg

This is a binary file and will not be displayed.

api/banners/warp.gif

This is a binary file and will not be displayed.

+16
api/channels.json
··· 1 + { 2 + "_doc": { 3 + "about": "Documentation for channels JSON. This object is ignored by the app; only 'channels' is used.", 4 + "fields": { 5 + "uri": "Complete feed generator URI" 6 + }, 7 + "localDevelopment": { 8 + "verifyLocalEndpoint": "curl -s http://localhost:5173/api/channels.json | cat@channels.json" 9 + } 10 + }, 11 + "channels": [ 12 + "at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/discover-video", 13 + "at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-videos", 14 + "at://did:plc:6i6n57nrkq6xavqbdo6bvkqr/app.bsky.feed.generator/og-videos-kps" 15 + ] 16 + }
+58
api/headers.json
··· 1 + { 2 + "_doc": { 3 + "about": "Documentation for header banner JSON. This object is ignored by the app; only 'headers' is used.", 4 + "fields": { 5 + "id": "Unique string identifier for the header", 6 + "imageUrl": "Image URL for the banner background. Can be absolute or relative (e.g., 'banners/header.png').", 7 + "destinationUrl": "URL to open when the banner is tapped", 8 + "title": "Primary text shown on the banner (optional)", 9 + "subtitle": "Secondary text shown on the banner (optional)", 10 + "titleColor": "Hex color for title text (e.g., '#000000')", 11 + "subtitleColor": "Hex color for subtitle text", 12 + "titleFontFamily": "Font family key loaded in app (e.g., 'Figtree')", 13 + "titleFontSize": "Number font size for title (e.g., 28)", 14 + "subtitleFontFamily": "Font family key for subtitle (e.g., 'Figtree')", 15 + "subtitleFontSize": "Number font size for subtitle (e.g., 16)", 16 + "titleOpacity": "Number between 0 and 1 for title opacity (e.g., 1)", 17 + "subtitleOpacity": "Number between 0 and 1 for subtitle opacity (e.g., 0.9)", 18 + "textOrder": "Order of text rendering: 'title-first' | 'subtitle-first'" 19 + }, 20 + "exampleHeader": { 21 + "id": "example-1", 22 + "imageUrl": "banners/header_3.2.png", 23 + "destinationUrl": "https://getorbyt.com", 24 + "title": "Orbyt", 25 + "subtitle": "a new video app for bluesky", 26 + "titleColor": "#000000", 27 + "subtitleColor": "#000000", 28 + "titleFontFamily": "Figtree", 29 + "titleFontSize": 28, 30 + "subtitleFontFamily": "Figtree", 31 + "subtitleFontSize": 16, 32 + "titleOpacity": 1, 33 + "subtitleOpacity": 0.9, 34 + "textOrder": "title-first" 35 + }, 36 + "localDevelopment": { 37 + "verifyLocalEndpoint": "curl -s http://localhost:5173/api/headers.json | cat@headers.json" 38 + } 39 + }, 40 + "headers": [ 41 + { 42 + "id": "discord-header", 43 + "imageUrl": "banners/3053370.png", 44 + "destinationUrl": "https://discord.gg/8gNQPFygnF", 45 + "title": null, 46 + "subtitle": "join the discord", 47 + "titleColor": "#000000", 48 + "subtitleColor": "#ffffff", 49 + "titleFontFamily": "Figtree", 50 + "titleFontSize": 28, 51 + "subtitleFontFamily": "Figtree", 52 + "subtitleFontSize": 20, 53 + "titleOpacity": 1, 54 + "subtitleOpacity": 0.9, 55 + "textOrder": "title-first" 56 + } 57 + ] 58 + }
+15
beta.html
··· 1 + <html> 2 + <head> 3 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"> 4 + <title>Join the waitlist</title> 5 + <script async src="https://tally.so/widgets/embed.js"></script> 6 + <style type="text/css"> 7 + html { margin: 0; height: 100%; overflow: hidden; background: #000000; } 8 + body { margin: 0; background: #000000; } 9 + iframe { position: absolute; top: 0; right: 0; bottom: 0; left: 0; border: 0; } 10 + </style> 11 + </head> 12 + <body> 13 + <iframe data-tally-src="https://tally.so/r/nWx7zQ?transparentBackground=1&formEventsForwarding=1" width="100%" height="100%" frameborder="0" marginheight="0" marginwidth="0" title="Join the waitlist"></iframe> 14 + </body> 15 + </html>
+93
callback.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 + <title>Orbyt OAuth Callback</title> 7 + <style> 8 + body { 9 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 10 + background-color: #000; 11 + color: #fff; 12 + display: flex; 13 + justify-content: center; 14 + align-items: center; 15 + min-height: 100vh; 16 + margin: 0; 17 + padding: 20px; 18 + } 19 + .container { 20 + text-align: center; 21 + max-width: 400px; 22 + } 23 + .logo { 24 + width: 80px; 25 + height: 80px; 26 + margin-bottom: 20px; 27 + } 28 + .title { 29 + font-size: 24px; 30 + font-weight: bold; 31 + margin-bottom: 10px; 32 + } 33 + .message { 34 + font-size: 16px; 35 + color: #ccc; 36 + margin-bottom: 20px; 37 + } 38 + .spinner { 39 + border: 2px solid #333; 40 + border-top: 2px solid #fff; 41 + border-radius: 50%; 42 + width: 30px; 43 + height: 30px; 44 + animation: spin 1s linear infinite; 45 + margin: 0 auto 20px; 46 + } 47 + @keyframes spin { 48 + 0% { transform: rotate(0deg); } 49 + 100% { transform: rotate(360deg); } 50 + } 51 + </style> 52 + </head> 53 + <body> 54 + <div class="container"> 55 + <img src="/orbyt-banner.png" alt="Orbyt" class="logo"> 56 + <div class="title">Orbyt</div> 57 + <div class="message">Completing authentication...</div> 58 + <div class="spinner"></div> 59 + <div class="message">You can close this window and return to the app.</div> 60 + </div> 61 + 62 + <script> 63 + // Extract URL parameters 64 + const urlParams = new URLSearchParams(window.location.search); 65 + const code = urlParams.get('code'); 66 + const state = urlParams.get('state'); 67 + const error = urlParams.get('error'); 68 + 69 + if (error) { 70 + // Handle OAuth error 71 + document.querySelector('.message').textContent = 'Authentication failed: ' + error; 72 + document.querySelector('.spinner').style.display = 'none'; 73 + } else if (code && state) { 74 + // Success - redirect back to app 75 + setTimeout(() => { 76 + // Try to redirect to the app using a custom URL scheme 77 + const appUrl = `orbyt://oauth/callback?code=${code}&state=${state}`; 78 + window.location.href = appUrl; 79 + 80 + // Fallback: show success message 81 + setTimeout(() => { 82 + document.querySelector('.message').textContent = 'Authentication successful! You can now return to the Orbyt app.'; 83 + document.querySelector('.spinner').style.display = 'none'; 84 + }, 1000); 85 + }, 1000); 86 + } else { 87 + // Invalid callback 88 + document.querySelector('.message').textContent = 'Invalid callback parameters.'; 89 + document.querySelector('.spinner').style.display = 'none'; 90 + } 91 + </script> 92 + </body> 93 + </html>
+184
css/embed.css
··· 1 + #post { 2 + margin: 0 auto; 3 + width: 100%; 4 + } 5 + #post.hidden { 6 + display: none !important; 7 + } 8 + #post-media { 9 + cursor: pointer; 10 + line-height: 0; 11 + position: relative; 12 + display: flex; 13 + flex-direction: column; 14 + align-items: center; 15 + justify-content: center; 16 + } 17 + .logo-overlay { 18 + box-sizing: border-box; 19 + display: flex; 20 + justify-content: space-between; 21 + padding: 0; 22 + position: absolute; 23 + top: 0; 24 + width: 100%; 25 + z-index: 10; 26 + } 27 + .post-overlay { 28 + background: linear-gradient(360deg, rgba(0,0,0,0.4) 0%, rgba(0, 0, 0, 0.2) 43.67%, rgba(0, 0, 0, 0) 96.43%); 29 + color: #ffffff; 30 + padding: 100px 20px 20px; 31 + position: absolute; 32 + bottom: 0; 33 + box-sizing: border-box; 34 + width: 100%; 35 + z-index: 10; 36 + } 37 + 38 + /*--- Video ---*/ 39 + video { 40 + width: 100%; 41 + height: auto; 42 + display: block; 43 + z-index: -1; 44 + aspect-ratio: 9 / 16; 45 + object-fit: contain; 46 + } 47 + 48 + /* Mobile: full width video with 9:16 aspect ratio */ 49 + @media screen and (max-width: 699px) { 50 + video { 51 + width: 100%; 52 + height: auto; 53 + max-width: 100%; 54 + aspect-ratio: 9 / 16; 55 + object-fit: contain; 56 + } 57 + } 58 + 59 + /* Desktop: video sizing handled by JS (postResizer), but ensure aspect ratio is maintained */ 60 + @media screen and (min-width: 700px) { 61 + video { 62 + width: auto; 63 + height: auto; 64 + aspect-ratio: 9 / 16; 65 + object-fit: contain; 66 + } 67 + } 68 + .mute-toggle-wrapper { 69 + display: flex; 70 + justify-content: center; 71 + margin-bottom: 30px; 72 + } 73 + #mute-toggle { 74 + background: rgba(0,0,0,0.5); 75 + border-radius: 35px; 76 + color: white; 77 + display: flex; 78 + line-height: 1; 79 + letter-spacing: 1.6px; 80 + font-weight: 900; 81 + font-size: 14px; 82 + justify-content: center; 83 + align-items: center; 84 + text-align: center; 85 + padding: 10px 14px; 86 + width: auto; 87 + margin: 0 auto; 88 + cursor: pointer; 89 + border: none; 90 + } 91 + #mute-toggle span { 92 + flex: auto; 93 + } 94 + #mute-toggle img { 95 + margin-right: 12px; 96 + } 97 + #unmuted { 98 + display: none; 99 + } 100 + 101 + /*--- Overlay ---*/ 102 + .logo { 103 + margin-top: 14px; 104 + margin-left: 14px; 105 + position: relative; 106 + } 107 + .logo img { 108 + width: 120px; 109 + } 110 + .logo a img { 111 + position: absolute; 112 + z-index: 2; 113 + } 114 + .logo .logo-shadow { 115 + position: absolute; 116 + top: 0; 117 + left: 0; 118 + z-index: 1; 119 + } 120 + 121 + .post-content { 122 + flex: 1; 123 + line-height: 28px; 124 + } 125 + .post-author { 126 + display: flex; 127 + align-items: center; 128 + margin: 8px 0 16px; 129 + line-height: 42px; 130 + min-width: 0; 131 + width: 100%; 132 + box-sizing: border-box; 133 + } 134 + .post-author .avatar { 135 + width: 42px; 136 + height: 42px; 137 + border-radius: 50%; 138 + margin-right: 10px; 139 + flex-shrink: 0; 140 + } 141 + .post-author .avatar-wrapper { 142 + display: flex; 143 + flex: 1; 144 + margin-right: 8px; 145 + min-width: 0; 146 + overflow: hidden; 147 + } 148 + .post-author .username { 149 + font-weight: 900; 150 + margin-right: 6px; 151 + min-width: 0; 152 + overflow: hidden; 153 + text-overflow: ellipsis; 154 + white-space: nowrap; 155 + } 156 + .post-author .username a { 157 + color: white; 158 + text-decoration: none; 159 + display: block; 160 + overflow: hidden; 161 + text-overflow: ellipsis; 162 + } 163 + .post-author .username a:active, 164 + .post-author .username a:hover, 165 + .post-author .username a:visited { 166 + color: white; 167 + text-decoration: none; 168 + } 169 + .likes { 170 + display: flex; 171 + align-items: center; 172 + line-height: 1; 173 + flex-shrink: 0; 174 + margin-left: auto; 175 + } 176 + .likes svg { 177 + margin-right: 6px; 178 + flex-shrink: 0; 179 + vertical-align: middle; 180 + } 181 + .likes span { 182 + line-height: 1; 183 + white-space: nowrap; 184 + }
+447
css/normalize.css
··· 1 + /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 + 3 + /** 4 + * 1. Set default font family to sans-serif. 5 + * 2. Prevent iOS text size adjust after orientation change, without disabling 6 + * user zoom. 7 + */ 8 + 9 + html { 10 + font-family: sans-serif; /* 1 */ 11 + -ms-text-size-adjust: 100%; /* 2 */ 12 + -webkit-text-size-adjust: 100%; /* 2 */ 13 + } 14 + 15 + /** 16 + * Remove default margin. 17 + */ 18 + 19 + body { 20 + margin: 0; 21 + } 22 + 23 + /* HTML5 display definitions 24 + ========================================================================== */ 25 + 26 + /** 27 + * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 + * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 + * and Firefox. 30 + * Correct `block` display not defined for `main` in IE 11. 31 + */ 32 + 33 + article, 34 + aside, 35 + details, 36 + figcaption, 37 + figure, 38 + footer, 39 + header, 40 + hgroup, 41 + main, 42 + menu, 43 + nav, 44 + section, 45 + summary { 46 + display: block; 47 + } 48 + 49 + /** 50 + * 1. Correct `inline-block` display not defined in IE 8/9. 51 + * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 + */ 53 + 54 + audio, 55 + canvas, 56 + progress, 57 + video { 58 + display: inline-block; /* 1 */ 59 + vertical-align: baseline; /* 2 */ 60 + } 61 + 62 + /** 63 + * Prevent modern browsers from displaying `audio` without controls. 64 + * Remove excess height in iOS 5 devices. 65 + */ 66 + 67 + audio:not([controls]) { 68 + display: none; 69 + height: 0; 70 + } 71 + 72 + /** 73 + * Address `[hidden]` styling not present in IE 8/9/10. 74 + * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 + */ 76 + 77 + [hidden], 78 + template { 79 + display: none; 80 + } 81 + 82 + /* Links 83 + ========================================================================== */ 84 + 85 + /** 86 + * Remove the gray background color from active links in IE 10. 87 + */ 88 + 89 + a { 90 + background-color: transparent; 91 + } 92 + 93 + /** 94 + * Improve readability when focused and also mouse hovered in all browsers. 95 + */ 96 + 97 + a:active, 98 + a:hover { 99 + outline: 0; 100 + } 101 + 102 + /* Text-level semantics 103 + ========================================================================== */ 104 + 105 + /** 106 + * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 + */ 108 + 109 + abbr[title] { 110 + border-bottom: 1px dotted; 111 + } 112 + 113 + /** 114 + * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 + */ 116 + 117 + b, 118 + strong { 119 + font-weight: bold; 120 + } 121 + 122 + /** 123 + * Address styling not present in Safari and Chrome. 124 + */ 125 + 126 + dfn { 127 + font-style: italic; 128 + } 129 + 130 + /** 131 + * Address variable `h1` font-size and margin within `section` and `article` 132 + * contexts in Firefox 4+, Safari, and Chrome. 133 + */ 134 + 135 + h1 { 136 + font-size: 2em; 137 + margin: 0.67em 0; 138 + } 139 + 140 + /** 141 + * Address styling not present in IE 8/9. 142 + */ 143 + 144 + mark { 145 + background: #ff0; 146 + color: #000; 147 + } 148 + 149 + /** 150 + * Address inconsistent and variable font size in all browsers. 151 + */ 152 + 153 + small { 154 + font-size: 80%; 155 + } 156 + 157 + /** 158 + * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 + */ 160 + 161 + sub, 162 + sup { 163 + font-size: 75%; 164 + line-height: 0; 165 + position: relative; 166 + vertical-align: baseline; 167 + } 168 + 169 + sup { 170 + top: -0.5em; 171 + } 172 + 173 + sub { 174 + bottom: -0.25em; 175 + } 176 + 177 + /* Embedded content 178 + ========================================================================== */ 179 + 180 + /** 181 + * Remove border when inside `a` element in IE 8/9/10. 182 + */ 183 + 184 + img { 185 + border: 0; 186 + } 187 + 188 + /** 189 + * Correct overflow not hidden in IE 9/10/11. 190 + */ 191 + 192 + svg:not(:root) { 193 + overflow: hidden; 194 + } 195 + 196 + /* Grouping content 197 + ========================================================================== */ 198 + 199 + /** 200 + * Address margin not present in IE 8/9 and Safari. 201 + */ 202 + 203 + figure { 204 + margin: 1em 40px; 205 + } 206 + 207 + /** 208 + * Address differences between Firefox and other browsers. 209 + */ 210 + 211 + hr { 212 + -moz-box-sizing: content-box; 213 + box-sizing: content-box; 214 + height: 0; 215 + } 216 + 217 + /** 218 + * Contain overflow in all browsers. 219 + */ 220 + 221 + pre { 222 + overflow: auto; 223 + } 224 + 225 + /** 226 + * Address odd `em`-unit font size rendering in all browsers. 227 + */ 228 + 229 + code, 230 + kbd, 231 + pre, 232 + samp { 233 + font-family: monospace, monospace; 234 + font-size: 1em; 235 + } 236 + 237 + /* Forms 238 + ========================================================================== */ 239 + 240 + /** 241 + * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 + * styling of `select`, unless a `border` property is set. 243 + */ 244 + 245 + /** 246 + * 1. Correct color not being inherited. 247 + * Known issue: affects color of disabled elements. 248 + * 2. Correct font properties not being inherited. 249 + * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 + */ 251 + 252 + button, 253 + input, 254 + optgroup, 255 + select, 256 + textarea { 257 + color: inherit; /* 1 */ 258 + font: inherit; /* 2 */ 259 + margin: 0; /* 3 */ 260 + } 261 + 262 + /** 263 + * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 + */ 265 + 266 + button { 267 + overflow: visible; 268 + } 269 + 270 + /** 271 + * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 + * All other form control elements do not inherit `text-transform` values. 273 + * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 + * Correct `select` style inheritance in Firefox. 275 + */ 276 + 277 + button, 278 + select { 279 + text-transform: none; 280 + } 281 + 282 + /** 283 + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 + * and `video` controls. 285 + * 2. Correct inability to style clickable `input` types in iOS. 286 + * 3. Improve usability and consistency of cursor style between image-type 287 + * `input` and others. 288 + */ 289 + 290 + button, 291 + html input[type="button"], /* 1 */ 292 + input[type="reset"], 293 + input[type="submit"] { 294 + -webkit-appearance: button; /* 2 */ 295 + cursor: pointer; /* 3 */ 296 + } 297 + 298 + /** 299 + * Re-set default cursor for disabled elements. 300 + */ 301 + 302 + button[disabled], 303 + html input[disabled] { 304 + cursor: default; 305 + } 306 + 307 + /** 308 + * Remove inner padding and border in Firefox 4+. 309 + */ 310 + 311 + button::-moz-focus-inner, 312 + input::-moz-focus-inner { 313 + border: 0; 314 + padding: 0; 315 + } 316 + 317 + /** 318 + * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 + * the UA stylesheet. 320 + */ 321 + 322 + input { 323 + line-height: normal; 324 + } 325 + 326 + /** 327 + * It's recommended that you don't attempt to style these elements. 328 + * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 + * 330 + * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 + * 2. Remove excess padding in IE 8/9/10. 332 + */ 333 + 334 + input[type="checkbox"], 335 + input[type="radio"] { 336 + box-sizing: border-box; /* 1 */ 337 + padding: 0; /* 2 */ 338 + } 339 + 340 + /** 341 + * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 + * `font-size` values of the `input`, it causes the cursor style of the 343 + * decrement button to change from `default` to `text`. 344 + */ 345 + 346 + input[type="number"]::-webkit-inner-spin-button, 347 + input[type="number"]::-webkit-outer-spin-button { 348 + height: auto; 349 + } 350 + 351 + /** 352 + * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 + * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 + * (include `-moz` to future-proof). 355 + */ 356 + 357 + input[type="search"] { 358 + -webkit-appearance: textfield; /* 1 */ 359 + -moz-box-sizing: content-box; 360 + -webkit-box-sizing: content-box; /* 2 */ 361 + box-sizing: content-box; 362 + } 363 + 364 + /** 365 + * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 + * Safari (but not Chrome) clips the cancel button when the search input has 367 + * padding (and `textfield` appearance). 368 + */ 369 + 370 + input[type="search"]::-webkit-search-cancel-button, 371 + input[type="search"]::-webkit-search-decoration { 372 + -webkit-appearance: none; 373 + } 374 + 375 + /** 376 + * Define consistent border, margin, and padding. 377 + */ 378 + 379 + fieldset { 380 + border: 1px solid #c0c0c0; 381 + margin: 0 2px; 382 + padding: 0.35em 0.625em 0.75em; 383 + } 384 + 385 + /** 386 + * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 + * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 + */ 389 + 390 + legend { 391 + border: 0; /* 1 */ 392 + padding: 0; /* 2 */ 393 + } 394 + 395 + /** 396 + * Remove default vertical scrollbar in IE 8/9/10/11. 397 + */ 398 + 399 + textarea { 400 + overflow: auto; 401 + } 402 + 403 + /** 404 + * Don't inherit the `font-weight` (applied by a rule above). 405 + * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 + */ 407 + 408 + optgroup { 409 + font-weight: bold; 410 + } 411 + 412 + /* Tables 413 + ========================================================================== */ 414 + 415 + /** 416 + * Remove most spacing between table cells. 417 + */ 418 + 419 + table { 420 + border-collapse: collapse; 421 + border-spacing: 0; 422 + } 423 + 424 + td, 425 + th { 426 + padding: 0; 427 + } 428 + /* 429 + FILE ARCHIVED ON 19:49:17 May 21, 2020 AND RETRIEVED FROM THE 430 + INTERNET ARCHIVE ON 06:35:48 Jan 10, 2026. 431 + JAVASCRIPT APPENDED BY WAYBACK MACHINE, COPYRIGHT INTERNET ARCHIVE. 432 + 433 + ALL OTHER CONTENT MAY ALSO BE PROTECTED BY COPYRIGHT (17 U.S.C. 434 + SECTION 108(a)(3)). 435 + */ 436 + /* 437 + playback timings (ms): 438 + captures_list: 0.819 439 + exclusion.robots: 0.038 440 + exclusion.robots.policy: 0.024 441 + esindex: 0.011 442 + cdx.remote: 9.199 443 + LoadShardBlock: 225.467 (3) 444 + PetaboxLoader3.resolve: 66.52 (3) 445 + PetaboxLoader3.datanode: 224.711 (4) 446 + load_resource: 73.17 447 + */
+69
css/post.css
··· 1 + /*--- Responsive styles ---*/ 2 + @media screen and (max-width: 700px) { 3 + #desktop { 4 + display: none; 5 + } 6 + } 7 + 8 + @media screen and (min-width: 700px) { 9 + .mobile-overlay { 10 + display: none; 11 + } 12 + #post { 13 + padding: 30px; 14 + display: flex; 15 + justify-content: center; 16 + } 17 + #desktop { 18 + box-sizing: border-box; 19 + display: flex; 20 + flex-direction: column; 21 + padding-left: 30px; 22 + padding-right: 60px; 23 + justify-content: flex-end; 24 + } 25 + .horizontal-rule { 26 + height: 10px; 27 + width: 100%; 28 + background-repeat: repeat-x; 29 + background-position: center; 30 + margin: 20px 0 30px; 31 + } 32 + .desktop-title { 33 + display: flex; 34 + flex-direction: row; 35 + flex-wrap: wrap; 36 + justify-content: space-between; 37 + align-items: center; 38 + } 39 + .logo { 40 + margin-left: -10px; 41 + margin-top: 0; 42 + line-height: 0; 43 + } 44 + .logo a img { 45 + width: 159px; 46 + position: relative; 47 + } 48 + .post-author { 49 + flex-wrap: wrap; 50 + margin-bottom: 0; 51 + max-width: 374px; 52 + } 53 + #post-caption-desktop { 54 + max-width: 374px; 55 + } 56 + .install-button { 57 + margin-right: 0; 58 + margin-top: 0; 59 + font-size: 12px; 60 + } 61 + video { 62 + border-radius: 10px; 63 + } 64 + } 65 + @media screen and (min-width: 800px) { 66 + .install-button { 67 + font-size: 16px; 68 + } 69 + }
+262
css/profile.css
··· 1 + html, body { 2 + overflow-x: hidden; 3 + } 4 + body { 5 + width: 100%; 6 + } 7 + header { 8 + padding: 20px; 9 + margin-bottom: 2px; 10 + } 11 + .desktop { 12 + display: none; 13 + } 14 + 15 + /* Links */ 16 + .install-button { 17 + margin: 0; 18 + } 19 + .svg-button { 20 + display: inline-block; 21 + width: 16px; 22 + height: 16px; 23 + flex-shrink: 0; 24 + margin-left: 8px; 25 + } 26 + .links { 27 + display: flex; 28 + justify-content: space-between; 29 + align-items: center; 30 + line-height: 0; 31 + margin-bottom: 20px; 32 + flex-wrap: wrap; 33 + } 34 + .hr-inline { 35 + width: 100%; 36 + margin-bottom: 20px; 37 + } 38 + 39 + /* Profile info */ 40 + .author .avatar { 41 + width: 100px; 42 + border-radius: 50%; 43 + border: 2px solid; 44 + } 45 + .author h1 { 46 + font-weight: 900; 47 + font-size: 30px; 48 + letter-spacing: 0.6px 49 + } 50 + .author .bio { 51 + white-space: pre-wrap; 52 + line-height: 1.35; 53 + } 54 + 55 + /* Profile posts */ 56 + .posts { 57 + padding-bottom: 20px; 58 + display: flex; 59 + flex-wrap: wrap; 60 + } 61 + .post { 62 + display: flex; 63 + position: relative; 64 + width: 50%; 65 + margin-bottom: 2px; 66 + box-sizing: border-box; 67 + color: white; 68 + align-items: flex-end; 69 + padding: 0; 70 + overflow: hidden; 71 + } 72 + .post a { 73 + width: 100%; 74 + height: 100%; 75 + position: absolute; 76 + display: flex; 77 + left: 0; 78 + top: 0; 79 + align-items: center; 80 + justify-content: center; 81 + z-index: 10; 82 + } 83 + .post .post-image-container { 84 + width: 100%; 85 + position: relative; 86 + line-height: 0; 87 + padding-bottom: 177.78%; 88 + } 89 + .post img { 90 + width: 100%; 91 + } 92 + .post img.post-placeholder { 93 + width: 100%; 94 + position: absolute; 95 + left: 0; 96 + top: 0; 97 + opacity: 0; 98 + } 99 + .post img.post-thumb { 100 + width: 100%; 101 + position: absolute; 102 + left: 0; 103 + top: 0; 104 + height: 100%; 105 + object-fit: cover; 106 + } 107 + .post .play-button { 108 + width: 60px; 109 + height: 60px; 110 + opacity: 0.8; 111 + } 112 + .post-overlay { 113 + position: absolute; 114 + width: 100%; 115 + display: flex; 116 + align-items: flex-end; 117 + background: linear-gradient(360deg, rgba(0,0,0,0.4) 0%, rgba(0, 0, 0, 0.2) 43.67%, rgba(0, 0, 0, 0) 96.43%); 118 + padding-top: 20px; 119 + } 120 + .post-overlay .caption { 121 + font-size: 12px; 122 + line-height: 1.3; 123 + padding: 10px; 124 + } 125 + footer { 126 + display: flex; 127 + align-items: center; 128 + justify-content: center; 129 + width: 100%; 130 + margin-top: 30px; 131 + margin-bottom: 50px; 132 + } 133 + #load-more { 134 + background: #888; 135 + border-radius: 30px; 136 + color: white; 137 + cursor: pointer; 138 + padding: 5px 20px; 139 + text-decoration: none; 140 + } 141 + #load-more:hover, 142 + #load-more:active, 143 + #load-more:visited { 144 + color: white; 145 + text-decoration: none; 146 + } 147 + 148 + .loading { 149 + padding: 20px; 150 + text-align: center; 151 + color: #B1BACF; 152 + } 153 + 154 + /*--- Responsive styles ---*/ 155 + @media screen and (max-width: 540px) { 156 + .post:nth-child(2n-1) { 157 + padding-right: 1px; 158 + } 159 + .post:nth-child(2n) { 160 + padding-left: 1px; 161 + } 162 + } 163 + 164 + @media screen and (min-width: 540px) { 165 + .post { 166 + width: 33.3%; 167 + padding-left: 1px; 168 + padding-right: 1px; 169 + } 170 + } 171 + 172 + @media screen and (min-width: 760px) { 173 + #profile { 174 + max-width: 1300px; 175 + margin: 0 auto; 176 + } 177 + 178 + .mobile { 179 + display: none; 180 + } 181 + .desktop { 182 + display: inherit; 183 + } 184 + 185 + header { 186 + box-sizing: border-box; 187 + width: 200px; 188 + height: 100%; 189 + position: fixed 190 + } 191 + 192 + .hr-inline { 193 + margin-top: 20px; 194 + margin-bottom: 30px; 195 + } 196 + 197 + .install-button-wrapper { 198 + display: flex; 199 + } 200 + 201 + .posts { 202 + margin-left: 200px; 203 + padding: 20px 10px; 204 + } 205 + 206 + .post img { 207 + border-radius: 10px; 208 + } 209 + 210 + .post { 211 + padding-left: 10px; 212 + padding-right: 10px; 213 + padding-bottom: 20px; 214 + } 215 + 216 + .post-overlay { 217 + box-sizing: border-box; 218 + padding-right: 20px; 219 + } 220 + .post-overlay .caption { 221 + font-size: 14px; 222 + line-height: 1.3; 223 + } 224 + } 225 + 226 + @media screen and (min-width: 900px) { 227 + header { 228 + width: 300px; 229 + } 230 + 231 + .posts { 232 + margin-left: 300px; 233 + } 234 + 235 + .post-overlay .caption { 236 + font-size: 16px; 237 + line-height: 1.3; 238 + padding: 15px; 239 + } 240 + 241 + } 242 + 243 + /* 244 + FILE ARCHIVED ON 19:49:23 May 21, 2020 AND RETRIEVED FROM THE 245 + INTERNET ARCHIVE ON 06:35:49 Jan 10, 2026. 246 + JAVASCRIPT APPENDED BY WAYBACK MACHINE, COPYRIGHT INTERNET ARCHIVE. 247 + 248 + ALL OTHER CONTENT MAY ALSO BE PROTECTED BY COPYRIGHT (17 U.S.C. 249 + SECTION 108(a)(3)). 250 + */ 251 + /* 252 + playback timings (ms): 253 + captures_list: 0.715 254 + exclusion.robots: 0.033 255 + exclusion.robots.policy: 0.021 256 + esindex: 0.01 257 + cdx.remote: 214.886 258 + LoadShardBlock: 96.739 (3) 259 + PetaboxLoader3.datanode: 110.514 (4) 260 + load_resource: 160.67 261 + PetaboxLoader3.resolve: 130.334 262 + */
+40
css/shared.css
··· 1 + body { 2 + background: #000; 3 + color: #B1BACF; 4 + font-family: 'Figtree', sans-serif; 5 + line-height: 28px; 6 + } 7 + 8 + .install-button { 9 + align-items: center; 10 + background: #F4F7FF; 11 + border-radius: 31px; 12 + box-shadow: 0px 6px 10px rgba(7, 25, 72, 0.13); 13 + color: black; 14 + display: flex; 15 + font-size: 16px; 16 + font-weight: normal; 17 + height: 40px; 18 + letter-spacing: 0.6px; 19 + margin-right: 20px; 20 + margin-top: 20px; 21 + padding: 0 14px; 22 + text-decoration: none; 23 + } 24 + .install-button img { 25 + margin-left: 8px; 26 + width: 18px; 27 + height: 18px; 28 + } 29 + .install-button:hover, 30 + .install-button:active { 31 + color: black; 32 + } 33 + 34 + .horizontal-rule { 35 + height: 10px; 36 + width: 100%; 37 + background-repeat: repeat-x; 38 + background-position: center; 39 + margin: 20px 0 30px; 40 + }
+438
css/skeleton.css
··· 1 + /* 2 + * Skeleton V2.0.4 3 + * Copyright 2014, Dave Gamache 4 + * www.getskeleton.com 5 + * Free to use under the MIT license. 6 + * http://www.opensource.org/licenses/mit-license.php 7 + * 12/29/2014 8 + */ 9 + 10 + 11 + /* Table of contents 12 + –––––––––––––––––––––––––––––––––––––––––––––––––– 13 + - Grid 14 + - Base Styles 15 + - Typography 16 + - Links 17 + - Buttons 18 + - Forms 19 + - Lists 20 + - Code 21 + - Tables 22 + - Spacing 23 + - Utilities 24 + - Clearing 25 + - Media Queries 26 + */ 27 + 28 + 29 + /* Grid 30 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 31 + .container { 32 + position: relative; 33 + width: 100%; 34 + max-width: 960px; 35 + margin: 0 auto; 36 + padding: 0 20px; 37 + box-sizing: border-box; } 38 + .column, 39 + .columns { 40 + width: 100%; 41 + float: left; 42 + box-sizing: border-box; } 43 + 44 + /* For devices larger than 400px */ 45 + @media (min-width: 400px) { 46 + .container { 47 + width: 85%; 48 + padding: 0; } 49 + } 50 + 51 + /* For devices larger than 550px */ 52 + @media (min-width: 550px) { 53 + .container { 54 + width: 80%; } 55 + .column, 56 + .columns { 57 + margin-left: 4%; } 58 + .column:first-child, 59 + .columns:first-child { 60 + margin-left: 0; } 61 + 62 + .one.column, 63 + .one.columns { width: 4.66666666667%; } 64 + .two.columns { width: 13.3333333333%; } 65 + .three.columns { width: 22%; } 66 + .four.columns { width: 30.6666666667%; } 67 + .five.columns { width: 39.3333333333%; } 68 + .six.columns { width: 48%; } 69 + .seven.columns { width: 56.6666666667%; } 70 + .eight.columns { width: 65.3333333333%; } 71 + .nine.columns { width: 74.0%; } 72 + .ten.columns { width: 82.6666666667%; } 73 + .eleven.columns { width: 91.3333333333%; } 74 + .twelve.columns { width: 100%; margin-left: 0; } 75 + 76 + .one-third.column { width: 30.6666666667%; } 77 + .two-thirds.column { width: 65.3333333333%; } 78 + 79 + .one-half.column { width: 48%; } 80 + 81 + /* Offsets */ 82 + .offset-by-one.column, 83 + .offset-by-one.columns { margin-left: 8.66666666667%; } 84 + .offset-by-two.column, 85 + .offset-by-two.columns { margin-left: 17.3333333333%; } 86 + .offset-by-three.column, 87 + .offset-by-three.columns { margin-left: 26%; } 88 + .offset-by-four.column, 89 + .offset-by-four.columns { margin-left: 34.6666666667%; } 90 + .offset-by-five.column, 91 + .offset-by-five.columns { margin-left: 43.3333333333%; } 92 + .offset-by-six.column, 93 + .offset-by-six.columns { margin-left: 52%; } 94 + .offset-by-seven.column, 95 + .offset-by-seven.columns { margin-left: 60.6666666667%; } 96 + .offset-by-eight.column, 97 + .offset-by-eight.columns { margin-left: 69.3333333333%; } 98 + .offset-by-nine.column, 99 + .offset-by-nine.columns { margin-left: 78.0%; } 100 + .offset-by-ten.column, 101 + .offset-by-ten.columns { margin-left: 86.6666666667%; } 102 + .offset-by-eleven.column, 103 + .offset-by-eleven.columns { margin-left: 95.3333333333%; } 104 + 105 + .offset-by-one-third.column, 106 + .offset-by-one-third.columns { margin-left: 34.6666666667%; } 107 + .offset-by-two-thirds.column, 108 + .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } 109 + 110 + .offset-by-one-half.column, 111 + .offset-by-one-half.columns { margin-left: 52%; } 112 + 113 + } 114 + 115 + 116 + /* Base Styles 117 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 118 + /* NOTE 119 + html is set to 62.5% so that all the REM measurements throughout Skeleton 120 + are based on 10px sizing. So basically 1.5rem = 15px :) */ 121 + html { 122 + font-size: 62.5%; } 123 + body { 124 + font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ 125 + line-height: 1.6; 126 + font-weight: 400; 127 + font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; 128 + color: #222; } 129 + 130 + 131 + /* Typography 132 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 133 + h1, h2, h3, h4, h5, h6 { 134 + margin-top: 0; 135 + margin-bottom: 2rem; 136 + font-weight: 300; } 137 + h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} 138 + h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } 139 + h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } 140 + h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } 141 + h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } 142 + h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } 143 + 144 + /* Larger than phablet */ 145 + @media (min-width: 550px) { 146 + h1 { font-size: 5.0rem; } 147 + h2 { font-size: 4.2rem; } 148 + h3 { font-size: 3.6rem; } 149 + h4 { font-size: 3.0rem; } 150 + h5 { font-size: 2.4rem; } 151 + h6 { font-size: 1.5rem; } 152 + } 153 + 154 + p { 155 + margin-top: 0; } 156 + 157 + 158 + /* Links 159 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 160 + a { 161 + color: #1EAEDB; } 162 + a:hover { 163 + color: #0FA0CE; } 164 + 165 + 166 + /* Buttons 167 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 168 + .button, 169 + button, 170 + input[type="submit"], 171 + input[type="reset"], 172 + input[type="button"] { 173 + display: inline-block; 174 + height: 38px; 175 + padding: 0 30px; 176 + color: #555; 177 + text-align: center; 178 + font-size: 11px; 179 + font-weight: 600; 180 + line-height: 38px; 181 + letter-spacing: .1rem; 182 + text-transform: uppercase; 183 + text-decoration: none; 184 + white-space: nowrap; 185 + background-color: transparent; 186 + border-radius: 4px; 187 + border: 1px solid #bbb; 188 + cursor: pointer; 189 + box-sizing: border-box; } 190 + .button:hover, 191 + button:hover, 192 + input[type="submit"]:hover, 193 + input[type="reset"]:hover, 194 + input[type="button"]:hover, 195 + .button:focus, 196 + button:focus, 197 + input[type="submit"]:focus, 198 + input[type="reset"]:focus, 199 + input[type="button"]:focus { 200 + color: #333; 201 + border-color: #888; 202 + outline: 0; } 203 + .button.button-primary, 204 + button.button-primary, 205 + input[type="submit"].button-primary, 206 + input[type="reset"].button-primary, 207 + input[type="button"].button-primary { 208 + color: #FFF; 209 + background-color: #33C3F0; 210 + border-color: #33C3F0; } 211 + .button.button-primary:hover, 212 + button.button-primary:hover, 213 + input[type="submit"].button-primary:hover, 214 + input[type="reset"].button-primary:hover, 215 + input[type="button"].button-primary:hover, 216 + .button.button-primary:focus, 217 + button.button-primary:focus, 218 + input[type="submit"].button-primary:focus, 219 + input[type="reset"].button-primary:focus, 220 + input[type="button"].button-primary:focus { 221 + color: #FFF; 222 + background-color: #1EAEDB; 223 + border-color: #1EAEDB; } 224 + 225 + 226 + /* Forms 227 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 228 + input[type="email"], 229 + input[type="number"], 230 + input[type="search"], 231 + input[type="text"], 232 + input[type="tel"], 233 + input[type="url"], 234 + input[type="password"], 235 + textarea, 236 + select { 237 + height: 38px; 238 + padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ 239 + background-color: #fff; 240 + border: 1px solid #D1D1D1; 241 + border-radius: 4px; 242 + box-shadow: none; 243 + box-sizing: border-box; } 244 + /* Removes awkward default styles on some inputs for iOS */ 245 + input[type="email"], 246 + input[type="number"], 247 + input[type="search"], 248 + input[type="text"], 249 + input[type="tel"], 250 + input[type="url"], 251 + input[type="password"], 252 + textarea { 253 + -webkit-appearance: none; 254 + -moz-appearance: none; 255 + appearance: none; } 256 + textarea { 257 + min-height: 65px; 258 + padding-top: 6px; 259 + padding-bottom: 6px; } 260 + input[type="email"]:focus, 261 + input[type="number"]:focus, 262 + input[type="search"]:focus, 263 + input[type="text"]:focus, 264 + input[type="tel"]:focus, 265 + input[type="url"]:focus, 266 + input[type="password"]:focus, 267 + textarea:focus, 268 + select:focus { 269 + border: 1px solid #33C3F0; 270 + outline: 0; } 271 + label, 272 + legend { 273 + display: block; 274 + margin-bottom: .5rem; 275 + font-weight: 600; } 276 + fieldset { 277 + padding: 0; 278 + border-width: 0; } 279 + input[type="checkbox"], 280 + input[type="radio"] { 281 + display: inline; } 282 + label > .label-body { 283 + display: inline-block; 284 + margin-left: .5rem; 285 + font-weight: normal; } 286 + 287 + 288 + /* Lists 289 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 290 + ul { 291 + list-style: circle inside; } 292 + ol { 293 + list-style: decimal inside; } 294 + ol, ul { 295 + padding-left: 0; 296 + margin-top: 0; } 297 + ul ul, 298 + ul ol, 299 + ol ol, 300 + ol ul { 301 + margin: 1.5rem 0 1.5rem 3rem; 302 + font-size: 90%; } 303 + li { 304 + margin-bottom: 1rem; } 305 + 306 + 307 + /* Code 308 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 309 + code { 310 + padding: .2rem .5rem; 311 + margin: 0 .2rem; 312 + font-size: 90%; 313 + white-space: nowrap; 314 + background: #F1F1F1; 315 + border: 1px solid #E1E1E1; 316 + border-radius: 4px; } 317 + pre > code { 318 + display: block; 319 + padding: 1rem 1.5rem; 320 + white-space: pre; } 321 + 322 + 323 + /* Tables 324 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 325 + th, 326 + td { 327 + padding: 12px 15px; 328 + text-align: left; 329 + border-bottom: 1px solid #E1E1E1; } 330 + th:first-child, 331 + td:first-child { 332 + padding-left: 0; } 333 + th:last-child, 334 + td:last-child { 335 + padding-right: 0; } 336 + 337 + 338 + /* Spacing 339 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 340 + button, 341 + .button { 342 + margin-bottom: 1rem; } 343 + input, 344 + textarea, 345 + select, 346 + fieldset { 347 + margin-bottom: 1.5rem; } 348 + pre, 349 + blockquote, 350 + dl, 351 + figure, 352 + table, 353 + p, 354 + ul, 355 + ol, 356 + form { 357 + margin-bottom: 2.5rem; } 358 + 359 + 360 + /* Utilities 361 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 362 + .u-full-width { 363 + width: 100%; 364 + box-sizing: border-box; } 365 + .u-max-full-width { 366 + max-width: 100%; 367 + box-sizing: border-box; } 368 + .u-pull-right { 369 + float: right; } 370 + .u-pull-left { 371 + float: left; } 372 + 373 + 374 + /* Misc 375 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 376 + hr { 377 + margin-top: 3rem; 378 + margin-bottom: 3.5rem; 379 + border-width: 0; 380 + border-top: 1px solid #E1E1E1; } 381 + 382 + 383 + /* Clearing 384 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 385 + 386 + /* Self Clearing Goodness */ 387 + .container:after, 388 + .row:after, 389 + .u-cf { 390 + content: ""; 391 + display: table; 392 + clear: both; } 393 + 394 + 395 + /* Media Queries 396 + –––––––––––––––––––––––––––––––––––––––––––––––––– */ 397 + /* 398 + Note: The best way to structure the use of media queries is to create the queries 399 + near the relevant code. For example, if you wanted to change the styles for buttons 400 + on small devices, paste the mobile query code up in the buttons section and style it 401 + there. 402 + */ 403 + 404 + 405 + /* Larger than mobile */ 406 + @media (min-width: 400px) {} 407 + 408 + /* Larger than phablet (also point when grid becomes active) */ 409 + @media (min-width: 550px) {} 410 + 411 + /* Larger than tablet */ 412 + @media (min-width: 750px) {} 413 + 414 + /* Larger than desktop */ 415 + @media (min-width: 1000px) {} 416 + 417 + /* Larger than Desktop HD */ 418 + @media (min-width: 1200px) {} 419 + 420 + /* 421 + FILE ARCHIVED ON 19:49:23 May 21, 2020 AND RETRIEVED FROM THE 422 + INTERNET ARCHIVE ON 06:35:49 Jan 10, 2026. 423 + JAVASCRIPT APPENDED BY WAYBACK MACHINE, COPYRIGHT INTERNET ARCHIVE. 424 + 425 + ALL OTHER CONTENT MAY ALSO BE PROTECTED BY COPYRIGHT (17 U.S.C. 426 + SECTION 108(a)(3)). 427 + */ 428 + /* 429 + playback timings (ms): 430 + captures_list: 0.716 431 + exclusion.robots: 0.099 432 + exclusion.robots.policy: 0.087 433 + esindex: 0.01 434 + cdx.remote: 111.482 435 + LoadShardBlock: 133.896 (3) 436 + PetaboxLoader3.datanode: 189.835 (4) 437 + load_resource: 58.238 438 + */
favicon_io/android-chrome-192x192.png

This is a binary file and will not be displayed.

favicon_io/android-chrome-512x512.png

This is a binary file and will not be displayed.

favicon_io/apple-touch-icon.png

This is a binary file and will not be displayed.

favicon_io/favicon-16x16.png

This is a binary file and will not be displayed.

favicon_io/favicon-32x32.png

This is a binary file and will not be displayed.

favicon_io/favicon.ico

This is a binary file and will not be displayed.

+1
favicon_io/site.webmanifest
··· 1 + {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
images/Default-avatar.png

This is a binary file and will not be displayed.

images/post/placeholder.png

This is a binary file and will not be displayed.

+4
images/post/play.svg
··· 1 + <svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <circle cx="30" cy="30" r="30" fill="rgba(255, 255, 255, 0.9)"/> 3 + <path d="M24 18L42 30L24 42V18Z" fill="#000"/> 4 + </svg>
+302
index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta name="google-site-verification" content="4xaimUg28uLCqKwsPNIz6QeG_tfnLlAdHgS5bC_90JU"/> 5 + <meta charset="utf-8"> 6 + <title>orbyt - video communities</title> 7 + <meta name="description" content="a new video app for bluesky"> 8 + <meta name="viewport" content="width=device-width, initial-scale=1"> 9 + 10 + <meta property="og:title" content="orbyt - video communities"/> 11 + <meta property="og:description" content="a new video app for bluesky"/> 12 + <meta property="og:type" content="website"/> 13 + <meta property="og:url" content="https://getorbyt.com/"/> 14 + <meta property="og:image" content="https://getorbyt.com/orbyt-banner.png"/> 15 + 16 + <link href="https://fonts.googleapis.com/css?family=Montserrat:400,500,700&display=swap" rel="stylesheet"> 17 + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"> 18 + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css"> 19 + 20 + <link rel="icon" type="image/png" href="favicon_io/favicon.png"> 21 + <link rel="apple-touch-icon" sizes="180x180" href="favicon_io/apple-touch-icon.png"> 22 + <link rel="icon" type="image/png" sizes="32x32" href="favicon_io/favicon-32x32.png"> 23 + <link rel="icon" type="image/png" sizes="16x16" href="favicon_io/favicon-16x16.png"> 24 + <link rel="icon" href="favicon_io/favicon.ico"> 25 + <link rel="manifest" href="favicon_io/site.webmanifest"> 26 + 27 + <link rel="preconnect" href="https://fonts.googleapis.com"> 28 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 29 + <link href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;900&display=swap" rel="stylesheet"> 30 + <style> 31 + 32 + a:hover { 33 + color: #fff; 34 + } 35 + 36 + html, body { 37 + margin: 0; 38 + padding: 0; 39 + height: 100%; 40 + min-height: 100vh; 41 + } 42 + 43 + body { 44 + background: #000 url(/orbyt-banner.png); 45 + background-repeat: no-repeat; 46 + background-size: 100%; 47 + font-family: 'Montserrat', sans-serif; 48 + position: relative; 49 + display: flex; 50 + align-items: center; 51 + justify-content: center; 52 + } 53 + 54 + .container { 55 + position: relative; 56 + width: 100%; 57 + max-width: 1200px; 58 + padding: 20px; 59 + min-height: 100vh; 60 + display: flex; 61 + flex-direction: column; 62 + } 63 + 64 + .main-content { 65 + flex: 1; 66 + display: flex; 67 + flex-direction: column; 68 + justify-content: center; 69 + } 70 + 71 + .download-button { 72 + background: #551DEF; 73 + border: 2px solid #000000; 74 + border-radius: 30px; 75 + height: 60px; 76 + font-weight: bold; 77 + font-size: 23px; 78 + line-height: 28px; 79 + display: flex; 80 + align-items: center; 81 + justify-content: center; 82 + text-align: center; 83 + text-decoration: none; 84 + color: #E0EBFF; 85 + max-width: 500px; 86 + margin: 0 auto 10px auto; 87 + } 88 + 89 + .row.first { 90 + margin-top: 0; 91 + margin-bottom: 0; 92 + } 93 + 94 + .row.buttons { 95 + text-align: center; 96 + margin-top: 40px; 97 + } 98 + 99 + .logo { 100 + text-align: center; 101 + } 102 + 103 + .logo-icon { 104 + width: 140px; 105 + height: auto; 106 + display: block; 107 + margin: 0 auto 0px auto; 108 + } 109 + 110 + .logo-text { 111 + font-family: 'Figtree', sans-serif; 112 + font-weight: 900; 113 + font-size: 48px; 114 + color: #f3f5fe; 115 + text-align: center; 116 + display: block; 117 + margin: 0 auto; 118 + } 119 + 120 + .motto { 121 + margin-top: 10px; 122 + margin-bottom: 0; 123 + } 124 + 125 + .motto div { 126 + text-align: center; 127 + } 128 + 129 + .motto-text { 130 + font-family: 'Figtree', sans-serif; 131 + font-weight: 500; 132 + font-size: 28px; 133 + color: #01f5b3; 134 + text-align: center; 135 + display: block; 136 + margin: 20px auto; 137 + letter-spacing: -0.02em; 138 + } 139 + 140 + .separator { 141 + height: 7px; 142 + margin-bottom: 40px; 143 + position: relative; 144 + overflow: hidden; 145 + display: flex; 146 + align-items: center; 147 + justify-content: flex-start; 148 + width: 100%; 149 + } 150 + 151 + .separator svg { 152 + width: 100%; 153 + height: 7px; 154 + } 155 + 156 + .footer { 157 + text-align: left; 158 + white-space: nowrap; 159 + display: block; 160 + margin-top: auto; 161 + margin-bottom: 40px; 162 + } 163 + 164 + .footer a { 165 + margin: 0 20px 0 0; 166 + display: inline-block; 167 + font-style: normal; 168 + font-weight: bold; 169 + font-size: 13px; 170 + text-align: left; 171 + letter-spacing: 0.2em; 172 + text-transform: uppercase; 173 + text-decoration: none; 174 + color: #ccc; 175 + } 176 + 177 + .footer a:hover { 178 + color: #fff; 179 + } 180 + 181 + @media (max-width: 500px) { 182 + .row.first { 183 + margin-top: 0; 184 + } 185 + 186 + body { 187 + background: #000 url(/orbyt-banner.png); 188 + background-repeat: no-repeat; 189 + background-size: 180%, 180%, cover; 190 + background-position-x: 50%; 191 + background-position-y: -10%; 192 + } 193 + 194 + .motto { 195 + margin-top: 10px; 196 + margin-bottom: 0; 197 + } 198 + 199 + .motto-text { 200 + font-size: 22px; 201 + } 202 + 203 + .row.buttons { 204 + margin-top: 30px; 205 + } 206 + 207 + .logo-icon { 208 + width: 100px; 209 + height: auto; 210 + } 211 + 212 + .logo-text { 213 + font-size: 36px; 214 + } 215 + 216 + .download-button { 217 + height: 50px; 218 + font-size: 20px; 219 + margin-bottom: 15px; 220 + } 221 + 222 + .separator { 223 + margin-bottom: 40px; 224 + } 225 + 226 + .footer { 227 + white-space: normal; 228 + margin-bottom: 40px; 229 + text-align: center; 230 + } 231 + 232 + .footer a { 233 + display: block; 234 + margin: 8px 0 8px 0; 235 + text-align: center; 236 + } 237 + } 238 + 239 + @media (min-width: 1500px) { 240 + body { 241 + background-position-y: -100px; 242 + } 243 + } 244 + 245 + @media (min-width: 1800px) { 246 + body { 247 + background-position-y: -200px; 248 + } 249 + } 250 + </style> 251 + </head> 252 + 253 + <body> 254 + <div class="container"> 255 + <div class="main-content"> 256 + <div class="row first"> 257 + <div class="logo twelve columns"> 258 + <img src="TV-Raw.png" alt="orbyt logo" class="logo-icon"/> 259 + <div class="logo-text">orbyt</div> 260 + </div> 261 + </div> 262 + <div class="row motto"> 263 + <div class="twelve columns"> 264 + <div class="motto-text">video communities</div> 265 + </div> 266 + </div> 267 + <div class="row buttons"> 268 + <a href="https://getorbyt.com/beta" class="download-button">join the waitlist</a> 269 + </div> 270 + </div> 271 + 272 + <div class="footer"> 273 + <div class="separator"> 274 + <svg height="7" fill="none" xmlns="http://www.w3.org/2000/svg"> 275 + <defs> 276 + <pattern id="squigglePattern" width="374" height="7" patternUnits="userSpaceOnUse"> 277 + <path d="M0 1C5.50021 1 5.50021 5.84456 11.0004 5.84456C16.5006 5.84456 16.5006 1 22.0008 1C27.501 1 27.501 5.84456 33.0013 5.84456C38.5015 5.84456 38.5015 1 44.0017 1C49.5019 1 49.5019 5.84456 55.0021 5.84456C60.5023 5.84456 60.5023 1 66.0025 1C71.5027 1 71.5027 5.84456 77.0029 5.84456C82.5031 5.84456 82.5031 1 87.9998 1C93.5 1 93.5 5.84456 98.9967 5.84456C104.497 5.84456 104.497 1 109.994 1C115.494 1 115.494 5.84456 120.99 5.84456C126.491 5.84456 126.491 1 131.991 1C137.491 1 137.491 5.84456 142.991 5.84456C148.491 5.84456 148.491 1 153.992 1C159.492 1 159.492 5.84456 164.988 5.84456C170.489 5.84456 170.489 1 175.985 1C181.486 1 181.486 5.84456 186.986 5.84456C192.486 5.84456 192.486 1 197.986 1C203.486 1 203.486 5.84456 208.987 5.84456C214.487 5.84456 214.487 1 219.987 1C225.487 1 225.487 5.84456 230.987 5.84456C236.488 5.84456 236.488 1 241.988 1C247.488 1 247.488 5.84456 252.988 5.84456C258.488 5.84456 258.488 1 263.989 1C269.489 1 269.489 5.84456 274.989 5.84456C280.489 5.84456 280.489 1 285.99 1C291.49 1 291.49 5.84456 296.99 5.84456C302.49 5.84456 302.49 1 307.99 1C313.491 1 313.491 5.84456 318.991 5.84456C324.491 5.84456 324.491 1 329.991 1C335.491 1 335.491 5.84456 340.992 5.84456C346.492 5.84456 346.492 1 351.992 1C357.492 1 357.492 5.84456 362.996 5.84456C368.5 5.84456 368.496 1 374 1" stroke="#551DEF" stroke-width="2" stroke-miterlimit="10"/> 278 + </pattern> 279 + </defs> 280 + <rect fill="url(#squigglePattern)" width="100%" height="100%"/> 281 + </svg> 282 + </div> 283 + <a href="https://bsky.app/profile/getorbyt.com">bluesky</a> 284 + <a href="https://discord.gg/8gNQPFygnF">discord</a> 285 + <a href="/terms.html">terms</a> 286 + <a href="/privacy.html">privacy</a> 287 + </div> 288 + 289 + <!-- Global site tag (gtag.js) - Google Analytics --> 290 + <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script> 291 + <script> 292 + window.dataLayer = window.dataLayer || []; 293 + function gtag() { 294 + dataLayer.push(arguments); 295 + } 296 + gtag('js', new Date()); 297 + gtag('config', 'G-XXXXXXXXXX'); 298 + </script> 299 + </div> 300 + 301 + </body> 302 + </html>
+649
js/bluesky-profile.js
··· 1 + /** 2 + * Bluesky Profile Integration with TanStack Query 3 + * Fetches and displays Bluesky profile data dynamically with caching and request deduplication 4 + */ 5 + 6 + import { getQueryClient, queryKeys, fetchQuery } from './query-client.js'; 7 + 8 + const BLUESKY_API_BASE = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile'; 9 + const BLUESKY_FEED_API_BASE = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed'; 10 + 11 + /** 12 + * Extract user handle from URL query parameter or path 13 + * Supports handles with or without @ prefix 14 + * Supports both query params (?user=handle) and path-based (@handle or /@handle) 15 + * Also checks window.__currentRoute for router-provided data 16 + */ 17 + function getUserHandleFromURL() { 18 + // First check for router-provided route data 19 + if (window.__currentRoute && window.__currentRoute.handle) { 20 + return window.__currentRoute.handle; 21 + } 22 + 23 + // Then check for query parameter 24 + const urlParams = new URLSearchParams(window.location.search); 25 + let handle = urlParams.get('user'); 26 + 27 + // If no query param, check for path-based routing (e.g., /@username or @username) 28 + if (!handle) { 29 + const path = window.location.pathname; 30 + // Match patterns like /@username or @username in the path 31 + const pathMatch = path.match(/[@]([^/]+)/); 32 + if (pathMatch) { 33 + handle = pathMatch[1]; 34 + } 35 + } 36 + 37 + // Default to wsj.com if no handle found 38 + if (!handle) { 39 + handle = 'wsj.com'; 40 + } 41 + 42 + // Remove @ prefix if present 43 + if (handle.startsWith('@')) { 44 + handle = handle.substring(1); 45 + } 46 + 47 + return handle.trim(); 48 + } 49 + 50 + /** 51 + * Fetch profile data from Bluesky API 52 + * This is the query function used by TanStack Query 53 + * @param {string} handle - Bluesky profile handle 54 + * @returns {Promise<Object>} Profile data 55 + */ 56 + async function fetchProfileQuery(handle) { 57 + const url = `${BLUESKY_API_BASE}?actor=${encodeURIComponent(handle)}`; 58 + const response = await fetch(url); 59 + 60 + // Check HTTP status 61 + if (!response.ok) { 62 + if (response.status === 404) { 63 + throw new Error(`Profile not found: ${handle}`); 64 + } 65 + throw new Error(`API error: ${response.status} ${response.statusText}`); 66 + } 67 + 68 + const data = await response.json(); 69 + 70 + // Bluesky API returns errors in JSON format with error field 71 + if (data.error) { 72 + throw new Error(data.message || `API error: ${data.error}`); 73 + } 74 + 75 + // Validate that we have the required fields 76 + if (!data.handle) { 77 + throw new Error('Invalid profile data: missing handle'); 78 + } 79 + 80 + return data; 81 + } 82 + 83 + /** 84 + * Fetch video posts from Bluesky API 85 + * This is the query function used by TanStack Query 86 + * @param {string} handle - Bluesky profile handle 87 + * @param {string} cursor - Pagination cursor (optional) 88 + * @param {number} limit - Number of posts to fetch (default 30) 89 + * @returns {Promise<Object>} Feed data with posts and cursor 90 + */ 91 + async function fetchVideoPostsQuery({ handle, cursor = null, limit = 30 }) { 92 + let url = `${BLUESKY_FEED_API_BASE}?actor=${encodeURIComponent(handle)}&filter=posts_with_video&limit=${limit}`; 93 + if (cursor) { 94 + url += `&cursor=${encodeURIComponent(cursor)}`; 95 + } 96 + 97 + const response = await fetch(url); 98 + 99 + if (!response.ok) { 100 + throw new Error(`API error: ${response.status} ${response.statusText}`); 101 + } 102 + 103 + const data = await response.json(); 104 + 105 + if (data.error) { 106 + throw new Error(data.message || `API error: ${data.error}`); 107 + } 108 + 109 + return { 110 + feed: data.feed || [], 111 + cursor: data.cursor || null, 112 + }; 113 + } 114 + 115 + /** 116 + * Update DOM elements with profile data 117 + * @param {Object} profile - Profile data from Bluesky API 118 + */ 119 + function updateProfileDisplay(profile) { 120 + // Update avatar 121 + const avatarEl = document.querySelector('.author .avatar'); 122 + if (avatarEl) { 123 + if (profile.avatar) { 124 + avatarEl.src = profile.avatar; 125 + avatarEl.alt = `${profile.displayName || profile.handle}'s avatar`; 126 + // Add error handler for broken images 127 + avatarEl.onerror = function () { 128 + this.src = '/images/Default-avatar.png'; 129 + }; 130 + } else { 131 + avatarEl.src = '/images/Default-avatar.png'; 132 + avatarEl.alt = `${profile.displayName || profile.handle}'s avatar`; 133 + } 134 + } 135 + 136 + // Update username 137 + const usernameEl = document.querySelector('.author h1.username'); 138 + if (usernameEl) { 139 + const displayName = profile.displayName || profile.handle; 140 + usernameEl.textContent = displayName; 141 + } 142 + 143 + // Update bio/description with clickable abbreviated links 144 + const bioEl = document.querySelector('.author .bio'); 145 + if (bioEl) { 146 + if (profile.description) { 147 + bioEl.innerHTML = truncateUrls(profile.description); 148 + } else { 149 + bioEl.innerHTML = ''; // Clear if no description 150 + } 151 + } 152 + 153 + // Update page title 154 + const displayName = profile.displayName || profile.handle; 155 + document.title = `${displayName} on orbyt`; 156 + 157 + // Update meta tags for SEO and social sharing 158 + updateMetaTags(profile, displayName); 159 + } 160 + 161 + /** 162 + * Update meta tags for SEO and social sharing 163 + * @param {Object} profile - Profile data 164 + * @param {string} displayName - Display name to use 165 + */ 166 + function updateMetaTags(profile, displayName) { 167 + // Update og:title 168 + let ogTitle = document.querySelector('meta[property="og:title"]'); 169 + if (!ogTitle) { 170 + ogTitle = document.createElement('meta'); 171 + ogTitle.setAttribute('property', 'og:title'); 172 + document.head.appendChild(ogTitle); 173 + } 174 + ogTitle.setAttribute('content', `@${displayName} on orbyt`); 175 + 176 + // Update og:description 177 + let ogDescription = document.querySelector('meta[property="og:description"]'); 178 + if (!ogDescription) { 179 + ogDescription = document.createElement('meta'); 180 + ogDescription.setAttribute('property', 'og:description'); 181 + document.head.appendChild(ogDescription); 182 + } 183 + const description = profile.description || `Profile page for ${displayName}`; 184 + ogDescription.setAttribute('content', description); 185 + 186 + // Update og:image 187 + let ogImage = document.querySelector('meta[property="og:image"]'); 188 + if (!ogImage) { 189 + ogImage = document.createElement('meta'); 190 + ogImage.setAttribute('property', 'og:image'); 191 + document.head.appendChild(ogImage); 192 + } 193 + ogImage.setAttribute('content', profile.avatar || '/images/Default-avatar.png'); 194 + 195 + // Update Twitter card meta tags 196 + let twitterTitle = document.querySelector('meta[property="twitter:title"]'); 197 + if (!twitterTitle) { 198 + twitterTitle = document.createElement('meta'); 199 + twitterTitle.setAttribute('property', 'twitter:title'); 200 + document.head.appendChild(twitterTitle); 201 + } 202 + twitterTitle.setAttribute('content', `@${displayName} on orbyt`); 203 + 204 + let twitterDescription = document.querySelector('meta[property="twitter:description"]'); 205 + if (!twitterDescription) { 206 + twitterDescription = document.createElement('meta'); 207 + twitterDescription.setAttribute('property', 'twitter:description'); 208 + document.head.appendChild(twitterDescription); 209 + } 210 + twitterDescription.setAttribute('content', description); 211 + 212 + let twitterImage = document.querySelector('meta[property="twitter:image"]'); 213 + if (!twitterImage) { 214 + twitterImage = document.createElement('meta'); 215 + twitterImage.setAttribute('property', 'twitter:image'); 216 + document.head.appendChild(twitterImage); 217 + } 218 + twitterImage.setAttribute('content', profile.avatar || '/images/Default-avatar.png'); 219 + 220 + // Update meta description 221 + let metaDescription = document.querySelector('meta[name="description"]'); 222 + if (!metaDescription) { 223 + metaDescription = document.createElement('meta'); 224 + metaDescription.setAttribute('name', 'description'); 225 + document.head.appendChild(metaDescription); 226 + } 227 + metaDescription.setAttribute('content', description); 228 + } 229 + 230 + /** 231 + * Show loading state 232 + */ 233 + function showLoading() { 234 + const avatarEl = document.querySelector('.author .avatar'); 235 + const usernameEl = document.querySelector('.author h1.username'); 236 + const bioEl = document.querySelector('.author .bio'); 237 + 238 + if (avatarEl) { 239 + avatarEl.style.opacity = '0.5'; 240 + } 241 + 242 + if (usernameEl) { 243 + usernameEl.textContent = 'Loading...'; 244 + } 245 + 246 + if (bioEl) { 247 + bioEl.textContent = 'Fetching profile...'; 248 + } 249 + } 250 + 251 + /** 252 + * Hide loading state 253 + */ 254 + function hideLoading() { 255 + const avatarEl = document.querySelector('.author .avatar'); 256 + if (avatarEl) { 257 + avatarEl.style.opacity = '1'; 258 + } 259 + } 260 + 261 + /** 262 + * Show error message 263 + * @param {string} message - Error message to display 264 + * @param {string} handle - Handle that failed to load 265 + */ 266 + function showError(message, handle) { 267 + const usernameEl = document.querySelector('.author h1.username'); 268 + const bioEl = document.querySelector('.author .bio'); 269 + 270 + if (usernameEl) { 271 + usernameEl.textContent = 'Unknown user'; 272 + } 273 + 274 + if (bioEl) { 275 + bioEl.textContent = ''; 276 + } 277 + 278 + hideLoading(); 279 + } 280 + 281 + /** 282 + * Filter posts to only include video posts (client-side safety check) 283 + * API should already filter, but this ensures we only display video posts 284 + * @param {Array} feed - Array of feed post objects 285 + * @returns {Array} Filtered array of video posts 286 + */ 287 + function filterVideoPosts(feed) { 288 + return feed.filter(item => { 289 + const embed = item.post?.embed || item.post?.record?.embed; 290 + return ( 291 + embed && 292 + (embed.$type === 'app.bsky.embed.video' || embed.$type === 'app.bsky.embed.video#view') 293 + ); 294 + }); 295 + } 296 + 297 + /** 298 + * Extract post ID from Bluesky URI 299 + * @param {string} uri - Post URI in format at://did:plc:.../app.bsky.feed.post/{postId} 300 + * @returns {string} Post ID 301 + */ 302 + function extractPostId(uri) { 303 + if (!uri) return null; 304 + const parts = uri.split('/'); 305 + return parts[parts.length - 1] || null; 306 + } 307 + 308 + /** 309 + * Render a video post HTML element 310 + * @param {Object} post - Post data from Bluesky API 311 + * @param {string} handle - User handle for link generation 312 + * @returns {HTMLElement} Post element 313 + */ 314 + function renderVideoPost(post, handle) { 315 + const postId = extractPostId(post.uri); 316 + if (!postId) { 317 + console.warn('Could not extract post ID from URI:', post.uri); 318 + return null; 319 + } 320 + 321 + const thumbnail = post.embed?.thumbnail || ''; 322 + let caption = post.record?.text || ''; 323 + 324 + // Truncate URLs in caption first 325 + caption = truncateUrls(caption); 326 + 327 + // Then truncate the entire caption to a maximum length 328 + caption = truncateText(caption, 90); 329 + 330 + const postDiv = document.createElement('div'); 331 + postDiv.className = 'post'; 332 + 333 + // Escape HTML attributes to prevent XSS 334 + const escapedThumbnail = escapeHtmlAttribute(thumbnail); 335 + // Don't escape caption HTML since truncateUrls now returns HTML with links 336 + const escapedHandle = escapeHtmlAttribute(handle); 337 + const escapedPostId = escapeHtmlAttribute(postId); 338 + 339 + postDiv.innerHTML = ` 340 + <div class="post-image-container"> 341 + <img 342 + class="post-placeholder" 343 + src="/images/post/placeholder.png" 344 + alt="" 345 + /> 346 + <img class="post-thumb" src="${escapedThumbnail}" alt="" /> 347 + </div> 348 + 349 + <a href="/@${escapedHandle}/${escapedPostId}"> 350 + <img class="play-button" src="/images/post/play.svg" alt="" /> 351 + </a> 352 + 353 + <div class="post-overlay"> 354 + <div class="caption">${caption}</div> 355 + </div> 356 + `; 357 + 358 + return postDiv; 359 + } 360 + 361 + /** 362 + * Escape HTML attribute value to prevent XSS 363 + * @param {string} text - Text to escape 364 + * @returns {string} Escaped attribute value 365 + */ 366 + function escapeHtmlAttribute(text) { 367 + if (!text) return ''; 368 + return String(text) 369 + .replace(/&/g, '&amp;') 370 + .replace(/"/g, '&quot;') 371 + .replace(/'/g, '&#39;') 372 + .replace(/</g, '&lt;') 373 + .replace(/>/g, '&gt;'); 374 + } 375 + 376 + /** 377 + * Escape HTML to prevent XSS 378 + * @param {string} text - Text to escape 379 + * @returns {string} Escaped HTML 380 + */ 381 + function escapeHtml(text) { 382 + const div = document.createElement('div'); 383 + div.textContent = text; 384 + return div.innerHTML; 385 + } 386 + 387 + /** 388 + * Convert URLs to clickable abbreviated links 389 + * @param {string} text - Text containing URLs 390 + * @returns {string} Text with URLs converted to abbreviated clickable links (HTML) 391 + */ 392 + function truncateUrls(text) { 393 + if (!text) return text; 394 + 395 + // Match URLs (http/https/www patterns) 396 + // This matches: http://example.com, https://example.com, www.example.com, example.com/path 397 + const urlRegex = 398 + /(https?:\/\/)?(www\.)?([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}([\/][^\s]*)?/gi; 399 + 400 + const parts = []; 401 + let lastIndex = 0; 402 + let match; 403 + 404 + // Find all URLs and split text around them 405 + while ((match = urlRegex.exec(text)) !== null) { 406 + // Add text before URL (escaped) 407 + if (match.index > lastIndex) { 408 + parts.push(escapeHtml(text.substring(lastIndex, match.index))); 409 + } 410 + 411 + // Process URL 412 + try { 413 + let urlString = match[0]; 414 + 415 + // If it doesn't have a protocol, add https:// for URL parsing 416 + if (!match[0].startsWith('http://') && !match[0].startsWith('https://')) { 417 + urlString = 'https://' + match[0]; 418 + } 419 + 420 + // Try to parse as URL to extract hostname and path 421 + const url = new URL(urlString); 422 + let domain = url.hostname; 423 + 424 + // Remove www prefix 425 + if (domain.toLowerCase().startsWith('www.')) { 426 + domain = domain.substring(4); 427 + } 428 + 429 + // Get pathname (first part after /) for abbreviation 430 + const pathname = url.pathname; 431 + let displayText = domain; 432 + if (pathname && pathname.length > 1) { 433 + // Get first segment of path (up to 8 characters) then add ... 434 + const pathSegment = pathname.substring(1).split('/')[0]; 435 + if (pathSegment && pathSegment.length > 0) { 436 + const shortPath = pathSegment.length > 8 ? pathSegment.substring(0, 8) : pathSegment; 437 + displayText = `${domain}/${shortPath}...`; 438 + } 439 + } 440 + 441 + // Create link with abbreviated text 442 + const finalUrl = match[0].startsWith('http') ? match[0] : urlString; 443 + const escapedUrl = escapeHtmlAttribute(finalUrl); 444 + const escapedDisplayText = escapeHtml(displayText); 445 + parts.push( 446 + `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">${escapedDisplayText}</a>` 447 + ); 448 + } catch (e) { 449 + // If URL parsing fails, fall back to manual extraction 450 + let cleanMatch = match[0]; 451 + // Remove protocol 452 + cleanMatch = cleanMatch.replace(/^https?:\/\//i, ''); 453 + // Remove www prefix 454 + cleanMatch = cleanMatch.replace(/^www\./i, ''); 455 + // Extract domain and path parts 456 + const parts_match = cleanMatch.split('/'); 457 + const domain = parts_match[0]; 458 + let displayText = domain; 459 + // Check if there's a path after the domain 460 + if (parts_match.length > 1 && parts_match[1]) { 461 + const pathSegment = parts_match[1]; 462 + const shortPath = pathSegment.length > 8 ? pathSegment.substring(0, 8) : pathSegment; 463 + displayText = `${domain}/${shortPath}...`; 464 + } 465 + const urlString = match[0].startsWith('http') ? match[0] : 'https://' + match[0]; 466 + const escapedUrl = escapeHtmlAttribute(urlString); 467 + const escapedDisplayText = escapeHtml(displayText); 468 + parts.push( 469 + `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">${escapedDisplayText}</a>` 470 + ); 471 + } 472 + 473 + lastIndex = match.index + match[0].length; 474 + } 475 + 476 + // Add remaining text after last URL (escaped) 477 + if (lastIndex < text.length) { 478 + parts.push(escapeHtml(text.substring(lastIndex))); 479 + } 480 + 481 + // If no URLs found, just escape and return the text 482 + if (parts.length === 0) { 483 + return escapeHtml(text); 484 + } 485 + 486 + return parts.join(''); 487 + } 488 + 489 + /** 490 + * Truncate text to a maximum character limit 491 + * @param {string} text - Text to truncate 492 + * @param {number} maxLength - Maximum character length (default: 150) 493 + * @returns {string} Truncated text with ellipsis if needed 494 + */ 495 + function truncateText(text, maxLength = 150) { 496 + if (!text) return text; 497 + if (text.length <= maxLength) return text; 498 + 499 + // Truncate and add ellipsis 500 + return text.substring(0, maxLength).trim() + '...'; 501 + } 502 + 503 + // Module-level variables to track state for pagination 504 + let currentVideoCursor = null; 505 + let videoPostsInitialized = false; 506 + 507 + /** 508 + * Populate video grid with posts from Bluesky using TanStack Query 509 + * @param {string} handle - User handle 510 + * @param {string} cursor - Optional pagination cursor 511 + * @returns {Promise<string>} Next cursor or null 512 + */ 513 + async function populateVideoGrid(handle, cursor = null) { 514 + try { 515 + const queryClient = await getQueryClient(); 516 + const postsContainer = document.querySelector('.posts'); 517 + if (!postsContainer) { 518 + console.warn('Posts container not found'); 519 + return null; 520 + } 521 + 522 + // Remove existing static posts if this is the first load 523 + if (!videoPostsInitialized) { 524 + const existingPosts = postsContainer.querySelectorAll('.post'); 525 + existingPosts.forEach(post => post.remove()); 526 + videoPostsInitialized = true; 527 + currentVideoCursor = null; // Reset cursor on first load 528 + } 529 + 530 + // Use TanStack Query to fetch video posts with caching 531 + const queryKey = queryKeys.feed(handle, cursor); 532 + const feedData = await fetchQuery( 533 + queryClient, 534 + queryKey, 535 + () => fetchVideoPostsQuery({ handle, cursor, limit: 30 }), 536 + { 537 + staleTime: 2 * 60 * 1000, // 2-minute stale time for feeds 538 + gcTime: 10 * 60 * 1000, // 10-minute cache time 539 + } 540 + ); 541 + 542 + const { feed, cursor: nextCursor } = feedData; 543 + 544 + if (!feed || feed.length === 0) { 545 + // If no more posts, hide footer 546 + const footer = document.querySelector('footer'); 547 + if (footer) { 548 + footer.style.display = 'none'; 549 + } 550 + currentVideoCursor = null; 551 + return null; 552 + } 553 + 554 + // Filter for video posts (API should already filter, but this is a safety check) 555 + const videoPosts = filterVideoPosts(feed); 556 + 557 + // Render each video post 558 + videoPosts.forEach(item => { 559 + const postElement = renderVideoPost(item.post, handle); 560 + if (postElement) { 561 + const footer = document.querySelector('footer'); 562 + if (footer) { 563 + postsContainer.insertBefore(postElement, footer); 564 + } else { 565 + postsContainer.appendChild(postElement); 566 + } 567 + } 568 + }); 569 + 570 + // Store cursor for pagination (UI state) 571 + currentVideoCursor = nextCursor; 572 + 573 + // Show or hide footer based on whether there are more posts 574 + const footer = document.querySelector('footer'); 575 + if (footer) { 576 + if (nextCursor && nextCursor !== '') { 577 + footer.style.display = 'flex'; 578 + } else { 579 + footer.style.display = 'none'; 580 + } 581 + } 582 + 583 + return nextCursor; 584 + } catch (error) { 585 + console.error('Error populating video grid:', error); 586 + return null; 587 + } 588 + } 589 + 590 + /** 591 + * Initialize profile loading on page load with TanStack Query 592 + */ 593 + async function initProfile() { 594 + try { 595 + const handle = getUserHandleFromURL(); 596 + 597 + if (!handle) { 598 + const usernameEl = document.querySelector('.author h1.username'); 599 + if (usernameEl) { 600 + usernameEl.textContent = 'Unknown user'; 601 + } 602 + hideLoading(); 603 + return; 604 + } 605 + 606 + showLoading(); 607 + 608 + // Reset video posts state when initializing a new profile load 609 + // This ensures posts reload when navigating back to a profile page 610 + videoPostsInitialized = false; 611 + currentVideoCursor = null; 612 + 613 + // Get query client 614 + const queryClient = await getQueryClient(); 615 + 616 + // Use TanStack Query to fetch profile with caching and request deduplication 617 + const queryKey = queryKeys.profile(handle); 618 + const profile = await fetchQuery(queryClient, queryKey, () => fetchProfileQuery(handle), { 619 + staleTime: 5 * 60 * 1000, // 5-minute stale time for profiles 620 + gcTime: 10 * 60 * 1000, // 10-minute cache time 621 + }); 622 + 623 + // Validate profile data 624 + if (!profile || !profile.handle) { 625 + throw new Error('Invalid profile data received'); 626 + } 627 + 628 + updateProfileDisplay(profile); 629 + hideLoading(); 630 + 631 + // Store profile in query cache for potential future use 632 + queryClient.setQueryData(queryKey, profile); 633 + 634 + // Initialize video posts grid 635 + populateVideoGrid(handle); 636 + } catch (error) { 637 + console.error('Failed to load profile:', error); 638 + const handle = getUserHandleFromURL(); 639 + showError(error.message, handle); 640 + } 641 + } 642 + 643 + // Export functions for ES6 module imports 644 + // Note: initProfile should be called explicitly from the HTML file 645 + // This allows it to be called when navigating back to a profile page 646 + export { populateVideoGrid, getUserHandleFromURL, initProfile }; 647 + export function getCurrentCursor() { 648 + return currentVideoCursor; 649 + }
+1163
js/post-view.js
··· 1 + /** 2 + * Bluesky Post View with TanStack Query 3 + * Fetches and displays a single Bluesky post with video player 4 + * Uses TanStack Query Core for caching and request deduplication 5 + */ 6 + 7 + import { getQueryClient, queryKeys, fetchQuery } from './query-client.js'; 8 + 9 + const BLUESKY_API_BASE = 'https://public.api.bsky.app/xrpc'; 10 + const GET_POSTS_ENDPOINT = `${BLUESKY_API_BASE}/app.bsky.feed.getPosts`; 11 + const GET_POST_THREAD_ENDPOINT = `${BLUESKY_API_BASE}/app.bsky.feed.getPostThread`; 12 + const GET_PROFILE_ENDPOINT = `${BLUESKY_API_BASE}/app.bsky.actor.getProfile`; 13 + 14 + /** 15 + * Parse URL to extract handle and post ID (rkey) 16 + * Supports: 17 + * - Router-provided data (window.__currentRoute) 18 + * - Query params: ?user=handle&post=postId 19 + * - Path-based: /@handle/postId 20 + * - Direct path: /handle/postId 21 + */ 22 + function parsePostURL() { 23 + // First check for router-provided route data 24 + if (window.__currentRoute) { 25 + if (window.__currentRoute.type === 'post') { 26 + return { 27 + handle: window.__currentRoute.handle ? window.__currentRoute.handle.trim() : null, 28 + postId: window.__currentRoute.postId ? window.__currentRoute.postId.trim() : null, 29 + }; 30 + } 31 + } 32 + 33 + const urlParams = new URLSearchParams(window.location.search); 34 + let handle = urlParams.get('user'); 35 + let postId = urlParams.get('post'); 36 + 37 + // If no query params, try path-based routing 38 + if (!handle || !postId) { 39 + const path = window.location.pathname; 40 + 41 + // Match patterns like /@username/postId or /username/postId 42 + const pathMatch = path.match(/[@]?([^/]+)\/([^/]+)$/); 43 + if (pathMatch) { 44 + handle = pathMatch[1]; 45 + postId = pathMatch[2]; 46 + 47 + // Remove @ prefix if present 48 + if (handle.startsWith('@')) { 49 + handle = handle.substring(1); 50 + } 51 + } 52 + } 53 + 54 + // Remove @ prefix if present in query param 55 + if (handle && handle.startsWith('@')) { 56 + handle = handle.substring(1); 57 + } 58 + 59 + return { 60 + handle: handle ? handle.trim() : null, 61 + postId: postId ? postId.trim() : null, 62 + }; 63 + } 64 + 65 + /** 66 + * Fetch profile for DID lookup (query function for TanStack Query) 67 + * @param {string} handle - User handle 68 + * @returns {Promise<Object>} Profile data with DID 69 + */ 70 + async function fetchProfileForDID(handle) { 71 + const url = `${GET_PROFILE_ENDPOINT}?actor=${encodeURIComponent(handle)}`; 72 + const response = await fetch(url); 73 + 74 + if (!response.ok) { 75 + if (response.status === 404) { 76 + throw new Error(`Profile not found: ${handle}`); 77 + } 78 + throw new Error(`API error: ${response.status} ${response.statusText}`); 79 + } 80 + 81 + const data = await response.json(); 82 + 83 + if (data.error) { 84 + throw new Error(data.message || `API error: ${data.error}`); 85 + } 86 + 87 + if (!data.did) { 88 + throw new Error('Invalid profile data: missing DID'); 89 + } 90 + 91 + return data; 92 + } 93 + 94 + /** 95 + * Build AT URI from handle and post ID using cached profile data 96 + * @param {string} handle - User handle 97 + * @param {string} rkey - Record key (post ID) 98 + * @returns {Promise<string>} AT URI 99 + */ 100 + async function buildPostURI(handle, rkey) { 101 + try { 102 + const queryClient = await getQueryClient(); 103 + // Use query cache to get profile (reuse profile query from bluesky-profile.js) 104 + const queryKey = queryKeys.profile(handle); 105 + 106 + // Try to get from cache first 107 + let profileData = queryClient.getQueryData(queryKey); 108 + 109 + // If not in cache, fetch it (will be cached for future use) 110 + if (!profileData) { 111 + profileData = await fetchQuery(queryClient, queryKey, () => fetchProfileForDID(handle), { 112 + staleTime: 5 * 60 * 1000, // 5-minute stale time 113 + gcTime: 10 * 60 * 1000, // 10-minute cache time 114 + }); 115 + } 116 + 117 + if (profileData && profileData.did) { 118 + return `at://${profileData.did}/app.bsky.feed.post/${rkey}`; 119 + } 120 + } catch (profileError) { 121 + console.warn('Could not fetch profile for DID lookup:', profileError); 122 + } 123 + throw new Error(`Could not build URI for post: @${handle}/${rkey}`); 124 + } 125 + 126 + /** 127 + * Fetch a single post from Bluesky API using getPosts endpoint (query function for TanStack Query) 128 + * @param {string} uri - AT URI of the post 129 + * @returns {Promise<Object>} Post data with stats 130 + */ 131 + async function fetchPostQuery(uri) { 132 + // Use getPosts endpoint which returns view format with playlist URL directly 133 + const postsUrl = `${GET_POSTS_ENDPOINT}?uris=${encodeURIComponent(uri)}`; 134 + const postsResponse = await fetch(postsUrl); 135 + 136 + if (!postsResponse.ok) { 137 + if (postsResponse.status === 404) { 138 + throw new Error(`Post not found: ${uri}`); 139 + } 140 + throw new Error(`API error: ${postsResponse.status} ${postsResponse.statusText}`); 141 + } 142 + 143 + const postsData = await postsResponse.json(); 144 + 145 + if (postsData.error) { 146 + throw new Error(postsData.message || `API error: ${postsData.error}`); 147 + } 148 + 149 + if (!postsData.posts || postsData.posts.length === 0) { 150 + throw new Error(`Post not found: ${uri}`); 151 + } 152 + 153 + const postView = postsData.posts[0]; 154 + 155 + // Fetch thread data to get engagement stats (repostCount, likeCount, etc.) 156 + try { 157 + const threadUrl = `${GET_POST_THREAD_ENDPOINT}?uri=${encodeURIComponent(uri)}&depth=0`; 158 + const threadResponse = await fetch(threadUrl); 159 + 160 + if (threadResponse.ok) { 161 + const threadData = await threadResponse.json(); 162 + if (threadData.thread?.post) { 163 + // Merge stats into post view 164 + postView.repostCount = threadData.thread.post.repostCount || 0; 165 + postView.likeCount = threadData.thread.post.likeCount || 0; 166 + postView.replyCount = threadData.thread.post.replyCount || 0; 167 + } 168 + } 169 + } catch (threadError) { 170 + console.warn('Could not fetch thread stats:', threadError); 171 + // Continue with post data only 172 + postView.repostCount = 0; 173 + postView.likeCount = 0; 174 + postView.replyCount = 0; 175 + } 176 + 177 + // Return in format compatible with existing renderPost function 178 + return { 179 + value: postView.record, 180 + uri: postView.uri, 181 + cid: postView.cid, 182 + author: postView.author, 183 + embed: postView.embed, 184 + repostCount: postView.repostCount || 0, 185 + likeCount: postView.likeCount || 0, 186 + replyCount: postView.replyCount || 0, 187 + }; 188 + } 189 + 190 + /** 191 + * Fetch a single post from Bluesky API using TanStack Query 192 + * @param {string} handle - User handle 193 + * @param {string} rkey - Record key (post ID) 194 + * @returns {Promise<Object>} Post data with stats 195 + */ 196 + async function fetchPost(handle, rkey) { 197 + // Build AT URI first (this will use cached profile if available) 198 + const uri = await buildPostURI(handle, rkey); 199 + 200 + // Use TanStack Query to fetch post with caching 201 + const queryClient = await getQueryClient(); 202 + const queryKey = queryKeys.post(handle, rkey); 203 + 204 + return await fetchQuery(queryClient, queryKey, () => fetchPostQuery(uri), { 205 + staleTime: 10 * 60 * 1000, // 10-minute stale time for posts (static after publish) 206 + gcTime: 30 * 60 * 1000, // 30-minute cache time for posts 207 + }); 208 + } 209 + 210 + /** 211 + * Fetch author profile from Bluesky API (query function for TanStack Query) 212 + * @param {string} handle - User handle or DID 213 + * @returns {Promise<Object>} Profile data 214 + */ 215 + async function fetchAuthorProfileQuery(handle) { 216 + const url = `${GET_PROFILE_ENDPOINT}?actor=${encodeURIComponent(handle)}`; 217 + const response = await fetch(url); 218 + 219 + if (!response.ok) { 220 + if (response.status === 404) { 221 + throw new Error(`Profile not found: ${handle}`); 222 + } 223 + throw new Error(`API error: ${response.status} ${response.statusText}`); 224 + } 225 + 226 + const data = await response.json(); 227 + 228 + if (data.error) { 229 + throw new Error(data.message || `API error: ${data.error}`); 230 + } 231 + 232 + return data; 233 + } 234 + 235 + /** 236 + * Fetch author profile using TanStack Query (reuses profile cache) 237 + * @param {string} handle - User handle or DID 238 + * @returns {Promise<Object>} Profile data 239 + */ 240 + async function fetchAuthorProfile(handle) { 241 + const queryClient = await getQueryClient(); 242 + const queryKey = queryKeys.profile(handle); 243 + 244 + // Use TanStack Query to fetch profile with caching 245 + // This will reuse the same cache as bluesky-profile.js 246 + return await fetchQuery(queryClient, queryKey, () => fetchAuthorProfileQuery(handle), { 247 + staleTime: 5 * 60 * 1000, // 5-minute stale time for profiles 248 + gcTime: 10 * 60 * 1000, // 10-minute cache time 249 + }); 250 + } 251 + 252 + /** 253 + * Extract video URL from post embed data 254 + * Can accept either postRecord (from getRecord) or postView (from getPosts) 255 + * @param {Object} postData - Post data from API (can be record or view format) 256 + * @returns {Object|null} Video data with URL, thumbnail, and aspect ratio 257 + */ 258 + function extractVideoData(postData) { 259 + if (!postData) return null; 260 + 261 + // Handle view format (from getPosts) - embed is directly on postData 262 + let embed = postData.embed; 263 + 264 + // Handle record format (from getRecord) - embed is on value.embed 265 + if (!embed && postData.value) { 266 + embed = postData.value.embed; 267 + } 268 + 269 + if (!embed) return null; 270 + 271 + // Check for direct video view format (from getPosts) 272 + if (embed.$type === 'app.bsky.embed.video#view') { 273 + return extractVideoDataFromEmbed(embed, postData.uri); 274 + } 275 + 276 + // Check for video record format (from getRecord) 277 + if (embed.$type === 'app.bsky.embed.video') { 278 + return extractVideoDataFromEmbed(embed, postData.uri || postData.value?.uri); 279 + } 280 + 281 + // Check for recordWithMedia format 282 + if (embed.$type === 'app.bsky.embed.recordWithMedia') { 283 + const media = embed.media; 284 + if ( 285 + media && 286 + (media.$type === 'app.bsky.embed.video#view' || media.$type === 'app.bsky.embed.video') 287 + ) { 288 + return extractVideoDataFromEmbed(media, postData.uri || postData.value?.uri); 289 + } 290 + } 291 + 292 + return null; 293 + } 294 + 295 + /** 296 + * Extract video data from embed object 297 + * @param {Object} embed - Embed object (can be record embed or view embed) 298 + * @param {string} uri - Post URI 299 + * @returns {Object|null} Video data 300 + */ 301 + function extractVideoDataFromEmbed(embed, uri) { 302 + // Check if this is a view embed (from getPosts - has playlist URL directly) 303 + if (embed.$type === 'app.bsky.embed.video#view') { 304 + if (embed.playlist) { 305 + // Use playlist URL directly (already in correct format) 306 + return { 307 + url: embed.playlist, 308 + thumbnail: embed.thumbnail || null, 309 + aspectRatio: embed.aspectRatio || { width: 9, height: 16 }, 310 + mimeType: 'application/vnd.apple.mpegurl', 311 + cid: embed.cid || null, 312 + did: null, 313 + }; 314 + } 315 + // If view but no playlist, try to construct from CID and DID 316 + if (embed.cid && uri) { 317 + const didMatch = uri.match(/did:plc:[^/]+/); 318 + const did = didMatch ? didMatch[0] : null; 319 + if (did) { 320 + return { 321 + url: `https://video.bsky.app/watch/${encodeURIComponent(did)}/${encodeURIComponent(embed.cid)}/playlist.m3u8`, 322 + thumbnail: embed.thumbnail || null, 323 + aspectRatio: embed.aspectRatio || { width: 9, height: 16 }, 324 + mimeType: 'application/vnd.apple.mpegurl', 325 + cid: embed.cid, 326 + did: did, 327 + }; 328 + } 329 + } 330 + } 331 + 332 + // Otherwise, extract from record embed (video blob from getRecord) 333 + const videoBlob = embed.video; 334 + const thumbnail = embed.thumbnail; 335 + const aspectRatio = embed.aspectRatio; 336 + 337 + if (!videoBlob) { 338 + return null; 339 + } 340 + 341 + // Extract DID from URI if available 342 + const didMatch = uri ? uri.match(/did:plc:[^/]+/) : null; 343 + const did = didMatch ? didMatch[0] : null; 344 + 345 + // Extract CID from BlobRef - handle both direct link and nested ref 346 + let cid = null; 347 + if (videoBlob.ref) { 348 + if (typeof videoBlob.ref === 'string') { 349 + cid = videoBlob.ref; 350 + } else if (videoBlob.ref.$link) { 351 + cid = videoBlob.ref.$link; 352 + } else if (videoBlob.ref.cid) { 353 + cid = videoBlob.ref.cid; 354 + } 355 + } 356 + 357 + if (!cid) { 358 + console.warn('Could not extract CID from video blob:', videoBlob); 359 + return null; 360 + } 361 + 362 + // Construct video URL using video.bsky.app 363 + let videoUrl = null; 364 + if (did && cid) { 365 + videoUrl = `https://video.bsky.app/watch/${encodeURIComponent(did)}/${encodeURIComponent(cid)}/playlist.m3u8`; 366 + } 367 + 368 + if (!videoUrl) { 369 + console.warn('Could not construct HLS URL, missing DID. CID:', cid, 'URI:', uri); 370 + return null; 371 + } 372 + 373 + return { 374 + url: videoUrl, 375 + thumbnail: thumbnail || null, 376 + aspectRatio: aspectRatio || { width: 9, height: 16 }, 377 + mimeType: videoBlob.mimeType || 'video/mp4', 378 + cid: cid, 379 + did: did, 380 + }; 381 + } 382 + 383 + /** 384 + * Format relative time (e.g., "2h", "3d", "1mo") 385 + * @param {string} createdAt - ISO timestamp 386 + * @returns {string} Formatted time 387 + */ 388 + function formatRelativeTime(createdAt) { 389 + if (!createdAt) return ''; 390 + 391 + const now = new Date(); 392 + const created = new Date(createdAt); 393 + const diffMs = now - created; 394 + const diffSecs = Math.floor(diffMs / 1000); 395 + const diffMins = Math.floor(diffSecs / 60); 396 + const diffHours = Math.floor(diffMins / 60); 397 + const diffDays = Math.floor(diffHours / 24); 398 + const diffMonths = Math.floor(diffDays / 30); 399 + 400 + if (diffMonths > 0) { 401 + return `${diffMonths}mo`; 402 + } else if (diffDays > 0) { 403 + return `${diffDays}d`; 404 + } else if (diffHours > 0) { 405 + return `${diffHours}h`; 406 + } else if (diffMins > 0) { 407 + return `${diffMins}m`; 408 + } else { 409 + return 'now'; 410 + } 411 + } 412 + 413 + /** 414 + * Escape HTML to prevent XSS 415 + * @param {string} text - Text to escape 416 + * @returns {string} Escaped HTML 417 + */ 418 + function escapeHtml(text) { 419 + if (!text) return ''; 420 + const div = document.createElement('div'); 421 + div.textContent = text; 422 + return div.innerHTML; 423 + } 424 + 425 + /** 426 + * Escape HTML attribute value to prevent XSS 427 + * @param {string} text - Text to escape 428 + * @returns {string} Escaped attribute value 429 + */ 430 + function escapeHtmlAttribute(text) { 431 + if (!text) return ''; 432 + return String(text) 433 + .replace(/&/g, '&amp;') 434 + .replace(/"/g, '&quot;') 435 + .replace(/'/g, '&#39;') 436 + .replace(/</g, '&lt;') 437 + .replace(/>/g, '&gt;'); 438 + } 439 + 440 + /** 441 + * Convert URLs to clickable abbreviated links 442 + * @param {string} text - Text containing URLs 443 + * @returns {string} Text with URLs converted to abbreviated clickable links (HTML) 444 + */ 445 + function convertUrlsToLinks(text) { 446 + if (!text) return text; 447 + 448 + // Match URLs (http/https/www patterns) 449 + // This matches: http://example.com, https://example.com, www.example.com, example.com/path 450 + const urlRegex = 451 + /(https?:\/\/)?(www\.)?([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}([\/][^\s]*)?/gi; 452 + 453 + const parts = []; 454 + let lastIndex = 0; 455 + let match; 456 + 457 + // Find all URLs and split text around them 458 + while ((match = urlRegex.exec(text)) !== null) { 459 + // Add text before URL (escaped) 460 + if (match.index > lastIndex) { 461 + parts.push(escapeHtml(text.substring(lastIndex, match.index))); 462 + } 463 + 464 + // Process URL 465 + try { 466 + let urlString = match[0]; 467 + 468 + // If it doesn't have a protocol, add https:// for URL parsing 469 + if (!match[0].startsWith('http://') && !match[0].startsWith('https://')) { 470 + urlString = 'https://' + match[0]; 471 + } 472 + 473 + // Try to parse as URL to extract hostname and path 474 + const url = new URL(urlString); 475 + let domain = url.hostname; 476 + 477 + // Remove www prefix 478 + if (domain.toLowerCase().startsWith('www.')) { 479 + domain = domain.substring(4); 480 + } 481 + 482 + // Get pathname (first part after /) for abbreviation 483 + const pathname = url.pathname; 484 + let displayText = domain; 485 + if (pathname && pathname.length > 1) { 486 + // Get first segment of path (up to 8 characters) then add ... 487 + const pathSegment = pathname.substring(1).split('/')[0]; 488 + if (pathSegment && pathSegment.length > 0) { 489 + const shortPath = pathSegment.length > 8 ? pathSegment.substring(0, 8) : pathSegment; 490 + displayText = `${domain}/${shortPath}...`; 491 + } 492 + } 493 + 494 + // Create link with abbreviated text 495 + const finalUrl = match[0].startsWith('http') ? match[0] : urlString; 496 + const escapedUrl = escapeHtmlAttribute(finalUrl); 497 + const escapedDisplayText = escapeHtml(displayText); 498 + parts.push( 499 + `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">${escapedDisplayText}</a>` 500 + ); 501 + } catch (e) { 502 + // If URL parsing fails, fall back to manual extraction 503 + let cleanMatch = match[0]; 504 + // Remove protocol 505 + cleanMatch = cleanMatch.replace(/^https?:\/\//i, ''); 506 + // Remove www prefix 507 + cleanMatch = cleanMatch.replace(/^www\./i, ''); 508 + // Extract domain and path parts 509 + const parts_match = cleanMatch.split('/'); 510 + const domain = parts_match[0]; 511 + let displayText = domain; 512 + // Check if there's a path after the domain 513 + if (parts_match.length > 1 && parts_match[1]) { 514 + const pathSegment = parts_match[1]; 515 + const shortPath = pathSegment.length > 8 ? pathSegment.substring(0, 8) : pathSegment; 516 + displayText = `${domain}/${shortPath}...`; 517 + } 518 + const urlString = match[0].startsWith('http') ? match[0] : 'https://' + match[0]; 519 + const escapedUrl = escapeHtmlAttribute(urlString); 520 + const escapedDisplayText = escapeHtml(displayText); 521 + parts.push( 522 + `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">${escapedDisplayText}</a>` 523 + ); 524 + } 525 + 526 + lastIndex = match.index + match[0].length; 527 + } 528 + 529 + // Add remaining text after last URL (escaped) 530 + if (lastIndex < text.length) { 531 + parts.push(escapeHtml(text.substring(lastIndex))); 532 + } 533 + 534 + // If no URLs found, just escape and return the text 535 + if (parts.length === 0) { 536 + return escapeHtml(text); 537 + } 538 + 539 + return parts.join(''); 540 + } 541 + 542 + /** 543 + * Update meta tags for social sharing 544 + * @param {Object} postData - Post data 545 + * @param {Object} authorProfile - Author profile data 546 + */ 547 + function updateMetaTags(postData, authorProfile) { 548 + const caption = postData.value?.text || 'Bluesky video post'; 549 + const authorName = authorProfile?.displayName || authorProfile?.handle || 'Unknown'; 550 + const handle = authorProfile?.handle || ''; 551 + const avatar = authorProfile?.avatar || ''; 552 + const videoData = extractVideoData(postData); 553 + const thumbnail = videoData?.thumbnail || avatar; 554 + 555 + // Update title 556 + document.title = `@${handle} - ${caption.substring(0, 50)}${caption.length > 50 ? '...' : ''}`; 557 + 558 + // Update meta description 559 + let metaDesc = document.querySelector('meta[name="description"]'); 560 + if (!metaDesc) { 561 + metaDesc = document.createElement('meta'); 562 + metaDesc.setAttribute('name', 'description'); 563 + document.head.appendChild(metaDesc); 564 + } 565 + metaDesc.setAttribute('content', caption); 566 + 567 + // Update OG tags 568 + const ogTags = { 569 + 'og:title': `@${handle} on orbyt`, 570 + 'og:description': caption, 571 + 'og:image': thumbnail, 572 + 'og:type': 'video.other', 573 + 'og:url': window.location.href, 574 + }; 575 + 576 + Object.entries(ogTags).forEach(([property, content]) => { 577 + let tag = document.querySelector(`meta[property="${property}"]`); 578 + if (!tag) { 579 + tag = document.createElement('meta'); 580 + tag.setAttribute('property', property); 581 + document.head.appendChild(tag); 582 + } 583 + tag.setAttribute('content', content); 584 + }); 585 + 586 + // Add video-specific OG tags if video URL exists 587 + if (videoData?.url) { 588 + let ogVideo = document.querySelector('meta[property="og:video"]'); 589 + if (!ogVideo) { 590 + ogVideo = document.createElement('meta'); 591 + ogVideo.setAttribute('property', 'og:video'); 592 + document.head.appendChild(ogVideo); 593 + } 594 + ogVideo.setAttribute('content', videoData.url); 595 + } 596 + 597 + // Update Twitter card tags 598 + const twitterTags = { 599 + 'twitter:card': 'summary_large_image', 600 + 'twitter:title': `@${handle} on orbyt`, 601 + 'twitter:description': caption, 602 + 'twitter:image': thumbnail, 603 + }; 604 + 605 + Object.entries(twitterTags).forEach(([name, content]) => { 606 + let tag = document.querySelector(`meta[name="${name}"]`); 607 + if (!tag) { 608 + tag = document.createElement('meta'); 609 + tag.setAttribute('name', name); 610 + document.head.appendChild(tag); 611 + } 612 + tag.setAttribute('content', content); 613 + }); 614 + } 615 + 616 + /** 617 + * Render post data into HTML 618 + * @param {Object} postData - Post data from API 619 + * @param {Object} authorProfile - Author profile data 620 + */ 621 + function renderPost(postData, authorProfile) { 622 + // Handle both view format (from getPosts) and record format (from getRecord) 623 + const postValue = postData.value || postData.record || postData; 624 + const caption = postValue?.text || ''; 625 + const createdAt = postValue?.createdAt || ''; 626 + const handle = authorProfile?.handle || postData.repo || ''; 627 + const displayName = authorProfile?.displayName || handle; 628 + const avatar = authorProfile?.avatar || ''; 629 + 630 + const videoData = extractVideoData(postData); 631 + 632 + // Update captions with clickable abbreviated links 633 + const captionMobile = document.getElementById('post-caption-mobile'); 634 + const captionDesktop = document.getElementById('post-caption-desktop'); 635 + const captionWithLinks = convertUrlsToLinks(caption); 636 + if (captionMobile) captionMobile.innerHTML = captionWithLinks; 637 + if (captionDesktop) captionDesktop.innerHTML = captionWithLinks; 638 + 639 + // Update author info 640 + const avatarMobile = document.getElementById('author-avatar-mobile'); 641 + const avatarDesktop = document.getElementById('author-avatar-desktop'); 642 + const linkMobile = document.getElementById('author-link-mobile'); 643 + const linkDesktop = document.getElementById('author-link-desktop'); 644 + const timeMobile = document.getElementById('post-time-mobile'); 645 + const timeDesktop = document.getElementById('post-time-desktop'); 646 + 647 + if (avatarMobile) { 648 + avatarMobile.src = avatar || '/images/Default-avatar.png'; 649 + avatarMobile.alt = `${displayName}'s avatar`; 650 + avatarMobile.onerror = function () { 651 + this.src = '/images/Default-avatar.png'; 652 + }; 653 + } 654 + if (avatarDesktop) { 655 + avatarDesktop.src = avatar || '/images/Default-avatar.png'; 656 + avatarDesktop.alt = `${displayName}'s avatar`; 657 + avatarDesktop.onerror = function () { 658 + this.src = '/images/Default-avatar.png'; 659 + }; 660 + } 661 + 662 + if (linkMobile) { 663 + linkMobile.textContent = displayName; 664 + linkMobile.href = `/@${encodeURIComponent(handle)}`; 665 + } 666 + if (linkDesktop) { 667 + linkDesktop.textContent = displayName; 668 + linkDesktop.href = `/@${encodeURIComponent(handle)}`; 669 + } 670 + 671 + const relativeTime = formatRelativeTime(createdAt); 672 + if (timeMobile) timeMobile.textContent = relativeTime; 673 + if (timeDesktop) timeDesktop.textContent = relativeTime; 674 + 675 + // Update likes count 676 + const likesCountMobile = document.getElementById('likes-count-mobile'); 677 + const likesCountDesktop = document.getElementById('likes-count-desktop'); 678 + // Get likeCount from postData if available (from getPostThread), otherwise default to 0 679 + const likesCount = postData.likeCount || 0; 680 + if (likesCountMobile) likesCountMobile.textContent = likesCount; 681 + if (likesCountDesktop) likesCountDesktop.textContent = likesCount; 682 + 683 + // Setup video player 684 + const videoEl = document.getElementById('vinit'); 685 + if (videoEl && videoData && videoData.url) { 686 + console.log('Video data found:', { 687 + url: videoData.url, 688 + thumbnail: videoData.thumbnail, 689 + aspectRatio: videoData.aspectRatio, 690 + }); 691 + 692 + // Set video attributes for autoplay and looping (TikTok style) 693 + videoEl.setAttribute('autoplay', ''); 694 + videoEl.setAttribute('loop', ''); 695 + videoEl.setAttribute('playsinline', ''); 696 + videoEl.muted = true; // Required for autoplay in most browsers 697 + 698 + // Set poster first 699 + if (videoData.thumbnail) { 700 + videoEl.poster = videoData.thumbnail; 701 + 702 + // Wait for poster to load before initializing responsive sizing (original pattern) 703 + const thumb = new Image(); 704 + thumb.onload = function () { 705 + thumb.onload = null; 706 + // Call postResizer after poster loads (matches original pattern) 707 + postResizer(); 708 + }; 709 + thumb.onerror = function () { 710 + // Even if poster fails to load, still initialize sizing 711 + postResizer(); 712 + }; 713 + thumb.src = videoData.thumbnail; 714 + } else { 715 + // No thumbnail, initialize sizing immediately 716 + postResizer(); 717 + } 718 + 719 + // Setup HLS playback 720 + if (videoData.url.includes('.m3u8')) { 721 + // Check if native HLS is supported (Safari/iOS) 722 + if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { 723 + // Native HLS support 724 + videoEl.src = videoData.url; 725 + videoEl.load(); 726 + 727 + // Try to play after loading 728 + videoEl.addEventListener('loadedmetadata', () => { 729 + videoEl.play().catch(err => { 730 + console.warn('Native HLS autoplay prevented:', err); 731 + }); 732 + }); 733 + } else if (typeof Hls !== 'undefined') { 734 + // Use hls.js for browsers that don't support native HLS 735 + // Clear any existing src first 736 + videoEl.src = ''; 737 + 738 + const hls = new Hls({ 739 + enableWorker: true, 740 + lowLatencyMode: true, 741 + autoStartLoad: true, 742 + }); 743 + 744 + hls.loadSource(videoData.url); 745 + hls.attachMedia(videoEl); 746 + 747 + hls.on(Hls.Events.MANIFEST_PARSED, () => { 748 + console.log('HLS manifest parsed, attempting playback'); 749 + // Try to play after manifest is parsed 750 + videoEl.play().catch(err => { 751 + console.warn('HLS.js autoplay prevented:', err); 752 + // Store HLS instance for later manual play 753 + videoEl.hlsInstance = hls; 754 + }); 755 + }); 756 + 757 + hls.on(Hls.Events.ERROR, (event, data) => { 758 + console.error('HLS.js error:', data); 759 + if (data.fatal) { 760 + switch (data.type) { 761 + case Hls.ErrorTypes.NETWORK_ERROR: 762 + console.error('Fatal network error, trying to recover...'); 763 + hls.startLoad(); 764 + break; 765 + case Hls.ErrorTypes.MEDIA_ERROR: 766 + console.error('Fatal media error, trying to recover...'); 767 + hls.recoverMediaError(); 768 + break; 769 + default: 770 + console.error('Fatal error, destroying HLS instance'); 771 + hls.destroy(); 772 + break; 773 + } 774 + } 775 + }); 776 + 777 + // Store HLS instance on video element for later access 778 + videoEl.hlsInstance = hls; 779 + } else { 780 + // Fallback: try to load directly (might work in some browsers) 781 + console.warn( 782 + 'HLS.js not available, attempting direct HLS load (may not work in all browsers)' 783 + ); 784 + videoEl.src = videoData.url; 785 + videoEl.load(); 786 + 787 + videoEl.addEventListener('loadedmetadata', () => { 788 + videoEl.play().catch(err => { 789 + console.warn('Direct HLS autoplay prevented:', err); 790 + }); 791 + }); 792 + } 793 + } else { 794 + // Regular video file (mp4, etc.) 795 + videoEl.src = videoData.url; 796 + videoEl.load(); 797 + 798 + // Try to play after loading 799 + videoEl.addEventListener('loadedmetadata', () => { 800 + videoEl.play().catch(err => { 801 + console.warn('Video autoplay prevented:', err); 802 + }); 803 + }); 804 + } 805 + 806 + // Add comprehensive error handling 807 + videoEl.addEventListener('error', e => { 808 + console.error('Video load error:', e); 809 + console.error('Video element error details:', { 810 + error: videoEl.error, 811 + errorCode: videoEl.error ? videoEl.error.code : null, 812 + errorMessage: videoEl.error ? videoEl.error.message : null, 813 + networkState: videoEl.networkState, 814 + readyState: videoEl.readyState, 815 + src: videoEl.src, 816 + videoUrl: videoData.url, 817 + }); 818 + }); 819 + 820 + // Add click handler to video element to play/pause (only if clicking directly on video) 821 + videoEl.addEventListener('click', e => { 822 + e.stopPropagation(); 823 + if (videoEl.paused) { 824 + videoEl.play().catch(err => console.warn('Play failed:', err)); 825 + } else { 826 + videoEl.pause(); 827 + } 828 + }); 829 + 830 + // Ensure video plays when user interacts with the page (for browsers that block autoplay) 831 + const handleUserInteraction = () => { 832 + if (videoEl.paused && videoEl.readyState >= 2) { 833 + videoEl.play().catch(err => { 834 + // Ignore autoplay errors - user will need to click to play 835 + if (err.name !== 'NotAllowedError') { 836 + console.warn('Play attempt failed:', err); 837 + } 838 + }); 839 + } 840 + }; 841 + 842 + // Listen for user interaction on the document 843 + ['click', 'touchstart', 'keydown'].forEach(eventType => { 844 + document.addEventListener(eventType, handleUserInteraction, { once: true, passive: true }); 845 + }); 846 + } else if (videoEl) { 847 + // No video available - show message 848 + console.warn('No video data found in post:', { 849 + videoEl: !!videoEl, 850 + videoData: videoData, 851 + postData: postData, 852 + }); 853 + const videoWrapper = document.getElementById('post-media'); 854 + if (videoWrapper) { 855 + videoWrapper.innerHTML = ` 856 + <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; text-align: center; padding: 20px;"> 857 + <p>No video available for this post</p> 858 + <a href="/@${encodeURIComponent(handle)}" style="color: #FF876C; text-decoration: underline;">Back to profile</a> 859 + </div> 860 + `; 861 + } 862 + } else { 863 + console.error('Video element not found in DOM'); 864 + } 865 + 866 + // Update meta tags 867 + updateMetaTags(postData, authorProfile); 868 + } 869 + 870 + /** 871 + * Initialize video player controls 872 + */ 873 + function initVideoControls() { 874 + const videoEl = document.getElementById('vinit'); 875 + const muteToggle = document.getElementById('mute-toggle'); 876 + const muteText = document.getElementById('mute-text'); 877 + 878 + if (!videoEl) return; 879 + 880 + // Ensure video is muted initially (required for autoplay) 881 + videoEl.muted = true; 882 + 883 + // Update mute text based on initial state 884 + if (muteText) { 885 + muteText.textContent = 'TAP TO UNMUTE'; 886 + } 887 + 888 + // Mute toggle 889 + if (muteToggle) { 890 + muteToggle.addEventListener('click', e => { 891 + e.preventDefault(); 892 + e.stopPropagation(); 893 + 894 + if (videoEl.muted) { 895 + videoEl.muted = false; 896 + // Try to play if paused (user interaction allows unmuted playback) 897 + if (videoEl.paused) { 898 + videoEl.play().catch(err => { 899 + console.warn('Unmuted play failed:', err); 900 + }); 901 + } 902 + if (muteText) muteText.textContent = 'TAP TO MUTE'; 903 + } else { 904 + videoEl.muted = true; 905 + if (muteText) muteText.textContent = 'TAP TO UNMUTE'; 906 + } 907 + }); 908 + } 909 + 910 + // Ensure video plays when it can 911 + const ensurePlayback = () => { 912 + if (videoEl.readyState >= 2) { 913 + // HAVE_CURRENT_DATA 914 + videoEl.play().catch(err => { 915 + // Autoplay was prevented - this is expected in some browsers 916 + if (err.name !== 'NotAllowedError') { 917 + console.warn('Playback error:', err); 918 + } 919 + }); 920 + } 921 + }; 922 + 923 + // Try to play when video is ready 924 + videoEl.addEventListener('canplay', ensurePlayback); 925 + videoEl.addEventListener('loadeddata', ensurePlayback); 926 + videoEl.addEventListener('loadedmetadata', ensurePlayback); 927 + 928 + // Also try after a short delay to handle async loading 929 + setTimeout(ensurePlayback, 500); 930 + 931 + // Handle video ended event to restart (for looping) 932 + videoEl.addEventListener('ended', () => { 933 + videoEl.currentTime = 0; 934 + videoEl.play().catch(err => { 935 + console.warn('Loop play failed:', err); 936 + }); 937 + }); 938 + } 939 + 940 + /** 941 + * Initialize responsive video sizing 942 + * This matches the original postResizer() function pattern 943 + */ 944 + function initResponsiveVideo() { 945 + const videoEl = document.getElementById('vinit'); 946 + const desktop = document.getElementById('desktop'); 947 + const postOverlay = document.getElementById('post-overlay'); 948 + 949 + if (!videoEl) return; 950 + 951 + function resizeVideo() { 952 + if (window.innerWidth >= 700) { 953 + const viewportWidth = window.innerWidth; 954 + const viewportHeight = window.innerHeight; 955 + 956 + // Base calculation: height-based sizing 957 + const availableHeight = viewportHeight - 60; 958 + const maxHeight = Math.min(1200, availableHeight); 959 + const minHeight = 400; 960 + let videoHeight = Math.max(minHeight, maxHeight); 961 + 962 + // Calculate width based on 9:16 aspect ratio 963 + let columnWidth = (videoHeight * 9) / 16; 964 + 965 + // Apply scaling factor based on viewport width to shrink video as screen gets narrower 966 + // This makes the video shrink gradually before switching to mobile view 967 + let scaleFactor = 1.0; 968 + if (viewportWidth < 900) { 969 + // Linear scaling from 1.0 at 900px to 0.75 at 700px 970 + scaleFactor = 0.75 + (0.25 * (viewportWidth - 700)) / 200; 971 + scaleFactor = Math.max(0.75, Math.min(1.0, scaleFactor)); 972 + videoHeight = videoHeight * scaleFactor; 973 + columnWidth = (videoHeight * 9) / 16; 974 + } 975 + 976 + // Account for available viewport width - ensure video doesn't exceed available space 977 + // Desktop content (max 374px, scales down with viewport via CSS) + padding + gap 978 + // Use a conservative estimate that scales with viewport 979 + let reservedSpace = 540; // Base: 374 + 60 + 30 + 40 + margin 980 + if (viewportWidth < 900) { 981 + // Scale reserved space proportionally as viewport shrinks 982 + const spaceScale = 0.7 + (0.3 * (viewportWidth - 700)) / 200; // 0.7 at 700px, 1.0 at 900px 983 + reservedSpace = Math.max(400, reservedSpace * spaceScale); 984 + } 985 + const maxVideoWidth = viewportWidth - reservedSpace; 986 + 987 + // If video width exceeds available space, scale down to fit 988 + if (columnWidth > maxVideoWidth && maxVideoWidth > 200) { 989 + columnWidth = maxVideoWidth; 990 + videoHeight = (columnWidth * 16) / 9; 991 + // Re-apply minimum height check 992 + if (videoHeight < minHeight) { 993 + videoHeight = minHeight; 994 + columnWidth = (videoHeight * 9) / 16; 995 + } 996 + } 997 + 998 + // Ensure minimum dimensions 999 + if (videoHeight < minHeight) { 1000 + videoHeight = minHeight; 1001 + columnWidth = (videoHeight * 9) / 16; 1002 + } 1003 + 1004 + videoEl.style.height = `${videoHeight}px`; 1005 + videoEl.style.width = `${columnWidth}px`; 1006 + // Desktop container width is handled by CSS responsive styles, don't override it 1007 + } else { 1008 + videoEl.style.width = '100%'; 1009 + videoEl.style.height = 'auto'; 1010 + videoEl.style.verticalAlign = 'middle'; 1011 + if (desktop) { 1012 + desktop.style.width = ''; 1013 + } 1014 + } 1015 + } 1016 + 1017 + function adjustOverlay() { 1018 + if (!postOverlay) return; 1019 + 1020 + const scrollPosition = window.scrollY; 1021 + const offsetHeight = videoEl.offsetHeight; 1022 + const displayHeight = document.documentElement.clientHeight; 1023 + const overlayOffset = Math.max(0, offsetHeight - displayHeight - scrollPosition); 1024 + 1025 + postOverlay.style.bottom = `${overlayOffset}px`; 1026 + } 1027 + 1028 + // Initial resize 1029 + resizeVideo(); 1030 + adjustOverlay(); 1031 + 1032 + // Resize on window resize 1033 + window.addEventListener('resize', () => { 1034 + resizeVideo(); 1035 + adjustOverlay(); 1036 + }); 1037 + 1038 + // Adjust overlay on scroll (throttled) 1039 + let ticking = false; 1040 + window.addEventListener('scroll', () => { 1041 + if (!ticking) { 1042 + window.requestAnimationFrame(() => { 1043 + adjustOverlay(); 1044 + ticking = false; 1045 + }); 1046 + ticking = true; 1047 + } 1048 + }); 1049 + } 1050 + 1051 + /** 1052 + * postResizer function - alias for compatibility with original pattern 1053 + */ 1054 + function postResizer() { 1055 + initResponsiveVideo(); 1056 + } 1057 + 1058 + /** 1059 + * Show error message 1060 + * @param {string} message - Error message 1061 + */ 1062 + function showError(message) { 1063 + const loading = document.getElementById('loading'); 1064 + const error = document.getElementById('error'); 1065 + const errorMessage = document.getElementById('error-message'); 1066 + 1067 + if (loading) loading.style.display = 'none'; 1068 + if (error) error.style.display = 'flex'; 1069 + if (errorMessage) errorMessage.textContent = message; 1070 + } 1071 + 1072 + /** 1073 + * Show post content 1074 + */ 1075 + function showPostContent() { 1076 + const loading = document.getElementById('loading'); 1077 + const error = document.getElementById('error'); 1078 + const post = document.getElementById('post'); 1079 + 1080 + if (loading) loading.style.display = 'none'; 1081 + if (error) error.style.display = 'none'; 1082 + if (post) { 1083 + // Remove hidden class to let CSS media queries handle display property 1084 + // CSS will set display:flex on desktop (>=700px) and display:block on mobile 1085 + post.classList.remove('hidden'); 1086 + } 1087 + } 1088 + 1089 + /** 1090 + * Main initialization function 1091 + */ 1092 + async function initPostView() { 1093 + try { 1094 + // Parse URL to get handle and post ID 1095 + const { handle, postId } = parsePostURL(); 1096 + 1097 + if (!handle || !postId) { 1098 + showError('Invalid URL format. Expected: /@handle/postId or ?user=handle&post=postId'); 1099 + return; 1100 + } 1101 + 1102 + // Fetch post data 1103 + const postData = await fetchPost(handle, postId); 1104 + 1105 + if (!postData || !postData.value) { 1106 + throw new Error('Invalid post data received from API'); 1107 + } 1108 + 1109 + // Get author info from postData (from getPosts, author is included) 1110 + let authorProfile = null; 1111 + 1112 + if (postData.author) { 1113 + // Use author data from getPosts response 1114 + authorProfile = postData.author; 1115 + } else { 1116 + // Fallback: fetch author profile if not included 1117 + const postHandle = postData.repo || handle; 1118 + const didMatch = postData.uri?.match(/did:plc:[^/]+/); 1119 + 1120 + try { 1121 + try { 1122 + authorProfile = await fetchAuthorProfile(postHandle); 1123 + } catch (handleError) { 1124 + if (didMatch) { 1125 + authorProfile = await fetchAuthorProfile(didMatch[0]); 1126 + } else { 1127 + throw handleError; 1128 + } 1129 + } 1130 + 1131 + if (!authorProfile || !authorProfile.handle) { 1132 + throw new Error('Invalid profile data'); 1133 + } 1134 + } catch (profileError) { 1135 + console.warn('Could not fetch author profile:', profileError); 1136 + authorProfile = { 1137 + handle: postHandle, 1138 + displayName: postHandle, 1139 + avatar: null, 1140 + description: null, 1141 + }; 1142 + } 1143 + } 1144 + 1145 + // Render post (this will handle video loading and call postResizer after poster loads) 1146 + renderPost(postData, authorProfile); 1147 + showPostContent(); 1148 + 1149 + // Initialize video controls 1150 + initVideoControls(); 1151 + // Note: initResponsiveVideo/postResizer is called after poster loads in renderPost() 1152 + } catch (error) { 1153 + console.error('Failed to initialize post view:', error); 1154 + showError(error.message || 'Failed to load post. Please check the URL and try again.'); 1155 + } 1156 + } 1157 + 1158 + // Initialize when DOM is ready 1159 + if (document.readyState === 'loading') { 1160 + document.addEventListener('DOMContentLoaded', initPostView); 1161 + } else { 1162 + initPostView(); 1163 + }
+106
js/query-client.js
··· 1 + /** 2 + * TanStack Query Client Configuration 3 + * Shared query client for Bluesky API queries 4 + * Uses ESM CDN import - no build tools required 5 + */ 6 + 7 + let QueryClient = null; 8 + let queryClientInstance = null; 9 + 10 + /** 11 + * Initialize TanStack Query Core from ESM CDN 12 + */ 13 + async function initQueryClient() { 14 + if (!QueryClient) { 15 + const module = await import('https://esm.sh/@tanstack/query-core@5.51.1'); 16 + QueryClient = module.QueryClient; 17 + } 18 + return QueryClient; 19 + } 20 + 21 + /** 22 + * Create and configure the query client 23 + * @returns {Promise<Object>} Configured QueryClient instance 24 + */ 25 + export async function createQueryClient() { 26 + const QC = await initQueryClient(); 27 + 28 + return new QC({ 29 + defaultOptions: { 30 + queries: { 31 + // Profile queries: 5-minute stale time (rarely changes) 32 + staleTime: 5 * 60 * 1000, // 5 minutes 33 + // Cache time: 10 minutes (how long to keep unused data) 34 + gcTime: 10 * 60 * 1000, // Previously called cacheTime 35 + // Retry failed requests up to 2 times 36 + retry: 2, 37 + // Retry delay with exponential backoff 38 + retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), 39 + }, 40 + }, 41 + }); 42 + } 43 + 44 + /** 45 + * Get or create singleton query client instance 46 + */ 47 + export async function getQueryClient() { 48 + if (!queryClientInstance) { 49 + queryClientInstance = await createQueryClient(); 50 + 51 + // Set up window focus refetch (manual implementation for vanilla JS) 52 + let lastFocusTime = Date.now(); 53 + window.addEventListener('focus', () => { 54 + const now = Date.now(); 55 + // Only refetch if window was unfocused for more than 30 seconds 56 + if (now - lastFocusTime > 30000) { 57 + // Refetch stale queries 58 + queryClientInstance 59 + .refetchQueries({ 60 + stale: true, 61 + }) 62 + .catch(err => { 63 + console.warn('Error refetching queries on focus:', err); 64 + }); 65 + } 66 + lastFocusTime = now; 67 + }); 68 + 69 + // Refetch on reconnect 70 + window.addEventListener('online', () => { 71 + queryClientInstance 72 + .refetchQueries({ 73 + stale: true, 74 + }) 75 + .catch(err => { 76 + console.warn('Error refetching queries on reconnect:', err); 77 + }); 78 + }); 79 + } 80 + return queryClientInstance; 81 + } 82 + 83 + /** 84 + * Query key factories for consistent query key generation 85 + */ 86 + export const queryKeys = { 87 + profile: handle => ['bluesky-profile', handle], 88 + feed: (handle, cursor) => ['bluesky-feed', handle, cursor || null], 89 + post: (handle, postId) => ['bluesky-post', handle, postId], 90 + }; 91 + 92 + /** 93 + * Helper function to fetch query with proper error handling 94 + * @param {Object} queryClient - QueryClient instance 95 + * @param {Array} queryKey - Query key array 96 + * @param {Function} queryFn - Function that returns a Promise 97 + * @param {Object} options - Additional query options 98 + * @returns {Promise} Query result 99 + */ 100 + export async function fetchQuery(queryClient, queryKey, queryFn, options = {}) { 101 + return queryClient.fetchQuery({ 102 + queryKey, 103 + queryFn, 104 + ...options, 105 + }); 106 + }
+653
js/router.js
··· 1 + /** 2 + * Client-side router for clean URLs on static hosting 3 + * Handles routes like /@username and /@username/postid 4 + * Loads content dynamically without changing the URL 5 + */ 6 + 7 + // Route patterns 8 + const ROUTES = { 9 + PROFILE: /^\/@([^/]+)\/?$/, 10 + POST: /^\/@([^/]+)\/([^/]+)\/?$/, 11 + }; 12 + 13 + // Current route state 14 + let currentRoute = null; 15 + let currentContent = null; 16 + 17 + /** 18 + * Parse the current pathname into route components 19 + * @returns {Object|null} Route object with type, handle, and postId, or null if no match 20 + */ 21 + function parseRoute(pathname) { 22 + // Match post route first (more specific) 23 + const postMatch = pathname.match(ROUTES.POST); 24 + if (postMatch) { 25 + return { 26 + type: 'post', 27 + handle: postMatch[1], 28 + postId: postMatch[2], 29 + }; 30 + } 31 + 32 + // Match profile route 33 + const profileMatch = pathname.match(ROUTES.PROFILE); 34 + if (profileMatch) { 35 + return { 36 + type: 'profile', 37 + handle: profileMatch[1], 38 + }; 39 + } 40 + 41 + return null; 42 + } 43 + 44 + /** 45 + * Get the HTML file path for a route 46 + * @param {Object} route - Route object 47 + * @returns {string} HTML file path 48 + */ 49 + function getRouteFilePath(route) { 50 + if (route.type === 'post') { 51 + return '/post-view.html'; 52 + } else if (route.type === 'profile') { 53 + return '/orbyt-profile.html'; 54 + } 55 + return null; 56 + } 57 + 58 + /** 59 + * Extract the body content from HTML string 60 + * @param {string} html - HTML string 61 + * @returns {Object} Object with body HTML, head content, and scripts 62 + */ 63 + function extractHTMLContent(html) { 64 + const parser = new DOMParser(); 65 + const doc = parser.parseFromString(html, 'text/html'); 66 + 67 + // Extract scripts from both head and body 68 + const allScripts = []; 69 + 70 + // Get all scripts from head 71 + const headScripts = doc.head.querySelectorAll('script'); 72 + headScripts.forEach(script => allScripts.push(script.cloneNode(true))); 73 + 74 + // Get all scripts from body and remove them from DOM for now 75 + const bodyScripts = Array.from(doc.body.querySelectorAll('script')); 76 + const scriptData = []; 77 + 78 + bodyScripts.forEach(script => { 79 + scriptData.push({ 80 + element: script.cloneNode(true), 81 + text: script.textContent, 82 + src: script.getAttribute('src'), 83 + type: script.getAttribute('type'), 84 + }); 85 + script.remove(); // Remove from DOM so it's not in innerHTML 86 + }); 87 + 88 + // Extract body content (now without scripts) 89 + const body = doc.body.innerHTML; 90 + 91 + // Add body scripts to allScripts array 92 + scriptData.forEach(data => { 93 + allScripts.push(data.element); 94 + }); 95 + 96 + // Extract head content (styles, meta tags, etc.) 97 + const headElements = Array.from(doc.head.children); 98 + 99 + // Separate styles from other head content 100 + const styles = []; 101 + const otherHead = []; 102 + 103 + headElements.forEach(element => { 104 + if (element.tagName === 'SCRIPT') { 105 + // Already collected above 106 + } else if (element.tagName === 'LINK' && element.rel === 'stylesheet') { 107 + styles.push(element); 108 + } else if (element.tagName === 'STYLE') { 109 + styles.push(element); 110 + } else { 111 + otherHead.push(element); 112 + } 113 + }); 114 + 115 + return { 116 + body: body, 117 + scripts: allScripts, 118 + styles, 119 + meta: otherHead, 120 + }; 121 + } 122 + 123 + /** 124 + * Set or update the base tag for relative path resolution 125 + * This ensures CSS, JS, and image paths resolve correctly when loading dynamic content 126 + */ 127 + function setBaseTag() { 128 + let baseTag = document.querySelector('base'); 129 + if (!baseTag) { 130 + baseTag = document.createElement('base'); 131 + document.head.insertBefore(baseTag, document.head.firstChild); 132 + } 133 + // Set base to current origin + path (root) for relative path resolution 134 + // This ensures CSS, JS, and image paths resolve correctly 135 + const baseUrl = window.location.origin + '/'; 136 + baseTag.href = baseUrl; 137 + 138 + // Debug logging (remove in production) 139 + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { 140 + console.log('[Router] Base tag set to:', baseUrl); 141 + } 142 + } 143 + 144 + /** 145 + * Update meta tags in the document head 146 + * @param {Array} metaElements - Array of meta and other head elements 147 + */ 148 + function updateHeadElements(metaElements) { 149 + // Remove dynamic meta tags (keep base ones) 150 + const head = document.head; 151 + const existingMeta = head.querySelectorAll( 152 + 'meta[data-dynamic], link[data-dynamic], title[data-dynamic]' 153 + ); 154 + existingMeta.forEach(el => el.remove()); 155 + 156 + // Add new meta tags 157 + metaElements.forEach(element => { 158 + const clone = element.cloneNode(true); 159 + clone.setAttribute('data-dynamic', 'true'); 160 + 161 + // Handle title separately 162 + if (element.tagName === 'TITLE') { 163 + let titleEl = head.querySelector('title'); 164 + if (!titleEl) { 165 + titleEl = document.createElement('title'); 166 + head.appendChild(titleEl); 167 + } 168 + titleEl.setAttribute('data-dynamic', 'true'); 169 + titleEl.textContent = element.textContent; 170 + } else { 171 + head.appendChild(clone); 172 + } 173 + }); 174 + } 175 + 176 + /** 177 + * Load and execute scripts in order 178 + * @param {Array} scriptElements - Array of script elements 179 + * @returns {Promise} Resolves when all scripts are loaded 180 + */ 181 + function loadScripts(scriptElements) { 182 + const loadPromises = scriptElements.map(script => { 183 + return new Promise((resolve, reject) => { 184 + const newScript = document.createElement('script'); 185 + 186 + // Copy attributes 187 + Array.from(script.attributes).forEach(attr => { 188 + newScript.setAttribute(attr.name, attr.value); 189 + }); 190 + 191 + // Handle external scripts (module or regular) 192 + // Per HTML spec: if src is present, load external file and ignore inline content 193 + const src = script.getAttribute('src'); 194 + if (src) { 195 + // Check if already loaded 196 + const existing = Array.from(document.querySelectorAll('script[src]')).find( 197 + script => script.getAttribute('src') === src 198 + ); 199 + if (existing) { 200 + // Script already loaded, resolve immediately 201 + resolve(); 202 + return; 203 + } 204 + 205 + newScript.onload = resolve; 206 + newScript.onerror = err => { 207 + console.error(`Failed to load script: ${src}`, err); 208 + reject(err); 209 + }; 210 + newScript.src = src; // Base tag will handle relative path resolution 211 + document.body.appendChild(newScript); 212 + } else if (script.textContent) { 213 + // Handle inline scripts (only if no src attribute) 214 + newScript.textContent = script.textContent; 215 + document.body.appendChild(newScript); 216 + resolve(); 217 + } else { 218 + // Script with no src and no content - resolve immediately 219 + resolve(); 220 + } 221 + }); 222 + }); 223 + 224 + return Promise.all(loadPromises); 225 + } 226 + 227 + /** 228 + * Load CSS styles and wait for them to load 229 + * @param {Array} styleElements - Array of link and style elements 230 + * @returns {Promise} Resolves when all stylesheets are loaded 231 + * Note: With base tag set, relative paths should resolve correctly 232 + */ 233 + async function loadStyles(styleElements) { 234 + const head = document.head; 235 + const loadPromises = []; 236 + 237 + styleElements.forEach(element => { 238 + // Check if style is already loaded 239 + if (element.tagName === 'LINK' && element.rel === 'stylesheet') { 240 + const href = element.getAttribute('href'); 241 + 242 + // Check if already loaded (by href attribute) 243 + const existing = Array.from(head.querySelectorAll('link[rel="stylesheet"]')).find( 244 + link => link.getAttribute('href') === href 245 + ); 246 + if (existing) { 247 + // Style already loaded - check if it's fully loaded and parsed 248 + // Stylesheets that are loaded will have a 'sheet' property (or styleSheet in IE) 249 + // Also check if the link has loaded state 250 + if (existing.sheet || existing.styleSheet) { 251 + return; // Already loaded and parsed, no need to wait 252 + } 253 + // Check if link has completed loading (sometimes sheet isn't immediately available) 254 + if (existing.readyState === 'complete' || existing.readyState === 'loaded') { 255 + return; // Already completed loading 256 + } 257 + // If it exists but not loaded yet (unlikely but possible), wait for it 258 + // Use a timeout to prevent hanging if it never loads 259 + loadPromises.push( 260 + new Promise(resolve => { 261 + const timeout = setTimeout(() => { 262 + console.warn(`Stylesheet load timeout: ${href}`); 263 + resolve(); // Resolve on timeout to not block 264 + }, 5000); // 5 second timeout 265 + 266 + const cleanup = () => { 267 + clearTimeout(timeout); 268 + existing.removeEventListener('load', onLoad); 269 + existing.removeEventListener('error', onError); 270 + }; 271 + 272 + const onLoad = () => { 273 + cleanup(); 274 + resolve(); 275 + }; 276 + 277 + const onError = () => { 278 + cleanup(); 279 + console.warn(`Stylesheet failed to load: ${href}`); 280 + resolve(); // Resolve even on error to not block 281 + }; 282 + 283 + existing.addEventListener('load', onLoad); 284 + existing.addEventListener('error', onError); 285 + }) 286 + ); 287 + return; 288 + } 289 + 290 + const link = document.createElement('link'); 291 + link.rel = 'stylesheet'; 292 + link.href = href; // Base tag will handle relative path resolution 293 + link.setAttribute('data-dynamic', 'true'); 294 + 295 + // Wait for stylesheet to load 296 + const loadPromise = new Promise(resolve => { 297 + const onLoad = () => { 298 + // Small delay to ensure styles are parsed and applied 299 + setTimeout(() => resolve(), 50); 300 + }; 301 + 302 + const onError = () => { 303 + console.warn(`Failed to load stylesheet: ${href}`); 304 + resolve(); // Resolve even on error to not block 305 + }; 306 + 307 + link.addEventListener('load', onLoad); 308 + link.addEventListener('error', onError); 309 + 310 + head.appendChild(link); 311 + 312 + // Check if stylesheet loaded synchronously (cached) 313 + // This can happen with cached resources 314 + if ( 315 + link.sheet || 316 + link.styleSheet || 317 + link.readyState === 'complete' || 318 + link.readyState === 'loaded' 319 + ) { 320 + link.removeEventListener('load', onLoad); 321 + link.removeEventListener('error', onError); 322 + setTimeout(() => resolve(), 50); 323 + } 324 + }); 325 + 326 + loadPromises.push(loadPromise); 327 + } else if (element.tagName === 'STYLE') { 328 + // Inline styles load immediately 329 + const style = document.createElement('style'); 330 + style.setAttribute('data-dynamic', 'true'); 331 + style.textContent = element.textContent; 332 + head.appendChild(style); 333 + } 334 + }); 335 + 336 + // Wait for all stylesheets to load 337 + await Promise.all(loadPromises); 338 + 339 + // Small delay to ensure styles are parsed and applied to DOM 340 + // This prevents flash of unstyled content 341 + return new Promise(resolve => setTimeout(resolve, 100)); 342 + } 343 + 344 + /** 345 + * Set route data on window for JavaScript to access 346 + * This allows existing JavaScript to read route info without query params 347 + * @param {Object} route - Route object 348 + */ 349 + function setRouteData(route) { 350 + // Store route data on window so JavaScript can access it 351 + // This avoids adding query params to the URL 352 + window.__currentRoute = route; 353 + 354 + // Also update history state without changing URL 355 + window.history.replaceState({ route }, '', window.location.pathname); 356 + } 357 + 358 + /** 359 + * Load content for a route 360 + * @param {Object} route - Route object 361 + * @returns {Promise} Resolves when content is loaded 362 + */ 363 + async function loadRouteContent(route) { 364 + const filePath = getRouteFilePath(route); 365 + if (!filePath) { 366 + throw new Error(`No file path for route type: ${route.type}`); 367 + } 368 + 369 + // Show loading state 370 + showLoading(); 371 + 372 + try { 373 + // Fetch the HTML file 374 + const response = await fetch(filePath); 375 + if (!response.ok) { 376 + throw new Error(`Failed to load ${filePath}: ${response.status}`); 377 + } 378 + 379 + const html = await response.text(); 380 + 381 + // Set base tag FIRST for relative path resolution 382 + // This ensures CSS, JS, and image paths resolve correctly 383 + setBaseTag(); 384 + 385 + // Extract content 386 + const { body, scripts, styles, meta } = extractHTMLContent(html); 387 + 388 + // Update head elements first 389 + updateHeadElements(meta); 390 + 391 + // Load styles and wait for them to load before inserting content 392 + // This prevents flash of unstyled content (FOUC) 393 + await loadStyles(styles); 394 + 395 + // Clear existing content 396 + const container = document.getElementById('router-content'); 397 + if (container) { 398 + container.innerHTML = body; 399 + } else { 400 + // Create container if it doesn't exist 401 + const newContainer = document.createElement('div'); 402 + newContainer.id = 'router-content'; 403 + newContainer.innerHTML = body; 404 + document.body.innerHTML = ''; 405 + document.body.appendChild(newContainer); 406 + } 407 + 408 + // Set route data for JavaScript to access 409 + setRouteData(route); 410 + 411 + // Load scripts (these will initialize the page) 412 + await loadScripts(scripts); 413 + 414 + // Hide loading state 415 + hideLoading(); 416 + 417 + // Store current route 418 + currentRoute = route; 419 + } catch (error) { 420 + console.error('Error loading route content:', error); 421 + showError(`Failed to load page: ${error.message}`); 422 + } 423 + } 424 + 425 + /** 426 + * Show loading state 427 + */ 428 + function showLoading() { 429 + const existingLoading = document.getElementById('router-loading'); 430 + if (existingLoading) return; 431 + 432 + const loading = document.createElement('div'); 433 + loading.id = 'router-loading'; 434 + loading.style.cssText = ` 435 + position: fixed; 436 + top: 0; 437 + left: 0; 438 + right: 0; 439 + bottom: 0; 440 + background: #000; 441 + display: flex; 442 + align-items: center; 443 + justify-content: center; 444 + color: #fff; 445 + font-family: -apple-system, BlinkMacSystemFont, sans-serif; 446 + z-index: 9999; 447 + `; 448 + loading.innerHTML = '<p>Loading...</p>'; 449 + document.body.appendChild(loading); 450 + } 451 + 452 + /** 453 + * Hide loading state 454 + */ 455 + function hideLoading() { 456 + const loading = document.getElementById('router-loading'); 457 + if (loading) { 458 + loading.remove(); 459 + } 460 + } 461 + 462 + /** 463 + * Show error state 464 + * @param {string} message - Error message 465 + */ 466 + function showError(message) { 467 + hideLoading(); 468 + 469 + const error = document.createElement('div'); 470 + error.id = 'router-error'; 471 + error.style.cssText = ` 472 + position: fixed; 473 + top: 0; 474 + left: 0; 475 + right: 0; 476 + bottom: 0; 477 + background: #000; 478 + display: flex; 479 + align-items: center; 480 + justify-content: center; 481 + color: #fff; 482 + font-family: -apple-system, BlinkMacSystemFont, sans-serif; 483 + flex-direction: column; 484 + gap: 20px; 485 + z-index: 9999; 486 + `; 487 + error.innerHTML = ` 488 + <p style="font-size: 18px;">${escapeHtml(message)}</p> 489 + <a href="/" style="color: #FF876C; text-decoration: underline;">Go Home</a> 490 + `; 491 + document.body.appendChild(error); 492 + } 493 + 494 + /** 495 + * Escape HTML to prevent XSS 496 + * @param {string} text - Text to escape 497 + * @returns {string} Escaped HTML 498 + */ 499 + function escapeHtml(text) { 500 + const div = document.createElement('div'); 501 + div.textContent = text; 502 + return div.innerHTML; 503 + } 504 + 505 + /** 506 + * Handle navigation to a new route 507 + * @param {string} pathname - Path to navigate to 508 + */ 509 + function navigate(pathname) { 510 + const route = parseRoute(pathname); 511 + 512 + if (!route) { 513 + // No route matched - show 404 514 + show404(); 515 + return; 516 + } 517 + 518 + // Load the route content 519 + loadRouteContent(route).catch(error => { 520 + console.error('Navigation error:', error); 521 + showError('Failed to navigate to page'); 522 + }); 523 + } 524 + 525 + /** 526 + * Show 404 error page 527 + */ 528 + function show404() { 529 + hideLoading(); 530 + 531 + const error = document.createElement('div'); 532 + error.id = 'router-404'; 533 + error.style.cssText = ` 534 + position: fixed; 535 + top: 0; 536 + left: 0; 537 + right: 0; 538 + bottom: 0; 539 + background: #000; 540 + display: flex; 541 + align-items: center; 542 + justify-content: center; 543 + color: #fff; 544 + font-family: -apple-system, BlinkMacSystemFont, sans-serif; 545 + flex-direction: column; 546 + gap: 20px; 547 + z-index: 9999; 548 + `; 549 + error.innerHTML = ` 550 + <div style="font-size: 8rem; font-weight: 900; line-height: 1;">404</div> 551 + <p style="font-size: 1.25rem; opacity: 0.9;">There's nothing to see here...</p> 552 + <a href="/" style="display: inline-flex; background: #fff; color: #000; padding: 12px 20px; border-radius: 999px; text-decoration: none; font-weight: 700;">Go Home</a> 553 + `; 554 + document.body.innerHTML = ''; 555 + document.body.appendChild(error); 556 + } 557 + 558 + /** 559 + * Initialize the router 560 + */ 561 + function initRouter() { 562 + const pathname = window.location.pathname; 563 + const route = parseRoute(pathname); 564 + 565 + // Only initialize router if we're on 404.html (handled by GitHub Pages) 566 + // Check if we're on the 404 page by looking for the router-content element 567 + // This element only exists in 404.html, not in other pages (index.html, terms.html, etc.) 568 + const is404Page = document.body.querySelector('#router-content') !== null; 569 + 570 + if (is404Page) { 571 + // We're on 404.html - handle routing 572 + if (route) { 573 + // This is a routed path - load the route content 574 + navigate(pathname); 575 + } else { 576 + // Not a routed path - show 404 577 + show404(); 578 + } 579 + 580 + // Handle browser back/forward buttons 581 + window.addEventListener('popstate', event => { 582 + const newPathname = window.location.pathname; 583 + const newRoute = parseRoute(newPathname); 584 + 585 + if (newRoute) { 586 + navigate(newPathname); 587 + } else { 588 + // Not a route - reload to show the actual page 589 + window.location.reload(); 590 + } 591 + }); 592 + 593 + // Intercept link clicks to handle internal navigation 594 + // This allows links to routes to work from anywhere 595 + document.addEventListener('click', event => { 596 + const link = event.target.closest('a'); 597 + if (!link) return; 598 + 599 + const href = link.getAttribute('href'); 600 + if (!href) return; 601 + 602 + // Check if it's an internal route 603 + try { 604 + const url = new URL(href, window.location.origin); 605 + 606 + // Only handle same-origin URLs 607 + if (url.origin !== window.location.origin) { 608 + return; // External link - let it navigate normally 609 + } 610 + 611 + const clickedRoute = parseRoute(url.pathname); 612 + if (clickedRoute) { 613 + // It's a routed URL - handle it client-side 614 + event.preventDefault(); 615 + 616 + // Update history 617 + window.history.pushState({ route: clickedRoute }, '', url.pathname); 618 + 619 + // Navigate 620 + navigate(url.pathname); 621 + } 622 + // If not a route, let it navigate normally (for /, terms.html, etc.) 623 + } catch (e) { 624 + // Relative URL - check if it's a clean URL pattern 625 + if (href.startsWith('/@') || href.startsWith('@')) { 626 + const fullPath = href.startsWith('/') ? href : '/' + href; 627 + const clickedRoute = parseRoute(fullPath); 628 + if (clickedRoute) { 629 + event.preventDefault(); 630 + window.history.pushState({ route: clickedRoute }, '', fullPath); 631 + navigate(fullPath); 632 + } 633 + } 634 + // Otherwise let it navigate normally 635 + } 636 + }); 637 + } 638 + // If not on 404.html, let the page load normally (index.html, terms.html, etc.) 639 + } 640 + 641 + // Export for use in other scripts if needed 642 + window.router = { 643 + navigate, 644 + parseRoute, 645 + getRouteFilePath, 646 + }; 647 + 648 + // Initialize when DOM is ready 649 + if (document.readyState === 'loading') { 650 + document.addEventListener('DOMContentLoaded', initRouter); 651 + } else { 652 + initRouter(); 653 + }
+15
oauth-client-metadata.json
··· 1 + { 2 + "client_id": "https://getorbyt.com/oauth-client-metadata.json", 3 + "client_name": "orbyt", 4 + "client_uri": "https://getorbyt.com", 5 + "logo_uri": "https://getorbyt.com/TV-Raw.png", 6 + "tos_uri": "https://getorbyt.com/terms.html", 7 + "policy_uri": "https://getorbyt.com/privacy.html", 8 + "redirect_uris": ["com.getorbyt:/oauth/callback"], 9 + "scope": "atproto transition:generic transition:chat.bsky", 10 + "grant_types": ["authorization_code", "refresh_token"], 11 + "response_types": ["code"], 12 + "token_endpoint_auth_method": "none", 13 + "application_type": "native", 14 + "dpop_bound_access_tokens": true 15 + }
orbyt-logo-wordmark.png

This is a binary file and will not be displayed.

orbyt-logo.png

This is a binary file and will not be displayed.

+186
orbyt-profile.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="utf-8"> 6 + 7 + <link rel="preconnect" href="https://fonts.googleapis.com"> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 + <link href="https://fonts.googleapis.com/css2?family=Figtree:wght@900&display=swap" rel="stylesheet"> 10 + <link rel="stylesheet" href="/css/normalize.css"> 11 + <link rel="stylesheet" href="/css/skeleton.css"> 12 + <link rel="stylesheet" href="/css/shared.css"> 13 + <link rel="stylesheet" href="/css/profile.css"> 14 + 15 + <title>Profile</title> 16 + <meta name="description" content="Profile page"> 17 + <meta property="og:title" content="Profile" /> 18 + <meta property="og:description" content="Profile page" /> 19 + <meta property="og:image" content="/images/Default-avatar.png" /> 20 + <meta property="twitter:card" content="summary" /> 21 + <meta property="twitter:site" content="orbyt_video" /> 22 + <meta property="twitter:title" content="Profile" /> 23 + <meta property="twitter:description" content="Profile page" /> 24 + <meta property="twitter:image" content="/images/Default-avatar.png" /> 25 + 26 + <meta name="viewport" content="width=device-width, initial-scale=1"> 27 + 28 + <style> 29 + header .logo-wordmark { 30 + display: flex; 31 + align-items: center; 32 + text-decoration: none; 33 + transition: opacity 0.2s ease; 34 + } 35 + header .logo-wordmark:hover { 36 + opacity: 0.8; 37 + } 38 + header .logo-text { 39 + font-family: 'Figtree', sans-serif; 40 + font-weight: 900; 41 + font-size: 42px; 42 + letter-spacing: -0.02em; 43 + line-height: 1; 44 + color: #ccd7e9; 45 + } 46 + .install-button { 47 + background-color: #ccd7e9 !important; 48 + color: black !important; 49 + } 50 + .install-button .svg-button { 51 + display: inline-block; 52 + width: 16px; 53 + height: 16px; 54 + flex-shrink: 0; 55 + margin-left: 8px; 56 + transition: transform 0.2s ease; 57 + } 58 + .install-button:hover .svg-button { 59 + transform: translateX(2px); 60 + } 61 + .install-button .svg-button path { 62 + stroke: black !important; 63 + fill: none; 64 + stroke-linecap: round; 65 + stroke-linejoin: round; 66 + } 67 + @media screen and (max-width: 540px) { 68 + header .logo-text { 69 + font-size: 32px; 70 + } 71 + } 72 + </style> 73 + </head> 74 + 75 + <body> 76 + <div id="profile"> 77 + <header> 78 + <div class="links"> 79 + <a href='/' class="logo-wordmark"> 80 + <span class="logo-text">orbyt</span> 81 + </a> 82 + <a class='install-button mobile' href="https://getorbyt.com/beta"> 83 + Waitlist 84 + <svg class="svg-button" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg"> 85 + <path d="M1.93892 19.9988L18.9736 2.96362" stroke="black" stroke-width="3.993"/> 86 + <path d="M19 16.7368L19 2.93875L5.20193 2.93875" stroke="black" stroke-width="3.993"/> 87 + </svg> 88 + </a> 89 + </div> 90 + 91 + <svg class="hr-inline mobile" height="7" fill="none" xmlns="http://www.w3.org/2000/svg"> 92 + <defs> 93 + <pattern id="squigglePattern" width="374" height="7" patternUnits="userSpaceOnUse"> 94 + <path d="M0 1C5.50021 1 5.50021 5.84456 11.0004 5.84456C16.5006 5.84456 16.5006 1 22.0008 1C27.501 1 27.501 5.84456 33.0013 5.84456C38.5015 5.84456 38.5015 1 44.0017 1C49.5019 1 49.5019 5.84456 55.0021 5.84456C60.5023 5.84456 60.5023 1 66.0025 1C71.5027 1 71.5027 5.84456 77.0029 5.84456C82.5031 5.84456 82.5031 1 87.9998 1C93.5 1 93.5 5.84456 98.9967 5.84456C104.497 5.84456 104.497 1 109.994 1C115.494 1 115.494 5.84456 120.99 5.84456C126.491 5.84456 126.491 1 131.991 1C137.491 1 137.491 5.84456 142.991 5.84456C148.491 5.84456 148.491 1 153.992 1C159.492 1 159.492 5.84456 164.988 5.84456C170.489 5.84456 170.489 1 175.985 1C181.486 1 181.486 5.84456 186.986 5.84456C192.486 5.84456 192.486 1 197.986 1C203.486 1 203.486 5.84456 208.987 5.84456C214.487 5.84456 214.487 1 219.987 1C225.487 1 225.487 5.84456 230.987 5.84456C236.488 5.84456 236.488 1 241.988 1C247.488 1 247.488 5.84456 252.988 5.84456C258.488 5.84456 258.488 1 263.989 1C269.489 1 269.489 5.84456 274.989 5.84456C280.489 5.84456 280.489 1 285.99 1C291.49 1 291.49 5.84456 296.99 5.84456C302.49 5.84456 302.49 1 307.99 1C313.491 1 313.491 5.84456 318.991 5.84456C324.491 5.84456 324.491 1 329.991 1C335.491 1 335.491 5.84456 340.992 5.84456C346.492 5.84456 346.492 1 351.992 1C357.492 1 357.492 5.84456 362.996 5.84456C368.5 5.84456 368.496 1 374 1" stroke="white" stroke-width="2" stroke-miterlimit="10"/> 95 + </pattern> 96 + </defs> 97 + 98 + <rect fill="url(#squigglePattern)" width="100%" height="100%"/> 99 + </svg> 100 + 101 + <div class="author"> 102 + <img class='avatar' src="/images/Default-avatar.png" alt="Avatar"> 103 + <h1 class="username"></h1> 104 + <div class="bio"></div> 105 + </div> 106 + 107 + <svg class="hr-inline desktop" width="374" height="7" viewBox="0 0 374 7" fill="none" xmlns="http://www.w3.org/2000/svg"> 108 + <path d="M0 1C5.50021 1 5.50021 5.84456 11.0004 5.84456C16.5006 5.84456 16.5006 1 22.0008 1C27.501 1 27.501 5.84456 33.0013 5.84456C38.5015 5.84456 38.5015 1 44.0017 1C49.5019 1 49.5019 5.84456 55.0021 5.84456C60.5023 5.84456 60.5023 1 66.0025 1C71.5027 1 71.5027 5.84456 77.0029 5.84456C82.5031 5.84456 82.5031 1 87.9998 1C93.5 1 93.5 5.84456 98.9967 5.84456C104.497 5.84456 104.497 1 109.994 1C115.494 1 115.494 5.84456 120.99 5.84456C126.491 5.84456 126.491 1 131.991 1C137.491 1 137.491 5.84456 142.991 5.84456C148.491 5.84456 148.491 1 153.992 1C159.492 1 159.492 5.84456 164.988 5.84456C170.489 5.84456 170.489 1 175.985 1C181.486 1 181.486 5.84456 186.986 5.84456C192.486 5.84456 192.486 1 197.986 1C203.486 1 203.486 5.84456 208.987 5.84456C214.487 5.84456 214.487 1 219.987 1C225.487 1 225.487 5.84456 230.987 5.84456C236.488 5.84456 236.488 1 241.988 1C247.488 1 247.488 5.84456 252.988 5.84456C258.488 5.84456 258.488 1 263.989 1C269.489 1 269.489 5.84456 274.989 5.84456C280.489 5.84456 280.489 1 285.99 1C291.49 1 291.49 5.84456 296.99 5.84456C302.49 5.84456 302.49 1 307.99 1C313.491 1 313.491 5.84456 318.991 5.84456C324.491 5.84456 324.491 1 329.991 1C335.491 1 335.491 5.84456 340.992 5.84456C346.492 5.84456 346.492 1 351.992 1C357.492 1 357.492 5.84456 362.996 5.84456C368.5 5.84456 368.496 1 374 1" stroke="white" stroke-width="2" stroke-miterlimit="10"/> 109 + </svg> 110 + 111 + <div class="install-button-wrapper desktop"> 112 + <a class='install-button' href="https://getorbyt.com/beta"> 113 + Waitlist 114 + <svg class="svg-button" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg"> 115 + <path d="M1.93892 19.9988L18.9736 2.96362" stroke="black" stroke-width="3.993"/> 116 + <path d="M19 16.7368L19 2.93875L5.20193 2.93875" stroke="black" stroke-width="3.993"/> 117 + </svg> 118 + </a> 119 + </div> 120 + </header> 121 + 122 + <div class="posts"> 123 + <footer style="display: none;"> 124 + <a id="load-more">Load more</a> 125 + </footer> 126 + </div> 127 + </div> 128 + 129 + <script type="module"> 130 + import { populateVideoGrid, getUserHandleFromURL, getCurrentCursor, initProfile } from '/js/bluesky-profile.js'; 131 + 132 + // Explicitly initialize profile when this HTML loads 133 + // This ensures profile loads when navigating back from other pages 134 + initProfile(); 135 + 136 + const loadingHTML = '<div class="loading"><p>Fetching videos&hellip;</p></div>'; 137 + 138 + async function loadMore() { 139 + const cursor = getCurrentCursor(); 140 + const footer = document.querySelector('footer'); 141 + const loadMoreBtn = document.getElementById('load-more'); 142 + 143 + if (!footer || !loadMoreBtn || !cursor) { 144 + if (footer) footer.style.display = 'none'; 145 + return; 146 + } 147 + 148 + loadMoreBtn.remove(); 149 + footer.insertAdjacentHTML('beforeend', loadingHTML); 150 + 151 + try { 152 + const handle = getUserHandleFromURL(); 153 + if (!handle) return; 154 + 155 + const nextCursor = await populateVideoGrid(handle, cursor); 156 + footer.querySelector('.loading')?.remove(); 157 + 158 + if (nextCursor) { 159 + footer.style.display = 'flex'; 160 + footer.appendChild(loadMoreBtn); 161 + } else { 162 + footer.style.display = 'none'; 163 + } 164 + } catch (error) { 165 + console.error('Error loading more posts:', error); 166 + footer.querySelector('.loading')?.remove(); 167 + footer.style.display = 'flex'; 168 + footer.appendChild(loadMoreBtn); 169 + } 170 + } 171 + 172 + document.getElementById('load-more')?.addEventListener('click', (e) => { 173 + e.preventDefault(); 174 + loadMore(); 175 + }); 176 + 177 + // Auto-load more if needed after initial load 178 + setTimeout(() => { 179 + if (document.querySelectorAll('.post').length < 6 && getCurrentCursor()) { 180 + loadMore(); 181 + } 182 + }, 1000); 183 + </script> 184 + 185 + </body> 186 + </html>
+1
pixelarticons--external-link.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M21 11V3h-8v2h4v2h-2v2h-2v2h-2v2H9v2h2v-2h2v-2h2V9h2V7h2v4zM11 5H3v16h16v-8h-2v6H5V7h6z"/></svg>
+389
post-view.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="utf-8"> 6 + 7 + <link rel="preconnect" href="https://fonts.googleapis.com"> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 + <link href="https://fonts.googleapis.com/css2?family=Figtree:wght@900&display=swap" rel="stylesheet"> 10 + <link rel="stylesheet" href="/css/normalize.css"> 11 + <link rel="stylesheet" href="/css/skeleton.css"> 12 + <link rel="stylesheet" href="/css/shared.css"> 13 + <link rel="stylesheet" href="/css/embed.css"> 14 + <link rel="stylesheet" href="/css/post.css"> 15 + 16 + <style> 17 + /* Prevent scrollbars */ 18 + html, body { 19 + overflow-x: hidden; 20 + overflow-y: hidden; 21 + } 22 + /* Override embed.css logo img styles - we don't have img tags anymore */ 23 + .logo img, 24 + .logo a img { 25 + display: none !important; 26 + } 27 + .logo-wordmark { 28 + display: flex; 29 + align-items: center; 30 + gap: 6px; 31 + text-decoration: none; 32 + transition: opacity 0.2s ease; 33 + line-height: 0; 34 + } 35 + .logo-wordmark:hover { 36 + opacity: 0.8; 37 + } 38 + .logo-text { 39 + color: #f3f5fe !important; 40 + font-family: 'Figtree', sans-serif; 41 + font-weight: 900; 42 + font-size: 42px; 43 + letter-spacing: -0.02em; 44 + line-height: 1; 45 + } 46 + @media screen and (max-width: 699px) { 47 + .logo-overlay.mobile-overlay .logo-text { 48 + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); 49 + } 50 + } 51 + @media screen and (max-width: 540px) { 52 + .logo-wordmark { 53 + gap: 5px; 54 + } 55 + } 56 + /* Override shared.css install-button img styles for SVG */ 57 + .install-button img { 58 + display: none !important; 59 + } 60 + .install-button .svg-button { 61 + display: inline-block; 62 + width: 16px; 63 + height: 16px; 64 + flex-shrink: 0; 65 + margin-left: 8px; 66 + transition: transform 0.2s ease; 67 + } 68 + .install-button:hover .svg-button { 69 + transform: translateX(2px); 70 + } 71 + .install-button .svg-button path { 72 + stroke: #373541 !important; 73 + fill: none; 74 + stroke-linecap: round; 75 + stroke-linejoin: round; 76 + } 77 + .horizontal-rule { 78 + margin: 20px 0 30px; 79 + width: 100%; 80 + background-image: none !important; 81 + background-repeat: no-repeat; 82 + overflow: hidden; 83 + } 84 + .horizontal-rule svg { 85 + display: block; 86 + width: 374px; 87 + height: 7px; 88 + flex-shrink: 0; 89 + min-width: 374px; 90 + } 91 + @media screen and (min-width: 700px) { 92 + .horizontal-rule { 93 + height: 7px; 94 + width: 100%; 95 + margin: 20px 0 30px; 96 + display: block; 97 + flex-shrink: 0; 98 + background-image: none !important; 99 + overflow: hidden; 100 + } 101 + .horizontal-rule svg { 102 + width: 100%; 103 + height: 100%; 104 + } 105 + } 106 + .install-button { 107 + background-color: #f3f5fe !important; 108 + color: #373541 !important; 109 + border: none !important; 110 + text-transform: none !important; 111 + letter-spacing: 0.6px !important; 112 + font-weight: 600 !important; 113 + transition: all 0.2s ease !important; 114 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important; 115 + padding: 0 18px !important; 116 + } 117 + .install-button:hover { 118 + background-color: #e8eefc !important; 119 + transform: translateY(-1px) !important; 120 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important; 121 + } 122 + .install-button:active { 123 + transform: translateY(0) !important; 124 + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15) !important; 125 + } 126 + .avatar { 127 + border-color: #FF876C; 128 + } 129 + /* Desktop logo sizing */ 130 + @media screen and (min-width: 700px) { 131 + /* Ensure desktop-title displays as flex row with logo on left and button on right */ 132 + .desktop-title { 133 + display: flex !important; 134 + flex-direction: row !important; 135 + flex-wrap: nowrap !important; 136 + justify-content: space-between !important; 137 + align-items: center !important; 138 + width: 100% !important; 139 + box-sizing: border-box; 140 + } 141 + .desktop-title .logo { 142 + flex-shrink: 0; 143 + margin-left: 0 !important; 144 + margin-top: 0 !important; 145 + } 146 + .desktop-title .logo-wordmark { 147 + line-height: 0; 148 + margin: 0 !important; 149 + } 150 + .desktop-title .logo-text { 151 + font-size: 42px; 152 + color: #f3f5fe !important; 153 + margin: 0 !important; 154 + padding: 0 !important; 155 + } 156 + /* Ensure desktop title button is aligned correctly with proper spacing */ 157 + .desktop-title .install-button { 158 + margin-right: 0 !important; 159 + margin-top: 0 !important; 160 + margin-left: 0 !important; 161 + align-items: center !important; 162 + display: flex !important; 163 + flex-shrink: 0; 164 + font-size: 16px !important; 165 + height: 42px !important; 166 + } 167 + /* Ensure desktop-title and horizontal-rule align at the same left position */ 168 + .desktop-title { 169 + margin-left: 0 !important; 170 + padding-left: 0 !important; 171 + } 172 + .horizontal-rule { 173 + margin-left: 0 !important; 174 + padding-left: 0 !important; 175 + width: 100% !important; 176 + overflow: hidden; 177 + } 178 + /* Ensure desktop layout matches original structure */ 179 + #desktop > div:last-child { 180 + display: flex; 181 + flex-direction: column; 182 + min-width: 0; 183 + flex-shrink: 1; 184 + max-width: 374px; 185 + width: 100%; 186 + } 187 + #desktop #post-caption-desktop { 188 + min-width: 0; 189 + flex-shrink: 1; 190 + } 191 + /* Add right padding to desktop container to prevent heart icon from touching screen edge */ 192 + #desktop { 193 + padding-right: 60px !important; 194 + min-width: 0; 195 + } 196 + /* Allow post-author to collapse and stack */ 197 + #desktop .post-author { 198 + flex-wrap: wrap !important; 199 + min-width: 0; 200 + overflow: visible; 201 + gap: 8px; 202 + align-items: center; 203 + } 204 + #desktop .post-author .avatar-wrapper { 205 + flex-shrink: 1; 206 + min-width: 0; 207 + overflow: hidden; 208 + flex: 0 1 auto; 209 + } 210 + #desktop .post-author .avatar-wrapper .username { 211 + overflow: hidden; 212 + text-overflow: ellipsis; 213 + white-space: nowrap; 214 + min-width: 0; 215 + } 216 + #desktop .post-author .likes { 217 + flex-shrink: 0; 218 + white-space: nowrap; 219 + margin-left: auto; 220 + flex-basis: auto; 221 + } 222 + } 223 + </style> 224 + 225 + <title>Loading post...</title> 226 + <meta name="description" content="Loading post..."> 227 + <meta name="viewport" content="width=device-width, initial-scale=1"> 228 + 229 + <!-- Meta tags will be updated dynamically by post-view.js --> 230 + <meta property="og:title" content="Loading post..." /> 231 + <meta property="og:type" content="video.other" /> 232 + <meta property="og:description" content="Loading post..." /> 233 + <meta property="og:url" content="" /> 234 + <meta property="twitter:card" content="summary_large_image" /> 235 + <meta property="twitter:site" content="orbyt_app" /> 236 + <meta property="twitter:title" content="Loading post..." /> 237 + <meta property="twitter:description" content="Loading post..." /> 238 + </head> 239 + 240 + <body> 241 + <div id="background"> </div> 242 + 243 + <!-- Loading state --> 244 + <div id="loading" style="display: flex; align-items: center; justify-content: center; height: 100vh; color: white;"> 245 + <p>Loading post...</p> 246 + </div> 247 + 248 + <!-- Error state --> 249 + <div id="error" style="display: none; flex-direction: column; align-items: center; justify-content: center; height: 100vh; color: white; text-align: center; padding: 20px;"> 250 + <p id="error-message">Failed to load post</p> 251 + <a href="/" style="color: #FF876C; text-decoration: underline; margin-top: 20px;">Go Home</a> 252 + </div> 253 + 254 + <!-- Post content (hidden initially) --> 255 + <div id="post" class="hidden"> 256 + 257 + <div id="post-media"> 258 + 259 + <div class="logo-overlay mobile-overlay"> 260 + 261 + <div class="logo"> 262 + <a href='/' class="logo-wordmark"> 263 + <span class="logo-text">orbyt</span> 264 + </a> 265 + </div> 266 + 267 + <a class='install-button' href="https://getorbyt.com/beta"> 268 + Waitlist 269 + <svg class="svg-button" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg"> 270 + <path d="M1.93892 19.9988L18.9736 2.96362" stroke-width="3.993"/> 271 + <path d="M19 16.7368L19 2.93875L5.20193 2.93875" stroke-width="3.993"/> 272 + </svg> 273 + </a> 274 + 275 + </div> 276 + 277 + <div id="post-overlay" class="post-overlay"> 278 + 279 + <div class='mute-toggle-wrapper'> 280 + <a id="mute-toggle"> 281 + <span id="mute-text"> 282 + LOADING... 283 + </span> 284 + </a> 285 + </div> 286 + 287 + <div id="post-caption-mobile" class="post-content mobile-overlay">Loading...</div> 288 + 289 + <div class="post-author mobile-overlay"> 290 + 291 + <span class='avatar-wrapper'> 292 + <img id="author-avatar-mobile" class='avatar' src="/images/Default-avatar.png" alt="avatar"> 293 + <div class="username"> 294 + <a id="author-link-mobile" href="orbyt-profile.html">Loading...</a> 295 + </div> 296 + <div id="post-time-mobile">Loading...</div> 297 + </span> 298 + 299 + <span class='likes'> 300 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="display: block;"> 301 + <g fill="none"> 302 + <path fill="#FE4359" d="M18.494 3.801c2.095 1.221 3.569 3.7 3.504 6.592C21.86 16.5 13.5 21 12 21s-9.861-4.5-9.998-10.607c-.065-2.892 1.409-5.37 3.504-6.592C7.466 2.66 9.928 2.653 12 4.338c2.072-1.685 4.534-1.679 6.494-.537"/> 303 + </g> 304 + </svg> 305 + <span id="likes-count-mobile">0</span> 306 + </span> 307 + 308 + </div> 309 + 310 + </div> 311 + 312 + <video id="vinit" muted playsinline loop autoplay oncontextmenu="return false;" style="width: 100%; height: auto; aspect-ratio: 9 / 16; object-fit: contain;"> 313 + </video> 314 + 315 + </div> 316 + 317 + <div id="desktop"> 318 + 319 + <div class="desktop-title"> 320 + 321 + <div class="logo"> 322 + <a href='/' class="logo-wordmark"> 323 + <span class="logo-text">orbyt</span> 324 + </a> 325 + </div> 326 + 327 + <a class='install-button' href="https://getorbyt.com/beta"> 328 + Waitlist 329 + <svg class="svg-button" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg"> 330 + <path d="M1.93892 19.9988L18.9736 2.96362" stroke-width="3.993"/> 331 + <path d="M19 16.7368L19 2.93875L5.20193 2.93875" stroke-width="3.993"/> 332 + </svg> 333 + </a> 334 + 335 + </div> 336 + 337 + <div class="horizontal-rule"> 338 + <svg width="374" height="7" viewBox="0 0 374 7" fill="none" xmlns="http://www.w3.org/2000/svg"> 339 + <path d="M0 1C5.50021 1 5.50021 5.84456 11.0004 5.84456C16.5006 5.84456 16.5006 1 22.0008 1C27.501 1 27.501 5.84456 33.0013 5.84456C38.5015 5.84456 38.5015 1 44.0017 1C49.5019 1 49.5019 5.84456 55.0021 5.84456C60.5023 5.84456 60.5023 1 66.0025 1C71.5027 1 71.5027 5.84456 77.0029 5.84456C82.5031 5.84456 82.5031 1 87.9998 1C93.5 1 93.5 5.84456 98.9967 5.84456C104.497 5.84456 104.497 1 109.994 1C115.494 1 115.494 5.84456 120.99 5.84456C126.491 5.84456 126.491 1 131.991 1C137.491 1 137.491 5.84456 142.991 5.84456C148.491 5.84456 148.491 1 153.992 1C159.492 1 159.492 5.84456 164.988 5.84456C170.489 5.84456 170.489 1 175.985 1C181.486 1 181.486 5.84456 186.986 5.84456C192.486 5.84456 192.486 1 197.986 1C203.486 1 203.486 5.84456 208.987 5.84456C214.487 5.84456 214.487 1 219.987 1C225.487 1 225.487 5.84456 230.987 5.84456C236.488 5.84456 236.488 1 241.988 1C247.488 1 247.488 5.84456 252.988 5.84456C258.488 5.84456 258.488 1 263.989 1C269.489 1 269.489 5.84456 274.989 5.84456C280.489 5.84456 280.489 1 285.99 1C291.49 1 291.49 5.84456 296.99 5.84456C302.49 5.84456 302.49 1 307.99 1C313.491 1 313.491 5.84456 318.991 5.84456C324.491 5.84456 324.491 1 329.991 1C335.491 1 335.491 5.84456 340.992 5.84456C346.492 5.84456 346.492 1 351.992 1C357.492 1 357.492 5.84456 362.996 5.84456C368.5 5.84456 368.496 1 374 1" stroke="white" stroke-width="2" stroke-miterlimit="10"/> 340 + </svg> 341 + </div> 342 + 343 + <div> 344 + 345 + <div id="post-caption-desktop" class="post-content">Loading...</div> 346 + 347 + <div class="post-author"> 348 + <span class='avatar-wrapper'> 349 + <img id="author-avatar-desktop" class='avatar' src="/images/Default-avatar.png" alt="avatar"> 350 + <div class="username"> 351 + <a id="author-link-desktop" href="orbyt-profile.html">Loading...</a> 352 + </div> 353 + <div id="post-time-desktop">Loading...</div> 354 + </span> 355 + 356 + <span class='likes'> 357 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="display: block;"> 358 + <g fill="none"> 359 + <path fill="#FE4359" d="M18.494 3.801c2.095 1.221 3.569 3.7 3.504 6.592C21.86 16.5 13.5 21 12 21s-9.861-4.5-9.998-10.607c-.065-2.892 1.409-5.37 3.504-6.592C7.466 2.66 9.928 2.653 12 4.338c2.072-1.685 4.534-1.679 6.494-.537"/> 360 + </g> 361 + </svg> 362 + <span id="likes-count-desktop">0</span> 363 + </span> 364 + </div> 365 + 366 + </div> 367 + 368 + </div> 369 + 370 + </div> 371 + 372 + <!-- HLS.js library for cross-browser HLS playback support --> 373 + <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script> 374 + 375 + <script type="module" src="/js/post-view.js"></script> 376 + 377 + <!-- Global site tag (gtag.js) - Google Analytics --> 378 + <script async src="https://www.googletagmanager.com/gtag/js?id=UA-157537168-1"></script> 379 + <script> 380 + window.dataLayer = window.dataLayer || []; 381 + function gtag(){dataLayer.push(arguments);} 382 + gtag('js', new Date()); 383 + 384 + gtag('config', 'UA-157537168-1'); 385 + </script> 386 + 387 + 388 + </body> 389 + </html>
+379
privacy.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 + <title>Privacy Policy - Orbyt</title> 7 + 8 + <!-- Favicon setup --> 9 + <link rel="apple-touch-icon" sizes="180x180" href="favicon_io/apple-touch-icon.png"> 10 + <link rel="icon" type="image/png" sizes="32x32" href="favicon_io/favicon-32x32.png"> 11 + <link rel="icon" type="image/png" sizes="16x16" href="favicon_io/favicon-16x16.png"> 12 + <link rel="icon" href="favicon_io/favicon.ico"> 13 + <link rel="manifest" href="favicon_io/site.webmanifest"> 14 + 15 + <!-- Theme colors for mobile browsers --> 16 + <meta name="theme-color" content="#ffffff"> 17 + <meta name="msapplication-TileColor" content="#ffffff"> 18 + <link rel="preconnect" href="https://fonts.googleapis.com"> 19 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 20 + <link href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;600;700;900&display=swap" rel="stylesheet"> 21 + <style> 22 + 23 + * { 24 + margin: 0; 25 + padding: 0; 26 + box-sizing: border-box; 27 + } 28 + 29 + body { 30 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 31 + background: #000000; 32 + min-height: 100vh; 33 + color: white; 34 + position: relative; 35 + overflow-x: hidden; 36 + overflow-y: auto; 37 + } 38 + 39 + .container { 40 + max-width: 800px; 41 + margin: 0 auto; 42 + padding: 2rem; 43 + min-height: 100vh; 44 + } 45 + 46 + .header { 47 + text-align: left; 48 + margin-bottom: 1.5rem; 49 + padding-bottom: 2rem; 50 + } 51 + 52 + .back-link { 53 + display: inline-flex; 54 + align-items: center; 55 + color: #cfd6e8; 56 + text-decoration: none; 57 + font-size: 1rem; 58 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 59 + font-weight: 700; 60 + margin-bottom: 2rem; 61 + transition: color 0.3s ease; 62 + } 63 + 64 + .back-link:hover { 65 + color: #ffffff; 66 + } 67 + 68 + .back-link svg { 69 + width: 20px; 70 + height: 20px; 71 + margin-right: 0.5rem; 72 + fill: currentColor; 73 + transition: color 0.3s ease; 74 + } 75 + 76 + h1 { 77 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 78 + font-size: 3rem; 79 + font-weight: 900; 80 + color: white; 81 + margin-bottom: 1rem; 82 + } 83 + 84 + .subtitle { 85 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 86 + font-size: 0.9rem; 87 + color: #cfd6e8; 88 + opacity: 0.8; 89 + } 90 + 91 + .content { 92 + line-height: 1.6; 93 + } 94 + 95 + .content h2 { 96 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 97 + font-size: 1.8rem; 98 + color: white; 99 + margin: 2rem 0 1rem 0; 100 + font-weight: 900; 101 + } 102 + 103 + .content h3 { 104 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 105 + font-size: 1.4rem; 106 + color: white; 107 + margin: 1.5rem 0 0.5rem 0; 108 + font-weight: 900; 109 + } 110 + 111 + .content p { 112 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 113 + font-size: 1rem; 114 + color: #cfd6e8; 115 + margin-bottom: 1rem; 116 + } 117 + 118 + .content ul { 119 + margin: 1rem 0; 120 + padding-left: 2rem; 121 + } 122 + 123 + .content li { 124 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 125 + font-size: 1rem; 126 + color: #cfd6e8; 127 + margin-bottom: 0.5rem; 128 + } 129 + 130 + .content strong { 131 + color: white; 132 + font-weight: 600; 133 + } 134 + 135 + .content a { 136 + color: #3797F0; 137 + text-decoration: none; 138 + transition: color 0.3s ease; 139 + } 140 + 141 + .content a:hover { 142 + color: #2A7CD6; 143 + } 144 + 145 + .separator { 146 + height: 20px; 147 + margin-top: 80px; 148 + margin-bottom: 40px; 149 + position: relative; 150 + overflow: hidden; 151 + display: flex; 152 + align-items: center; 153 + justify-content: center; 154 + } 155 + 156 + .separator svg { 157 + width: 100%; 158 + min-width: 200px; 159 + height: 20px; 160 + overflow: visible; 161 + } 162 + 163 + .footer { 164 + text-align: left; 165 + white-space: nowrap; 166 + display: block; 167 + margin-bottom: 40px; 168 + } 169 + 170 + .footer a { 171 + margin: 0 20px 0 0; 172 + display: inline-block; 173 + font-style: normal; 174 + font-weight: bold; 175 + font-size: 13px; 176 + text-align: left; 177 + letter-spacing: 0.2em; 178 + text-transform: uppercase; 179 + text-decoration: none; 180 + color: #ccc; 181 + } 182 + 183 + .footer a:hover { 184 + color: #fff; 185 + } 186 + 187 + @media (max-width: 768px) { 188 + .container { 189 + padding: 1rem; 190 + } 191 + 192 + h1 { 193 + font-size: 2.5rem; 194 + } 195 + 196 + .content h2 { 197 + font-size: 1.5rem; 198 + } 199 + 200 + .content h3 { 201 + font-size: 1.2rem; 202 + } 203 + 204 + .separator { 205 + margin-top: 80px; 206 + margin-bottom: 40px; 207 + } 208 + 209 + .footer { 210 + white-space: normal; 211 + margin-bottom: 40px; 212 + text-align: center; 213 + } 214 + 215 + .footer a { 216 + display: block; 217 + margin: 8px 0 8px 0; 218 + text-align: center; 219 + } 220 + } 221 + </style> 222 + </head> 223 + <body> 224 + 225 + 226 + <div class="container"> 227 + <div class="header"> 228 + <h1>Privacy Policy</h1> 229 + <p class="subtitle">Last Updated: December 8, 2025</p> 230 + </div> 231 + 232 + <div class="content"> 233 + <p>This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.</p> 234 + 235 + <p>We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy.</p> 236 + 237 + <h2>Interpretation and Definitions</h2> 238 + 239 + <h3>Interpretation</h3> 240 + <p>The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.</p> 241 + 242 + <h3>Definitions</h3> 243 + <p>For the purposes of this Privacy Policy:</p> 244 + <ul> 245 + <li><strong>Account</strong> means a unique account created for You to access our Service or parts of our Service.</li> 246 + <li><strong>Affiliate</strong> means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority.</li> 247 + <li><strong>Application</strong> refers to Orbyt, the software program provided by the Company.</li> 248 + <li><strong>Company</strong> (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Orbyt.</li> 249 + <li><strong>Country</strong> refers to: California, United States</li> 250 + <li><strong>Device</strong> means any device that can access the Service such as a computer, a cellphone or a digital tablet.</li> 251 + <li><strong>Personal Data</strong> is any information that relates to an identified or identifiable individual.</li> 252 + <li><strong>Service</strong> refers to the Application.</li> 253 + <li><strong>Service Provider</strong> means any natural or legal person who processes the data on behalf of the Company.</li> 254 + <li><strong>Third-party Social Media Service</strong> refers to any website or any social network website through which a User can log in or create an account to use the Service.</li> 255 + <li><strong>Usage Data</strong> refers to data collected automatically, either generated by the use of the Service or from the Service infrastructure itself.</li> 256 + <li><strong>You</strong> means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.</li> 257 + </ul> 258 + 259 + <h2>Collecting and Using Your Personal Data</h2> 260 + 261 + <h3>Types of Data Collected</h3> 262 + 263 + <h4>Personal Data</h4> 264 + <p>While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:</p> 265 + <ul> 266 + <li>Email address</li> 267 + <li>First name and last name</li> 268 + <li>Usage Data</li> 269 + </ul> 270 + 271 + <h4>Usage Data</h4> 272 + <p>Usage Data is collected automatically when using the Service.</p> 273 + <p>Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.</p> 274 + <p>When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.</p> 275 + 276 + <h4>Information from Third-Party Social Media Services</h4> 277 + <p>The Company allows You to create an account and log in to use the Service through the following Third-party Social Media Services:</p> 278 + <ul> 279 + <li>Google</li> 280 + <li>Apple</li> 281 + <li>Bluesky</li> 282 + </ul> 283 + <p>If You decide to register through or otherwise grant us access to a Third-Party Social Media Service, We may collect Personal data that is already associated with Your Third-Party Social Media Service's account, such as Your name, Your email address, Your activities or Your contact list associated with that account.</p> 284 + 285 + <h2>Use of Your Personal Data</h2> 286 + <p>The Company may use Personal Data for the following purposes:</p> 287 + <ul> 288 + <li><strong>To provide and maintain our Service</strong>, including to monitor the usage of our Service.</li> 289 + <li><strong>To manage Your Account</strong>: to manage Your registration as a user of the Service.</li> 290 + <li><strong>For the performance of a contract</strong>: the development, compliance and undertaking of the purchase contract for the products, items or services You have purchased or of any other contract with Us through the Service.</li> 291 + <li><strong>To contact You</strong>: To contact You by email, telephone calls, SMS, or other equivalent forms of electronic communication.</li> 292 + <li><strong>To provide You</strong> with news, special offers and general information about other goods, services and events which we offer.</li> 293 + <li><strong>To manage Your requests</strong>: To attend and manage Your requests to Us.</li> 294 + <li><strong>For business transfers</strong>: We may use Your information to evaluate or conduct a merger, divestiture, restructuring, reorganization, dissolution, or other sale or transfer of some or all of Our assets.</li> 295 + <li><strong>For other purposes</strong>: We may use Your information for other purposes, such as data analysis, identifying usage trends, determining the effectiveness of our promotional campaigns and to evaluate and improve our Service.</li> 296 + </ul> 297 + 298 + <h2>Sharing Your Personal Data</h2> 299 + <p>We may share Your personal information in the following situations:</p> 300 + <ul> 301 + <li><strong>With Service Providers</strong>: We may share Your personal information with Service Providers to monitor and analyze the use of our Service, to contact You.</li> 302 + <li><strong>For business transfers</strong>: We may share or transfer Your personal information in connection with, or during negotiations of, any merger, sale of Company assets, financing, or acquisition of all or a portion of Our business to another company.</li> 303 + <li><strong>With Affiliates</strong>: We may share Your information with Our affiliates, in which case we will require those affiliates to honor this Privacy Policy.</li> 304 + <li><strong>With business partners</strong>: We may share Your information with Our business partners to offer You certain products, services or promotions.</li> 305 + <li><strong>With other users</strong>: when You share personal information or otherwise interact in the public areas with other users, such information may be viewed by all users and may be publicly distributed outside.</li> 306 + <li><strong>With Your consent</strong>: We may disclose Your personal information for any other purpose with Your consent.</li> 307 + </ul> 308 + 309 + <h2>Retention of Your Personal Data</h2> 310 + <p>The Company will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.</p> 311 + 312 + <h2>Transfer of Your Personal Data</h2> 313 + <p>Your information, including Personal Data, is processed at the Company's operating offices and in any other places where the parties involved in the processing are located. It means that this information may be transferred to — and maintained on — computers located outside of Your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.</p> 314 + 315 + <h2>Delete Your Personal Data</h2> 316 + <p>You have the right to delete or request that We assist in deleting the Personal Data that We have collected about You.</p> 317 + <p>Our Service may give You the ability to delete certain information about You from within the Service.</p> 318 + <p>You may update, amend, or delete Your information at any time by signing in to Your Account, if you have one, and visiting the account settings section that allows you to manage Your personal information. You may also contact Us to request access to, correct, or delete any personal information that You have provided to Us.</p> 319 + 320 + <h2>Disclosure of Your Personal Data</h2> 321 + 322 + <h3>Business Transactions</h3> 323 + <p>If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be transferred. We will provide notice before Your Personal Data is transferred and becomes subject to a different Privacy Policy.</p> 324 + 325 + <h3>Law enforcement</h3> 326 + <p>Under certain circumstances, the Company may be required to disclose Your Personal Data if required to do so by law or in response to valid requests by public authorities (e.g. a court or a government agency).</p> 327 + 328 + <h3>Other legal requirements</h3> 329 + <p>The Company may disclose Your Personal Data in the good faith belief that such action is necessary to:</p> 330 + <ul> 331 + <li>Comply with a legal obligation</li> 332 + <li>Protect and defend the rights or property of the Company</li> 333 + <li>Prevent or investigate possible wrongdoing in connection with the Service</li> 334 + <li>Protect the personal safety of Users of the Service or the public</li> 335 + <li>Protect against legal liability</li> 336 + </ul> 337 + 338 + <h2>Security of Your Personal Data</h2> 339 + <p>The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While We strive to use commercially acceptable means to protect Your Personal Data, We cannot guarantee its absolute security.</p> 340 + 341 + <h2>Children's Privacy</h2> 342 + <p>Our Service does not address anyone under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age of 13 without verification of parental consent, We take steps to remove that information from Our servers.</p> 343 + 344 + <h2>Links to Other Websites</h2> 345 + <p>Our Service may contain links to other websites that are not operated by Us. If You click on a third party link, You will be directed to that third party's site. We strongly advise You to review the Privacy Policy of every site You visit.</p> 346 + <p>We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.</p> 347 + 348 + <h2>Changes to this Privacy Policy</h2> 349 + <p>We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.</p> 350 + <p>We will let You know via email and/or a prominent notice on Our Service, prior to the change becoming effective and update the "Last updated" date at the top of this Privacy Policy.</p> 351 + <p>You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.</p> 352 + 353 + <h2>Contact Us</h2> 354 + <p>If you have any questions about this Privacy Policy, You can contact us:</p> 355 + <p>Email: <a href="mailto:support@getorbyt.com">support@getorbyt.com</a></p> 356 + </div> 357 + 358 + <div class="separator"> 359 + <svg viewBox="0 0 200 20" preserveAspectRatio="none"> 360 + <path d="M0,10 Q2.5,4 5,10 T10,10 T15,10 T20,10 T25,10 T30,10 T35,10 T40,10 T45,10 T50,10 T55,10 T60,10 T65,10 T70,10 T75,10 T80,10 T85,10 T90,10 T95,10 T100,10 T105,10 T110,10 T115,10 T120,10 T125,10 T130,10 T135,10 T140,10 T145,10 T150,10 T155,10 T160,10 T165,10 T170,10 T175,10 T180,10 T185,10 T190,10 T195,10 T200,10" 361 + stroke="#551DEF" 362 + stroke-width="2.5" 363 + fill="none" 364 + stroke-linecap="round" 365 + vector-effect="non-scaling-stroke"/> 366 + </svg> 367 + </div> 368 + 369 + <div class="footer"> 370 + <a href="https://bsky.app/profile/getorbyt.com">bluesky</a> 371 + <a href="https://discord.gg/8gNQPFygnF">discord</a> 372 + <a href="/terms.html">terms</a> 373 + <a href="/privacy.html">privacy</a> 374 + </div> 375 + </div> 376 + 377 + 378 + </body> 379 + </html>
+345
terms.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 + <title>Terms of Use - Orbyt</title> 7 + 8 + <!-- Favicon setup --> 9 + <link rel="apple-touch-icon" sizes="180x180" href="favicon_io/apple-touch-icon.png"> 10 + <link rel="icon" type="image/png" sizes="32x32" href="favicon_io/favicon-32x32.png"> 11 + <link rel="icon" type="image/png" sizes="16x16" href="favicon_io/favicon-16x16.png"> 12 + <link rel="icon" href="favicon_io/favicon.ico"> 13 + <link rel="manifest" href="favicon_io/site.webmanifest"> 14 + 15 + <!-- Theme colors for mobile browsers --> 16 + <meta name="theme-color" content="#ffffff"> 17 + <meta name="msapplication-TileColor" content="#ffffff"> 18 + <link rel="preconnect" href="https://fonts.googleapis.com"> 19 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 20 + <link href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;600;700;900&display=swap" rel="stylesheet"> 21 + <style> 22 + 23 + * { 24 + margin: 0; 25 + padding: 0; 26 + box-sizing: border-box; 27 + } 28 + 29 + body { 30 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 31 + background: #000000; 32 + min-height: 100vh; 33 + color: white; 34 + position: relative; 35 + overflow-x: hidden; 36 + overflow-y: auto; 37 + } 38 + 39 + .container { 40 + max-width: 800px; 41 + margin: 0 auto; 42 + padding: 2rem; 43 + min-height: 100vh; 44 + } 45 + 46 + .header { 47 + text-align: left; 48 + margin-bottom: 1.5rem; 49 + padding-bottom: 2rem; 50 + } 51 + 52 + .back-link { 53 + display: inline-flex; 54 + align-items: center; 55 + color: #cfd6e8; 56 + text-decoration: none; 57 + font-size: 1rem; 58 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 59 + font-weight: 700; 60 + margin-bottom: 2rem; 61 + transition: color 0.3s ease; 62 + } 63 + 64 + .back-link:hover { 65 + color: #ffffff; 66 + } 67 + 68 + .back-link svg { 69 + width: 20px; 70 + height: 20px; 71 + margin-right: 0.5rem; 72 + fill: currentColor; 73 + transition: color 0.3s ease; 74 + } 75 + 76 + h1 { 77 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 78 + font-size: 3rem; 79 + font-weight: 900; 80 + color: white; 81 + margin-bottom: 1rem; 82 + } 83 + 84 + .subtitle { 85 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 86 + font-size: 0.9rem; 87 + color: #cfd6e8; 88 + opacity: 0.8; 89 + } 90 + 91 + .content { 92 + line-height: 1.6; 93 + } 94 + 95 + .content h2 { 96 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 97 + font-size: 1.8rem; 98 + color: white; 99 + margin: 2rem 0 1rem 0; 100 + font-weight: 900; 101 + } 102 + 103 + .content h3 { 104 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 105 + font-size: 1.4rem; 106 + color: white; 107 + margin: 1.5rem 0 0.5rem 0; 108 + font-weight: 900; 109 + } 110 + 111 + .content p { 112 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 113 + font-size: 1rem; 114 + color: #cfd6e8; 115 + margin-bottom: 1rem; 116 + } 117 + 118 + .content ul { 119 + margin: 1rem 0; 120 + padding-left: 2rem; 121 + } 122 + 123 + .content li { 124 + font-family: 'Figtree', -apple-system, BlinkMacSystemFont, sans-serif; 125 + font-size: 1rem; 126 + color: #cfd6e8; 127 + margin-bottom: 0.5rem; 128 + } 129 + 130 + .content strong { 131 + color: white; 132 + font-weight: 600; 133 + } 134 + 135 + .content a { 136 + color: #3797F0; 137 + text-decoration: none; 138 + transition: color 0.3s ease; 139 + } 140 + 141 + .content a:hover { 142 + color: #2A7CD6; 143 + } 144 + 145 + .separator { 146 + height: 20px; 147 + margin-top: 80px; 148 + margin-bottom: 40px; 149 + position: relative; 150 + overflow: hidden; 151 + display: flex; 152 + align-items: center; 153 + justify-content: center; 154 + } 155 + 156 + .separator svg { 157 + width: 100%; 158 + min-width: 200px; 159 + height: 20px; 160 + overflow: visible; 161 + } 162 + 163 + .footer { 164 + text-align: left; 165 + white-space: nowrap; 166 + display: block; 167 + margin-bottom: 40px; 168 + } 169 + 170 + .footer a { 171 + margin: 0 20px 0 0; 172 + display: inline-block; 173 + font-style: normal; 174 + font-weight: bold; 175 + font-size: 13px; 176 + text-align: left; 177 + letter-spacing: 0.2em; 178 + text-transform: uppercase; 179 + text-decoration: none; 180 + color: #ccc; 181 + } 182 + 183 + .footer a:hover { 184 + color: #fff; 185 + } 186 + 187 + @media (max-width: 768px) { 188 + .container { 189 + padding: 1rem; 190 + } 191 + 192 + h1 { 193 + font-size: 2.5rem; 194 + } 195 + 196 + .content h2 { 197 + font-size: 1.5rem; 198 + } 199 + 200 + .content h3 { 201 + font-size: 1.2rem; 202 + } 203 + 204 + .separator { 205 + margin-top: 80px; 206 + margin-bottom: 40px; 207 + } 208 + 209 + .footer { 210 + white-space: normal; 211 + margin-bottom: 40px; 212 + text-align: center; 213 + } 214 + 215 + .footer a { 216 + display: block; 217 + margin: 8px 0 8px 0; 218 + text-align: center; 219 + } 220 + } 221 + </style> 222 + </head> 223 + <body> 224 + 225 + 226 + <div class="container"> 227 + <div class="header"> 228 + <h1>Terms of Use</h1> 229 + <p class="subtitle">Last Updated: December 8, 2025</p> 230 + </div> 231 + 232 + <div class="content"> 233 + <p>Welcome to Orbyt, a video social networking application! We're excited to have you on board as a user. Orbyt runs on the Authenticated Transfer Protocol ("AT Protocol"), a decentralized social networking protocol that supports many different kinds of services. These Terms of Service apply to the Orbyt service.</p> 234 + 235 + <p>Before you dive into using Orbyt, please take a moment to carefully read these Terms. This document outlines our legal obligations to each other and they apply to your use of our services.</p> 236 + 237 + <p>Orbyt is available as a mobile application ("Orbyt App" or "the App"). Your use of Orbyt is subject to these Terms of Service ("Terms"), and Orbyt Privacy Policy, which are referenced in our Terms and available to read separately.</p> 238 + 239 + <p>These terms only apply to social networking that happens on Orbyt services, including the Sites and Orbyt App. If you're using another social networking application on the AT Protocol that isn't Orbyt (we call this a "Developer Application"), the developers of the other service will provide the terms and conditions that govern your experience.</p> 240 + 241 + <p>When we say "Orbyt," "we," "us," and "our" in these Terms, we mean Orbyt.</p> 242 + 243 + <h2>Important Notes</h2> 244 + <ul> 245 + <li>Orbyt is available as a mobile application ("Orbyt App" or "the App").</li> 246 + <li>Your use of Orbyt is subject to these Terms of Service ("Terms"), and Orbyt Privacy Policy.</li> 247 + <li>These terms only apply to social networking that happens on Orbyt services.</li> 248 + <li>When we say "Orbyt," "we," "us," and "our" in these Terms, we mean Orbyt.</li> 249 + </ul> 250 + 251 + <h2>Who Can Use Orbyt</h2> 252 + 253 + <h3>Eligibility</h3> 254 + <p>To use Orbyt, you must be at least 13 years old and legally permitted to use Orbyt based on the laws in your country. If you are not yet an adult according to the laws of your country, your parent or legal guardian must read these Terms on your behalf. If you're a parent or legal guardian, and you allow your child (who meets the minimum age for your country) to use the services, then these terms also apply to you, and you're responsible for your child's activity on the services.</p> 255 + 256 + <h3>Accounts</h3> 257 + <p>To use Orbyt, you must create an account ("Account"). Don't share your Account with others, because you're responsible for all activities under your Account, whether or not you know about them. If you're using Orbyt on behalf of a company or organization, you confirm that you have the authority to represent your company or organization. If you believe your Account created through Orbyt has been compromised, report it to us at <a href="mailto:support@getorbyt.com">support@getorbyt.com</a></p> 258 + 259 + <h2>User Content</h2> 260 + 261 + <h3>User Content</h3> 262 + <p>"User Content" means any type of content you make available on Orbyt ("Content"), such as your posts, profile, photos, videos, direct messages, or links.</p> 263 + 264 + <h3>Your Responsibility for User Content</h3> 265 + <p>You, not Orbyt, are solely responsible for all of your User Content. Orbyt does not control others' use of your content.</p> 266 + 267 + <h3>Orbyt's Use of User Content</h3> 268 + <p>User Content is only used only in connection with: (a) providing Orbyt and Content, including by sharing User Content throughout Orbyt and the AT Protocol as well as developing and improving our current and future offerings; and (b) promoting and marketing Orbyt.</p> 269 + 270 + <h3>Orbyt's Permission to Use Your User Content</h3> 271 + <p>You keep your ownership of User Content, subject to the license below. Orbyt does not own rights to your User Content except as provided in that license. By sharing User Content through Orbyt, you grant us permission to:</p> 272 + <ul> 273 + <li>Use User Content to develop, provide, and improve Orbyt, the AT Protocol, and any of our future offerings.</li> 274 + <li>Modify or otherwise utilize User Content in any media.</li> 275 + <li>Grant others the right to take the actions above.</li> 276 + <li>Remove or modify User Content for any reason, including User Content that we believe violates these Terms or other policies.</li> 277 + </ul> 278 + 279 + <h2>General Prohibitions and Enforcement</h2> 280 + <p>You agree not to use Orbyt to:</p> 281 + <ul> 282 + <li>Violate any applicable laws or regulations</li> 283 + <li>Infringe on the rights of others</li> 284 + <li>Post content that is harmful, offensive, or inappropriate</li> 285 + <li>Attempt to gain unauthorized access to our systems</li> 286 + <li>Interfere with the proper functioning of the service</li> 287 + </ul> 288 + 289 + <h2>Termination</h2> 290 + 291 + <h3>Your Right to Termination</h3> 292 + <p>If you want to delete your Account, you can do so through your account settings. If your account was terminated by Orbyt, we retain the right to keep a copy of your data for trust and safety purposes. These Terms will survive termination of your Account.</p> 293 + 294 + <h3>Our Right to Termination</h3> 295 + <p>We reserve the right to suspend or terminate your account and/or specific services offered within Orbyt without notice, at our discretion, for the following reasons:</p> 296 + <ul> 297 + <li>We believe you've violated these Terms, the Orbyt Privacy Policy or the Orbyt Copyright Policy</li> 298 + <li>We're required to do so due to a court order, legal requirement, or regulatory requirement</li> 299 + <li>Continuing to allow your account to be active, giving you access to some or all services, or hosting your content creates risk for Orbyt, other users, or third parties</li> 300 + </ul> 301 + 302 + <h2>Appeals</h2> 303 + <p>You can appeal any enforcement decision from these Terms, including suspension or termination, by emailing <a href="mailto:support@getorbyt.com">support@getorbyt.com</a>.</p> 304 + 305 + <h2>Updates</h2> 306 + <p>We update our terms from time to time, and will either post the updated Terms or send other communications to let you know about any changes. If you keep using Orbyt, you agree to the updated Terms. If you don't agree, you must stop using Orbyt.</p> 307 + 308 + <h2>Warranty Disclaimers</h2> 309 + <p><strong>Summary:</strong> We work hard to offer great services, but there are certain aspects that we can't guarantee.</p> 310 + <p>Our Services and all included content are provided on an "as is" basis without warranty of any kind, whether express or implied. WITHOUT LIMITING THE FOREGOING, WE DISCLAIM ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT, AND ANY WARRANTIES ARISING OUT OF COURSE OF DEALING OR USAGE OF TRADE. WE MAKE NO WARRANTY THAT ORBYT WILL MEET YOUR REQUIREMENTS OR BE AVAILABLE ON AN UNINTERRUPTED, SECURE, OR ERROR-FREE BASIS.</p> 311 + 312 + <h2>Limitation of Liability</h2> 313 + <p><strong>Summary:</strong> This section limits what you can recover from us in a dispute to any direct damages that you suffer up to $100. This doesn't apply where it would be illegal to do so.</p> 314 + <p>TO THE MAXIMUM EXTENT PERMITTED BY LAW, ORBYT WILL NOT BE LIABLE FOR ANY INCIDENTAL, SPECIAL, EXEMPLARY OR CONSEQUENTIAL DAMAGES, OR DAMAGES FOR LOST PROFITS, LOST REVENUES, LOST SAVINGS, LOST BUSINESS OPPORTUNITY, LOSS OF DATA OR GOODWILL, SERVICE INTERRUPTION, COMPUTER OR MOBILE DEVICE DAMAGE OR SYSTEM FAILURE OR THE COST OF SUBSTITUTE SERVICES OF ANY KIND ARISING OUT OF OR IN CONNECTION WITH THESE TERMS OR FROM THE USE OF OR INABILITY TO USE ORBYT OR FOR ANY ERROR OR DEFECT IN ORBYT.</p> 315 + 316 + <h2>Governing Law</h2> 317 + <p>These Terms and any action related thereto will be governed by the laws of the State of California, without regard to conflict of laws provisions.</p> 318 + 319 + <h2>Contact Information</h2> 320 + <p>If you have any questions about these Terms or Orbyt, please contact us:</p> 321 + <p>Email: <a href="mailto:support@getorbyt.com">support@getorbyt.com</a></p> 322 + </div> 323 + 324 + <div class="separator"> 325 + <svg viewBox="0 0 200 20" preserveAspectRatio="none"> 326 + <path d="M0,10 Q2.5,4 5,10 T10,10 T15,10 T20,10 T25,10 T30,10 T35,10 T40,10 T45,10 T50,10 T55,10 T60,10 T65,10 T70,10 T75,10 T80,10 T85,10 T90,10 T95,10 T100,10 T105,10 T110,10 T115,10 T120,10 T125,10 T130,10 T135,10 T140,10 T145,10 T150,10 T155,10 T160,10 T165,10 T170,10 T175,10 T180,10 T185,10 T190,10 T195,10 T200,10" 327 + stroke="#551DEF" 328 + stroke-width="2.5" 329 + fill="none" 330 + stroke-linecap="round" 331 + vector-effect="non-scaling-stroke"/> 332 + </svg> 333 + </div> 334 + 335 + <div class="footer"> 336 + <a href="https://bsky.app/profile/getorbyt.com">bluesky</a> 337 + <a href="https://discord.gg/8gNQPFygnF">discord</a> 338 + <a href="/terms.html">terms</a> 339 + <a href="/privacy.html">privacy</a> 340 + </div> 341 + </div> 342 + 343 + 344 + </body> 345 + </html>