Replace the entire color system with a design-driven warm palette: - Surface tokens: cold zinc grays → warm stone (#faf8f5 to #0f0e0d) - Accent tokens: cold teal → warm amber (#fffbeb to #451a03) - All bg-white → bg-surface-50 across templates (warm cream instead of pure white) - Critical CSS: all hardcoded hex values updated to warm palette - Prism code blocks: cold gray backgrounds → warm stone - Pagefind search UI: blue buttons/links → amber interactive colors - Dark mode: warm dark surfaces with amber accents throughout Design system documented in .interface-design/system.md Confab-Link: http://localhost:8080/sessions/bd3f7012-c703-47e9-bfe2-2ad04ce1842d
436 lines
18 KiB
Plaintext
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-accent-600 dark:text-accent-400 hover:underline">← 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…</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-accent-200 border-t-accent-600 rounded-full animate-spin"></div>
|
|
<p class="mt-4 text-surface-500">Loading starred repositories…</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-accent-600 text-accent-700 dark:text-accent-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-accent-600 text-accent-700 dark:text-accent-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-accent-600 text-accent-700 dark:text-accent-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-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 placeholder-surface-400 text-sm focus:outline-none focus:ring-2 focus:ring-accent-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-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-accent-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-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-xs focus:outline-none focus:ring-2 focus:ring-accent-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-accent-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-accent-600 focus:ring-accent-500">
|
|
Show archived
|
|
</label>
|
|
|
|
{# Active filter summary #}
|
|
<template x-if="hasActiveFilters">
|
|
<button
|
|
@click="clearFilters()"
|
|
class="text-xs text-accent-600 dark:text-accent-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-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-surface-400 dark:hover:border-surface-500 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-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-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-accent-600 dark:text-accent-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-accent-600 hover:bg-accent-700 text-white rounded-lg transition-colors"
|
|
>
|
|
Load More
|
|
<span class="text-accent-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>
|