1/** 2 * RSS Feed Reader mit Paging - Rosier Design 3 * Vanilla JavaScript - Keine Abhängigkeiten 4 * Einbindung nur über ein Script-Tag 5 * Design basiert auf rosier.de/news/ 6 */ 7 8(function() { 9 'use strict'; 10 11 // CSS als String (kompiliert aus SCSS, scoped auf .rosier-rss-feed-wrapper) 12 const RSS_FEED_STYLES = ` 13 @import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&family=Source+Sans+3:wght@300;400;500;600;700&display=swap'); 14 15 .rosier-rss-feed-wrapper { 16 font-family: 'Source Sans 3', 'Open Sans', -apple-system, BlinkMacSystemFont, sans-serif; 17 max-width: 1200px; 18 margin: 0 auto; 19 color: #333333; 20 } 21 .rosier-rss-feed-wrapper *, .rosier-rss-feed-wrapper *::before, .rosier-rss-feed-wrapper *::after { 22 box-sizing: border-box; 23 } 24 .rosier-rss-feed-wrapper .rss-loading { 25 text-align: center; 26 padding: 80px 20px; 27 color: #666666; 28 } 29 .rosier-rss-feed-wrapper .rss-loading-spinner { 30 width: 48px; 31 height: 48px; 32 border: 3px solid #e0e0e0; 33 border-top: 3px solid #374d46; 34 border-radius: 50%; 35 margin: 0 auto 24px; 36 animation: rosier-rss-spin 0.8s linear infinite; 37 } 38 @keyframes rosier-rss-spin { 39 0% { transform: rotate(0deg); } 40 100% { transform: rotate(360deg); } 41 } 42 .rosier-rss-feed-wrapper .rss-error { 43 text-align: center; 44 padding: 48px 24px; 45 background: #fef2f2; 46 border: 1px solid #fecaca; 47 border-radius: 4px; 48 color: #991b1b; 49 } 50 .rosier-rss-feed-wrapper .rss-retry-btn { 51 margin-top: 20px; 52 padding: 12px 32px;
53 background: #374d46; 54 color: white; 55 border: none; 56 cursor: pointer; 57 font-family: inherit; 58 font-size: 14px; 59 font-weight: 600; 60 text-transform: uppercase; 61 letter-spacing: 0.5px; 62 transition: background 0.3s ease; 63 } 64 .rosier-rss-feed-wrapper .rss-retry-btn:hover { 65 background: #2a3b35; 66 } 67 .rosier-rss-feed-wrapper .rss-feed-items { 68 display: grid; 69 grid-template-columns: repeat(3, 1fr); 70 gap: 30px; 71 } 72 .rosier-rss-feed-wrapper .rss-item { 73 display: flex; 74 flex-direction: column; 75 background: #ffffff; 76 overflow: hidden; 77 box-shadow: 2px 4px 20px 0px rgba(0, 0, 0, 0.06); 78 transition: box-shadow 0.3s ease, transform 0.3s ease; 79 } 80 .rosier-rss-feed-wrapper .rss-item:hover { 81 transform: translateY(-3px); 82 box-shadow: 2px 6px 24px 0px rgba(0, 0, 0, 0.1); 83 } 84 .rosier-rss-feed-wrapper .rss-item:hover .rss-item-title a { 85 color: #374d46; 86 } 87 .rosier-rss-feed-wrapper .rss-item-image { 88 width: 100%; 89 aspect-ratio: 16 / 9; 90 overflow: hidden; 91 position: relative; 92 background: #f8f8f8; 93 } 94 .rosier-rss-feed-wrapper .rss-item-image img { 95 width: 100%; 96 height: 100%; 97 object-fit: cover; 98 } 99 .rosier-rss-feed-wrapper .rss-item-content { 100 padding: 20px; 101 display: flex; 102 flex-direction: column; 103 flex: 1; 104 } 105 .rosier-rss-feed-wrapper .rss-item-meta { 106 display: flex; 107 flex-wrap: wrap; 108 align-items: center; 109 gap: 10px; 110 margin-bottom: 12px; 111 } 112 .rosier-rss-feed-wrapper .rss-item-category {
113 background: #374d46; 114 color: white; 115 padding: 4px 10px; 116 font-size: 0.75em; 117 font-weight: 600; 118 text-transform: uppercase; 119 letter-spacing: 0.5px; 120 } 121 .rosier-rss-feed-wrapper .rss-item-date { 122 color: #666666; 123 font-size: 0.8125em; 124 font-weight: 400; 125 } 126 .rosier-rss-feed-wrapper .rss-item-author { 127 color: #666666; 128 font-size: 0.8125em; 129 } 130 .rosier-rss-feed-wrapper .rss-item-title { 131 margin: 0 0 12px; 132 font-size: 1.25em; 133 font-weight: 400; 134 line-height: 1.4; 135 font-family: 'Source Sans 3', sans-serif; 136 } 137 .rosier-rss-feed-wrapper .rss-item-title a { 138 color: #333333; 139 text-decoration: none; 140 transition: color 0.3s ease; 141 } 142 .rosier-rss-feed-wrapper .rss-item-description { 143 margin: 0 0 16px; 144 color: #666666; 145 font-size: 0.9375em; 146 line-height: 1.6; 147 flex-grow: 1; 148 } 149 .rosier-rss-feed-wrapper .rss-item-readmore { 150 display: inline-flex; 151 align-items: center; 152 justify-content: center; 153 padding: 12px 24px;
154 background: #374d46; 155 color: white; 156 text-decoration: none; 157 font-size: 14px; 158 font-weight: 600; 159 transition: background 0.3s ease; 160 align-self: flex-start; 161 border: none; 162 } 163 .rosier-rss-feed-wrapper .rss-item-readmore:hover { 164 background: #2a3b35; 165 color: white; 166 } 167 .rosier-rss-feed-wrapper .rss-paging { 168 margin-top: 40px; 169 text-align: center; 170 } 171 .rosier-rss-feed-wrapper .rss-paging-list { 172 display: flex; 173 justify-content: center; 174 align-items: center; 175 gap: 8px; 176 list-style: none; 177 margin: 0; 178 padding: 0; 179 flex-wrap: wrap; 180 } 181 .rosier-rss-feed-wrapper .rss-paging-item { 182 display: flex; 183 } 184 .rosier-rss-feed-wrapper .rss-paging-ellipsis { 185 padding: 12px 8px; 186 color: #666666; 187 font-weight: 500; 188 } 189 .rosier-rss-feed-wrapper .rss-paging-btn { 190 display: flex; 191 align-items: center; 192 justify-content: center; 193 min-width: 44px; 194 height: 44px; 195 padding: 12px 20px; 196 background: transparent; 197 border: 2px solid #374d46; 198 color: #374d46; 199 font-family: inherit; 200 font-size: 14px; 201 font-weight: 600; 202 cursor: pointer; 203 transition: all 0.3s ease; 204 } 205 .rosier-rss-feed-wrapper .rss-paging-btn:hover:not(:disabled):not(.active) {
206 background: #374d46; 207 color: white; 208 } 209 .rosier-rss-feed-wrapper .rss-paging-btn.active { 210 background: #374d46; 211 border-color: #374d46; 212 color: white; 213 } 214 .rosier-rss-feed-wrapper .rss-paging-btn:disabled { 215 opacity: 0.4; 216 cursor: not-allowed; 217 } 218 .rosier-rss-feed-wrapper .rss-paging-prev, 219 .rosier-rss-feed-wrapper .rss-paging-next { 220 min-width: auto; 221 padding: 12px 24px; 222 } 223 .rosier-rss-feed-wrapper .rss-paging-info { 224 text-align: center; 225 margin-top: 16px; 226 font-size: 14px; 227 color: #666666; 228 } 229 @media (max-width: 992px) { 230 .rosier-rss-feed-wrapper .rss-feed-items { 231 grid-template-columns: repeat(2, 1fr); 232 gap: 24px; 233 } 234 } 235 @media (max-width: 600px) { 236 .rosier-rss-feed-wrapper .rss-feed-items { 237 grid-template-columns: 1fr; 238 gap: 24px; 239 } 240 .rosier-rss-feed-wrapper .rss-item-title { 241 font-size: 1.125em; 242 } 243 .rosier-rss-feed-wrapper .rss-item-content { 244 padding: 16px; 245 } 246 .rosier-rss-feed-wrapper .rss-paging-list { 247 gap: 6px; 248 } 249 .rosier-rss-feed-wrapper .rss-paging-btn { 250 min-width: 40px; 251 height: 40px; 252 font-size: 13px; 253 padding: 10px 16px; 254 } 255 .rosier-rss-feed-wrapper .rss-paging-number { 256 display: none; 257 } 258 .rosier-rss-feed-wrapper .rss-paging-number.active, 259 .rosier-rss-feed-wrapper .rss-paging-number:first-of-type, 260 .rosier-rss-feed-wrapper .rss-paging-number:last-of-type { 261 display: flex; 262 } 263 } 264 .rosier-rss-feed-wrapper.rss-feed--list .rss-feed-items { 265 display: flex; 266 flex-direction: column; 267 gap: 24px; 268 } 269 .rosier-rss-feed-wrapper.rss-feed--list .rss-item { 270 flex-direction: row; 271 align-items: flex-start; 272 } 273 .rosier-rss-feed-wrapper.rss-feed--list .rss-item-image { 274 width: 320px; 275 min-width: 320px; 276 aspect-ratio: 16/9; 277 } 278 .rosier-rss-feed-wrapper.rss-feed--list .rss-item-content { 279 padding: 20px; 280 } 281 @media (max-width: 768px) { 282 .rosier-rss-feed-wrapper.rss-feed--list .rss-item { 283 flex-direction: column; 284 } 285 .rosier-rss-feed-wrapper.rss-feed--list .rss-item-image { 286 width: 100%; 287 min-width: 100%; 288 } 289 .rosier-rss-feed-wrapper.rss-feed--list .rss-item-content { 290 padding: 16px; 291 } 292 } 293 `; 294 295 // CSS injizieren (nur einmal) 296 function injectStyles() { 297 if (document.getElementById('rosier-rss-feed-styles')) return; 298 299 const styleElement = document.createElement('style'); 300 styleElement.id = 'rosier-rss-feed-styles'; 301 styleElement.textContent = RSS_FEED_STYLES; 302 document.head.appendChild(styleElement); 303 } 304 305 class RSSFeedReader { 306 constructor(options = {}) { 307 this.feedUrl = options.feedUrl || 'https://blog.rosier.de/rss.xml'; 308 this.containerSelector = options.containerSelector || '#rss-feed-container'; 309 this.itemsPerPage = options.itemsPerPage || 5; 310 this.listView = options.listView || false; 311 this.currentPage = 1; 312 this.feedItems = []; 313 this.totalPages = 0; 314 315 // CSS injizieren 316 //injectStyles(); 317 318 this.init(); 319 } 320 321 async init() { 322 const container = document.querySelector(this.containerSelector); 323 if (!container) { 324 console.error(`Container ${this.containerSelector} nicht gefunden`); 325 return; 326 } 327 328 this.container = container; 329 330 // Wrapper-Klasse hinzufügen 331 this.container.classList.add('rosier-rss-feed-wrapper'); 332 if (this.listView) { 333 this.container.classList.add('rss-feed--list'); 334 } 335 336 this.showLoading(); 337 338 try { 339 await this.fetchFeed(); 340 this.render(); 341 } catch (error) { 342 this.showError(error.message); 343 } 344 } 345 346 async fetchFeed() { 347 // CORS-Proxy verwenden, falls nötig 348 const proxyUrl = 'https://api.allorigins.win/raw?url='; 349 const url = proxyUrl + encodeURIComponent(this.feedUrl); 350 351 try { 352 const response = await fetch(url); 353 if (!response.ok) { 354 throw new Error(`HTTP Fehler: ${response.status}`); 355 } 356 357 const xmlText = await response.text(); 358 this.parseFeed(xmlText); 359 } catch (error) { 360 // Fallback: Direkter Aufruf versuchen 361 try { 362 const response = await fetch(this.feedUrl); 363 const xmlText = await response.text(); 364 this.parseFeed(xmlText); 365 } catch (fallbackError) { 366 throw new Error('RSS-Feed konnte nicht geladen werden: ' + error.message); 367 } 368 } 369 } 370 371 parseFeed(xmlText) { 372 const parser = new DOMParser(); 373 const xmlDoc = parser.parseFromString(xmlText, 'application/xml'); 374 375 // Prüfe auf Parser-Fehler 376 const parseError = xmlDoc.querySelector('parsererror'); 377 if (parseError) { 378 throw new Error('RSS-Feed konnte nicht geparst werden'); 379 } 380 381 // RSS 2.0 Format 382 const items = xmlDoc.querySelectorAll('item'); 383 384 this.feedItems = Array.from(items).map(item => { 385 return { 386 title: this.getElementText(item, 'title'), 387 link: this.getElementText(item, 'link'), 388 description: this.getElementText(item, 'description'), 389 pubDate: this.getElementText(item, 'pubDate'), 390 author: this.getElementText(item, 'author') || this.getElementText(item, 'dc\\:creator'), 391 category: this.getElementText(item, 'category'), 392 image: this.extractImage(item) 393 }; 394 }); 395 396 this.totalPages = Math.ceil(this.feedItems.length / this.itemsPerPage); 397 } 398 399 getElementText(parent, tagName) { 400 const element = parent.querySelector(tagName); 401 return element ? element.textContent.trim() : ''; 402 } 403 404 extractImage(item) { 405 // Versuche Bild aus verschiedenen Quellen zu extrahieren 406 const enclosure = item.querySelector('enclosure[type^="image"]'); 407 if (enclosure) { 408 return enclosure.getAttribute('url'); 409 } 410 411 // Suche im media:content 412 const mediaContent = item.querySelector('content[url]'); 413 if (mediaContent) { 414 return mediaContent.getAttribute('url'); 415 } 416 417 // Suche Bild in der Description 418 const description = this.getElementText(item, 'description'); 419 const imgMatch = description.match(/<img[^>]+src=["']([^"']+)["']/i); 420 if (imgMatch) { 421 return imgMatch[1]; 422 } 423 424 return ''; 425 } 426 427 formatDate(dateString) { 428 if (!dateString) return ''; 429 430 try { 431 const date = new Date(dateString); 432 return date.toLocaleDateString('de-DE', { 433 year: 'numeric', 434 month: 'long', 435 day: 'numeric' 436 }); 437 } catch (e) { 438 return dateString; 439 } 440 } 441
442 stripHtml(html) { 443 const tmp = document.createElement('div'); 444 tmp.innerHTML = html; 445 return tmp.textContent || tmp.innerText || ''; 446 } 447 448 truncateText(text, maxLength = 150) { 449 const stripped = this.stripHtml(text); 450 if (stripped.length <= maxLength) return stripped; 451 return stripped.substring(0, maxLength).trim() + '...'; 452 } 453 454 getCurrentPageItems() { 455 const startIndex = (this.currentPage - 1) * this.itemsPerPage; 456 const endIndex = startIndex + this.itemsPerPage; 457 return this.feedItems.slice(startIndex, endIndex); 458 } 459 460 render() { 461 const items = this.getCurrentPageItems(); 462 463 let html = '<div class="rss-feed">'; 464 465 // Feed Items 466 html += '<div class="rss-feed-items">'; 467 items.forEach(item => { 468 html += this.renderItem(item); 469 }); 470 html += '</div>'; 471 472 // Paging 473 if (this.totalPages > 1) { 474 html += this.renderPaging(); 475 } 476 477 html += '</div>'; 478 479 this.container.innerHTML = html; 480 this.bindEvents(); 481 } 482 483 renderItem(item) { 484 const imageHtml = item.image 485 ? `<div class="rss-item-image"><img src="${item.image}" alt="${this.escapeHtml(item.title)}" loading="lazy"></div>` 486 : ''; 487 488 const categoryHtml = item.category 489 ? `<span class="rss-item-category">${this.escapeHtml(item.category)}</span>` 490 : ''; 491 492 const dateHtml = item.pubDate 493 ? `<span class="rss-item-date">${this.formatDate(item.pubDate)}</span>` 494 : ''; 495 496 //const authorHtml = item.author 497 // ? `<span class="rss-item-author">von ${this.escapeHtml(item.author)}</span>` 498 // : ''; 499 500 const authorHtml = ''; 501 502 return ` 503 <article class="rss-item"> 504 ${imageHtml} 505 <div class="rss-item-content"> 506 <div class="rss-item-meta"> 507 ${categoryHtml} 508 ${dateHtml} 509 ${authorHtml} 510 </div> 511 <h3 class="rss-item-title"> 512 <a href="${this.escapeHtml(item.link)}" target="_blank" rel="noopener noreferrer"> 513 ${this.escapeHtml(item.title)} 514 </a> 515 </h3> 516 <p class="rss-item-description">${this.truncateText(item.description)}</p> 517 <a href="${this.escapeHtml(item.link)}" class="rss-item-readmore" target="_blank" rel="noopener noreferrer"> 518 Weiterlesen 519 </a> 520 </div> 521 </article> 522 `; 523 } 524 525 renderPaging() { 526 let pagingHtml = '<nav class="rss-paging" aria-label="Seitennavigation">'; 527 pagingHtml += '<ul class="rss-paging-list">'; 528 529 // Zurück-Button 530 pagingHtml += ` 531 <li class="rss-paging-item">
532 <button class="rss-paging-btn rss-paging-prev" 533 data-page="${this.currentPage - 1}" 534 ${this.currentPage === 1 ? 'disabled' : ''}> 535 â Zurück 536 </button> 537 </li> 538 `; 539 540 // Seitenzahlen 541 const pageNumbers = this.getPageNumbers(); 542 pageNumbers.forEach(pageNum => { 543 if (pageNum === '...') { 544 pagingHtml += `<li class="rss-paging-item rss-paging-ellipsis">...</li>`; 545 } else { 546 const isActive = pageNum === this.currentPage; 547 pagingHtml += ` 548 <li class="rss-paging-item"> 549 <button class="rss-paging-btn rss-paging-number ${isActive ? 'active' : ''}" 550 data-page="${pageNum}" 551 ${isActive ? 'aria-current="page"' : ''}> 552 ${pageNum} 553 </button> 554 </li> 555 `; 556 } 557 }); 558 559 // Weiter-Button 560 pagingHtml += ` 561 <li class="rss-paging-item">
562 <button class="rss-paging-btn rss-paging-next" 563 data-page="${this.currentPage + 1}" 564 ${this.currentPage === this.totalPages ? 'disabled' : ''}> 565 Weiter â 566 </button> 567 </li> 568 `; 569 570 pagingHtml += '</ul>'; 571 pagingHtml += `<div class="rss-paging-info">Seite ${this.currentPage} von ${this.totalPages}</div>`; 572 pagingHtml += '</nav>'; 573 574 return pagingHtml; 575 } 576 577 getPageNumbers() { 578 const pages = []; 579 const maxVisible = 5; 580 581 if (this.totalPages <= maxVisible) { 582 for (let i = 1; i <= this.totalPages; i++) { 583 pages.push(i); 584 } 585 } else { 586 // Immer erste Seite 587 pages.push(1); 588 589 if (this.currentPage > 3) { 590 pages.push('...'); 591 } 592 593 // Seiten um aktuelle Seite 594 const start = Math.max(2, this.currentPage - 1); 595 const end = Math.min(this.totalPages - 1, this.currentPage + 1); 596 597 for (let i = start; i <= end; i++) { 598 pages.push(i); 599 } 600 601 if (this.currentPage < this.totalPages - 2) { 602 pages.push('...'); 603 } 604 605 // Immer letzte Seite 606 pages.push(this.totalPages); 607 } 608 609 return pages; 610 } 611 612 bindEvents() { 613 const pagingButtons = this.container.querySelectorAll('.rss-paging-btn[data-page]'); 614 pagingButtons.forEach(btn => { 615 btn.addEventListener('click', (e) => { 616 const page = parseInt(e.target.dataset.page, 10); 617 if (page >= 1 && page <= this.totalPages) { 618 this.goToPage(page); 619 } 620 }); 621 }); 622 } 623 624 goToPage(page) { 625 this.currentPage = page; 626 this.render(); 627 628 // Scroll zum Anfang des Containers 629 this.container.scrollIntoView({ behavior: 'smooth', block: 'start' }); 630 } 631 632 showLoading() { 633 this.container.innerHTML = ` 634 <div class="rss-loading"> 635 <div class="rss-loading-spinner"></div> 636 <p>RSS-Feed wird geladen...</p> 637 </div> 638 `; 639 } 640 641 showError(message) { 642 this.container.innerHTML = ` 643 <div class="rss-error"> 644 <p>â ï¸ ${this.escapeHtml(message)}</p> 645 <button class="rss-retry-btn" onclick="location.reload()">Erneut versuchen</button> 646 </div> 647 `; 648 } 649 650 escapeHtml(text) { 651 const div = document.createElement('div'); 652 div.textContent = text; 653 return div.innerHTML; 654 } 655 } 656 657 // Auto-Initialisierung wenn DOM bereit 658 function autoInit() { 659 // Automatisch initialisieren wenn Container vorhanden 660 if (document.querySelector('#rss-feed-container')) { 661 new RSSFeedReader({ 662 feedUrl: 'https://blog.rosier.de/rss.xml', 663 containerSelector: '#rss-feed-container', 664 itemsPerPage: 6 665 }); 666 } 667 } 668 669 // DOM Ready Check 670 if (document.readyState === 'loading') { 671 document.addEventListener('DOMContentLoaded', autoInit); 672 } else { 673 autoInit(); 674 } 675 676 // Export für manuelle Initialisierung 677 window.RSSFeedReader = RSSFeedReader; 678 679})(); 680 681
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.