feat: redesign starred page with GitHub Lists tabs, sort, and filters

- Add tab bar for GitHub Lists (All, per-list tabs, Uncategorized)
- Add sort controls (stars, recently starred, recently updated, name)
- Add filter controls (language, star count range, archived toggle)
- Add language color dots, formatted star/fork counts, topic overflow
- Fix starred count on GitHub activity page (fetch totalCount from API)
- All rendering remains client-side via Alpine.js (no build OOM risk)

Confab-Link: http://localhost:8080/sessions/b130e9e5-4723-435d-8d5a-fc38113381c9
This commit is contained in:
Ricardo
2026-03-03 11:01:05 +01:00
parent 4fb7e2e92e
commit 9b5fe6014d
2 changed files with 335 additions and 87 deletions

View File

@@ -1,12 +1,32 @@
/** /**
* GitHub Starred Repos Metadata * GitHub Starred Repos Metadata
* Provides build timestamp only — the starred page fetches all data * Fetches the starred API response (cached 15min) to extract totalCount.
* client-side via Alpine.js to avoid loading 5000+ objects into * Only totalCount is passed to Eleventy's data cascade — the full star
* Eleventy's memory during build (causes OOM on constrained containers). * list is discarded after parsing, keeping build memory low.
* The starred page fetches all data client-side via Alpine.js.
*/ */
export default function () { import EleventyFetch from "@11ty/eleventy-fetch";
return {
buildDate: new Date().toISOString(), const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
};
export default async function () {
try {
const url = `${INDIEKIT_URL}/githubapi/api/starred/all`;
const response = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
return {
totalCount: response.totalCount || 0,
buildDate: new Date().toISOString(),
};
} catch (error) {
console.log(`[githubStarred] Could not fetch starred count: ${error.message}`);
return {
totalCount: 0,
buildDate: new Date().toISOString(),
};
}
} }

View File

@@ -34,35 +34,6 @@ eleventyExcludeFromCollections: true
</p> </p>
</header> </header>
{# Search box #}
<template x-if="!loading && allStars.length > 0">
<div class="mb-6">
<div class="relative">
<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"
placeholder="Search starred repos by name, description, topic, or language..."
class="w-full pl-10 pr-4 py-2.5 rounded-lg border border-surface-300 dark:border-surface-600 bg-white dark:bg-surface-800 text-surface-900 dark:text-surface-100 placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<template x-if="searchQuery">
<button @click="searchQuery = ''" class="absolute right-3 top-1/2 -translate-y-1/2 text-surface-400 hover:text-surface-600">
<svg class="w-5 h-5" 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>
<template x-if="searchQuery">
<p class="mt-2 text-sm text-surface-500">
<span x-text="filteredStars.length"></span> results for &ldquo;<span x-text="searchQuery"></span>&rdquo;
</p>
</template>
</div>
</template>
{# Loading state #} {# Loading state #}
<template x-if="loading"> <template x-if="loading">
<div class="text-center py-12"> <div class="text-center py-12">
@@ -76,9 +47,141 @@ eleventyExcludeFromCollections: true
<p class="text-surface-600 dark:text-surface-400" x-text="error"></p> <p class="text-surface-600 dark:text-surface-400" x-text="error"></p>
</template> </template>
{# All starred repos — client-side rendered #} {# Main content — shown after loading #}
<template x-if="!loading && allStars.length > 0"> <template x-if="!loading && allStars.length > 0">
<div> <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">
{# All tab #}
<button
@click="activeTab = 'all'; resetView()"
:class="activeTab === 'all'
? 'border-primary-600 text-primary-700 dark:text-primary-400'
: 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300 hover:border-surface-300'"
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-primary-600 text-primary-700 dark:text-primary-400'
: 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300 hover:border-surface-300'"
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-primary-600 text-primary-700 dark:text-primary-400'
: 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300 hover:border-surface-300'"
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-white dark:bg-surface-800 text-surface-900 dark:text-surface-100 placeholder-surface-400 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<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-white dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<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-white dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-xs focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<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-primary-600 text-white'
: 'bg-white 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-primary-600 focus:ring-primary-500">
Show archived
</label>
{# Active filter summary #}
<template x-if="hasActiveFilters">
<button
@click="clearFilters()"
class="text-xs text-primary-600 dark:text-primary-400 hover:underline"
>Clear filters</button>
</template>
</div>
</div>
{# ===== RESULTS SUMMARY ===== #}
<div class="mb-4 text-sm text-surface-500">
<span x-text="resultSummary"></span>
</div>
{# ===== REPO GRID ===== #}
<div class="grid gap-3 sm:gap-4 md:grid-cols-2" id="starred-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"> <template x-for="repo in visibleStars" :key="repo.fullName">
<article class="starred-card p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors"> <article class="starred-card p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
@@ -100,27 +203,30 @@ eleventyExcludeFromCollections: true
<template x-if="repo.topics && repo.topics.length"> <template x-if="repo.topics && repo.topics.length">
<div class="flex flex-wrap gap-1.5 mb-2"> <div class="flex flex-wrap gap-1.5 mb-2">
<template x-for="topic in repo.topics" :key="topic"> <template x-for="topic in repo.topics.slice(0, 5)" :key="topic">
<span class="text-xs px-2 py-0.5 bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded" x-text="topic"></span> <span class="text-xs px-2 py-0.5 bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded" x-text="topic"></span>
</template> </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-500 rounded" x-text="'+' + (repo.topics.length - 5)"></span>
</template>
</div> </div>
</template> </template>
<div class="flex flex-wrap items-center gap-3 text-xs text-surface-500"> <div class="flex flex-wrap items-center gap-3 text-xs text-surface-500">
<template x-if="repo.language"> <template x-if="repo.language">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<span class="w-2.5 h-2.5 rounded-full bg-primary-500"></span> <span class="w-2.5 h-2.5 rounded-full" :style="'background:' + languageColor(repo.language)"></span>
<span x-text="repo.language"></span> <span x-text="repo.language"></span>
</span> </span>
</template> </template>
<span class="flex items-center gap-1"> <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> <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="repo.stars"></span> <span x-text="formatNumber(repo.stars)"></span>
</span> </span>
<template x-if="repo.forks > 0"> <template x-if="repo.forks > 0">
<span class="flex items-center gap-1"> <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> <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="repo.forks"></span> <span x-text="formatNumber(repo.forks)"></span>
</span> </span>
</template> </template>
<template x-if="repo.license"> <template x-if="repo.license">
@@ -134,74 +240,196 @@ eleventyExcludeFromCollections: true
</template> </template>
</div> </div>
{# Load More button #} {# ===== EMPTY FILTERED STATE ===== #}
<template x-if="!searchQuery && visibleCount < allStars.length"> <template x-if="sortedStars.length === 0">
<div class="text-center py-12">
<p class="text-surface-500">No repos match your current filters.</p>
<button @click="clearFilters()" class="mt-2 text-sm text-primary-600 dark:text-primary-400 hover:underline">Clear all filters</button>
</div>
</template>
{# ===== LOAD MORE ===== #}
<template x-if="visibleCount < sortedStars.length">
<div class="mt-6 text-center"> <div class="mt-6 text-center">
<button <button
@click="visibleCount = Math.min(visibleCount + 50, allStars.length)" @click="visibleCount = Math.min(visibleCount + 50, sortedStars.length)"
class="px-6 py-2.5 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors" class="px-6 py-2.5 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
> >
Load More Load More
<span class="text-primary-200" x-text="'(' + (allStars.length - visibleCount) + ' remaining)'"></span> <span class="text-primary-200" x-text="'(' + (sortedStars.length - visibleCount) + ' remaining)'"></span>
</button> </button>
</div> </div>
</template> </template>
</div> </div>
</template> </template>
</div> </div>
{# Alpine.js component #} {# Alpine.js component #}
<script> <script>
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("starredPage", () => ({ Alpine.data("starredPage", () => ({
allStars: [], // Data from API
totalCount: 0, allStars: [],
lastSync: null, listMeta: [],
loading: true, totalCount: 0,
error: null, lastSync: null,
searchQuery: "", loading: true,
visibleCount: 50, error: null,
get filteredStars() { // Controls
if (!this.searchQuery) return this.allStars; 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(); const q = this.searchQuery.toLowerCase();
return this.allStars.filter(r => result = result.filter(r =>
(r.fullName && r.fullName.toLowerCase().includes(q)) || (r.fullName && r.fullName.toLowerCase().includes(q)) ||
(r.description && r.description.toLowerCase().includes(q)) || (r.description && r.description.toLowerCase().includes(q)) ||
(r.language && r.language.toLowerCase().includes(q)) || (r.language && r.language.toLowerCase().includes(q)) ||
(r.topics && r.topics.some(t => t.toLowerCase().includes(q))) (r.topics && r.topics.some(t => t.toLowerCase().includes(q)))
); );
}, }
return result;
},
get visibleStars() { get sortedStars() {
if (this.searchQuery) return this.filteredStars; const sorted = [...this.filteredStars];
return this.allStars.slice(0, this.visibleCount); 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;
}
},
formatDate(iso) { get visibleStars() {
if (!iso) return ""; return this.sortedStars.slice(0, this.visibleCount);
try { },
return new Date(iso).toLocaleDateString("en-US", {
year: "numeric", month: "short", day: "numeric"
});
} catch { return iso; }
},
async init() { get availableLanguages() {
try { const langs = new Set(this.tabStars.map(r => r.language).filter(Boolean));
const response = await fetch("/githubapi/api/starred/all"); return [...langs].sort();
if (!response.ok) throw new Error("API returned " + response.status); },
const data = await response.json();
this.allStars = data.stars || []; get uncategorizedCount() {
this.totalCount = data.totalCount || 0; return this.allStars.filter(r => !r.lists || r.lists.length === 0).length;
this.lastSync = data.lastSync || null; },
} catch (err) {
this.error = "Could not load starred repositories. Try refreshing the page."; get hasActiveFilters() {
console.error("[starred]", err); return this.searchQuery || this.languageFilter || this.starCountMin > 0 || this.showArchived;
} finally { },
this.loading = false;
} 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 {
const res = await fetch("/githubapi/api/starred/all");
if (!res.ok) throw new Error("API returned " + res.status);
const data = await res.json();
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> </script>