lerobot/robot-learning-tutorial
508
1---2import HtmlEmbed from "./HtmlEmbed.astro";3import Image from "./Image.astro";4import lerobotLogo from "../content/assets/image/figures/ch1/ch1-lerobot-figure1.png";5 6interface Props {7 title: string; // may contain HTML (e.g., <br/>)8 titleRaw?: string; // plain title for slug/PDF (optional)9 description?: string;10 authors?: Array<11 string | { name: string; url?: string; affiliationIndices?: number[] }12 >;13 affiliations?: Array<{ id: number; name: string; url?: string }>;14 affiliation?: string; // legacy single affiliation15 published?: string;16 doi?: string;17 pdfProOnly?: boolean; // Gate PDF download to Pro users only18}19 20const {21 title,22 titleRaw,23 description,24 authors = [],25 affiliations = [],26 affiliation,27 published,28 doi,29 pdfProOnly = false,30} = Astro.props as Props;31 32type Author = { name: string; url?: string; affiliationIndices?: number[] };33 34function normalizeAuthors(35 input: Array<36 | string37 | {38 name?: string;39 url?: string;40 link?: string;41 affiliationIndices?: number[];42 affiliations?: number[];43 }44 >,45): Author[] {46 return (Array.isArray(input) ? input : [])47 .map((a) => {48 if (typeof a === "string") {49 return { name: a } as Author;50 }51 const name = (a?.name ?? "").toString();52 const url = (a?.url ?? a?.link) as string | undefined;53 // Support both 'affiliationIndices' and 'affiliations' as property names54 const affiliationIndices = Array.isArray((a as any)?.affiliationIndices)55 ? (a as any).affiliationIndices56 : Array.isArray((a as any)?.affiliations)57 ? (a as any).affiliations58 : undefined;59 return { name, url, affiliationIndices } as Author;60 })61 .filter((a) => a.name && a.name.trim().length > 0);62}63 64const normalizedAuthors: Author[] = normalizeAuthors(authors as any);65 66// Determine if affiliation superscripts should be shown (only when there are multiple distinct affiliations referenced by authors)67const authorAffiliationIndexSet = new Set<number>();68for (const author of normalizedAuthors) {69 const indices = Array.isArray(author.affiliationIndices)70 ? author.affiliationIndices71 : [];72 for (const idx of indices) {73 if (typeof idx === "number") {74 authorAffiliationIndexSet.add(idx);75 }76 }77}78const hasMultipleAffiliations =79 Array.isArray(affiliations) && affiliations.length > 1;80const shouldShowAffiliationSupers = hasMultipleAffiliations && authorAffiliationIndexSet.size > 0;81 82function stripHtml(text: string): string {83 return String(text || "").replace(/<[^>]*>/g, "");84}85 86function slugify(text: string): string {87 return (88 String(text || "")89 .normalize("NFKD")90 .replace(/\p{Diacritic}+/gu, "")91 .toLowerCase()92 .replace(/[^a-z0-9]+/g, "-")93 .replace(/^-+|-+$/g, "")94 .slice(0, 120) || "article"95 );96}97 98const pdfBase = titleRaw ? stripHtml(titleRaw) : stripHtml(title);99const pdfFilename = `${slugify(pdfBase)}.pdf`;100---101 102<section class="hero">103 <h1 class="hero-title" set:html={title} />104 <!-- <div class="hero-banner">105 <HtmlEmbed src="banner.html" frameless />106 <Image 107 src={lerobotLogo} 108 alt="LeRobot Logo" 109 />110 {description && <p class="hero-desc">{description}</p>}111 </div> -->112</section>113 114<header class="meta" aria-label="Article meta information">115 <div class="meta-container">116 {117 normalizedAuthors.length > 0 && (118 <div class="meta-container-cell">119 <h3>Author{normalizedAuthors.length > 1 ? "s" : ""}</h3>120 <ul class="authors">121 {normalizedAuthors.map((a, i) => {122 const supers =123 shouldShowAffiliationSupers &&124 Array.isArray(a.affiliationIndices) &&125 a.affiliationIndices.length ? (126 <sup>{a.affiliationIndices.join(", ")}</sup>127 ) : null;128 return (129 <li>130 {a.url ? <a href={a.url}>{a.name}</a> : a.name}{supers}{i < normalizedAuthors.length - 1 && <span set:html=", " />}131 </li>132 );133 })}134 </ul>135 </div>136 )137 }138 {139 Array.isArray(affiliations) && affiliations.length > 0 && (140 <div class="meta-container-cell meta-container-cell--affiliations">141 <h3>Affiliation{affiliations.length > 1 ? "s" : ""}</h3>142 {hasMultipleAffiliations ? (143 <ol class="affiliations">144 {affiliations.map((af) => (145 <li value={af.id}>146 {af.url ? (147 <a href={af.url} target="_blank" rel="noopener noreferrer">148 {af.name}149 </a>150 ) : (151 af.name152 )}153 </li>154 ))}155 </ol>156 ) : (157 <p>158 {affiliations[0]?.url ? (159 <a160 href={affiliations[0].url}161 target="_blank"162 rel="noopener noreferrer"163 >164 {affiliations[0].name}165 </a>166 ) : (167 affiliations[0]?.name168 )}169 </p>170 )}171 </div>172 )173 }174 {175 (!affiliations || affiliations.length === 0) && affiliation && (176 <div class="meta-container-cell meta-container-cell--affiliations">177 <h3>Affiliation</h3>178 <p>{affiliation}</p>179 </div>180 )181 }182 {183 published && (184 <div class="meta-container-cell meta-container-cell--published">185 <h3>Published</h3>186 <p>{published}</p>187 </div>188 )189 }190 <!-- {doi && (191 <div class="meta-container-cell">192 <h3>DOI</h3>193 <p><a href={`https://doi.org/${doi}`} target="_blank" rel="noopener noreferrer">{doi}</a></p>194 </div>195 )} -->196 <div class="meta-container-cell meta-container-cell--pdf">197 <div class="pdf-header-wrapper">198 <h3>PDF</h3>199 <span class="pro-badge-wrapper" style="display: none;">200 <span class="pro-badge-prefix">- you are</span>201 <span class="pro-badge">PRO</span>202 </span>203 <span class="pro-only-label" style="display: none;">204 <span class="pro-only-dash">-</span>205 <span class="pro-only-text">pro only</span>206 <svg class="pro-only-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">207 <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>208 <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>209 </svg>210 </span>211 </div>212 <div id="pdf-download-container" data-pdf-pro-only={pdfProOnly.toString()}>213 <p class="pdf-loading">Checking access...</p>214 <p class="pdf-pro-only" style="display: none;">215 <a216 class="button"217 href={`http://arxiv.org/pdf/2510.12403`}218 target="_blank"219 rel="noopener noreferrer"220 >221 Download PDF222 </a>223 </p>224 <div class="pdf-locked" style="display: none;">225 <a 226 class="button button-locked" 227 href="https://huggingface.co/subscribe/pro"228 target="_blank"229 rel="noopener noreferrer"230 >231 <svg class="lock-icon" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" role="img" width="1em" height="1em" viewBox="0 0 12 12" fill="none">232 <path d="M6.48 1.26c0 1.55.67 2.58 1.5 3.24.86.68 1.9 1 2.58 1.07v.86A5.3 5.3 0 0 0 7.99 7.5a3.95 3.95 0 0 0-1.51 3.24h-.96c0-1.55-.67-2.58-1.5-3.24a5.3 5.3 0 0 0-2.58-1.07v-.86A5.3 5.3 0 0 0 4.01 4.5a3.95 3.95 0 0 0 1.51-3.24h.96Z" fill="currentColor"></path>233 </svg>234 <span class="locked-title">Subscribe to Pro</span>235 </a>236 </div>237 </div>238 </div>239 </div>240</header>241 242<script>243 // PDF access control for Pro users only244 245 // ⚙️ Configuration for local development246 const LOCAL_IS_PRO = false; // Set to true to test Pro access locally247 248 const FALLBACK_TIMEOUT_MS = 3000;249 let userPlanChecked = false;250 251 // Check if PDF Pro gating is enabled252 const pdfContainer = document.querySelector("#pdf-download-container") as HTMLElement;253 const pdfProOnly = pdfContainer?.getAttribute("data-pdf-pro-only") === "true";254 255 /**256 * Check if user has Pro access257 * Isolated logic for Pro user verification258 * Expected plan structure: { user: "pro", org: "enterprise" }259 */260 function isProUser(plan: any): boolean {261 if (!plan) return false;262 263 // Check if user property is "pro"264 return plan.user === "pro";265 }266 267 /**268 * Update UI based on user's Pro status269 */270 function updatePdfAccess(isPro: boolean) {271 const loadingEl = document.querySelector(".pdf-loading") as HTMLElement;272 const proOnlyEl = document.querySelector(".pdf-pro-only") as HTMLElement;273 const lockedEl = document.querySelector(".pdf-locked") as HTMLElement;274 const proOnlyLabel = document.querySelector(".pro-only-label") as HTMLElement;275 const proBadgeWrapper = document.querySelector(".pro-badge-wrapper") as HTMLElement;276 277 // Hide loading state278 if (loadingEl) loadingEl.style.display = "none";279 280 // If PDF Pro gating is disabled, just show the download button281 if (!pdfProOnly) {282 if (proOnlyEl) proOnlyEl.style.display = "block";283 if (proOnlyLabel) proOnlyLabel.style.display = "none";284 if (lockedEl) lockedEl.style.display = "none";285 if (proBadgeWrapper) proBadgeWrapper.style.display = "none";286 return;287 }288 289 // Show appropriate state based on Pro status290 if (isPro) {291 if (proOnlyEl) proOnlyEl.style.display = "block";292 if (proOnlyLabel) proOnlyLabel.style.display = "none";293 if (lockedEl) lockedEl.style.display = "none";294 if (proBadgeWrapper) proBadgeWrapper.style.display = "inline-flex";295 } else {296 if (proOnlyEl) proOnlyEl.style.display = "none";297 if (proOnlyLabel) proOnlyLabel.style.display = "inline-flex";298 if (lockedEl) lockedEl.style.display = "block";299 if (proBadgeWrapper) proBadgeWrapper.style.display = "none";300 }301 }302 303 /**304 * Handle user plan response305 */306 function handleUserPlan(plan: any) {307 userPlanChecked = true;308 const isPro = isProUser(plan);309 updatePdfAccess(isPro);310 311 // Optional: log for debugging312 console.log("[PDF Access]", { plan, isPro });313 }314 315 /**316 * Fallback behavior when no parent window responds317 * Uses LOCAL_IS_PRO configuration for local development318 */319 function handleFallback() {320 if (LOCAL_IS_PRO) {321 handleUserPlan({ user: "pro" });322 } else {323 handleUserPlan({ user: "free" });324 }325 }326 327 // If PDF Pro gating is disabled, show the download button immediately328 if (!pdfProOnly) {329 updatePdfAccess(true);330 } else {331 // Listen for messages from parent window (Hugging Face Spaces)332 window.addEventListener("message", (event) => {333 if (event.data.type === "USER_PLAN") {334 handleUserPlan(event.data.plan);335 }336 });337 338 // Request user plan on page load339 if (window.parent && window.parent !== window) {340 // We're in an iframe, request user plan341 window.parent.postMessage({ type: "USER_PLAN_REQUEST" }, "*");342 343 // Fallback if no response after timeout344 setTimeout(() => {345 if (!userPlanChecked) {346 handleFallback();347 }348 }, FALLBACK_TIMEOUT_MS);349 } else {350 // Not in iframe (local development), use fallback immediately351 handleFallback();352 }353 }354</script>355 356<style>357 /* Hero (full-width) */358 .hero {359 width: 100%;360 padding: 48px 16px 16px;361 text-align: center;362 }363 .hero-title {364 font-size: clamp(28px, 4vw, 48px);365 font-weight: 800;366 line-height: 1.1;367 margin: 0 0 8px;368 max-width: 100%;369 margin: auto;370 }371 .hero-banner {372 max-width: 980px;373 margin: 0 auto;374 border: 1px solid var(--border-color);375 border-radius: 8px;376 margin-top: 8px;377 margin-bottom: 8px;378 overflow: hidden;379 }380 .hero-banner .ri-root {381 border-radius: 8px;382 }383 .hero-desc {384 color: var(--muted-color);385 font-style: italic;386 margin: 0 0 16px 0;387 }388 389 /* Meta (byline-like header) */390 .meta {391 border-top: 1px solid var(--border-color);392 border-bottom: 1px solid var(--border-color);393 padding: 1rem 0;394 font-size: 0.9rem;395 }396 .meta-container {397 max-width: 760px;398 display: flex;399 flex-direction: row;400 justify-content: space-between;401 margin: 0 auto;402 padding: 0 var(--content-padding-x);403 gap: 8px;404 }405 /* Subtle underline for links in meta; keep buttons without underline */406 .meta-container a:not(.button) {407 color: var(--primary-color);408 text-decoration: underline;409 text-underline-offset: 2px;410 text-decoration-thickness: 0.06em;411 text-decoration-color: var(--link-underline);412 transition: text-decoration-color 0.15s ease-in-out;413 }414 .meta-container a:hover {415 text-decoration-color: var(--link-underline-hover);416 }417 .meta-container a.button,418 .meta-container .button {419 text-decoration: none;420 }421 .meta-container-cell {422 display: flex;423 flex-direction: column;424 gap: 8px;425 flex: 1;426 min-width: 0;427 }428 .meta-container-cell h3 {429 margin: 0;430 font-size: 12px;431 font-weight: 400;432 color: var(--muted-color);433 text-transform: uppercase;434 letter-spacing: 0.02em;435 }436 .meta-container-cell p {437 margin: 0;438 }439 .authors {440 margin: 0;441 list-style-type: none;442 padding-left: 0;443 display: flex;444 flex-wrap: wrap;445 }446 .authors li {447 white-space: nowrap;448 padding:0;449 }450 .affiliations {451 margin: 0;452 padding-left: 1.25em;453 }454 .affiliations li {455 margin: 0;456 }457 458 header.meta .meta-container {459 flex-wrap: wrap;460 row-gap: 12px;461 }462 463 @media (max-width: 768px) {464 .meta-container-cell--affiliations,465 .meta-container-cell--pdf {466 text-align: right;467 }468 }469 470 @media print {471 .meta-container-cell--pdf {472 display: none !important;473 }474 }475 476 /* PDF access control styles */477 .pdf-header-wrapper {478 display: flex;479 align-items: center;480 gap: 6px;481 line-height: 1;482 }483 484 .pdf-header-wrapper h3 {485 line-height: 1;486 }487 488 .pdf-loading {489 color: var(--muted-color);490 font-size: 0.9em;491 }492 493 .pdf-pro-only {494 margin: 0;495 }496 497 .pro-badge-wrapper {498 display: inline-flex;499 align-items: center;500 gap: 5px;501 font-style: normal;502 }503 504 .pro-badge-prefix {505 font-size: 0.85em;506 opacity: 0.5;507 font-weight: 400;508 font-style: normal;509 }510 511 .pro-badge {512 display: inline-block;513 border: 1px solid rgba(0, 0, 0, 0.025);514 background: linear-gradient(to bottom right, #f9a8d4, #86efac, #fde047);515 color: black;516 padding: 1px 5px;517 border-radius: 3px;518 font-size: 0.5rem;519 font-weight: 700;520 font-style: normal;521 letter-spacing: 0.025em;522 text-transform: uppercase;523 }524 525 /* Dark mode pro badge */526 :global(.dark) .pro-badge,527 :global([data-theme="dark"]) .pro-badge {528 background: linear-gradient(to bottom right, #ec4899, #22c55e, #eab308);529 border-color: rgba(255, 255, 255, 0.15);530 }531 532 .pro-only-label {533 display: inline-flex;534 flex-direction: row;535 align-items: center;536 gap: 5px;537 font-size: 0.85em;538 opacity: 0.5;539 font-weight: 400;540 line-height: 1;541 }542 543 .pro-only-dash {544 display: inline-flex;545 align-items: center;546 line-height: 1;547 }548 549 .pro-only-icon {550 width: 11px;551 height: 11px;552 flex-shrink: 0;553 display: inline-flex;554 align-items: center;555 }556 557 .pro-only-text {558 display: inline-flex;559 align-items: center;560 line-height: 1;561 }562 563 .pdf-locked {564 display: block;565 }566 567 .button-locked {568 display: inline-flex;569 align-items: center;570 gap: 6px;571 background: linear-gradient(135deg, 572 var(--primary-color) 0%, 573 oklch(from var(--primary-color) calc(l - 0.1) calc(c + 0.05) calc(h - 60)) 100%);574 border-radius: var(--button-radius);575 padding: var(--button-padding-y) var(--button-padding-x);576 font-size: var(--button-font-size);577 line-height: 1;578 color: var(--on-primary);579 position: relative;580 overflow: hidden;581 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);582 font-weight: normal;583 border-color: rgba(0, 0, 0, 0.15);584 }585 586 .button-locked:active {587 transform: translateY(0);588 }589 590 .lock-icon {591 font-size: 1em;592 flex-shrink: 0;593 position: relative;594 z-index: 1;595 }596 597 .locked-title {598 position: relative;599 z-index: 1;600 }601 602 /* Dark mode locked button - inherits from light mode variables */603 604 @media (max-width: 768px) {605 .meta-container-cell--pdf {606 display: flex;607 flex-direction: column;608 align-items: flex-end;609 }610 }611</style>612 