Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 238 additions & 16 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ function statusBadge(status) {
function renderStoreCard(repo) {
const owner = escape(repo.owner?.login || "");
const isOfficial = (repo.owner?.login || "").toLowerCase() === "corvidlabs";
const topics = (repo.topics || []).slice(0, 5);
const topics = (repo.topics || []).filter((t) => t !== "fledge-plugin" && t !== "fledge-template" && t !== "fledge").slice(0, 5);
const installed = isInstalled(repo.full_name);
const fullEsc = escape(repo.full_name || "");
const installedPlugin = installedBySource.get((repo.full_name || "").toLowerCase());
Expand All @@ -290,6 +290,19 @@ function renderStoreCard(repo) {
: `<button class="btn btn-sm btn-outline" data-action="readme" data-owner="${escape(repo.owner?.login)}" data-repo="${escape(repo.name)}">Readme</button>
<button class="btn btn-sm" data-action="install" data-source="${fullEsc}">Install</button>`;

const lang = repo.language || "";
const topicTagsHtml = topics.map((t) => {
const active = storeFilters.topics.has(t.toLowerCase());
return `<button type="button" class="topic-tag${active ? " active" : ""}" data-action="toggle-topic" data-value="${escape(t)}">${escape(t)}</button>`;
}).join("");

const langStat = lang
? `<button type="button" class="store-card-stat store-card-stat-link" data-action="set-language" data-value="${escape(lang)}">${escape(lang)}</button>`
: "";
const ownerLink = repo.owner?.login
? `<button type="button" class="store-card-author-link" data-action="set-owner" data-value="${escape(repo.owner.login)}">${owner}</button>`
: owner;

return `
<div class="store-card${installed ? " store-card-installed" : ""}">
<div class="store-card-header">
Expand All @@ -299,7 +312,7 @@ function renderStoreCard(repo) {
<a href="${escape(repo.html_url)}" target="_blank" rel="noopener">${escape(repo.name)}</a>
</div>
<div class="store-card-author">
${owner}
${ownerLink}
${isOfficial ? '<span class="badge badge-official">official</span>' : ""}
${installed ? '<span class="badge badge-installed">installed</span>' : ""}
</div>
Expand All @@ -308,12 +321,12 @@ function renderStoreCard(repo) {
<div class="store-card-desc">${escape(repo.description || "No description")}</div>
<div class="store-card-topics">
${typeBadge(repo.topics)}
${topics.filter((t) => t !== "fledge-plugin" && t !== "fledge-template").map((t) => `<span class="topic-tag">${escape(t)}</span>`).join("")}
${topicTagsHtml}
</div>
<div class="store-card-footer">
<div class="store-card-stats">
<span class="store-card-stat">${repo.stargazers_count || 0} stars</span>
${repo.language ? `<span class="store-card-stat">${escape(repo.language)}</span>` : ""}
${langStat}
<span class="store-card-stat">${timeAgo(repo.updated_at)}</span>
</div>
<div class="store-card-actions">${actions}</div>
Expand Down Expand Up @@ -557,10 +570,180 @@ function renderDoctor(report) {
return `${summary}${sections}`;
}

// --- Store filter state ---

const storeFilters = {
category: "all",
q: "",
language: "",
owner: "",
license: "",
topics: new Set(),
};

let storeFacets = { topics: [], languages: [], owners: [], licenses: [] };

const FACET_INITIAL_LIMIT = 8;
const facetExpanded = { topics: false, languages: false, owners: false, licenses: false };

function buildStoreUrl() {
const params = new URLSearchParams({ category: storeFilters.category });
if (storeFilters.q) params.set("q", storeFilters.q);
if (storeFilters.language) params.set("language", storeFilters.language);
if (storeFilters.owner) params.set("owner", storeFilters.owner);
if (storeFilters.license) params.set("license", storeFilters.license);
for (const t of storeFilters.topics) params.append("topic", t);
return `/github/browse?${params}`;
}

function activeFilterCount() {
let n = 0;
if (storeFilters.q) n += 1;
if (storeFilters.language) n += 1;
if (storeFilters.owner) n += 1;
if (storeFilters.license) n += 1;
n += storeFilters.topics.size;
return n;
}

function clearStoreFilters() {
storeFilters.q = "";
storeFilters.language = "";
storeFilters.owner = "";
storeFilters.license = "";
storeFilters.topics.clear();
const search = $("#store-search");
if (search) search.value = "";
loaders.store();
}

function toggleTopic(topic) {
const key = String(topic || "").toLowerCase();
if (!key) return;
if (storeFilters.topics.has(key)) storeFilters.topics.delete(key);
else storeFilters.topics.add(key);
loaders.store();
}

function setSingleFilter(key, value) {
if (key !== "language" && key !== "owner" && key !== "license") return;
const next = String(value || "");
storeFilters[key] = storeFilters[key] === next ? "" : next;
loaders.store();
}

function renderActiveFilters() {
const wrap = $("#store-active-filters");
if (!wrap) return;
const chips = [];
if (storeFilters.q) {
chips.push({ kind: "q", label: storeFilters.q, onRemove: "clear-q" });
}
for (const t of storeFilters.topics) {
chips.push({ kind: "topic", label: t, onRemove: "toggle-topic", value: t });
}
if (storeFilters.language) {
chips.push({ kind: "lang", label: storeFilters.language, onRemove: "set-language", value: storeFilters.language });
}
if (storeFilters.owner) {
chips.push({ kind: "owner", label: storeFilters.owner, onRemove: "set-owner", value: storeFilters.owner });
}
if (storeFilters.license) {
chips.push({ kind: "license", label: storeFilters.license, onRemove: "set-license", value: storeFilters.license });
}
if (chips.length === 0) {
wrap.innerHTML = "";
return;
}
wrap.innerHTML = chips.map((c) => `
<button type="button" class="active-filter-chip" data-action="${c.onRemove}"${c.value != null ? ` data-value="${escape(c.value)}"` : ""} title="Remove filter">
<span class="active-filter-chip-key">${c.kind}</span>
<span>${escape(c.label)}</span>
<span class="active-filter-chip-x">&times;</span>
</button>`).join("");
}

function renderFilterGroup(label, key, options, activeSet, action) {
if (!options || options.length === 0) {
return `
<div class="filter-group">
<div class="filter-group-name">${label}</div>
<div class="filter-group-empty">— none —</div>
</div>`;
}
const expanded = facetExpanded[key];
const visible = expanded ? options : options.slice(0, FACET_INITIAL_LIMIT);
const remainder = options.length - visible.length;
const items = visible.map((opt) => {
const lower = opt.value.toLowerCase();
const active = activeSet ? activeSet(lower, opt.value) : false;
return `
<button type="button" class="filter-option${active ? " active" : ""}" data-action="${action}" data-value="${escape(opt.value)}">
<span class="filter-option-label">${escape(opt.value)}</span>
<span class="filter-option-count">${opt.count}</span>
</button>`;
}).join("");

const toggle = options.length > FACET_INITIAL_LIMIT
? `<button type="button" class="filter-group-toggle" data-action="toggle-facet" data-facet="${key}">${expanded ? "Show less" : `Show ${remainder} more`}</button>`
: "";

return `
<div class="filter-group">
<div class="filter-group-name">${label}</div>
${items}
${toggle}
</div>`;
}

function renderFilterRail() {
const wrap = $("#filter-groups");
if (!wrap) return;
const html = [
renderFilterGroup(
"Topic",
"topics",
storeFacets.topics || [],
(lower) => storeFilters.topics.has(lower),
"toggle-topic",
),
renderFilterGroup(
"Language",
"languages",
storeFacets.languages || [],
(_, raw) => storeFilters.language === raw,
"set-language",
),
renderFilterGroup(
"Author",
"owners",
storeFacets.owners || [],
(_, raw) => storeFilters.owner.toLowerCase() === raw.toLowerCase(),
"set-owner",
),
renderFilterGroup(
"License",
"licenses",
storeFacets.licenses || [],
(_, raw) => storeFilters.license === raw,
"set-license",
),
].join("");
wrap.innerHTML = html;

const clear = $("#clear-filters");
if (clear) clear.hidden = activeFilterCount() === 0;
}

function renderResultCount(total) {
const el = $("#store-result-count");
if (!el) return;
if (total == null) { el.textContent = ""; return; }
el.textContent = `${total} result${total === 1 ? "" : "s"}`;
}

// --- Page loaders ---

let currentCategory = "all";
let currentSearch = "";
const loaded = {};

const loaders = {
Expand All @@ -578,17 +761,23 @@ const loaders = {
async store() {
const grid = $("#store-grid");
html(grid, '<div class="loading">Loading packages from GitHub</div>');
renderActiveFilters();
renderResultCount(null);
await refreshInstalled();
const params = new URLSearchParams({ category: currentCategory });
if (currentSearch) params.set("q", currentSearch);
const data = await api(`/github/browse?${params}`);
const data = await api(buildStoreUrl());
if (data?.error) {
storeFacets = { topics: [], languages: [], owners: [], licenses: [] };
renderFilterRail();
html(grid, renderError(data.error));
return;
}
const items = data?.items || [];
storeFacets = data?.facets || { topics: [], languages: [], owners: [], licenses: [] };
renderFilterRail();
renderActiveFilters();
renderResultCount(typeof data?.total === "number" ? data.total : items.length);
if (items.length === 0) {
html(grid, renderEmpty("No packages found. Try a different search or category."));
html(grid, renderEmpty("No packages match these filters. Try removing one or clearing all."));
} else {
html(grid, items.map(renderStoreCard).join(""));
}
Expand Down Expand Up @@ -841,6 +1030,23 @@ document.addEventListener("click", (e) => {
else if (action === "run-lane") runLane(btn.dataset.name, btn);
else if (action === "open-repo") openRepo();
else if (action === "close-output") $("#run-output-card")?.classList.add("hidden");
else if (action === "toggle-topic") toggleTopic(btn.dataset.value);
else if (action === "set-language") setSingleFilter("language", btn.dataset.value);
else if (action === "set-owner") setSingleFilter("owner", btn.dataset.value);
else if (action === "set-license") setSingleFilter("license", btn.dataset.value);
else if (action === "clear-q") {
storeFilters.q = "";
const search = $("#store-search");
if (search) search.value = "";
loaders.store();
}
else if (action === "toggle-facet") {
const key = btn.dataset.facet;
if (key && key in facetExpanded) {
facetExpanded[key] = !facetExpanded[key];
renderFilterRail();
}
}
});

// Nav links
Expand All @@ -856,7 +1062,7 @@ $$(".category-tabs .tab").forEach((tab) => {
tab.addEventListener("click", () => {
$$(".category-tabs .tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
currentCategory = tab.dataset.category;
storeFilters.category = tab.dataset.category;
loaders.store();
});
});
Expand All @@ -871,19 +1077,35 @@ $$(".section-tab").forEach((tab) => {
});
});

// Store search
$("#store-search-btn").addEventListener("click", () => {
currentSearch = $("#store-search").value.trim();
// Store search — debounced live filter; Enter forces an immediate run.
let searchDebounce = null;

function commitStoreSearch() {
const next = $("#store-search").value.trim();
if (next === storeFilters.q) return;
storeFilters.q = next;
loaders.store();
}

$("#store-search").addEventListener("input", () => {
if (searchDebounce) clearTimeout(searchDebounce);
searchDebounce = setTimeout(commitStoreSearch, 300);
});

$("#store-search").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
currentSearch = $("#store-search").value.trim();
loaders.store();
e.preventDefault();
if (searchDebounce) clearTimeout(searchDebounce);
commitStoreSearch();
} else if (e.key === "Escape" && $("#store-search").value) {
$("#store-search").value = "";
if (searchDebounce) clearTimeout(searchDebounce);
commitStoreSearch();
}
});

$("#clear-filters")?.addEventListener("click", () => clearStoreFilters());

// Modal close
$(".modal-backdrop")?.addEventListener("click", () => {
$("#readme-modal")?.classList.add("hidden");
Expand Down
22 changes: 18 additions & 4 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,33 @@
<header class="page-head">
<div class="page-kicker">Store</div>
<h1 class="page-title">Browse Packages</h1>
<p class="page-lede">Discover plugins and templates from the fledge ecosystem.</p>
<p class="page-lede">Discover plugins and templates from the fledge ecosystem. Filter by topic, language, or author.</p>
</header>
<div class="toolbar">
<input type="text" id="store-search" placeholder="Search plugins and templates..." class="search-input">
<button id="store-search-btn" class="btn">Search</button>
</div>
<div class="category-tabs">
<button class="tab active" data-category="all">All</button>
<button class="tab" data-category="plugins">Plugins</button>
<button class="tab" data-category="templates">Templates</button>
</div>
<div id="store-grid" class="store-grid">
<div class="loading">Loading packages from GitHub</div>
<div class="store-layout">
<aside class="filter-rail" id="filter-rail">
<div class="filter-rail-header">
<span class="filter-rail-title">Filters</span>
<button class="btn-link" id="clear-filters" hidden>Clear all</button>
</div>
<div id="filter-groups"></div>
</aside>
<div class="store-results">
<div class="store-results-header">
<div id="store-active-filters" class="active-filters"></div>
<div class="store-result-count" id="store-result-count"></div>
</div>
<div id="store-grid" class="store-grid">
<div class="loading">Loading packages from GitHub</div>
</div>
</div>
</div>
</div>

Expand Down
Loading
Loading