Files
indiekit-blog/starred.njk
Ricardo 9b5fe6014d 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
2026-03-03 11:01:05 +01:00

436 lines
18 KiB
Plaintext

---
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-primary-600 dark:text-primary-400 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 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-primary-200 border-t-primary-600 rounded-full animate-spin"></div>
<p class="mt-4 text-surface-500">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">
{# 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">
<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-primary-600 dark:hover:text-primary-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-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" :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 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-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, 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="'(' + (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 {
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>