├── .github-pages-trigger ├── .specstory ├── .gitignore └── history │ └── 2025-08-26_14-28Z-analyse-den-aufbau-der-app.md ├── mastowall-favicon.png ├── .cursorindexingignore ├── config.json ├── .gitignore ├── LICENSE ├── deploy-to-cloudron.sh ├── README.md ├── index.html ├── styles.css └── script.js /.github-pages-trigger: -------------------------------------------------------------------------------- 1 | # Force rebuild 2 | -------------------------------------------------------------------------------- /.specstory/.gitignore: -------------------------------------------------------------------------------- 1 | # SpecStory explanation file 2 | /.what-is-this.md 3 | -------------------------------------------------------------------------------- /mastowall-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstockm/mastowall/HEAD/mastowall-favicon.png -------------------------------------------------------------------------------- /.cursorindexingignore: -------------------------------------------------------------------------------- 1 | 2 | # Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references 3 | .specstory/** 4 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "navbarBrandText": "Mastowall 2 - written by AI - Prompting: Ralf Stockmann (rstockm)", 3 | "defaultServerUrl": "https://mastodon.social", 4 | "navbarColor": "#ffffff", 5 | "includeReplies": true, 6 | "hashtags": "" 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Deployment scripts with passwords 2 | pull-from-cloudron.sh 3 | push-to-cloudron.sh 4 | 5 | # SSH keys 6 | *.pem 7 | *.key 8 | id_rsa* 9 | cloudron_key* 10 | 11 | # Environment files 12 | .env 13 | .env.local 14 | 15 | # Server-side code (not published) 16 | app_data/ 17 | 18 | # Security documentation (internal) 19 | SECURITY_AUDIT.md 20 | SECURITY_IMPLEMENTED.md 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ralf Stockmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /deploy-to-cloudron.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Deploy Mastowall to Cloudron via SFTP 4 | # Usage: ./deploy-to-cloudron.sh 5 | 6 | set -e 7 | 8 | HOST="my.wolkenbar.de" 9 | PORT="222" 10 | USERNAME="radmin@follow.wolkenbar.de" 11 | REMOTE_PATH="/app/data/public" 12 | 13 | echo "🚀 Deploying Mastowall to Cloudron..." 14 | echo "Host: $HOST:$PORT" 15 | echo "User: $USERNAME" 16 | echo "" 17 | 18 | # Files to upload 19 | FILES=( 20 | "index.html" 21 | "script.js" 22 | "styles.css" 23 | "config.json" 24 | "mastowall-favicon.png" 25 | ) 26 | 27 | # Create SFTP batch file 28 | BATCH_FILE=$(mktemp) 29 | echo "cd $REMOTE_PATH" > "$BATCH_FILE" 30 | 31 | for file in "${FILES[@]}"; do 32 | if [ -f "$file" ]; then 33 | echo "put $file" >> "$BATCH_FILE" 34 | echo " ✓ Queued: $file" 35 | else 36 | echo " ⚠️ Missing: $file" 37 | fi 38 | done 39 | 40 | echo "" 41 | echo "📤 Uploading files..." 42 | echo "Please enter your Cloudron password when prompted." 43 | echo "" 44 | 45 | # Execute SFTP 46 | sftp -P "$PORT" -b "$BATCH_FILE" "$USERNAME@$HOST" 47 | 48 | # Cleanup 49 | rm "$BATCH_FILE" 50 | 51 | echo "" 52 | echo "✅ Deployment complete!" 53 | echo "🌐 Check: https://follow.wolkenbar.de" 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mastowall 2 2 | 3 | Mastowall is a modern social wall application that displays posts from the [Mastodon](https://joinmastodon.org/) social network. It was written entirely by AI ([Claude Sonnet 4.5](https://www.anthropic.com/), ChatGPT 4, and ChatGPT 5), guided only by text prompts. 4 | 5 | image 6 | 7 | The new People screen - who is writing on the wall? 8 | 9 | image 10 | 11 | 12 | 13 | 🔗 **[Try it live](https://rstockm.github.io/mastowall/)** 14 | 15 | Try it live: [[Mastowall for the Open Source Summit]](https://rstockm.github.io/mastowall/?hashtags=opensourcesummit,ossummit&server=https://mastodon.social) 16 | 17 | Use your own hashtags and server via the settings: 18 | 19 | image 20 | 21 | 22 | 23 | ## ✨ Features 24 | 25 | ### 🎨 **Modern UI Design** 26 | - **Three View Modes:** Switch between Grid (Posts), People (Avatar Cards), and Settings 27 | - **Sticky Header:** Always accessible navigation with smooth animations 28 | - **Responsive Masonry Layout:** Beautiful grid that adapts to any screen size 29 | - **Clean & Modern:** Professional white design with subtle gradients and shadows 30 | 31 | ### 📱 **Content Display** 32 | - **Real-Time Updates:** Posts refresh every 10 seconds automatically 33 | - **Rich Media Support:** Display images, videos, and multi-image carousals 34 | - **Lightbox View:** Click any media to see it in full-screen overlay 35 | - **Relative Timestamps:** Human-readable time display (e.g., "5 minutes ago") 36 | - **Avatar Cards:** View contributors grouped by author with post counts 37 | - **Smart Filtering:** Exclude replies or include them based on your needs 38 | 39 | ### 🔧 **Configuration** 40 | - **URL Parameters:** Easy sharing with `?hashtags=tag1,tag2&server=mastodon.social` 41 | - **Zero State Screen:** Intuitive setup for first-time visitors 42 | - **Multi-Server Support:** Connect to any Mastodon instance 43 | - **Follow Feature:** Authenticated users can follow contributors directly 44 | - **Share Function:** Copy current configuration to clipboard 45 | 46 | ### 🔐 **Privacy & Security** 47 | - **Client-Side First:** Most processing happens in your browser 48 | - **Secure Authentication:** OAuth 2.0 for Mastodon connections 49 | - **No Data Storage:** Your configuration stays in the URL, not on servers 50 | 51 | ## 🛠️ Technology Stack 52 | 53 | - **[Bootstrap 5](https://getbootstrap.com/)** - Modern responsive framework 54 | - **[jQuery](https://jquery.com/)** - DOM manipulation and AJAX 55 | - **[Masonry](https://masonry.desandro.com/)** - Cascading grid layout 56 | - **[DOMPurify](https://github.com/cure53/DOMPurify)** - XSS protection for user-generated content 57 | - **[Bootstrap Icons](https://icons.getbootstrap.com/)** - Icon library 58 | - **Vanilla JavaScript** - Modern ES6+ for application logic 59 | 60 | ## 🚀 Quick Start 61 | 62 | ### Basic Usage 63 | 64 | 1. **Visit** [https://rstockm.github.io/mastowall/](https://rstockm.github.io/mastowall/) 65 | 2. **Enter** up to 3 hashtags you want to follow 66 | 3. **Select** a Mastodon server (default: mastodon.social) 67 | 4. **Click** "Start" and watch the wall come alive! 68 | 69 | ### URL Parameters 70 | 71 | Share specific configurations by using URL parameters: 72 | 73 | ``` 74 | https://rstockm.github.io/mastowall/?hashtags=wwdc,apple&server=mastodon.social 75 | ``` 76 | 77 | **Parameters:** 78 | - `hashtags` - Comma-separated list of hashtags (no # symbol needed) 79 | - `server` - Mastodon instance URL (e.g., `mastodon.social`) 80 | 81 | ### Connect to Mastodon (Optional) 82 | 83 | To use the Follow feature: 84 | 85 | 1. Click the **Connect** button (🔗 icon) in the header 86 | 2. Enter your own Mastodon instance URL 87 | 3. Authorize the connection 88 | 4. Follow contributors directly from the People view 89 | 90 | *Note: Authentication is handled securely via OAuth 2.0. Your credentials are never stored.* 91 | 92 | ## 🤝 Related Projects 93 | 94 | - **[Mastotags](https://rstockm.github.io/mastotags/)** - Discover trending hashtag combinations 95 | 96 | ## 🤖 AI-Powered Development 97 | 98 | Mastowall 2 demonstrates the potential of AI-assisted software development. The entire application—including all code, UI/UX design, and documentation—was created through conversations with multiple AI models: **Claude Sonnet 4.5** (Anthropic), **ChatGPT 4** (OpenAI), and **ChatGPT 5** (OpenAI). 99 | 100 | The development process: 101 | - Human developer described desired features and requirements 102 | - AI models provided solutions, code implementations, and optimization suggestions 103 | - Iterative refinement through natural language conversation 104 | - **Every line of code** was written by AI 105 | 106 | This project serves as a proof of concept for modern AI-assisted development workflows. 107 | 108 | ## 📝 License 109 | 110 | This project is open source and available under the MIT License. 111 | 112 | ## 👤 Author 113 | 114 | **Ralf Stockmann** ([@rstockm](https://github.com/rstockm)) 115 | - Prompting & Project Direction 116 | - AI Collaboration & Workflow Design 117 | 118 | --- 119 | 120 | **Powered by AI** 🤖 | **Built for Mastodon** 🐘 | **Made with ❤️** 121 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mastowall 2 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 38 | 39 |
40 |
41 |
42 |
43 |
44 |
45 |

Welcome to the Mastowall

46 |

Please enter up to three hashtags to load posts:

47 |
48 |
49 |
50 |
51 | # 52 | 53 |
54 |
55 |
56 |
57 | # 58 | 59 |
60 |
61 |
62 |
63 | # 64 | 65 |
66 |
67 |
68 | 69 |
70 | https:// 71 | 72 |
73 |
74 | 75 | 76 |

Tip: For better hashtag combinations use Mastotags.

77 |
78 |
79 |
80 |
81 |
82 |
83 | 84 |
85 |
86 |
87 | 88 |
89 |
90 |

People

91 |

Author list will appear here

92 |
93 |
94 | 95 | 101 | 102 | 103 |
104 | 105 |
106 |
107 | 108 | 109 |
110 | 111 | 112 |
113 |
114 |
Connect with your instance
115 |
116 |
117 | 118 |
119 | https:// 120 | 121 |
122 |
123 |
124 | 125 | 126 |
127 |
128 |
129 |
130 | 131 | 132 |
133 |
134 |
Disconnect from your instance?
135 |

You will be logged out and need to reconnect to follow people.

136 |
137 | 138 | 139 |
140 |
141 |
142 | 143 | 144 |
145 |
146 | 147 |
148 | 151 | 154 | 157 | 160 | 163 |
164 |
165 |
166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* Add some custom CSS for the cards */ 2 | .card { 3 | margin-bottom: 20px; 4 | border-radius: 10px; 5 | box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.2); 6 | } 7 | 8 | /* Position the avatar and username in the top left of the card */ 9 | .card-avatar { 10 | height: 50px; 11 | width: 50px; 12 | border-radius: 50%; 13 | position: absolute; 14 | top: 10px; 15 | left: 10px; 16 | } 17 | 18 | /* Settings icon styling entfernt - jetzt Teil der Button-Group */ 19 | 20 | .card-username { 21 | position: absolute; 22 | top: 10px; 23 | left: 70px; 24 | } 25 | 26 | .card-text.text-right { 27 | text-align: right; 28 | margin-top: -20px !important; 29 | } 30 | 31 | /* Add padding to the card body to prevent overlay with avatar and username */ 32 | .card-body { 33 | padding-top: 40px; 34 | padding-bottom: 10px; 35 | } 36 | 37 | /* Style the media image */ 38 | .card-img-top { 39 | width: 100%; 40 | height: auto; 41 | } 42 | 43 | /* Ensure carousel keeps constant height based on first slide */ 44 | .carousel { 45 | position: relative; 46 | } 47 | 48 | .carousel-inner { 49 | width: 100%; 50 | } 51 | 52 | .carousel-inner .carousel-item { 53 | display: flex; 54 | align-items: center; 55 | justify-content: center; 56 | background: #000; /* letterbox background */ 57 | height: 100%; 58 | } 59 | 60 | .carousel-inner .carousel-item img { 61 | width: 100%; 62 | height: 100%; 63 | object-fit: contain; /* no cropping */ 64 | } 65 | 66 | .card-title { 67 | font-weight: normal; 68 | } 69 | 70 | .card-text { 71 | margin-bottom: 1px !important; 72 | } 73 | 74 | .card { 75 | font-size: 0.9em; /* adjust this value to get the desired text size */ 76 | } 77 | 78 | /* Remove indent of URLs */ 79 | .invisible { 80 | font-size: 0 !important; 81 | line-height: 0 !important; 82 | } 83 | 84 | .hashtag { 85 | margin-right: 0px !important; 86 | } 87 | 88 | /* Custom navbar styles */ 89 | .navbar { 90 | background-color: rgb(255, 255, 255) !important; 91 | margin: -10px 0 10px 0 !important; 92 | border: 0 !important; 93 | padding: 15px !important; 94 | min-height: auto !important; 95 | height: 54px !important; 96 | border-radius: 10px; 97 | box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.05); 98 | position: sticky !important; 99 | top: -10px !important; 100 | display: flex; 101 | align-items: center; 102 | gap: 0.5rem; 103 | z-index: 1000 !important; 104 | } 105 | 106 | .navbar-brand { 107 | color: rgba(100, 100, 100, 0.9) !important; 108 | font-size: 0.9em; 109 | padding: 0 !important; 110 | margin: 0 !important; 111 | height: auto !important; 112 | line-height: 1 !important; 113 | } 114 | 115 | .navbar-brand-dark { 116 | color: rgba(30, 30, 30, 0.9) !important; /* 30% dunkler als der Rest */ 117 | } 118 | 119 | .navbar-info { 120 | color: rgba(60, 60, 60, 0.9) !important; 121 | font-size: 1.2em; 122 | padding: 0 0.5rem !important; 123 | margin: 0 !important; 124 | height: auto !important; 125 | flex: 1; 126 | overflow: hidden; 127 | text-overflow: ellipsis; 128 | white-space: nowrap; 129 | line-height: 1.25 !important; 130 | } 131 | 132 | .hashtag { 133 | margin-right: 8px; 134 | display: inline-block; 135 | } 136 | 137 | .col-sm-3 { 138 | padding-left: 0px !important; 139 | padding-right: 0px !important; 140 | } 141 | 142 | /* Set the background color of the body */ 143 | body { 144 | background-color: #eeeeee; 145 | margin-top: 00px !important; 146 | margin-left: 20px !important; 147 | margin-right: 20px !important; 148 | } 149 | 150 | /* Zero State Modern Card Styles */ 151 | .zero-state-card { 152 | background: #ffffff; 153 | border-radius: 16px; 154 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); 155 | padding: 24px 48px; /* 50% weniger oben/unten (24px statt 48px) */ 156 | max-width: 600px; 157 | margin: 0 auto; 158 | } 159 | 160 | .zero-state-header { 161 | margin-bottom: 16px; /* 50% weniger (16px statt 32px) */ 162 | } 163 | 164 | .zero-state-title { 165 | font-size: 2rem; 166 | font-weight: 600; 167 | color: #2c3e50; 168 | margin-bottom: 12px; 169 | } 170 | 171 | .zero-state-subtitle { 172 | font-size: 1.1rem; 173 | color: #7f8c8d; 174 | margin-bottom: 0; 175 | } 176 | 177 | .zero-state-form { 178 | text-align: left; 179 | } 180 | 181 | .zero-state-form .form-group { 182 | margin-bottom: 20px; 183 | } 184 | 185 | .zero-state-form label { 186 | font-weight: 500; 187 | color: #34495e; 188 | margin-bottom: 8px; 189 | font-size: 0.95rem; 190 | } 191 | 192 | .zero-state-form .form-control { 193 | border-radius: 8px; 194 | border: 2px solid #e0e0e0; 195 | padding: 12px 16px; 196 | font-size: 1rem; 197 | transition: border-color 0.2s ease, box-shadow 0.2s ease; 198 | } 199 | 200 | .zero-state-form .form-control:focus { 201 | border-color: #007bff; 202 | box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); 203 | outline: none; 204 | } 205 | 206 | .zero-state-form .btn-primary { 207 | width: 100%; 208 | border-radius: 8px; 209 | padding: 14px 32px; 210 | font-weight: 500; 211 | font-size: 1.05rem; 212 | background-color: #007bff; 213 | border: none; 214 | transition: background-color 0.2s ease, transform 0.1s ease; 215 | } 216 | 217 | .zero-state-form .btn-primary:hover { 218 | background-color: #0056b3; 219 | transform: translateY(-1px); 220 | } 221 | 222 | .zero-state-form .btn-primary:active { 223 | transform: translateY(0); 224 | } 225 | 226 | .zero-state-tip { 227 | font-size: 0.9rem; 228 | color: #95a5a6; 229 | margin-top: 16px; 230 | } 231 | 232 | .zero-state-tip a { 233 | color: #007bff; 234 | text-decoration: none; 235 | font-weight: 500; 236 | } 237 | 238 | .zero-state-tip a:hover { 239 | text-decoration: underline; 240 | } 241 | 242 | /* Responsive adjustments */ 243 | @media (max-width: 768px) { 244 | .zero-state-card { 245 | padding: 32px 24px; 246 | } 247 | 248 | .zero-state-title { 249 | font-size: 1.5rem; 250 | } 251 | 252 | .zero-state-subtitle { 253 | font-size: 1rem; 254 | } 255 | } 256 | 257 | @media (max-width: 1000px) { 258 | .col-sm-3 { 259 | flex: 0 0 50%; 260 | max-width: 50%; 261 | } 262 | .navbar-brand { 263 | display: none; 264 | } 265 | } 266 | 267 | @media (max-width: 600px) { 268 | .col-sm-3 { 269 | flex: 0 0 100%; 270 | max-width: 100%; 271 | } 272 | .navbar-brand { 273 | display: none; 274 | } 275 | } 276 | 277 | .avatar-img { 278 | width: 50px; 279 | height: 50px; 280 | } 281 | 282 | .avatar-link { 283 | display: inline-block; 284 | cursor: pointer; 285 | transition: opacity 0.2s ease; 286 | } 287 | 288 | .avatar-link:hover { 289 | opacity: 0.8; 290 | } 291 | 292 | .container { 293 | max-width: 2000px !important; 294 | } 295 | 296 | .footer { 297 | background-color: rgb(200, 200, 200); 298 | color: #f2f2f2; 299 | position: fixed; 300 | left: 0; 301 | bottom: 0; 302 | width: 100%; 303 | padding-top: 2px !important; /* reduce padding-top to half */ 304 | padding-bottom: 2px !important; /* reduce padding-bottom to half */ 305 | font-size: 0.9em; 306 | } 307 | 308 | /* Overlay for enlarged media */ 309 | #media-overlay { 310 | position: fixed; 311 | top: 0; 312 | left: 0; 313 | right: 0; 314 | bottom: 0; 315 | background: rgba(0, 0, 0, 0.85); 316 | z-index: 1050; 317 | display: flex; 318 | align-items: center; 319 | justify-content: center; 320 | padding: 20px; 321 | } 322 | 323 | #overlay-content img, 324 | #overlay-content video { 325 | max-width: 95vw; 326 | max-height: 90vh; 327 | border-radius: 6px; 328 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); 329 | } 330 | 331 | #overlay-close { 332 | position: absolute; 333 | top: 12px; 334 | right: 16px; 335 | width: 40px; 336 | height: 40px; 337 | display: flex; 338 | align-items: center; 339 | justify-content: center; 340 | border: none; 341 | border-radius: 50%; 342 | background: rgba(0, 0, 0, 0.6); 343 | color: #fff; 344 | font-size: 22px; 345 | line-height: 1; 346 | text-align: center; 347 | padding: 0; 348 | cursor: pointer; 349 | } 350 | 351 | #overlay-close:hover { 352 | background: rgba(0, 0, 0, 0.8); 353 | } 354 | 355 | /* View toggle styles */ 356 | #view-toggle { 357 | display: flex; 358 | gap: 8px; 359 | align-items: center; 360 | padding: 0 !important; 361 | margin: 0 !important; 362 | height: auto !important; 363 | } 364 | 365 | /* Connect Status Icon */ 366 | #connect-status-icon { 367 | font-size: 0.9em; 368 | color: rgba(100, 100, 100, 0.6); 369 | cursor: pointer; 370 | transition: all 0.2s ease; 371 | margin: 0 0 0 12px !important; 372 | padding: 0 !important; 373 | line-height: 1 !important; 374 | } 375 | 376 | #connect-status-icon:hover { 377 | color: rgba(255, 255, 255, 0.9); 378 | transform: scale(1.1); 379 | } 380 | 381 | #connect-status-icon.connected { 382 | color: rgba(100, 100, 100, 0.9); 383 | } 384 | 385 | #view-toggle .btn { 386 | min-width: 80px; 387 | font-size: 0.85em; 388 | padding: 4px 12px; 389 | } 390 | 391 | /* Modern Button Group Styles */ 392 | #view-toggle .btn-group { 393 | border-radius: 6px; 394 | overflow: hidden; 395 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 396 | height: 32px; /* Feste Höhe für konsistente Darstellung */ 397 | } 398 | 399 | #view-toggle .btn-group .btn { 400 | border: none; 401 | border-radius: 0; 402 | min-width: 40px; 403 | padding: 6px 10px; 404 | font-size: 0.85em; 405 | transition: all 0.15s ease; 406 | position: relative; 407 | height: 32px; /* Gleiche Höhe wie Container */ 408 | display: flex; 409 | align-items: center; 410 | justify-content: center; 411 | background-color: rgba(248, 249, 250, 0.8); /* Leichte Transparenz */ 412 | color: rgba(108, 117, 125, 0.9); /* Leichte Transparenz */ 413 | } 414 | 415 | #view-toggle .btn-group .btn:first-child { 416 | border-top-left-radius: 6px; 417 | border-bottom-left-radius: 6px; 418 | } 419 | 420 | #view-toggle .btn-group .btn:last-child { 421 | border-top-right-radius: 6px; 422 | border-bottom-right-radius: 6px; 423 | } 424 | 425 | /* Entferne Trennlinien zwischen Buttons für nahtlose Verbindung */ 426 | #view-toggle .btn-group .btn:not(:last-child) { 427 | border-right: none; 428 | } 429 | 430 | /* Aktiver Button - dezentere Farbe passend zum Header */ 431 | #view-toggle .btn-group .btn.btn-primary { 432 | background-color: rgba(52, 58, 84, 0.7); /* Dunkelblau-grau passend zum Header-Hintergrund */ 433 | color: rgba(255, 255, 255, 0.95); 434 | border-color: rgba(52, 58, 84, 0.7); 435 | } 436 | 437 | /* Hover-Effekt für gesamte Gruppe */ 438 | #view-toggle .btn-group .btn:hover { 439 | background-color: rgba(233, 236, 239, 0.9); 440 | color: #495057; 441 | } 442 | 443 | #view-toggle .btn-group .btn.btn-primary:hover { 444 | background-color: rgba(52, 58, 84, 0.85); /* Etwas dunkler beim Hover */ 445 | color: white; 446 | } 447 | 448 | /* Icon-Styling */ 449 | #view-toggle .btn-group .btn i { 450 | font-size: 1em; 451 | line-height: 1; 452 | } 453 | 454 | /* Verbundener Button - einheitliche Farbe */ 455 | #view-toggle .btn-group .btn.btn-success { 456 | background-color: #28a745; 457 | color: white; 458 | border-color: #28a745; 459 | } 460 | 461 | #view-toggle .btn-group .btn.btn-success:hover { 462 | background-color: #1e7e34; 463 | color: white; 464 | } 465 | 466 | /* People list styles */ 467 | .people-list { 468 | max-width: 800px; 469 | margin: 0 auto; 470 | } 471 | 472 | .people-item { 473 | display: flex; 474 | align-items: flex-start; 475 | padding: 12px 20px; 476 | background: #fff; 477 | margin-bottom: 10px; 478 | border-radius: 10px; 479 | box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.2); 480 | transition: opacity 400ms ease, transform 400ms ease; 481 | will-change: opacity, transform; 482 | } 483 | 484 | .people-item:hover { 485 | background: #f5f5f5; 486 | } 487 | 488 | .people-item.is-hidden { 489 | opacity: 0; 490 | transform: translateY(8px); 491 | } 492 | 493 | .people-item .people-avatar { 494 | visibility: hidden; 495 | } 496 | 497 | .people-avatar { 498 | width: 50px; 499 | height: 50px; 500 | border-radius: 50%; 501 | margin-right: 15px; 502 | flex-shrink: 0; 503 | } 504 | 505 | .people-info { 506 | flex: 1; 507 | display: flex; 508 | flex-direction: column; 509 | min-width: 0; 510 | margin-right: 15px; 511 | } 512 | 513 | .people-name-wrapper { 514 | display: flex; 515 | align-items: center; 516 | gap: 8px; 517 | margin-bottom: 4px; 518 | } 519 | 520 | .people-name { 521 | font-weight: 500; 522 | font-size: 1.1em; 523 | } 524 | 525 | .people-profile-link { 526 | opacity: 0; 527 | color: rgba(108, 117, 125, 0.8); 528 | font-size: 0.9em; 529 | transition: opacity 0.2s ease, color 0.2s ease; 530 | text-decoration: none; 531 | } 532 | 533 | .people-item:hover .people-profile-link { 534 | opacity: 1; 535 | } 536 | 537 | .people-profile-link:hover { 538 | color: #007bff; 539 | } 540 | 541 | .people-profile-link i { 542 | font-size: 0.85em; 543 | } 544 | 545 | .people-bio { 546 | font-size: 0.85em; 547 | color: #666; 548 | line-height: 1.4; 549 | overflow: hidden; 550 | display: -webkit-box; 551 | -webkit-line-clamp: 2; 552 | -webkit-box-orient: vertical; 553 | text-overflow: ellipsis; 554 | transition: all 0.3s ease; 555 | margin-bottom: 0; 556 | } 557 | 558 | .people-item.expanded .people-bio { 559 | -webkit-line-clamp: unset; 560 | display: block; 561 | margin-bottom: 12px; 562 | } 563 | 564 | .people-posts { 565 | display: none; 566 | margin-top: 12px; 567 | padding-top: 12px; 568 | } 569 | 570 | .people-item.expanded .people-posts { 571 | display: block; 572 | } 573 | 574 | .people-post { 575 | margin-bottom: 12px; 576 | padding: 10px; 577 | background: #f8f9fa; 578 | border-radius: 10px; 579 | box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.2); 580 | font-size: 0.85em; 581 | } 582 | 583 | .people-post:last-child { 584 | margin-bottom: 0; 585 | } 586 | 587 | .people-post-content { 588 | color: #333; 589 | line-height: 1.5; 590 | margin-bottom: 6px; 591 | } 592 | 593 | .people-post-content p { 594 | margin: 0; 595 | } 596 | 597 | .people-post-meta { 598 | font-size: 0.9em; 599 | color: #999; 600 | } 601 | 602 | .people-post-meta a { 603 | color: #999; 604 | text-decoration: none; 605 | } 606 | 607 | .people-post-meta a:hover { 608 | color: #666; 609 | text-decoration: underline; 610 | } 611 | 612 | .people-item { 613 | cursor: pointer; 614 | } 615 | 616 | .people-actions { 617 | display: flex; 618 | align-items: center; 619 | gap: 0; 620 | border-radius: 6px; 621 | overflow: hidden; 622 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 623 | height: 32px; 624 | flex-shrink: 0; 625 | align-self: flex-start; 626 | margin-top: 19px !important; 627 | } 628 | 629 | .people-count { 630 | background: rgba(248, 249, 250, 0.8); 631 | color: rgba(108, 117, 125, 0.9); 632 | padding: 6px 12px; 633 | font-size: 0.85em; 634 | font-weight: normal; 635 | min-width: 45px; 636 | text-align: center; 637 | height: 32px; 638 | display: flex; 639 | align-items: center; 640 | justify-content: center; 641 | border-right: none; 642 | } 643 | 644 | .people-actions .follow-btn { 645 | border: none; 646 | border-radius: 0; 647 | height: 32px; 648 | padding: 6px 12px; 649 | font-size: 0.85em; 650 | margin: 0; 651 | background-color: rgba(248, 249, 250, 0.8); 652 | color: rgba(108, 117, 125, 0.9); 653 | transition: all 0.15s ease; 654 | } 655 | 656 | .people-actions .follow-btn:hover { 657 | background-color: rgba(233, 236, 239, 0.9); 658 | color: #495057; 659 | } 660 | 661 | .people-actions .follow-btn.btn-success { 662 | background-color: #28a745; 663 | color: white; 664 | } 665 | 666 | .people-actions .follow-btn.btn-success:hover { 667 | background-color: #1e7e34; 668 | } 669 | 670 | .people-actions .follow-btn.btn-warning { 671 | background-color: #ffc107; 672 | color: #212529; 673 | } 674 | 675 | .people-actions .follow-btn.btn-warning:hover { 676 | background-color: #e0a800; 677 | } 678 | 679 | .people-actions .follow-btn.btn-secondary { 680 | background-color: #6c757d; 681 | color: white; 682 | } 683 | 684 | .people-actions .follow-btn.btn-outline-danger { 685 | background-color: rgba(220, 53, 69, 0.1); 686 | color: #dc3545; 687 | } 688 | 689 | /* Animation layer */ 690 | #animation-layer { 691 | position: fixed; 692 | top: 0; 693 | left: 0; 694 | right: 0; 695 | bottom: 0; 696 | z-index: 2000; /* Höher als navbar (1000) */ 697 | pointer-events: none; 698 | display: none; 699 | } 700 | 701 | #animation-layer.active { 702 | display: block; 703 | } 704 | 705 | .fly-avatar { 706 | position: absolute; 707 | border-radius: 50%; 708 | pointer-events: none; 709 | will-change: transform, opacity; 710 | z-index: 2001 !important; /* Über animation-layer */ 711 | display: block !important; 712 | visibility: visible !important; 713 | } 714 | 715 | /* Connect overlay */ 716 | #connect-overlay, 717 | #disconnect-overlay { 718 | position: fixed; 719 | inset: 0; 720 | background: rgba(0,0,0,0.6); 721 | z-index: 2100; 722 | display: flex; 723 | align-items: center; 724 | justify-content: center; 725 | } 726 | 727 | .connect-modal { 728 | width: 90%; 729 | max-width: 480px; 730 | background: #fff; 731 | border-radius: 16px; 732 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); 733 | padding: 32px; 734 | } 735 | 736 | .connect-modal-title { 737 | font-size: 1.5rem; 738 | font-weight: 600; 739 | color: #2c3e50; 740 | margin-bottom: 24px; 741 | } 742 | 743 | .connect-modal-label { 744 | font-weight: 500; 745 | color: #34495e; 746 | margin-bottom: 8px; 747 | font-size: 0.95rem; 748 | display: block; 749 | } 750 | 751 | .connect-modal-text { 752 | color: #7f8c8d; 753 | font-size: 1rem; 754 | line-height: 1.5; 755 | margin-bottom: 24px; 756 | } 757 | 758 | .connect-input-wrapper { 759 | display: flex; 760 | align-items: center; 761 | border: 2px solid #e0e0e0; 762 | border-radius: 8px; 763 | overflow: hidden; 764 | transition: border-color 0.2s ease; 765 | } 766 | 767 | .connect-input-wrapper:focus-within { 768 | border-color: #007bff; 769 | } 770 | 771 | .connect-input-prefix { 772 | padding: 10px 0 10px 14px; 773 | background: #f8f9fa; 774 | color: #6c757d; 775 | font-size: 1rem; 776 | font-weight: 500; 777 | white-space: nowrap; 778 | } 779 | 780 | .connect-input { 781 | border: none !important; 782 | padding: 10px 14px 10px 8px !important; 783 | font-size: 1rem; 784 | flex: 1; 785 | box-shadow: none !important; 786 | } 787 | 788 | .connect-input:focus { 789 | outline: none; 790 | } 791 | 792 | .zero-state-form .form-group { margin-bottom: 10px; } 793 | .connect-modal-actions { 794 | display: flex; 795 | justify-content: flex-end; 796 | gap: 12px; 797 | margin-top: 18px; 798 | } 799 | 800 | .connect-modal-actions .btn { 801 | border-radius: 8px; 802 | padding: 10px 24px; 803 | font-weight: 500; 804 | } 805 | 806 | /* Modern Toast Notification */ 807 | .notification-toast { 808 | position: fixed; 809 | top: 80px; 810 | left: 50%; 811 | transform: translateX(-50%) translateY(-100px); 812 | background: #ffffff; 813 | border-radius: 12px; 814 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); 815 | padding: 16px 24px; 816 | z-index: 3000; 817 | opacity: 0; 818 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 819 | pointer-events: none; 820 | } 821 | 822 | .notification-toast.show { 823 | opacity: 1; 824 | transform: translateX(-50%) translateY(0); 825 | } 826 | 827 | .notification-content { 828 | display: flex; 829 | align-items: center; 830 | gap: 12px; 831 | } 832 | 833 | .notification-content i { 834 | font-size: 1.2rem; 835 | } 836 | 837 | .notification-success .notification-content i { 838 | color: #28a745; 839 | } 840 | 841 | .notification-error .notification-content i { 842 | color: #dc3545; 843 | } 844 | 845 | .notification-content span { 846 | font-size: 1rem; 847 | color: #2c3e50; 848 | font-weight: 500; 849 | } 850 | 851 | /* Mobile Burger Menu */ 852 | .mobile-burger { 853 | display: none; 854 | background: none; 855 | border: none; 856 | font-size: 1.5rem; 857 | color: #333; 858 | padding: 0.5rem; 859 | cursor: pointer; 860 | margin-left: auto; 861 | align-self: center; 862 | } 863 | 864 | /* Show burger on mobile, hide desktop nav */ 865 | @media (max-width: 767px) { 866 | .desktop-nav { 867 | display: none !important; 868 | } 869 | /* Hide desktop icons group on mobile */ 870 | #view-toggle { 871 | display: none !important; 872 | } 873 | 874 | .mobile-burger.d-none { 875 | display: flex !important; 876 | } 877 | 878 | .navbar { 879 | display: flex; 880 | align-items: center; 881 | padding: 15px 5px !important; 882 | margin-left: 0 !important; 883 | margin-right: 0 !important; 884 | } 885 | 886 | .navbar-info { 887 | display: block !important; 888 | font-size: 0.9em !important; 889 | max-width: calc(100% - 140px); 890 | } 891 | 892 | /* Unified padding for all containers on mobile - same as posts */ 893 | .container, 894 | #app-content, 895 | #people-container, 896 | #zero-state .container { 897 | padding-left: 3.75px !important; 898 | padding-right: 3.75px !important; 899 | } 900 | 901 | /* Zero-state card same spacing */ 902 | .zero-state-card { 903 | margin-left: 0 !important; 904 | margin-right: 0 !important; 905 | } 906 | 907 | /* Column padding unified */ 908 | .col-sm-3, 909 | .col-md-4, 910 | .col-lg-3, 911 | .col-md-8, 912 | .col-lg-6 { 913 | padding-left: 3.75px !important; 914 | padding-right: 3.75px !important; 915 | } 916 | 917 | /* People container cards */ 918 | #people-container .card { 919 | margin-left: 0 !important; 920 | margin-right: 0 !important; 921 | } 922 | 923 | /* People cards layout - count top right, avatar with button below */ 924 | .people-item { 925 | display: grid !important; 926 | grid-template-columns: 60px 1fr !important; /* free the right side for content */ 927 | grid-template-rows: auto auto !important; 928 | gap: 0.5rem !important; 929 | align-items: start !important; 930 | position: relative !important; 931 | padding-right: 12px !important; /* reduce right inner padding on mobile */ 932 | padding-left: 10px !important; /* as requested for mobile */ 933 | } 934 | 935 | .people-avatar { 936 | width: 60px !important; 937 | height: 60px !important; 938 | grid-row: 1 / 2 !important; 939 | grid-column: 1 / 2 !important; 940 | } 941 | 942 | .people-info { 943 | grid-row: 1 / 2 !important; 944 | grid-column: 2 / 3 !important; /* spans full right content area */ 945 | min-width: 0; 946 | margin-right: 0 !important; /* remove extra right gap */ 947 | padding-right: 1.5rem !important; /* just enough for top-right count */ 948 | } 949 | 950 | .people-count { 951 | position: absolute !important; 952 | top: 8px !important; 953 | right: 12px !important; 954 | margin: 0 !important; 955 | padding: 0 !important; 956 | min-width: 0 !important; 957 | height: auto !important; 958 | line-height: 1 !important; 959 | font-size: 0.95rem !important; 960 | font-weight: 400 !important; 961 | background: transparent !important; 962 | color: #666 !important; 963 | } 964 | 965 | .people-actions { 966 | grid-row: 2 / 3 !important; 967 | grid-column: 1 / 2 !important; 968 | display: flex !important; 969 | width: 100% !important; 970 | } 971 | 972 | .people-actions .follow-btn { 973 | width: 100% !important; 974 | padding: 0.4rem 0.3rem !important; 975 | font-size: 0.75rem !important; 976 | line-height: 1.2 !important; 977 | white-space: nowrap !important; 978 | } 979 | } 980 | 981 | /* Mobile Menu Overlay */ 982 | .mobile-menu-overlay { 983 | position: fixed; 984 | inset: 0; 985 | background: rgba(0,0,0,0.7); 986 | z-index: 3000; 987 | display: flex; 988 | align-items: flex-start; 989 | justify-content: flex-end; 990 | padding-top: 60px; 991 | } 992 | 993 | .mobile-menu-content { 994 | background: white; 995 | width: 280px; 996 | max-width: 85vw; 997 | height: calc(100vh - 60px); 998 | box-shadow: -4px 0 12px rgba(0,0,0,0.2); 999 | display: flex; 1000 | flex-direction: column; 1001 | animation: slideInRight 0.3s ease-out; 1002 | } 1003 | 1004 | @keyframes slideInRight { 1005 | from { 1006 | transform: translateX(100%); 1007 | } 1008 | to { 1009 | transform: translateX(0); 1010 | } 1011 | } 1012 | 1013 | .mobile-menu-close { 1014 | align-self: flex-end; 1015 | background: none; 1016 | border: none; 1017 | font-size: 1.8rem; 1018 | padding: 1rem; 1019 | color: #666; 1020 | cursor: pointer; 1021 | } 1022 | 1023 | .mobile-menu-items { 1024 | display: flex; 1025 | flex-direction: column; 1026 | padding: 1rem 0; 1027 | } 1028 | 1029 | .mobile-menu-item { 1030 | display: flex; 1031 | align-items: center; 1032 | gap: 1rem; 1033 | padding: 1rem 1.5rem; 1034 | background: none; 1035 | border: none; 1036 | border-bottom: 1px solid #f0f0f0; 1037 | text-align: left; 1038 | cursor: pointer; 1039 | transition: background 0.2s ease; 1040 | } 1041 | 1042 | .mobile-menu-item:hover, 1043 | .mobile-menu-item:active { 1044 | background: #f8f9fa; 1045 | } 1046 | 1047 | .mobile-menu-item.active { 1048 | background: #e3f2fd; 1049 | border-left: 4px solid #007bff; 1050 | } 1051 | 1052 | .mobile-menu-item i { 1053 | font-size: 1.2rem; 1054 | color: #666; 1055 | width: 24px; 1056 | text-align: center; 1057 | } 1058 | 1059 | .mobile-menu-item.active i { 1060 | color: #007bff; 1061 | } 1062 | 1063 | .mobile-menu-item span { 1064 | font-size: 1rem; 1065 | color: #333; 1066 | font-weight: 500; 1067 | } 1068 | 1069 | .mobile-menu-item.active span { 1070 | color: #007bff; 1071 | } 1072 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | // The existingPosts array is used to track already displayed posts 2 | let existingPosts = []; 3 | 4 | // Author metadata: authorId -> {count, displayName, avatarUrl, representativeNode} 5 | let authorData = new Map(); 6 | 7 | // getUrlParameter helps to fetch URL parameters 8 | function getUrlParameter(name) { 9 | name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); 10 | var regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); 11 | var results = regex.exec(location.search); 12 | return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); 13 | } 14 | 15 | // secondsAgo calculates how many seconds have passed since the provided date 16 | const secondsAgo = date => Math.floor((new Date() - date) / 1000); 17 | 18 | // timeAgo formats the time elapsed in a human readable format 19 | const timeAgo = function(seconds) { 20 | const intervals = [ 21 | { limit: 31536000, text: 'years' }, 22 | { limit: 2592000, text: 'months' }, 23 | { limit: 86400, text: 'days' }, 24 | { limit: 3600, text: 'hours' }, 25 | { limit: 60, text: 'minutes' } 26 | ]; 27 | 28 | for (let interval of intervals) { 29 | if (seconds >= interval.limit) { 30 | return Math.floor(seconds / interval.limit) + ` ${interval.text} ago`; 31 | } 32 | } 33 | return Math.floor(seconds) + " seconds ago"; 34 | }; 35 | 36 | let includeReplies; 37 | 38 | // fetchConfig fetches the configuration from the config.json file 39 | const fetchConfig = async function() { 40 | try { 41 | const config = await $.getJSON('config.json'); 42 | // Split text to style "Mastowall 2" darker 43 | const text = config.navbarBrandText; 44 | const parts = text.split(' - '); 45 | if (parts.length >= 2) { 46 | const styledText = `${parts[0]} - ${parts.slice(1).join(' - ')}`; 47 | $('#navbar-brand').html(styledText); 48 | } else { 49 | $('#navbar-brand').text(text); 50 | } 51 | // Ensure JSON color is not overridden by CSS !important 52 | const navbarEl = document.querySelector('.navbar'); 53 | if (navbarEl && navbarEl.style && typeof navbarEl.style.setProperty === 'function') { 54 | navbarEl.style.setProperty('background-color', config.navbarColor, 'important'); 55 | } else { 56 | $('.navbar').css('background-color', config.navbarColor); 57 | } 58 | includeReplies = config.includeReplies; 59 | return config; 60 | } catch (error) { 61 | console.error("Error loading config.json:", error); 62 | } 63 | } 64 | 65 | // fetchPosts fetches posts from the server using the given hashtag 66 | const fetchPosts = async function(serverUrl, hashtag) { 67 | try { 68 | const posts = await $.get(`${serverUrl}/api/v1/timelines/tag/${hashtag}?limit=40`); 69 | return posts; 70 | } catch (error) { 71 | console.error(`Error loading posts for hashtag #${hashtag}:`, error); 72 | } 73 | }; 74 | 75 | // updateTimesOnPage updates the time information displayed for each post 76 | const updateTimesOnPage = function() { 77 | $('.card-text a').each(function() { 78 | const date = new Date($(this).attr('data-time')); 79 | const newTimeAgo = timeAgo(secondsAgo(date)); 80 | $(this).text(newTimeAgo); 81 | }); 82 | }; 83 | 84 | // displayPost creates and displays a post 85 | const displayPost = function(post) { 86 | if (existingPosts.includes(post.id) || (!includeReplies && post.in_reply_to_id !== null)) return; 87 | 88 | existingPosts.push(post.id); 89 | 90 | // Collect author metadata 91 | const authorId = post.account.id; 92 | const acctRaw = (post.account.acct || '').trim(); 93 | const username = (post.account.username || '').trim(); 94 | let acctFull = acctRaw; 95 | if (!acctFull || acctFull.indexOf('@') === -1) { 96 | // Try to derive full acct user@domain 97 | try { 98 | const urlObj = new URL(post.account.url || ''); 99 | const host = urlObj.hostname; 100 | const userFromUrl = (urlObj.pathname || '').split('/').filter(Boolean).pop() || username || acctRaw; 101 | if (userFromUrl && host) acctFull = `${userFromUrl.replace(/^@/, '')}@${host}`; 102 | } catch (e) { 103 | if (username && (post.account.url || '').length > 0) { 104 | try { 105 | const host = new URL(post.account.url).hostname; 106 | acctFull = `${username}@${host}`; 107 | } catch(e2) {} 108 | } 109 | } 110 | } 111 | if (authorData.has(authorId)) { 112 | const meta = authorData.get(authorId); 113 | meta.count++; 114 | if (!meta.acct && acctFull) meta.acct = acctFull; 115 | if (!meta.displayName && post.account.display_name) meta.displayName = post.account.display_name; 116 | if (!meta.avatarUrl && post.account.avatar) meta.avatarUrl = post.account.avatar; 117 | if (!meta.bio && post.account.note) meta.bio = post.account.note; 118 | // Keep all posts 119 | meta.recentPosts.push(post); 120 | } else { 121 | authorData.set(authorId, { 122 | count: 1, 123 | displayName: post.account.display_name, 124 | avatarUrl: post.account.avatar, 125 | acct: acctFull || '', 126 | bio: post.account.note || '', 127 | recentPosts: [post], 128 | representativeNode: null // Will be set after card is rendered 129 | }); 130 | } 131 | 132 | const attachments = Array.isArray(post.media_attachments) ? post.media_attachments : []; 133 | const imageAttachments = attachments.filter(att => !(att.url || '').endsWith('.mp4')); 134 | const hasVideo = attachments.some(att => (att.url || '').endsWith('.mp4')); 135 | 136 | let mediaHTML = ''; 137 | if (imageAttachments.length > 1) { 138 | const carouselId = `carousel-${post.id}`; 139 | const indicators = imageAttachments.map((_, idx) => ` 140 |
  • 141 | `).join(''); 142 | const slides = imageAttachments.map((att, idx) => ` 143 | 146 | `).join(''); 147 | mediaHTML = ` 148 | 152 | `; 153 | } else if (imageAttachments.length === 1) { 154 | mediaHTML = ``; 155 | } else if (hasVideo) { 156 | const videoAtt = attachments.find(att => (att.url || '').endsWith('.mp4')); 157 | mediaHTML = ``; 158 | } 159 | 160 | // Construct profile URL 161 | let profileUrl = post.account.url || ''; 162 | if (!profileUrl && acctFull && acctFull.includes('@')) { 163 | const parts = acctFull.split('@'); 164 | const username = parts[0]; 165 | const domain = parts[1]; 166 | profileUrl = `https://${domain}/@${username}`; 167 | } 168 | 169 | let cardHTML = ` 170 |
    171 |
    172 |
    173 | ${profileUrl ? `` : ''} 174 | 175 | ${profileUrl ? `` : ''} 176 |

    ${DOMPurify.sanitize(post.account.display_name)}

    177 |
    178 | ${mediaHTML} 179 |

    ${DOMPurify.sanitize(post.content)}

    180 | ${post.spoiler_text ? `

    ${DOMPurify.sanitize(post.spoiler_text)}

    ` : ''} 181 |

    ${timeAgo(secondsAgo(new Date(post.created_at)))}

    182 |
    183 |
    184 | `; 185 | 186 | let $card = $(cardHTML); 187 | $('#wall').prepend($card); 188 | $('.masonry-grid').masonry('prepended', $card); 189 | 190 | // Always set representative node to the most recently rendered avatar 191 | const avatarNode = $card.find('.avatar-img')[0]; 192 | if (avatarNode && authorData.has(authorId)) { 193 | authorData.get(authorId).representativeNode = avatarNode; 194 | console.log('Set representativeNode for author', authorId, avatarNode); 195 | } 196 | 197 | // Initialize carousel if present and relayout Masonry on slide and image load 198 | const $carousel = $card.find('.carousel'); 199 | if ($carousel.length) { 200 | $carousel.carousel({ interval: 2000 }); 201 | 202 | // Lock height to first slide once it's loaded 203 | const $firstImg = $carousel.find('.carousel-item img').first(); 204 | const setHeight = function() { 205 | const h = $firstImg.height(); 206 | if (h) { 207 | $carousel.find('.carousel-inner').css('height', h + 'px'); 208 | $('.masonry-grid').masonry('layout'); 209 | } 210 | }; 211 | if ($firstImg[0] && $firstImg[0].complete) { 212 | setHeight(); 213 | } else { 214 | $firstImg.on('load', setHeight); 215 | } 216 | $carousel.on('slid.bs.carousel', function() { 217 | $('.masonry-grid').masonry('layout'); 218 | }); 219 | $carousel.imagesLoaded(function() { 220 | $('.masonry-grid').masonry('layout'); 221 | }); 222 | } else { 223 | $card.imagesLoaded(function() { 224 | $('.masonry-grid').masonry('layout'); 225 | }); 226 | } 227 | }; 228 | 229 | // Set the document title based on the first hashtag in the URL 230 | document.addEventListener('DOMContentLoaded', function() { 231 | const hashtags = getUrlParameter('hashtags'); 232 | if (hashtags) { 233 | const firstHashtag = hashtags.split(',')[0]; 234 | document.title = `#${firstHashtag} - Mastowall 2`; 235 | } 236 | }); 237 | 238 | // updateWall displays all posts 239 | const updateWall = function(posts) { 240 | if (!posts || posts.length === 0) return; 241 | 242 | posts.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); 243 | posts.forEach(post => displayPost(post)); 244 | }; 245 | 246 | // hashtagsString returns a single string based on the given array of hashtags 247 | const hashtagsString = function(hashtagsArray) { 248 | return `${hashtagsArray.map(hashtag => `#${hashtag}`).join(' ')}`; 249 | } 250 | 251 | // updateHashtagsOnPage updates the displayed hashtags 252 | const updateHashtagsOnPage = function(hashtagsArray) { 253 | const hashtagsText = hashtagsArray.length > 0 ? hashtagsString(hashtagsArray) : 'No hashtags set'; 254 | $('#hashtag-display').html(hashtagsText); 255 | }; 256 | 257 | // updateHashtagsInTitle updates the document title by appending the given array of hashtags 258 | const updateHashtagsInTitle = function(hashtagsArray) { 259 | const baseTitle = document.title; 260 | document.title = `${baseTitle} | ${hashtagsString(hashtagsArray)}`; 261 | } 262 | 263 | // handleHashtagDisplayClick handles the event when the hashtag display is clicked 264 | const handleHashtagDisplayClick = function(serverUrl) { 265 | $('#app-content').addClass('d-none'); 266 | $('#zero-state').removeClass('d-none'); 267 | 268 | const currentHashtags = getUrlParameter('hashtags').split(','); 269 | 270 | for (let i = 0; i < currentHashtags.length; i++) { 271 | $(`#hashtag${i+1}`).val(currentHashtags[i]); 272 | } 273 | 274 | // Remove https:// prefix for display in input field 275 | const displayValue = serverUrl.replace(/^https:\/\//, ''); 276 | $('#serverUrl').val(displayValue); 277 | }; 278 | 279 | // handleHashtagFormSubmit handles the submission of the hashtag form 280 | const handleHashtagFormSubmit = function(e, hashtagsArray) { 281 | e.preventDefault(); 282 | 283 | let hashtags = [ 284 | $('#hashtag1').val(), 285 | $('#hashtag2').val(), 286 | $('#hashtag3').val() 287 | ] 288 | .map(function(h){ 289 | h = (h || '').trim(); 290 | if (h.startsWith('#')) h = h.slice(1); // accept and ignore leading '#' 291 | return h; 292 | }); 293 | 294 | hashtags = hashtags.filter(function(hashtag) { 295 | return hashtag !== '' && /^[\p{L}\p{N}_]+$/u.test(hashtag); 296 | }); 297 | 298 | let serverUrl = $('#serverUrl').val().trim(); 299 | 300 | // Always ensure https:// prefix; input holds only host 301 | if (!serverUrl.startsWith('http://') && !serverUrl.startsWith('https://')) { 302 | serverUrl = 'https://' + serverUrl; 303 | } 304 | 305 | if (!/^https:\/\/[\w.\-]+\/?$/.test(serverUrl)) { 306 | alert('Invalid server URL.'); 307 | return; 308 | } 309 | 310 | const newUrl = window.location.origin + window.location.pathname + `?hashtags=${hashtags.join(',')}&server=${serverUrl}`; 311 | 312 | window.location.href = newUrl; 313 | }; 314 | 315 | // Initialize isFirstLoad flag 316 | let isFirstLoad = true; 317 | 318 | // Current view state: 'posts', 'people', or 'settings' 319 | let currentView = 'posts'; 320 | 321 | // Keep the current view encoded in the URL for shareability 322 | const updateUrlForView = function(view) { 323 | try { 324 | const url = new URL(window.location.href); 325 | if (view === 'people') { 326 | url.searchParams.set('view', 'people'); 327 | } else { 328 | // Default (posts) or settings: don't encode in URL 329 | url.searchParams.delete('view'); 330 | } 331 | history.replaceState({}, '', url.toString()); 332 | } catch (e) { 333 | // no-op if URL API not available 334 | } 335 | }; 336 | 337 | // updateButtonStates updates the visual state of all navigation buttons 338 | let updateButtonStates = function() { 339 | // Reset all buttons to outline style 340 | $('#toggle-posts, #toggle-people, #settings-btn').removeClass('btn-primary').addClass('btn-outline-primary'); 341 | 342 | // Set active button based on current view 343 | switch(currentView) { 344 | case 'posts': 345 | $('#toggle-posts').removeClass('btn-outline-primary').addClass('btn-primary'); 346 | break; 347 | case 'people': 348 | $('#toggle-people').removeClass('btn-outline-primary').addClass('btn-primary'); 349 | break; 350 | case 'settings': 351 | $('#settings-btn').removeClass('btn-outline-primary').addClass('btn-primary'); 352 | break; 353 | } 354 | }; 355 | 356 | // renderPeopleList generates and displays the sorted author list 357 | const renderPeopleList = function() { 358 | const $container = $('#people-container'); 359 | $container.empty(); 360 | 361 | if (authorData.size === 0) { 362 | $container.html('

    People

    No authors yet

    '); 363 | return; 364 | } 365 | 366 | // Convert to array and sort by count descending 367 | const sortedAuthors = Array.from(authorData.entries()) 368 | .map(([id, data]) => ({ id, ...data })) 369 | .sort((a, b) => b.count - a.count); 370 | 371 | let listHTML = '
    '; 372 | sortedAuthors.forEach(author => { 373 | const bioText = author.bio || ''; 374 | const sanitizedBio = DOMPurify.sanitize(bioText, {ALLOWED_TAGS: []}); 375 | 376 | // Prepare recent posts HTML 377 | let postsHTML = ''; 378 | if (author.recentPosts && author.recentPosts.length > 0) { 379 | postsHTML = '
    '; 380 | // Sort by date descending (newest first) 381 | const sortedPosts = [...author.recentPosts].sort((a, b) => 382 | new Date(b.created_at) - new Date(a.created_at) 383 | ); 384 | sortedPosts.forEach(post => { 385 | const postContent = DOMPurify.sanitize(post.content); 386 | const postDate = new Date(post.created_at); 387 | const timeAgoText = timeAgo(secondsAgo(postDate)); 388 | 389 | postsHTML += ` 390 |
    391 |
    ${postContent}
    392 | 395 |
    396 | `; 397 | }); 398 | postsHTML += '
    '; 399 | } 400 | 401 | // Construct profile URL from acct 402 | let profileUrl = ''; 403 | if (author.acct && author.acct.includes('@')) { 404 | const parts = author.acct.split('@'); 405 | const username = parts[0]; 406 | const domain = parts[1]; 407 | profileUrl = `https://${domain}/@${username}`; 408 | } 409 | 410 | listHTML += ` 411 | 426 | `; 427 | }); 428 | listHTML += '
    '; 429 | 430 | $container.html(listHTML); 431 | console.log(`People list rendered: ${authorData.size} authors`); 432 | }; 433 | 434 | // animatePostsToPeople performs FLIP animation from posts to people view 435 | const animatePostsToPeople = async function() { 436 | // Check for reduced motion preference 437 | const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 438 | 439 | if (prefersReducedMotion) { 440 | // Simple crossfade without animation 441 | $('#app-content').fadeOut(200); 442 | renderPeopleList(); 443 | // Remove is-hidden class from all people items since we're not animating 444 | $('#people-container').removeClass('d-none'); 445 | $('#people-container .people-item').removeClass('is-hidden'); 446 | $('#people-container .people-item .people-avatar').css({ visibility: 'visible' }); 447 | $('#people-container').fadeIn(200); 448 | // Load follow statuses 449 | prefetchFollowStatuses().catch(() => {}); 450 | return; 451 | } 452 | 453 | const $layer = $('#animation-layer'); 454 | const clones = []; 455 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; 456 | 457 | // STEP 1: Measure start positions from avatars with actual layout 458 | const startRects = new Map(); 459 | 460 | // Get all avatars WITHOUT :visible filter (it's unreliable with Masonry) 461 | const $allAvatars = $('#wall .avatar-img'); 462 | console.log('Total avatars in wall:', $allAvatars.length); 463 | 464 | $allAvatars.each(function() { 465 | const authorId = $(this).attr('data-author-id'); 466 | if (!authorId) return; 467 | 468 | // Skip if we already have this author (use first/topmost avatar only) 469 | if (startRects.has(authorId)) return; 470 | 471 | // Check if element has dimensions using offsetWidth/offsetHeight 472 | const hasSize = this.offsetWidth > 0 && this.offsetHeight > 0; 473 | 474 | if (hasSize) { 475 | const rect = this.getBoundingClientRect(); 476 | 477 | if (rect.width > 0 && rect.height > 0) { 478 | const startPos = { 479 | left: rect.left, 480 | top: rect.top + scrollTop, 481 | width: rect.width, 482 | height: rect.height 483 | }; 484 | startRects.set(authorId, startPos); 485 | if (startRects.size <= 2) { 486 | console.log(`Start: ${authorId.substr(0,8)} - left:${rect.left.toFixed(0)} top:${rect.top.toFixed(0)} scroll:${scrollTop}`); 487 | } 488 | } 489 | } 490 | }); 491 | 492 | console.log('Start rects collected:', startRects.size, 'of', $allAvatars.length, 'avatars'); 493 | 494 | // STEP 2: Render people list and measure end positions in final on-screen placement 495 | renderPeopleList(); 496 | // Parallel: Follow-Status vorab laden, damit Buttons korrekt angezeigt werden 497 | prefetchFollowStatuses().catch(() => {}); 498 | const navbarHeight = $('.navbar').outerHeight() || 54; 499 | 500 | // Measure scrollTop AGAIN before showing people-container (it may have changed) 501 | const currentScrollTop = window.pageYOffset || document.documentElement.scrollTop; 502 | console.log('scrollTop changed from', scrollTop, 'to', currentScrollTop); 503 | 504 | // Show container, but keep it invisible and fixed at final position to get correct rects 505 | $('#people-container') 506 | .removeClass('d-none') 507 | .css({ visibility: 'hidden', position: 'absolute', top: (navbarHeight + currentScrollTop) + 'px', left: 0, right: 0 }); 508 | 509 | // Force layout and give a tick for styles 510 | $('#people-container')[0].offsetHeight; 511 | await new Promise(resolve => setTimeout(resolve, 10)); 512 | 513 | const endRects = new Map(); 514 | $('#people-container .people-avatar').each(function() { 515 | const authorId = $(this).attr('data-author-id'); 516 | const rect = this.getBoundingClientRect(); 517 | const endRect = { 518 | left: rect.left, 519 | top: rect.top + currentScrollTop, // Absolute Position with current scroll 520 | width: rect.width, 521 | height: rect.height 522 | }; 523 | endRects.set(authorId, endRect); 524 | if (endRects.size <= 2) { 525 | console.log(`End: ${authorId.substr(0,8)} - left:${rect.left.toFixed(0)} top:${rect.top.toFixed(0)} scroll:${currentScrollTop}`); 526 | } 527 | }); 528 | 529 | // Hide again and reset positioning; will be shown for real after animation 530 | $('#people-container') 531 | .addClass('d-none') 532 | .css({ visibility: '', position: '', top: '', left: '', right: '' }); 533 | 534 | console.log('Animation setup complete - Start rects:', startRects.size, 'End rects:', endRects.size); 535 | 536 | // Debug first start rect 537 | const firstStart = Array.from(startRects.values())[0]; 538 | console.log('First start rect:', firstStart); 539 | 540 | // STEP 3: Create clones for each author 541 | $layer.addClass('active').empty(); 542 | console.log('Animation layer active, creating clones...'); 543 | 544 | authorData.forEach((data, authorId) => { 545 | const start = startRects.get(authorId); 546 | const end = endRects.get(authorId); 547 | 548 | if (!start || !end) { 549 | console.log('Skipping author', authorId, '- missing positions'); 550 | return; 551 | } 552 | 553 | // Use initial scrollTop for positioning (from when start positions were measured) 554 | const $clone = $('', { 555 | src: data.avatarUrl, 556 | class: 'fly-avatar', 557 | css: { 558 | position: 'absolute', 559 | left: start.left + 'px', 560 | top: (start.top - scrollTop) + 'px', // Use initial scrollTop (viewport-relative for fixed layer) 561 | width: start.width + 'px', 562 | height: start.height + 'px', 563 | opacity: 1, 564 | zIndex: 2001, 565 | display: 'block', 566 | visibility: 'visible' 567 | } 568 | }); 569 | 570 | $layer.append($clone); 571 | clones.push({ $clone, start, end }); 572 | if (clones.length <= 2) { 573 | console.log(`Clone ${clones.length}: ${authorId.substr(0,8)} start(${start.left.toFixed(0)},${start.top.toFixed(0)}) -> end(${end.left.toFixed(0)},${end.top.toFixed(0)})`); 574 | } 575 | }); 576 | 577 | // STEP 4: Shrink and move post cards toward center 578 | const viewportCenterX = window.innerWidth / 2; 579 | const viewportCenterY = window.innerHeight / 2; 580 | 581 | $('#wall .card').each(function() { 582 | const rect = this.getBoundingClientRect(); 583 | const cardCenterX = rect.left + rect.width / 2; 584 | const cardCenterY = rect.top + rect.height / 2; 585 | const deltaX = viewportCenterX - cardCenterX; 586 | const deltaY = viewportCenterY - cardCenterY; 587 | 588 | $(this).css({ 589 | transition: 'transform 800ms ease-out, opacity 800ms ease-out', 590 | transform: `translate(${deltaX * 0.3}px, ${deltaY * 0.3}px) scale(0.3)`, 591 | opacity: 0 592 | }); 593 | }); 594 | 595 | // STEP 5: Animate clones to end positions 596 | requestAnimationFrame(() => { 597 | requestAnimationFrame(() => { 598 | clones.forEach(({ $clone, start, end }) => { 599 | const deltaX = end.left - start.left; 600 | // Use currentScrollTop for end position (measured when people-container was positioned) 601 | const deltaY = (end.top - currentScrollTop) - (start.top - scrollTop); // Viewport-relative 602 | const scaleX = end.width / start.width; 603 | const scaleY = end.height / start.height; 604 | 605 | $clone.css({ 606 | transition: 'transform 1800ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 1800ms ease-out', 607 | transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` 608 | }); 609 | }); 610 | }); 611 | }); 612 | 613 | console.log('Animation started with', clones.length, 'clones'); 614 | 615 | // STEP 6: Hide app-content NOW (after measurement) and show people container during flight 616 | $('#app-content').addClass('d-none'); 617 | 618 | setTimeout(() => { 619 | $('#people-container').removeClass('d-none').css({ visibility: '' }); 620 | 621 | // Staggered reveal for organic build-up (boxes without avatars) 622 | const items = Array.from(document.querySelectorAll('#people-container .people-item')); 623 | items.forEach((el, idx) => { 624 | setTimeout(() => el.classList.remove('is-hidden'), 80 * idx); 625 | }); 626 | // Zweiter Versuch, sobald die DOM-Elemente sichtbar sind 627 | prefetchFollowStatuses().catch(() => {}); 628 | }, 400); 629 | 630 | // STEP 7: Crossfade from clones to real avatars in boxes 631 | setTimeout(() => { 632 | // Show real avatars in boxes 633 | document.querySelectorAll('#people-container .people-avatar').forEach(av => { 634 | av.style.visibility = 'visible'; 635 | av.style.opacity = '0'; 636 | av.style.transition = 'opacity 300ms ease'; 637 | // Force reflow 638 | av.offsetHeight; 639 | av.style.opacity = '1'; 640 | }); 641 | 642 | // Fade out clones simultaneously 643 | $('.fly-avatar').css({ 644 | transition: 'opacity 300ms ease', 645 | opacity: 0 646 | }); 647 | 648 | // Remove clones and layer after crossfade 649 | setTimeout(() => { 650 | $layer.removeClass('active').empty(); 651 | }, 350); 652 | 653 | // Reset wall cards for next time 654 | $('#wall .card').css({ transition: '', opacity: '', transform: '' }); 655 | }, 1800); 656 | }; 657 | 658 | // Lädt Follow-Status (ausschließlich über Backend-Proxy) und aktualisiert Buttons 659 | async function prefetchFollowStatuses() { 660 | const token = sessionStorage.getItem('mw_token'); 661 | const home = sessionStorage.getItem('mw_home'); 662 | if (!token || !home) return; 663 | 664 | // Sammle eindeutige accts aus der gerade gerenderten Liste 665 | const accts = []; 666 | $('#people-container .follow-btn').each(function() { 667 | const a = ($(this).attr('data-acct') || '').trim(); 668 | if (a) accts.push(a.toLowerCase()); 669 | }); 670 | const uniqueAccts = Array.from(new Set(accts)).slice(0, 50); // Limit für Performance 671 | console.log('Prefetch start. unique accts:', uniqueAccts.length, uniqueAccts.slice(0, 5)); 672 | if (uniqueAccts.length === 0) { console.log('No accts to check.'); return; } 673 | 674 | // Serverseitig über relations.php (vermeidet CORS/403 und ist stabiler) 675 | try { 676 | const resp = await fetch('https://follow.wolkenbar.de/relations.php', { 677 | method: 'POST', 678 | headers: { 'Content-Type': 'application/json' }, 679 | body: JSON.stringify({ token, home, accts: uniqueAccts }) 680 | }); 681 | if (!resp.ok) { console.warn('relations.php HTTP', resp.status); return; } 682 | const data = await resp.json(); 683 | if (!data || !Array.isArray(data.relations)) { console.warn('relations.php: malformed response'); return; } 684 | const map = new Map(); 685 | data.relations.forEach(r => { if (r && r.acct) map.set(String(r.acct).toLowerCase(), r); }); 686 | console.log('Relations fetched:', data.relations.length, 'map size:', map.size); 687 | $('#people-container .follow-btn').each(function() { 688 | const $btn = $(this); 689 | const acct = ($btn.attr('data-acct') || '').trim().toLowerCase(); 690 | const r = map.get(acct); 691 | if (!r) return; 692 | if (r.following) { 693 | $btn.prop('disabled', true).removeClass('btn-outline-success btn-warning btn-outline-danger btn-secondary').addClass('btn-success').text('Following'); 694 | } else if (r.requested) { 695 | $btn.prop('disabled', true).removeClass('btn-outline-success btn-success btn-outline-danger btn-secondary').addClass('btn-warning').text('Requested'); 696 | } else { 697 | $btn.prop('disabled', false).removeClass('btn-success btn-warning btn-outline-danger btn-secondary').addClass('btn-outline-success').text('+ Follow'); 698 | } 699 | }); 700 | } catch (e) { 701 | console.error('relations.php fetch failed:', e); 702 | // Buttons bleiben im Default 703 | } 704 | } 705 | 706 | // On document ready, the script configures Masonry, handles events, fetches and displays posts 707 | $(document).ready(async function() { 708 | const config = await fetchConfig(); 709 | const defaultServerUrl = (config && config.defaultServerUrl) || 'https://mastodon.social'; 710 | $('.masonry-grid').masonry({ 711 | itemSelector: '.col-sm-3', 712 | columnWidth: '.col-sm-3', 713 | percentPosition: true 714 | }); 715 | 716 | // Initial reshuffle after 3 seconds only on first load 717 | if (isFirstLoad) { 718 | setTimeout(function() { 719 | $('.masonry-grid').masonry('layout'); 720 | isFirstLoad = false; 721 | }, 3000); 722 | } 723 | 724 | setInterval(function() { 725 | $('.masonry-grid').masonry('layout'); 726 | }, 10000); 727 | 728 | const hashtagsParam = getUrlParameter('hashtags'); 729 | let hashtagsArray = hashtagsParam ? hashtagsParam.split(',') : []; 730 | // Only use config presets if no URL parameters exist at all (first load) 731 | if (hashtagsArray.length === 0 && !hashtagsParam) { 732 | const preset = (config && typeof config.hashtags === 'string') ? config.hashtags.trim() : ''; 733 | if (preset) { 734 | hashtagsArray = preset.split(','); 735 | } 736 | } 737 | const serverUrl = getUrlParameter('server') || defaultServerUrl; 738 | 739 | $('#hashtag-display').on('click', function() { 740 | handleHashtagDisplayClick(serverUrl); 741 | }); 742 | 743 | // Settings button handler is now in the view toggle handlers section 744 | 745 | if (hashtagsArray.length > 0 && hashtagsArray[0] !== '') { 746 | $('#view-toggle').removeClass('d-none'); 747 | updateButtonStates(); // Initialize button states 748 | // Ensure presets also appear in the URL for Settings and shareability 749 | try { 750 | const url = new URL(window.location.href); 751 | url.searchParams.set('hashtags', hashtagsArray.join(',')); 752 | if (serverUrl) url.searchParams.set('server', serverUrl); 753 | history.replaceState({}, '', url.toString()); 754 | } catch (e) {} 755 | // Make sure zero-state is hidden when loading with presets 756 | $('#zero-state').addClass('d-none'); 757 | $('#app-content').removeClass('d-none'); 758 | const allPosts = await Promise.all(hashtagsArray.map(hashtag => fetchPosts(serverUrl, hashtag))); 759 | updateWall(allPosts.flat()); 760 | // Apply initial view from URL (supports ?view=people); default is posts 761 | try { 762 | const initialView = new URL(window.location.href).searchParams.get('view'); 763 | if (initialView === 'people') { 764 | $('#toggle-people').click(); 765 | } else { 766 | updateUrlForView('posts'); 767 | } 768 | } catch (e) {} 769 | setInterval(async function() { 770 | const newPosts = await Promise.all(hashtagsArray.map(hashtag => fetchPosts(serverUrl, hashtag))); 771 | updateWall(newPosts.flat()); 772 | }, 10000); 773 | } else { 774 | if (hashtagsArray.length > 0) { 775 | // We had presets from config: render immediately 776 | $('#view-toggle').removeClass('d-none'); 777 | updateButtonStates(); 778 | // Ensure presets also appear in the URL for Settings and shareability 779 | try { 780 | const url = new URL(window.location.href); 781 | url.searchParams.set('hashtags', hashtagsArray.join(',')); 782 | if (serverUrl) url.searchParams.set('server', serverUrl); 783 | history.replaceState({}, '', url.toString()); 784 | } catch (e) {} 785 | const allPosts = await Promise.all(hashtagsArray.map(hashtag => fetchPosts(serverUrl, hashtag))); 786 | updateWall(allPosts.flat()); 787 | updateUrlForView('posts'); 788 | } else { 789 | // Show zero state and prefill inputs from current serverUrl/defaultServerUrl 790 | handleHashtagDisplayClick(serverUrl); 791 | currentView = 'settings'; // Set to settings when in zero state 792 | $('#view-toggle').removeClass('d-none'); 793 | updateButtonStates(); // Initialize button states for settings view 794 | } 795 | } 796 | 797 | updateHashtagsOnPage(hashtagsArray); 798 | updateHashtagsInTitle(hashtagsArray); 799 | 800 | $('#hashtag-form').on('submit', function(e) { 801 | handleHashtagFormSubmit(e, hashtagsArray); 802 | }); 803 | 804 | updateTimesOnPage(); 805 | setInterval(updateTimesOnPage, 60000); 806 | 807 | // If we just returned from OAuth, optimistically show connected 808 | if (getUrlParameter('mw_auth') === '1') { 809 | $('#connect-status-icon').removeClass('fa-unlink').addClass('fa-link connected').attr('title', 'Connected'); 810 | // Clean URL (remove mw_auth) 811 | try { 812 | const url = new URL(window.location.href); 813 | url.searchParams.delete('mw_auth'); 814 | window.history.replaceState({}, document.title, url.toString()); 815 | } catch (e) {} 816 | } 817 | 818 | // OAuth: Token aus URL-Fragment übernehmen (weitergereicht vom Backend) 819 | (function storeTokenFromHash(){ 820 | try { 821 | const hash = window.location.hash; 822 | if (!hash) return; 823 | const params = new URLSearchParams(hash.slice(1)); 824 | const token = params.get('mw_token'); 825 | const home = params.get('mw_home'); 826 | if (token && home) { 827 | sessionStorage.setItem('mw_token', token); 828 | sessionStorage.setItem('mw_home', home); 829 | // Fragment entfernen 830 | const hrefNoHash = window.location.href.split('#')[0]; 831 | window.history.replaceState({}, document.title, hrefNoHash); 832 | } 833 | } catch (err) { 834 | console.warn('Could not parse token fragment:', err); 835 | } 836 | })(); 837 | 838 | const showOverlayWithMedia = function(src, isVideo) { 839 | const $overlay = $('#media-overlay'); 840 | const $content = $('#overlay-content'); 841 | $content.empty(); 842 | if (isVideo) { 843 | const $video = $('