AkurAI Build
Menu

Public repository / 439f0dcabf5cdf2b120c7724be895cf1df30507f

AkurAI-Framework / site/frontend/ai/components/content/item_carousel.js

Latest change 67a966d4abf972b300e8c6c214a30a0279103337 - feat(ai): Artifacts content components + geo map + item carousel (AB3-C) by Ólafur Búi Ólafsson

/**
 * Item Carousel — Artifacts / content component.
 * Payload: { items: Array<{ title: string, image?: string, subtitle?: string, href?: string }> }
 * Horizontal scroll-snap carousel with prev/next buttons.
 */

function esc(str) {
  return String(str ?? "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

function safeHref(href) {
  if (!href) return null;
  const str = String(href);
  if (/^(https?:\/\/|\/)/i.test(str)) return str;
  return null;
}

function renderItem(item) {
  const { title, image, subtitle, href } = item;
  const safe = safeHref(href);
  const tag = safe ? "a" : "div";
  const hrefAttr = safe ? ` href="${esc(safe)}"` : "";
  const target = safe ? ` target="_blank" rel="noopener noreferrer"` : "";

  const imgHtml = image
    ? `<img class="tc-content-carousel__img" src="${esc(image)}" alt="${esc(title)}" loading="lazy">`
    : `<div class="tc-content-carousel__img tc-content-carousel__img--placeholder" aria-hidden="true">
        <svg viewBox="0 0 80 56" fill="none" width="80" height="56">
          <rect width="80" height="56" rx="6" fill="var(--panel-2)"/>
          <path d="M26 42l10-13 7 9 5-6 10 10H18z" fill="var(--border-2)"/>
          <circle cx="24" cy="22" r="5" fill="var(--border-2)"/>
        </svg>
       </div>`;

  return `<${tag} class="tc-content-carousel__item"${hrefAttr}${target}>
  ${imgHtml}
  <div class="tc-content-carousel__info">
    <span class="tc-content-carousel__title">${esc(title)}</span>
    ${subtitle ? `<span class="tc-content-carousel__sub">${esc(subtitle)}</span>` : ""}
  </div>
</${tag}>`;
}

export const def = {
  name: "item_carousel",
  category: "Artifacts",
  title: "Item Carousel",

  match(payload) {
    return (
      payload !== null &&
      typeof payload === "object" &&
      Array.isArray(payload.items) &&
      payload.items.length > 0
    );
  },

  render(payload) {
    const { items } = payload;

    const wrap = document.createElement("div");
    wrap.className = "tc-content-carousel";
    wrap.setAttribute("role", "region");
    wrap.setAttribute("aria-label", "Item carousel");

    wrap.innerHTML = `
<button class="tc-content-carousel__nav tc-content-carousel__nav--prev" aria-label="Previous" type="button" disabled>
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="18" height="18" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<div class="tc-content-carousel__track" role="list">
  ${items.map((item) => `<div role="listitem">${renderItem(item)}</div>`).join("")}
</div>
<button class="tc-content-carousel__nav tc-content-carousel__nav--next" aria-label="Next" type="button">
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="18" height="18" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>
</button>
`;

    const track = wrap.querySelector(".tc-content-carousel__track");
    const prevBtn = wrap.querySelector(".tc-content-carousel__nav--prev");
    const nextBtn = wrap.querySelector(".tc-content-carousel__nav--next");

    function updateButtons() {
      prevBtn.disabled = track.scrollLeft <= 4;
      nextBtn.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 4;
    }

    function scrollBy(dir) {
      const itemEl = track.querySelector(".tc-content-carousel__item");
      const step = itemEl ? itemEl.offsetWidth + 12 : 200;
      track.scrollBy({ left: dir * step, behavior: "smooth" });
    }

    prevBtn.addEventListener("click", () => scrollBy(-1));
    nextBtn.addEventListener("click", () => scrollBy(1));
    track.addEventListener("scroll", updateButtons, { passive: true });

    // Initial state after paint
    requestAnimationFrame(updateButtons);

    return wrap;
  },

  example: {
    items: [
      { title: "AkurAI Framework", subtitle: "Native ESM, zero deps", href: "https://github.com/olibuijr/AkurAI-Framework" },
      { title: "Streaming Chat", subtitle: "SSE-based token streaming" },
      { title: "Tool UI", subtitle: "30+ generative components" },
      { title: "Geo Map", subtitle: "No map library" },
      { title: "Item Carousel", subtitle: "This component" },
    ],
  },
};