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:
@@ -1,12 +1,32 @@
|
||||
/**
|
||||
* GitHub Starred Repos Metadata
|
||||
* Provides build timestamp only — the starred page fetches all data
|
||||
* client-side via Alpine.js to avoid loading 5000+ objects into
|
||||
* Eleventy's memory during build (causes OOM on constrained containers).
|
||||
* Fetches the starred API response (cached 15min) to extract totalCount.
|
||||
* Only totalCount is passed to Eleventy's data cascade — the full star
|
||||
* 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";
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
318
starred.njk
318
starred.njk
@@ -34,35 +34,6 @@ eleventyExcludeFromCollections: true
|
||||
</p>
|
||||
</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 “<span x-text="searchQuery"></span>”
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Loading state #}
|
||||
<template x-if="loading">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
{# All starred repos — client-side rendered #}
|
||||
{# 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">
|
||||
{# 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">
|
||||
<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">
|
||||
@@ -100,27 +203,30 @@ eleventyExcludeFromCollections: true
|
||||
|
||||
<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" :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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-surface-500">
|
||||
<template x-if="repo.language">
|
||||
<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>
|
||||
</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="repo.stars"></span>
|
||||
<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="repo.forks"></span>
|
||||
<span x-text="formatNumber(repo.forks)"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="repo.license">
|
||||
@@ -134,18 +240,27 @@ eleventyExcludeFromCollections: true
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Load More button #}
|
||||
<template x-if="!searchQuery && visibleCount < allStars.length">
|
||||
{# ===== EMPTY FILTERED STATE ===== #}
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -154,28 +269,119 @@ eleventyExcludeFromCollections: true
|
||||
<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() {
|
||||
if (!this.searchQuery) return this.allStars;
|
||||
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();
|
||||
return this.allStars.filter(r =>
|
||||
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() {
|
||||
if (this.searchQuery) return this.filteredStars;
|
||||
return this.allStars.slice(0, this.visibleCount);
|
||||
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) {
|
||||
@@ -187,12 +393,34 @@ eleventyExcludeFromCollections: true
|
||||
} 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 response = await fetch("/githubapi/api/starred/all");
|
||||
if (!response.ok) throw new Error("API returned " + response.status);
|
||||
const data = await response.json();
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user