---
layout: '@/layouts/Doc.astro'
title: 📚 Projects
---

Sam Foreman

> [!TIP] <span style="color: var(--foreground2)">📂 [`saforem2/`](https://github.com/saforem2?tab=repositories)</span>

<style>{`
  /* Sortable table */
  #repo-table {
    width: 100%;
    margin-block: 1lh;
    border-collapse: collapse;
  }
  #repo-table th button {
    appearance: none;
    background: none;
    border: none;
    padding: 0;
    font: inherit;
    color: inherit;
    cursor: pointer;
    text-align: inherit;
  }
  #repo-table th[data-sort] button:hover,
  #repo-table th[data-sort] button:focus-visible {
    color: var(--accent, var(--blue));
  }
  .repo-arrow {
    display: inline-block;
    width: 1ch;
    color: var(--foreground2);
    font-size: 0.85em;
  }
  #repo-table th[data-sort-dir="asc"] .repo-arrow::before { content: "↑"; }
  #repo-table th[data-sort-dir="desc"] .repo-arrow::before { content: "↓"; }
  #repo-table td { vertical-align: top; }
  #repo-table td.col-stars,
  #repo-table td.col-updated { white-space: nowrap; }
  #repo-table td.col-desc { color: var(--foreground2); }

  #repo-grid {
    display: flex;
    flex-wrap: wrap;
    gap: 1lh 2ch;
    margin-top: 1lh;
  }
  .repo-card {
    flex: 1 1 30ch;
    max-width: 44ch;
    display: flex;
    flex-direction: column;
    padding: 1lh 2ch;
    gap: 0.5lh;
    background-color: var(--background1);
    transition: background-color 0.15s ease;
  }
  .repo-card:hover {
    background-color: var(--background2);
    --box-border-color: var(--accent, var(--foreground0));
  }
  .repo-card h3 {
    margin: 0;
    font-weight: 600;
    word-break: break-word;
    border: none !important;
  }
  .repo-card h3 a {
    color: var(--accent, var(--blue));
    text-decoration: none;
  }
  .repo-card h3 a:hover {
    text-decoration: underline;
  }
  .repo-description {
    color: var(--foreground1);
    margin: 0;
    flex: 1;
  }
  .repo-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5lh 1.5ch;
    margin-top: auto;
    padding-top: 0.5lh;
    border-top: 1px solid var(--background3, var(--background2));
    /* Legible footer: readable base color, no blanket opacity (the old
       0.7 dimmed language + stars into an illegible grey). Individual
       pieces carry their own emphasis via .repo-language / .repo-stars. */
    color: var(--foreground2);
    font-size: 0.9em;
  }
  .repo-meta span {
    display: inline-flex;
    align-items: center;
    gap: 0.5ch;
    white-space: nowrap;
  }
  /* Language name reads at full strength; the colored dot carries the
     GitHub-linguist hue set inline on each card. */
  .repo-language {
    color: var(--foreground1);
  }
  .repo-language-dot {
    width: 1ch;
    height: 1ch;
    border-radius: 50%;
    /* color set inline per language; fallback to foreground2 */
    background: var(--lang-color, var(--foreground2));
    flex-shrink: 0;
  }
  /* Star count gets a touch of warmth so it stands out from the muted
     "updated" date without shouting. */
  .repo-stars {
    color: var(--foreground1);
  }
  .repo-star-glyph {
    color: var(--yellow, #e3b341);
  }
  #repo-grid-status {
    color: var(--foreground2);
    margin-top: 1lh;
  }
`}</style>

<div>
    <h2 class="anchored">
        <a href="https://github.com/saforem2/">
            <code>saforem2</code>
        </a>
        's GitHub Repositories
    </h2>
    <p id="repo-grid-status">
        Loading repositories from GitHub…
    </p>
    <table id="repo-table" hidden>
        <thead>
            <tr>
                <th data-sort="stars" data-sort-dir="desc"><button type="button" aria-label="Sort by stars">⭐ <span class="repo-arrow"></span></button></th>
                <th data-sort="updated"><button type="button" aria-label="Sort by last updated">Updated <span class="repo-arrow"></span></button></th>
                <th data-sort="name"><button type="button" aria-label="Sort by repository name">Repo <span class="repo-arrow"></span></button></th>
                <th>Description</th>
                <th data-sort="lang"><button type="button" aria-label="Sort by language">Lang <span class="repo-arrow"></span></button></th>
            </tr>
        </thead>
        <tbody></tbody>
    </table>
    <div id="repo-grid" data-github-user="saforem2"></div>
</div>

<script>{`
  (async function () {
    const grid = document.getElementById("repo-grid");
    const status = document.getElementById("repo-grid-status");
    const table = document.getElementById("repo-table");
    const tbody = table ? table.querySelector("tbody") : null;
    if (!grid || !table || !tbody) return;
    const limit = grid.getAttribute("data-limit");
    const cap = limit ? parseInt(limit, 10) : Infinity;
    const showCards = grid.getAttribute("data-show-cards") !== "false";
    const username = grid.getAttribute("data-github-user") || "saforem2";
    const apiUrl =
      "https://api.github.com/users/" +
      username +
      "/repos?per_page=100&sort=updated";

    function formatNumber(n) {
      if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\\.0$/, "") + "M";
      if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\\.0$/, "") + "k";
      return String(n);
    }
    function fmtDate(iso) { return iso.slice(0, 10); }
    // GitHub-linguist colors for the languages that actually appear across
    // saforem2's repos (plus a neutral fallback). Keeps the language dot a
    // real signal instead of a monochrome square.
    const LANG_COLORS = {
      "Python": "#3572A5",
      "Jupyter Notebook": "#DA5B0B",
      "C++": "#f34b7d",
      "C": "#555555",
      "Lua": "#000080",
      "Shell": "#89e051",
      "HTML": "#e34c26",
      "CSS": "#563d7c",
      "SCSS": "#c6538c",
      "JavaScript": "#f1e05a",
      "TypeScript": "#3178c6",
      "TeX": "#3D6117",
      "Makefile": "#427819",
      "Astro": "#ff5a03",
      "Vim Script": "#199f4b",
      "Rust": "#dea584",
      "Go": "#00ADD8",
    };
    function compare(a, b, key, dir) {
      const mul = dir === "asc" ? 1 : -1;
      let cmp = 0;
      if (key === "stars") {
        cmp = a.stargazers_count - b.stargazers_count;
        if (cmp === 0) cmp = a.updated_at.localeCompare(b.updated_at);
      } else if (key === "updated") {
        cmp = a.updated_at.localeCompare(b.updated_at);
      } else if (key === "name") {
        cmp = a.name.localeCompare(b.name);
      } else if (key === "lang") {
        cmp = (a.language || "").localeCompare(b.language || "");
      }
      return cmp * mul;
    }

    let repos = [];
    let sortKey = "stars";
    let sortDir = "desc";

    function makeCell(text, cls) {
      const td = document.createElement("td");
      td.textContent = text;
      if (cls) td.className = cls;
      return td;
    }
    function makeRepoCell(repo) {
      const td = document.createElement("td");
      const a = document.createElement("a");
      a.href = repo.html_url;
      a.textContent = repo.name;
      a.target = "_blank";
      a.rel = "noopener noreferrer";
      td.appendChild(a);
      return td;
    }
    function makeCard(repo) {
      const card = document.createElement("div");
      card.className = "repo-card";
      card.setAttribute("box-", "square");
      const header = document.createElement("row");
      header.setAttribute("align-", "center between");
      const title = document.createElement("h3");
      const link = document.createElement("a");
      link.href = repo.html_url;
      link.textContent = repo.name;
      link.target = "_blank";
      link.rel = "noopener noreferrer";
      title.appendChild(link);
      const badge = document.createElement("span");
      badge.setAttribute("is-", "badge");
      badge.setAttribute("variant-", "background2");
      badge.textContent = repo.private ? "Private" : "Public";
      header.appendChild(title);
      header.appendChild(badge);
      const desc = document.createElement("p");
      desc.className = "repo-description";
      desc.textContent = repo.description || "No description provided.";
      const meta = document.createElement("div");
      meta.className = "repo-meta";
      if (repo.language) {
        const langSpan = document.createElement("span");
        langSpan.className = "repo-language";
        const dot = document.createElement("span");
        dot.className = "repo-language-dot";
        const color = LANG_COLORS[repo.language];
        if (color) dot.style.setProperty("--lang-color", color);
        langSpan.appendChild(dot);
        const langText = document.createElement("span");
        langText.textContent = repo.language;
        langSpan.appendChild(langText);
        meta.appendChild(langSpan);
      }
      // Only show the star count when there's at least one — a "0" on
      // every card is noise.
      if ((repo.stargazers_count || 0) > 0) {
        const starsSpan = document.createElement("span");
        starsSpan.className = "repo-stars";
        const starGlyph = document.createElement("span");
        starGlyph.className = "repo-star-glyph";
        starGlyph.textContent = ""; // nerd-font star, matches TUI aesthetic
        starsSpan.appendChild(starGlyph);
        starsSpan.appendChild(
          document.createTextNode(" " + formatNumber(repo.stargazers_count)),
        );
        meta.appendChild(starsSpan);
      }
      const updatedSpan = document.createElement("span");
      updatedSpan.className = "repo-updated";
      updatedSpan.textContent = "Updated " + fmtDate(repo.updated_at);
      meta.appendChild(updatedSpan);
      card.appendChild(header);
      card.appendChild(desc);
      card.appendChild(meta);
      return card;
    }

    function render() {
      const sorted = repos.slice().sort((a, b) => compare(a, b, sortKey, sortDir));
      const shown = sorted.slice(0, cap);
      tbody.replaceChildren();
      if (showCards) grid.replaceChildren();
      shown.forEach((repo) => {
        const tr = document.createElement("tr");
        tr.append(
          makeCell(formatNumber(repo.stargazers_count || 0), "col-stars"),
          makeCell(fmtDate(repo.updated_at), "col-updated"),
          makeRepoCell(repo),
          makeCell(repo.description || "", "col-desc"),
          makeCell(repo.language || "")
        );
        tbody.appendChild(tr);
        if (showCards) grid.appendChild(makeCard(repo));
      });
      table.querySelectorAll("th[data-sort]").forEach((th) => {
        if (th.getAttribute("data-sort") === sortKey) th.setAttribute("data-sort-dir", sortDir);
        else th.removeAttribute("data-sort-dir");
      });
    }

    table.querySelectorAll("th[data-sort]").forEach((th) => {
      th.addEventListener("click", () => {
        const key = th.getAttribute("data-sort");
        if (key === sortKey) {
          sortDir = sortDir === "asc" ? "desc" : "asc";
        } else {
          sortKey = key;
          sortDir = key === "name" || key === "lang" ? "asc" : "desc";
        }
        render();
      });
    });

    try {
      const res = await fetch(apiUrl);
      if (!res.ok) throw new Error("GitHub API error: " + res.status);
      const data = await res.json();
      repos = data.filter((r) => !r.fork);
      if (repos.length === 0) {
        if (status) status.textContent = "No repositories found.";
        return;
      }
      if (status) status.remove();
      table.hidden = false;
      render();
    } catch (err) {
      console.error(err);
      if (status) status.textContent = "Failed to load repositories from GitHub.";
    }
  })();
`}</script>
