---
layout: layouts/base.njk
title: Starred Repositories
permalink: /github/starred/
eleventyExcludeFromCollections: true
---

<div class="starred-page" x-data="starredPage" x-cloak>
  <header class="mb-6 sm:mb-8">
    <div class="flex items-center gap-2 mb-4">
      <a href="/github/" class="text-sm text-accent-700 dark:text-accent-300 hover:underline">&larr; GitHub Activity</a>
    </div>
    <h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2 flex items-center gap-3">
      <svg class="w-8 h-8 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
        <path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
      </svg>
      Starred Repositories
    </h1>
    <p class="text-surface-600 dark:text-surface-400">
      <template x-if="loading">
        <span>Loading starred repos&hellip;</span>
      </template>
      <template x-if="!loading && totalCount > 0">
        <span>
          <span x-text="totalCount"></span> repos starred on GitHub.
          <template x-if="lastSync">
            <span>Last synced <span class="font-mono" x-text="formatDate(lastSync)"></span>.</span>
          </template>
        </span>
      </template>
      <template x-if="!loading && totalCount === 0">
        <span>No starred repositories found. The cache may still be syncing.</span>
      </template>
    </p>
  </header>

  {# Loading state #}
  <template x-if="loading">
    <div class="text-center py-12">
      <div class="inline-block w-8 h-8 border-4 border-emerald-200 border-t-emerald-600 rounded-full animate-spin"></div>
      <p class="mt-4 text-surface-600 dark:text-surface-400">Loading starred repositories&hellip;</p>
    </div>
  </template>

  {# Error state #}
  <template x-if="error">
    <p class="text-surface-600 dark:text-surface-400" x-text="error"></p>
  </template>

  {# Main content — shown after loading #}
  <template x-if="!loading && allStars.length > 0">
    <div>

      {# ===== TAB BAR ===== #}
      <div class="mb-4 -mx-4 px-4 overflow-x-auto scrollbar-thin">
        <div class="flex gap-1 min-w-max border-b border-surface-200 dark:border-surface-700" role="tablist" aria-label="Starred repository lists">
          {# All tab #}
          <button
            @click="activeTab = 'all'; resetView()"
            :class="activeTab === 'all'
              ? 'border-emerald-600 text-emerald-700 dark:text-emerald-400'
              : 'border-transparent text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 hover:border-surface-300'"
            :aria-selected="(activeTab === 'all').toString()"
            role="tab"
            class="px-3 py-2 text-sm font-medium border-b-2 whitespace-nowrap transition-colors"
          >
            All
            <span class="ml-1 text-xs opacity-70" x-text="'(' + allStars.length + ')'"></span>
          </button>

          {# List tabs #}
          <template x-for="list in listMeta" :key="list.slug">
            <button
              @click="activeTab = list.slug; resetView()"
              :class="activeTab === list.slug
                ? 'border-emerald-600 text-emerald-700 dark:text-emerald-400'
                : 'border-transparent text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 hover:border-surface-300'"
              :aria-selected="(activeTab === list.slug).toString()"
              role="tab"
              class="px-3 py-2 text-sm font-medium border-b-2 whitespace-nowrap transition-colors"
            >
              <span x-text="list.name"></span>
              <span class="ml-1 text-xs opacity-70" x-text="'(' + tabCountForList(list.slug) + ')'"></span>
            </button>
          </template>

          {# Uncategorized tab #}
          <button
            @click="activeTab = 'uncategorized'; resetView()"
            :class="activeTab === 'uncategorized'
              ? 'border-emerald-600 text-emerald-700 dark:text-emerald-400'
              : 'border-transparent text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 hover:border-surface-300'"
            :aria-selected="(activeTab === 'uncategorized').toString()"
            role="tab"
            class="px-3 py-2 text-sm font-medium border-b-2 whitespace-nowrap transition-colors"
          >
            Uncategorized
            <span class="ml-1 text-xs opacity-70" x-text="'(' + uncategorizedCount + ')'"></span>
          </button>
        </div>
      </div>

      {# ===== CONTROLS BAR ===== #}
      <div class="mb-6 space-y-3">
        {# Search + Sort row #}
        <div class="flex flex-col sm:flex-row gap-3">
          {# Search #}
          <div class="relative flex-1">
            <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
            </svg>
            <input
              type="text"
              x-model="searchQuery"
              @input="resetView()"
              placeholder="Search by name, description, topic, or language..."
              class="w-full pl-10 pr-10 py-2 rounded-lg border border-surface-300 dark:border-surface-600 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 placeholder-surface-400 text-sm"
            >
            <template x-if="searchQuery">
              <button @click="searchQuery = ''; resetView()" class="absolute right-3 top-1/2 -translate-y-1/2 text-surface-400 hover:text-surface-600">
                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
                </svg>
              </button>
            </template>
          </div>

          {# Sort dropdown #}
          <select
            x-model="sortBy"
            @change="resetView()"
            class="px-3 py-2 rounded-lg border border-surface-300 dark:border-surface-600 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm"
          >
            <option value="stars">Sort: Stars</option>
            <option value="starredAt">Sort: Recently Starred</option>
            <option value="pushedAt">Sort: Recently Updated</option>
            <option value="name">Sort: Name</option>
          </select>
        </div>

        {# Filters row #}
        <div class="flex flex-wrap items-center gap-2">
          {# Language filter #}
          <select
            x-model="languageFilter"
            @change="resetView()"
            class="px-2 py-1.5 rounded border border-surface-300 dark:border-surface-600 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-xs"
          >
            <option value="">All Languages</option>
            <template x-for="lang in availableLanguages" :key="lang">
              <option :value="lang" x-text="lang"></option>
            </template>
          </select>

          {# Star count filter #}
          <div class="flex rounded border border-surface-300 dark:border-surface-600 overflow-hidden">
            <template x-for="opt in starFilterOptions" :key="opt.value">
              <button
                @click="starCountMin = opt.value; resetView()"
                :class="starCountMin === opt.value
                  ? 'bg-emerald-600 text-white'
                  : 'bg-surface-50 dark:bg-surface-800 text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700'"
                class="px-2.5 py-1 text-xs font-medium transition-colors"
                x-text="opt.label"
              ></button>
            </template>
          </div>

          {# Archived toggle #}
          <label class="flex items-center gap-1.5 text-xs text-surface-600 dark:text-surface-400 cursor-pointer">
            <input type="checkbox" x-model="showArchived" @change="resetView()" class="rounded border-surface-300 text-emerald-600">
            Show archived
          </label>

          {# Active filter summary #}
          <template x-if="hasActiveFilters">
            <button
              @click="clearFilters()"
              class="text-xs text-emerald-600 dark:text-emerald-400 hover:underline"
            >Clear filters</button>
          </template>
        </div>
      </div>

      {# ===== RESULTS SUMMARY ===== #}
      <div class="mb-4 text-sm text-surface-600 dark:text-surface-400">
        <span x-text="resultSummary"></span>
      </div>

      {# ===== REPO GRID ===== #}
      <div class="grid gap-3 sm:gap-4 md:grid-cols-2" id="starred-grid">
        <template x-for="repo in visibleStars" :key="repo.fullName">
          <article class="starred-card p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-emerald-400 dark:hover:border-emerald-600 transition-colors shadow-sm">
            <div class="flex items-center gap-2 mb-1">
              <template x-if="repo.ownerAvatar">
                <img :src="repo.ownerAvatar" :alt="repo.ownerLogin" class="w-5 h-5 rounded-full" loading="lazy">
              </template>
              <h3 class="font-semibold text-surface-900 dark:text-surface-100 truncate">
                <a :href="repo.url" class="hover:text-surface-600 dark:hover:text-surface-400" target="_blank" rel="noopener" x-text="repo.fullName"></a>
              </h3>
              <template x-if="repo.archived">
                <span class="text-xs px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 rounded">Archived</span>
              </template>
            </div>

            <template x-if="repo.description">
              <p class="text-sm text-surface-600 dark:text-surface-400 mb-2 line-clamp-2" x-text="repo.description"></p>
            </template>

            <template x-if="repo.topics && repo.topics.length">
              <div class="flex flex-wrap gap-1.5 mb-2">
                <template x-for="topic in repo.topics.slice(0, 5)" :key="topic">
                  <span class="text-xs px-2 py-0.5 bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 rounded" x-text="topic"></span>
                </template>
                <template x-if="repo.topics.length > 5">
                  <span class="text-xs px-2 py-0.5 bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400 rounded" x-text="'+' + (repo.topics.length - 5)"></span>
                </template>
              </div>
            </template>

            <div class="flex flex-wrap items-center gap-3 text-xs text-surface-600 dark:text-surface-400">
              <template x-if="repo.language">
                <span class="flex items-center gap-1">
                  <span class="w-2.5 h-2.5 rounded-full" :style="'background:' + languageColor(repo.language)"></span>
                  <span x-text="repo.language"></span>
                </span>
              </template>
              <span class="flex items-center gap-1">
                <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/></svg>
                <span x-text="formatNumber(repo.stars)"></span>
              </span>
              <template x-if="repo.forks > 0">
                <span class="flex items-center gap-1">
                  <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></svg>
                  <span x-text="formatNumber(repo.forks)"></span>
                </span>
              </template>
              <template x-if="repo.license">
                <span x-text="repo.license"></span>
              </template>
              <template x-if="repo.starredAt">
                <span class="font-mono" x-text="'Starred ' + formatDate(repo.starredAt)"></span>
              </template>
            </div>
          </article>
        </template>
      </div>

      {# ===== EMPTY FILTERED STATE ===== #}
      <template x-if="sortedStars.length === 0">
        <div class="text-center py-12">
          <p class="text-surface-600 dark:text-surface-400">No repos match your current filters.</p>
          <button @click="clearFilters()" class="mt-2 text-sm text-emerald-600 dark:text-emerald-400 hover:underline">Clear all filters</button>
        </div>
      </template>

      {# ===== LOAD MORE ===== #}
      <template x-if="visibleCount < sortedStars.length">
        <div class="mt-6 text-center">
          <button
            @click="visibleCount = Math.min(visibleCount + 50, sortedStars.length)"
            class="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors"
          >
            Load More
            <span class="text-emerald-200" x-text="'(' + (sortedStars.length - visibleCount) + ' remaining)'"></span>
          </button>
        </div>
      </template>

    </div>
  </template>
</div>

{# Alpine.js component #}
<script>
document.addEventListener("alpine:init", () => {
  Alpine.data("starredPage", () => ({
    // Data from API
    allStars: [],
    listMeta: [],
    totalCount: 0,
    lastSync: null,
    loading: true,
    error: null,

    // Controls
    activeTab: "all",
    sortBy: "stars",
    searchQuery: "",
    languageFilter: "",
    starCountMin: 0,
    showArchived: false,
    visibleCount: 50,

    // Star filter options
    starFilterOptions: [
      { label: "Any", value: 0 },
      { label: "100+", value: 100 },
      { label: "1K+", value: 1000 },
      { label: "10K+", value: 10000 },
      { label: "100K+", value: 100000 },
    ],

    // --- Computed ---

    get tabStars() {
      if (this.activeTab === "all") return this.allStars;
      if (this.activeTab === "uncategorized")
        return this.allStars.filter(r => !r.lists || r.lists.length === 0);
      return this.allStars.filter(r => r.lists && r.lists.includes(this.activeTab));
    },

    get filteredStars() {
      let result = this.tabStars;
      if (!this.showArchived) result = result.filter(r => !r.archived);
      if (this.languageFilter) result = result.filter(r => r.language === this.languageFilter);
      if (this.starCountMin) result = result.filter(r => (r.stars || 0) >= this.starCountMin);
      if (this.searchQuery) {
        const q = this.searchQuery.toLowerCase();
        result = result.filter(r =>
          (r.fullName && r.fullName.toLowerCase().includes(q)) ||
          (r.description && r.description.toLowerCase().includes(q)) ||
          (r.language && r.language.toLowerCase().includes(q)) ||
          (r.topics && r.topics.some(t => t.toLowerCase().includes(q)))
        );
      }
      return result;
    },

    get sortedStars() {
      const sorted = [...this.filteredStars];
      switch (this.sortBy) {
        case "stars":
          return sorted.sort((a, b) => (b.stars || 0) - (a.stars || 0));
        case "starredAt":
          return sorted.sort((a, b) => (b.starredAt || "").localeCompare(a.starredAt || ""));
        case "pushedAt":
          return sorted.sort((a, b) => (b.pushedAt || "").localeCompare(a.pushedAt || ""));
        case "name":
          return sorted.sort((a, b) => (a.fullName || "").localeCompare(b.fullName || ""));
        default:
          return sorted;
      }
    },

    get visibleStars() {
      return this.sortedStars.slice(0, this.visibleCount);
    },

    get availableLanguages() {
      const langs = new Set(this.tabStars.map(r => r.language).filter(Boolean));
      return [...langs].sort();
    },

    get uncategorizedCount() {
      return this.allStars.filter(r => !r.lists || r.lists.length === 0).length;
    },

    get hasActiveFilters() {
      return this.searchQuery || this.languageFilter || this.starCountMin > 0 || this.showArchived;
    },

    get resultSummary() {
      const total = this.sortedStars.length;
      const showing = Math.min(this.visibleCount, total);
      const tabName = this.activeTab === "all" ? "all repos"
        : this.activeTab === "uncategorized" ? "uncategorized"
        : (this.listMeta.find(l => l.slug === this.activeTab)?.name || this.activeTab);
      if (this.hasActiveFilters) {
        return showing + " of " + total + " matching repos in " + tabName;
      }
      return "Showing " + showing + " of " + total + " repos in " + tabName;
    },

    // --- Methods ---

    tabCountForList(slug) {
      return this.allStars.filter(r => r.lists && r.lists.includes(slug)).length;
    },

    resetView() {
      this.visibleCount = 50;
    },

    clearFilters() {
      this.searchQuery = "";
      this.languageFilter = "";
      this.starCountMin = 0;
      this.showArchived = false;
      this.resetView();
    },

    formatDate(iso) {
      if (!iso) return "";
      try {
        return new Date(iso).toLocaleDateString("en-US", {
          year: "numeric", month: "short", day: "numeric"
        });
      } catch { return iso; }
    },

    formatNumber(n) {
      if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
      return String(n);
    },

    languageColor(lang) {
      const colors = {
        JavaScript: "#f1e05a", TypeScript: "#3178c6", Python: "#3572A5",
        Rust: "#dea584", Go: "#00ADD8", Java: "#b07219", Ruby: "#701516",
        "C++": "#f34b7d", C: "#555555", "C#": "#178600", PHP: "#4F5D95",
        Swift: "#F05138", Kotlin: "#A97BFF", Dart: "#00B4AB",
        Shell: "#89e051", HTML: "#e34c26", CSS: "#563d7c", Vue: "#41b883",
        Svelte: "#ff3e00", Lua: "#000080", Zig: "#ec915c", Nix: "#7e7eff",
        Elixir: "#6e4a7e", Haskell: "#5e5086", Scala: "#c22d40",
        Jupyter: "#DA5B0B", R: "#198CE7", SCSS: "#c6538c",
      };
      return colors[lang] || "#8b8b8b";
    },

    // --- Init ---

    async init() {
      try {
        let data = null;
        for (const path of ["/github/api/starred/all", "/githubapi/api/starred/all"]) {
          try {
            const res = await fetch(path);
            if (res.ok) {
              data = await res.json();
              break;
            }
          } catch {
            // Try next candidate path.
          }
        }

        if (!data) throw new Error("API returned no usable response");
        this.allStars = data.stars || [];
        this.listMeta = (data.listMeta || []).sort((a, b) => a.name.localeCompare(b.name));
        this.totalCount = data.totalCount || 0;
        this.lastSync = data.lastSync || null;
      } catch (err) {
        this.error = "Could not load starred repositories. Try refreshing the page.";
        console.error("[starred]", err);
      } finally {
        this.loading = false;
      }
    },
  }));
});
</script>
