mirror of
https://github.com/mustbeperfect/definitive-opensource.git
synced 2026-08-24 10:13:18 -05:00
Added initial version of web viewer.
This commit is contained in:
+3
-1
@@ -1 +1,3 @@
|
||||
The future web client for definitive-opensource. This will use a Nuxt (Vue) + Typescript frontend with a Go backend.
|
||||
I'm so tired of choosing between Bun, Deno, or Node (and then npm, pnpm, or yarn) and then deciding between Svelte, Vue, or React. And then the quintillion dependencies and blablabla. WHY IS WEB DEVELOPMENT SO BLOATED.
|
||||
|
||||
Anyways the definitive-opensource website is pure javascript with minimal styling ;).
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>definitive-opensource</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="controls">
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
placeholder="Search by name or description..."
|
||||
/>
|
||||
|
||||
<select id="categoryFilter">
|
||||
<option value="">All Categories</option>
|
||||
</select>
|
||||
|
||||
<select id="subcategoryFilter">
|
||||
<option value="">All Subcategories</option>
|
||||
</select>
|
||||
|
||||
<select id="platformFilter">
|
||||
<option value="">All Platforms</option>
|
||||
</select>
|
||||
|
||||
<select id="tagFilter">
|
||||
<option value="">All Tags</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="appContainer">Loading data...</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,216 @@
|
||||
const endpoints = {
|
||||
applications:
|
||||
"https://raw.githubusercontent.com/mustbeperfect/definitive-opensource/main/core/data/dynamic/applications.json",
|
||||
categories:
|
||||
"https://raw.githubusercontent.com/mustbeperfect/definitive-opensource/main/core/data/static/categories.json",
|
||||
platforms:
|
||||
"https://raw.githubusercontent.com/mustbeperfect/definitive-opensource/main/core/data/static/platforms.json",
|
||||
tags: "https://raw.githubusercontent.com/mustbeperfect/definitive-opensource/main/core/data/static/tags.json",
|
||||
};
|
||||
|
||||
let db = {
|
||||
apps: [],
|
||||
categories: [],
|
||||
subcategories: [],
|
||||
platforms: [],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const appContainer = document.getElementById("appContainer");
|
||||
const searchInput = document.getElementById("search");
|
||||
const categoryFilter = document.getElementById("categoryFilter");
|
||||
const subcategoryFilter = document.getElementById("subcategoryFilter");
|
||||
const platformFilter = document.getElementById("platformFilter");
|
||||
const tagFilter = document.getElementById("tagFilter");
|
||||
|
||||
// UTILS
|
||||
// 1. Sanitize HTML to prevent XSS
|
||||
const escapeHTML = (str) => {
|
||||
if (!str) return "";
|
||||
return String(str).replace(
|
||||
/[&<>'"]/g,
|
||||
(tag) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})[tag],
|
||||
);
|
||||
};
|
||||
|
||||
// 2. Debounce function to limit rapid-fire function calls
|
||||
const debounce = (func, delay) => {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const [appRes, catRes, platRes, tagRes] = await Promise.all([
|
||||
fetch(endpoints.applications),
|
||||
fetch(endpoints.categories),
|
||||
fetch(endpoints.platforms),
|
||||
fetch(endpoints.tags),
|
||||
]);
|
||||
|
||||
const [appData, catData, platData, tagData] = await Promise.all([
|
||||
appRes.json(),
|
||||
catRes.json(),
|
||||
platRes.json(),
|
||||
tagRes.json(),
|
||||
]);
|
||||
|
||||
db.apps = appData.applications || [];
|
||||
db.categories = catData.categories || [];
|
||||
db.subcategories = catData.subcategories || [];
|
||||
db.platforms = platData.platforms || [];
|
||||
db.tags = tagData.properties || [];
|
||||
|
||||
populateFilters();
|
||||
renderApps();
|
||||
setupEventListeners();
|
||||
} catch (error) {
|
||||
appContainer.innerHTML = `<strong>Error loading data:</strong> ${escapeHTML(error.message)}`;
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function populateFilters() {
|
||||
// Use map and join to avoid DOM thrashing (innerHTML += in a loop)
|
||||
const catHTML = db.categories
|
||||
.map(
|
||||
(cat) =>
|
||||
`<option value="${escapeHTML(cat.id)}">${escapeHTML(cat.name)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
categoryFilter.insertAdjacentHTML("beforeend", catHTML);
|
||||
|
||||
const platHTML = db.platforms
|
||||
.map(
|
||||
(plat) =>
|
||||
`<option value="${escapeHTML(plat.id)}">${escapeHTML(plat.name)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
platformFilter.insertAdjacentHTML("beforeend", platHTML);
|
||||
|
||||
const tagHTML = db.tags
|
||||
.map(
|
||||
(tag) =>
|
||||
`<option value="${escapeHTML(tag.id)}">${escapeHTML(tag.name)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
tagFilter.insertAdjacentHTML("beforeend", tagHTML);
|
||||
|
||||
updateSubcategoryDropdown();
|
||||
}
|
||||
|
||||
function updateSubcategoryDropdown() {
|
||||
const selectedCategory = categoryFilter.value;
|
||||
|
||||
const filteredSubcategories =
|
||||
selectedCategory === ""
|
||||
? db.subcategories
|
||||
: db.subcategories.filter((sub) => sub.parent === selectedCategory);
|
||||
|
||||
// Rebuild options efficiently
|
||||
const subHTML =
|
||||
'<option value="">All Subcategories</option>' +
|
||||
filteredSubcategories
|
||||
.map(
|
||||
(sub) =>
|
||||
`<option value="${escapeHTML(sub.id)}">${escapeHTML(sub.name)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
subcategoryFilter.innerHTML = subHTML;
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Add a 300ms debounce to the search input so it doesn't fire on every single keystroke
|
||||
searchInput.addEventListener("input", debounce(renderApps, 300));
|
||||
|
||||
categoryFilter.addEventListener("change", () => {
|
||||
updateSubcategoryDropdown();
|
||||
renderApps();
|
||||
});
|
||||
|
||||
subcategoryFilter.addEventListener("change", renderApps);
|
||||
platformFilter.addEventListener("change", renderApps);
|
||||
tagFilter.addEventListener("change", renderApps);
|
||||
}
|
||||
|
||||
function renderApps() {
|
||||
const searchTerm = searchInput.value.toLowerCase();
|
||||
const selectedCategory = categoryFilter.value;
|
||||
const selectedSubcategory = subcategoryFilter.value;
|
||||
const selectedPlatform = platformFilter.value;
|
||||
const selectedTag = tagFilter.value;
|
||||
|
||||
// PERFORMANCE: Pre-calculate valid subcategories OUTSIDE the loop
|
||||
let validSubcategoryIds = null;
|
||||
if (selectedCategory !== "") {
|
||||
validSubcategoryIds = db.subcategories
|
||||
.filter((sub) => sub.parent === selectedCategory)
|
||||
.map((sub) => sub.id);
|
||||
}
|
||||
|
||||
const filteredApps = db.apps.filter((app) => {
|
||||
const matchesSearch =
|
||||
app.name.toLowerCase().includes(searchTerm) ||
|
||||
app.description.toLowerCase().includes(searchTerm);
|
||||
|
||||
let matchesCategory = true;
|
||||
if (validSubcategoryIds) {
|
||||
matchesCategory = validSubcategoryIds.includes(app.category);
|
||||
}
|
||||
|
||||
const matchesSubcategory =
|
||||
selectedSubcategory === "" || app.category === selectedSubcategory;
|
||||
const matchesPlatform =
|
||||
selectedPlatform === "" || app.platforms.includes(selectedPlatform);
|
||||
const matchesTag = selectedTag === "" || app.tags.includes(selectedTag);
|
||||
|
||||
return (
|
||||
matchesSearch &&
|
||||
matchesCategory &&
|
||||
matchesSubcategory &&
|
||||
matchesPlatform &&
|
||||
matchesTag
|
||||
);
|
||||
});
|
||||
|
||||
if (filteredApps.length === 0) {
|
||||
appContainer.innerHTML =
|
||||
"<div>No applications match your current filters.</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
// Use map and join instead of appending elements in a loop to improve render time
|
||||
appContainer.innerHTML = filteredApps
|
||||
.map(
|
||||
(app) => `
|
||||
<div class="app-card">
|
||||
<div class="app-title">
|
||||
<a href="${escapeHTML(app.homepage_url)}" target="_blank" rel="noopener noreferrer">${escapeHTML(app.name)}</a>
|
||||
</div>
|
||||
<div class="app-property"><strong>Description:</strong> ${escapeHTML(app.description)}</div>
|
||||
<div class="app-property"><strong>Category:</strong> ${escapeHTML(app.category)}</div>
|
||||
<div class="app-property"><strong>Repository:</strong> <a href="${escapeHTML(app.repo_url)}" target="_blank">${escapeHTML(app.repo_url)}</a></div>
|
||||
<div class="app-property"><strong>Language:</strong> ${escapeHTML(app.language)}</div>
|
||||
<div class="app-property"><strong>Stars:</strong> ${escapeHTML(app.stars)}</div>
|
||||
<div class="app-property"><strong>License:</strong> ${escapeHTML(app.license)}</div>
|
||||
<div class="app-property"><strong>Last Commit:</strong> ${escapeHTML(app.last_commit)}</div>
|
||||
<div class="app-property"><strong>Platforms:</strong> ${escapeHTML(app.platforms.join(", "))}</div>
|
||||
<div class="app-property"><strong>Tags:</strong> ${escapeHTML(app.tags.join(", "))}</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,31 @@
|
||||
body {
|
||||
font-family: monospace, sans-serif;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
border: 1px solid #ccc;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.app-property {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
Reference in New Issue
Block a user