1/* Featured projects + staff picks UI helpers (issue #115). 2 * 3 * Exposes a small namespace ``FeaturedProjects`` with: 4 * - DEFAULT_API: the projects-api base URL the rest of the projects UI 5 * already points at. 6 * - ribbon(): returns the gold-ribbon HTML fragment for use inside an 7 * image wrapper. 8 * - badge(note): returns the inline "Staff Pick" badge for the view 9 * page header. Encodes ``note`` for the tooltip safely. 10 * - cardHtml(project): returns the inner card HTML for a single 11 * featured project (no wrapper). Shared by the hub carousel and the 12 * home-page widget (issue #153) so both stay in visual lock-step. 13 * - mountCarousel(container, projects): renders a scrollable shelf of 14 * featured cards into a container. Returns true when at least one 15 * card was rendered so the caller can `classList.remove('d-none')` 16 * conditionally â we want the section hidden entirely on a fresh 17 * install with no featured projects. 18 * - load(limit): fetches /api/projects/featured?limit=N as JSON. 19 * 20 * Keep this dependency-free so the hub can include it before the larger 21 * project-search bundle without a forced load order. 22 */ 23(function () { 24 'use strict'; 25 26 var DEFAULT_API = 'https://projects.kevsrobots.com'; 27 28 function esc(text) { 29 var d = document.createElement('div'); 30 d.textContent = text == null ? '' : String(text); 31 return d.innerHTML; 32 } 33 34 function ribbon(label) { 35 var text = label || 'Featured'; 36 return ( 37 '<span class="featured-ribbon" aria-label="' + esc(text) + ' project">' + 38 '<i class="fas fa-star" aria-hidden="true"></i>' + esc(text) + 39 '</span>' 40 ); 41 } 42 43 function badge(note) { 44 // Tooltip via Bootstrap's data-bs-toggle. The title attribute is 45 // escaped so a malicious note can't break out of the HTML attribute. 46 var tooltip = note ? esc(note) : 'Hand-picked by the kevsrobots team'; 47 return ( 48 '<span class="staff-pick-badge" ' + 49 'data-bs-toggle="tooltip" ' + 50 'data-bs-placement="bottom" ' + 51 'title="' + tooltip + '">' + 52 '<i class="fas fa-star" aria-hidden="true"></i>' + 53 'Staff Pick' + 54 '</span>' 55 ); 56 } 57 58 function cardThumbStyle(project) { 59 if (project && project.cover_image) { 60 return 'background-image:url(\'' + esc(project.cover_image) + '\');background-size:cover;background-position:center;'; 61 } 62 // Cheap deterministic gradient based on the project id so cards 63 // without a cover image still feel distinct. 64 var id = (project && project.id) || 0; 65 var hue = (id * 53) % 360; 66 return 'background:linear-gradient(135deg,hsl(' + hue + ',70%,55%),hsl(' + ((hue + 60) % 360) + ',70%,45%));'; 67 } 68 69 // Inner card HTML for one featured project (no outer wrapper). Shared 70 // between the hub carousel and the home-page widget (issue #153) so the 71 // visual treatment stays consistent in both surfaces. 72 function cardHtml(project) { 73 // Issue #152: prefer canonical /projects/<owner>/<slug> URL when the 74 // FeaturedProjectResponse surfaced a slug. 75 var href = (project.slug && project.author_username) 76 ? '/projects/' + encodeURIComponent(project.author_username) + '/' + encodeURIComponent(project.slug) 77 : '/projects/view.html?id=' + encodeURIComponent(project.id); 78 var note = project.featured_note ? '<div class="featured-note">' + esc(project.featured_note) + '</div>' : ''; 79 var author = project.author_username ? esc(project.author_username) : ''; 80 return ( 81 '<a href="' + href + '" class="text-decoration-none">' + 82 '<div class="card kr-project-card h-100 border-0 shadow-sm card-hover">' + 83 ribbon('Featured') + 84 '<div class="project-thumb" style="' + cardThumbStyle(project) + '"></div>' + 85 '<div class="card-body">' + 86 '<div class="fw-bold text-dark text-truncate" title="' + esc(project.title) + '">' + esc(project.title) + '</div>' + 87 (author ? '<div class="small text-muted">by ' + author + '</div>' : '') + 88 note + 89 '</div>' + 90 '</div>' + 91 '</a>' 92 ); 93 } 94 95 function carouselCardHtml(project) { 96 return '<div class="featured-carousel-card">' + cardHtml(project) + '</div>'; 97 } 98 99 function mountCarousel(container, projects) { 100 if (!container) return false;
101 if (!projects || !projects.length) { 102 container.innerHTML = ''; 103 return false; 104 } 105 container.innerHTML = projects.map(carouselCardHtml).join(''); 106 // Activate tooltips inside the rendered cards (best-effort â bootstrap 107 // may not be loaded yet on some pages). 108 if (window.bootstrap && window.bootstrap.Tooltip) { 109 container.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function (el) { 110 new window.bootstrap.Tooltip(el); 111 }); 112 } 113 return true; 114 } 115 116 function load(opts) { 117 opts = opts || {}; 118 var api = opts.api || DEFAULT_API; 119 var limit = opts.limit || 6; 120 return fetch(api + '/api/projects/featured?limit=' + encodeURIComponent(limit)) 121 .then(function (resp) { 122 if (!resp.ok) throw new Error('Featured fetch failed: ' + resp.status); 123 return resp.json(); 124 }); 125 } 126 127 window.FeaturedProjects = { 128 DEFAULT_API: DEFAULT_API, 129 ribbon: ribbon, 130 badge: badge, 131 cardHtml: cardHtml, 132 mountCarousel: mountCarousel, 133 load: load 134 }; 135})();
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.