diff --git a/.eleventy.js b/.eleventy.js index 88c2d5251..4aaff3896 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -40,6 +40,10 @@ module.exports = function (eleventyConfig) { // eslint-disable-next-line no-unused-vars const live_packages = Object.entries(data.packages).map(([id, pkg]) => pkg).filter(pkg => !pkg.removed); + // Load libraries data + const librariesData = JSON.parse(fs.readFileSync("libraries.json", "utf8")); + const libraries = librariesData.libraries || []; + eleventyConfig.addCollection("packages", () => { return live_packages.map(pkg => ({ ...pkg, @@ -72,6 +76,27 @@ module.exports = function (eleventyConfig) { }).slice(0,9); }); + // Add libraries collection + eleventyConfig.addCollection("libraries", () => { + return libraries.map(lib => { + // Extract unique Python versions from all releases + const pythonVersions = [...new Set( + (lib.releases || []) + .flatMap(release => release.python_versions || []) + )].sort(); + + return { + name: lib.name, + description: lib.description || '', + author: lib.author || '', + issues: lib.issues || '', + releases: lib.releases || [], + python_versions: pythonVersions, + permalink: `/library/${lib.name}/` + }; + }).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + }); + // simple to date string for some dates without times eleventyConfig.addFilter("date_format", (date) => { if (typeof date !== "string" ) return date; diff --git a/.gitignore b/.gitignore index b00004829..2afeca44d 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ _site/* # Database # ###################### workspace.json +libraries.json diff --git a/Makefile b/Makefile index a0fc8c115..d0ada1264 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,19 @@ build: npm install curl -o workspace.json -L "https://github.com/packagecontrol/thecrawl/releases/download/crawler-status/workspace.json" + curl -o libraries.json -L "https://raw.githubusercontent.com/packagecontrol/channel/refs/heads/main/repository.json" npx @11ty/eleventy +fetch: + curl -o workspace.json -L "https://github.com/packagecontrol/thecrawl/releases/download/crawler-status/workspace.json" + curl -o libraries.json -L "https://raw.githubusercontent.com/packagecontrol/channel/refs/heads/main/repository.json" + lint: npx eslint clean: rm -rf _site/* + rm -f libraries.json serve: open http://localhost:8080/ diff --git a/_includes/header.njk b/_includes/header.njk index eeea09131..078bbdc2c 100644 --- a/_includes/header.njk +++ b/_includes/header.njk @@ -2,6 +2,7 @@

Package Control R

diff --git a/libraries/index.njk b/libraries/index.njk new file mode 100644 index 000000000..2a5772d16 --- /dev/null +++ b/libraries/index.njk @@ -0,0 +1,58 @@ +--- +layout: layout.njk +permalink: "/libraries/" +date: Last Modified +--- + +{% import "libraries/macros.njk" as libs %} + +

+ + {{ collections.libraries | length }} + + Libraries +

+ +
+
+ + + +
+ + +
+
+
+ +
+

Results

+ +
+ +
+

All Libraries

+ +
+ +{% import "libraries/macros.njk" as libs %} + + + \ No newline at end of file diff --git a/libraries/library.njk b/libraries/library.njk new file mode 100644 index 000000000..23e5f1719 --- /dev/null +++ b/libraries/library.njk @@ -0,0 +1,60 @@ +--- +layout: layout.njk +permalink: "library/{{ lib.name | safe }}/" +pagination: + data: collections.libraries + size: 1 + alias: lib +eleventyComputed: + title: "{{ lib.name }}" + description: "{{ lib.description }}" +--- + +{% import "libraries/macros.njk" as libs %} + +

{{ lib.name }}

+

{{ lib.description }}

+ +

+ By {{ lib.author }} + {% if lib.python_versions and lib.python_versions | length > 0 %} +
+ Python versions: + {% for version in lib.python_versions %} + {{ version }}{{ ", " if not loop.last }} + {% endfor %} + {% endif %} +

+ +{% if lib.releases | length > 1 %} +

Releases

+{% else %} +

Release

+{% endif %} + +{% for release in lib.releases %} +

+ {% if release.base %} + {{ release.asset or 'Latest Release' }} + {% endif %} + {% if release.python_versions and release.python_versions | length > 0 %} +
+ Python: {{ release.python_versions | join(', ') }} + {% endif %} +

+{% endfor %} + +

Links

+ + \ No newline at end of file diff --git a/libraries/macros.njk b/libraries/macros.njk new file mode 100644 index 000000000..61150d9bf --- /dev/null +++ b/libraries/macros.njk @@ -0,0 +1,38 @@ +{% macro card(lib) %} +
+

+ + {{ lib.name }} + +

+

{{ lib.description }}

+
+ by {{ lib.author }} +
+ {% if lib.issues %} + {{ logo('bug') }} Issues + {% endif %} + {% if lib.python_versions %} +
+ {% for version in lib.python_versions %} + py{{ version }} + {% endfor %} +
+ {% endif %} +
+
+
+{% endmacro %} + +{% macro logo(which) %} + {# https://primer.style/octicons #} + {% if which == 'library' %} + + {% elif which == 'home' %} + + {% elif which == 'bug' %} + + {% elif which == 'repo' %} + + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/libraries/searchindex.json.njk b/libraries/searchindex.json.njk new file mode 100644 index 000000000..8a076a710 --- /dev/null +++ b/libraries/searchindex.json.njk @@ -0,0 +1,15 @@ +--- +permalink: "/libraries/searchindex.json" +--- +[ +{% for lib in collections.libraries -%} + { + "name": "{{ lib.name }}", + "description": "{{ lib.description | nl2br | replace('\n', '') }}", + "author": "{{ lib.author }}", + "issues": "{{ lib.issues }}", + "python_versions": {{ lib.python_versions | dump | safe }}, + "permalink": "/library/{{ lib.name | urlencode }}/" + }{{ "," if not loop.last }} +{%- endfor %} +] \ No newline at end of file diff --git a/static/index.js b/static/index.js index 8dc1e8731..1867d92bf 100644 --- a/static/index.js +++ b/static/index.js @@ -1,20 +1,36 @@ -import { Data } from './module/data.js'; -import { List } from './module/list.js'; -import { Search } from './module/search.js'; +import { AppFactory } from './module/app-factory.js'; import { Sort } from './module/sort.js'; -const data = await new Data().get(); -const list = new List(); +// Detect app type based on current path +const isLibrariesPage = window.location.pathname.startsWith('/libraries'); +const appType = isLibrariesPage ? 'libraries' : 'packages'; -function goSearch(value, sortBy = 'relevance', page = 1) { - const srch = new Search(value, data); +// Create app components using factory +const app = await AppFactory.createApp(appType); +const { data, list, search: createSearch, defaultSort } = app; + +// Update counter for static pages +const counter = document.querySelector('.counter, h1 .counter'); +if (counter) { + counter.textContent = data.length; + counter.setAttribute('data-all', data.length); +} + +// Render initial data for libraries (packages handle this differently) +if (isLibrariesPage) { + const initialSorted = Sort.sort(data, 'name'); + list.renderAll(initialSorted); +} + +function goSearch(value, sortBy = defaultSort, page = 1) { + const srch = createSearch(value, data); // Update URL with search query, sort parameter, and page const params = new URLSearchParams(); if (value.length > 0) { params.set('q', value); } - if (sortBy !== 'relevance') { + if (sortBy !== defaultSort) { params.set('sort', sortBy); } if (page > 1) { @@ -22,7 +38,8 @@ function goSearch(value, sortBy = 'relevance', page = 1) { } const queryString = params.toString(); - const newUrl = queryString ? '?' + queryString : '/'; + const basePath = isLibrariesPage ? '/libraries/' : '/'; + const newUrl = queryString ? basePath + '?' + queryString : basePath; // Only push state if URL is actually changing if (window.location.search !== (queryString ? '?' + queryString : '')) { @@ -33,8 +50,14 @@ function goSearch(value, sortBy = 'relevance', page = 1) { list.clear(); if (value.length < 1) { - // no search query - revert to static homepage + // no search query - revert to homepage list.revertToNormal(); + + // Re-render sorted data for libraries (packages handle this differently) + if (isLibrariesPage) { + const sortedData = Sort.sort(data, sortBy); + list.renderAll(sortedData); + } return } @@ -70,7 +93,7 @@ input.value = query; // Only show search results if there's a query or explicit sort parameter if (query || sortBy || urlParams.has('page')) { - const effectiveSortBy = sortBy ?? 'relevance'; + const effectiveSortBy = sortBy ?? defaultSort; sortSelect.value = effectiveSortBy; goSearch(query.toLowerCase(), effectiveSortBy, page); } @@ -80,9 +103,17 @@ const handleInput = () => { if (query === '') { list.revertToNormal(); + + // Re-render sorted data for libraries + if (isLibrariesPage) { + const sortedData = Sort.sort(data, sortSelect.value); + list.renderAll(sortedData); + } + // Update URL to remove search parameters - if (window.location.pathname !== '/' || window.location.search !== '') { - history.pushState({}, '', '/'); + const basePath = isLibrariesPage ? '/libraries/' : '/'; + if (window.location.pathname !== basePath || window.location.search !== '') { + history.pushState({}, '', basePath); } } else { goSearch(query, sortSelect.value); @@ -127,10 +158,16 @@ window.addEventListener('popstate', () => { // Handle navigation if (query || sortBy || urlParams.has('page')) { - const effectiveSortBy = sortBy || (query ? 'relevance' : 'name'); + const effectiveSortBy = sortBy || (query ? defaultSort : defaultSort); sortSelect.value = effectiveSortBy; goSearch(query, effectiveSortBy, page); } else { list.revertToNormal(); + + // Re-render sorted data for libraries + if (isLibrariesPage) { + const sortedData = Sort.sort(data, sortSelect.value || defaultSort); + list.renderAll(sortedData); + } } }); diff --git a/static/module/app-factory.js b/static/module/app-factory.js new file mode 100644 index 000000000..a04b5017b --- /dev/null +++ b/static/module/app-factory.js @@ -0,0 +1,47 @@ +import { PackageData, LibraryData } from './base-data.js'; +import { PackageList, LibraryList } from './base-list.js'; +import { PackageSearch, LibrarySearch } from './base-search.js'; +import { PackageCard, LibraryCard } from './base-card.js'; + +export class AppFactory { + static create(type) { + switch (type) { + case 'packages': + return { + dataProvider: new PackageData(), + cardRenderer: new PackageCard(), + get listManager() { + return new PackageList(this.cardRenderer); + }, + searchProvider: (value, data) => new PackageSearch(value, data), + defaultSort: 'relevance' + }; + + case 'libraries': + return { + dataProvider: new LibraryData(), + cardRenderer: new LibraryCard(), + get listManager() { + return new LibraryList(this.cardRenderer); + }, + searchProvider: (value, data) => new LibrarySearch(value, data), + defaultSort: 'name' + }; + + default: + throw new Error(`Unsupported app type: ${type}`); + } + } + + static async createApp(type) { + const components = this.create(type); + const data = await components.dataProvider.get(); + + return { + data, + list: components.listManager, + search: components.searchProvider, + defaultSort: components.defaultSort + }; + } +} \ No newline at end of file diff --git a/static/module/base-card.js b/static/module/base-card.js new file mode 100644 index 000000000..03ff3488a --- /dev/null +++ b/static/module/base-card.js @@ -0,0 +1,173 @@ +export class BaseCard { + constructor(config = {}) { + this.config = { + templateId: null, + approach: 'template', // 'template' or 'string' + ...config + }; + } + + render(data) { + switch (this.config.approach) { + case 'template': + return this.renderTemplate(data); + case 'string': + return this.renderString(data); + default: + throw new Error(`Unsupported rendering approach: ${this.config.approach}`); + } + } + + renderTemplate(data) { + const template = document.querySelector(`template#${this.config.templateId}`); + if (!template) { + console.error(`Template not found: ${this.config.templateId}`); + return ''; + } + + const clone = template.content.cloneNode(true); + this.populateTemplate(clone, data); + return clone; + } + + renderString(data) { + const template = document.getElementById(this.config.templateId); + if (!template) { + console.error(`Template not found: ${this.config.templateId}`); + return ''; + } + + let html = template.innerHTML; + return this.populateString(html, data); + } + + populateTemplate(clone, data) { + // Override in subclasses + throw new Error('populateTemplate must be implemented by subclass'); + } + + populateString(html, data) { + // Override in subclasses + throw new Error('populateString must be implemented by subclass'); + } + + escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + createButton(name, baseUrl = '/') { + const li = document.createElement('li'); + const a = document.createElement('a'); + + const isPlatform = ['linux','macos','windows'].includes(name); + + if (isPlatform) { + a.classList.add('button', 'platform', `platform-${name}`); + a.setAttribute('href', `${baseUrl}?q=${encodeURI(`platform:"${name}"`)}`); + } else { + a.classList.add('button', 'label'); + a.setAttribute('href', `${baseUrl}?q=${encodeURI(`label:"${name}"`)}`); + } + + a.innerText = name; + li.appendChild(a); + return li; + } +} + +// Package card implementation +export class PackageCard extends BaseCard { + constructor() { + super({ + templateId: 'package-card', + approach: 'template' + }); + } + + populateTemplate(clone, pkg) { + // Set basic info + clone.querySelector('a').innerHTML = pkg.name; + clone.querySelector('a').setAttribute('href', pkg.permalink); + clone.querySelector('p').innerHTML = 'by ' + pkg.author; + + // Handle stars + const dl = clone.querySelector('.stars')?.closest('dl'); + if (dl) { + if (pkg.stars && pkg.stars !== '0') { + dl.setAttribute('title', `${pkg.stars} ${pkg.stars < 2 ? 'star' : 'stars'} on GitHub`); + clone.querySelector('.stars').innerText = pkg.stars; + } else { + dl.remove(); + } + } + + // Handle platforms and labels + const labels = clone.querySelector('ul.labels'); + if (labels) { + this.addPlatforms(labels, pkg.platforms); + this.addLabels(labels, pkg.labels); + } + } + + addPlatforms(parent, platforms) { + if (!platforms || platforms.length < 1) return; + + platforms.split(',').forEach(item => { + parent.appendChild(this.createButton(item.trim())); + }); + } + + addLabels(parent, labels) { + if (!labels || labels.length < 1) return; + + labels.split(',').forEach(item => { + parent.appendChild(this.createButton(item.trim())); + }); + } + + render(pkg) { + return this.renderTemplate(pkg); + } +} + +// Library card implementation +export class LibraryCard extends BaseCard { + constructor() { + super({ + templateId: 'library-card', + approach: 'string' + }); + } + + populateString(html, library) { + let result = html + .replace(/placeholder-name/g, this.escapeHtml(library.name)) + .replace(/placeholder-description/g, this.escapeHtml(library.description)) + .replace(/placeholder-author/g, this.escapeHtml(library.author)) + .replace(/placeholder-issues/g, library.issues || '') + .replace(/placeholder-permalink/g, library.permalink); + + // Handle Python versions by replacing the entire python-versions div + if (library.python_versions && library.python_versions.length > 0) { + const pythonVersionsHtml = library.python_versions + .map(version => `py${this.escapeHtml(version)}`) + .join(''); + result = result.replace( + /
.*?<\/div>/s, + `
${pythonVersionsHtml}
` + ); + } else { + // Remove the python-versions div if no versions + result = result.replace(/
.*?<\/div>/s, ''); + } + + return result; + } + + render(library) { + return this.renderString(library); + } +} \ No newline at end of file diff --git a/static/module/base-data.js b/static/module/base-data.js new file mode 100644 index 000000000..74856bc3c --- /dev/null +++ b/static/module/base-data.js @@ -0,0 +1,48 @@ +export class BaseData { + constructor(config = {}) { + this.data = null; + this.config = { + endpoint: '/searchindex.json', + contentType: 'application/json', + ...config + }; + } + + async get() { + if (this.data) { + return this.data; + } + + try { + const response = await fetch(this.config.endpoint); + const contentType = response.headers.get("content-type") ?? ''; + + if (!response.ok || !contentType.includes(this.config.contentType)) { + throw new Error('bad response'); + } + + this.data = await response.json(); + return this.data; + } catch (error) { + console.error(`Error fetching data from ${this.config.endpoint}:`, error); + return []; + } + } +} + +// Specialized data classes +export class PackageData extends BaseData { + constructor() { + super({ + endpoint: '/packages/searchindex.json' + }); + } +} + +export class LibraryData extends BaseData { + constructor() { + super({ + endpoint: '/libraries/searchindex.json' + }); + } +} \ No newline at end of file diff --git a/static/module/base-list.js b/static/module/base-list.js new file mode 100644 index 000000000..91df7e939 --- /dev/null +++ b/static/module/base-list.js @@ -0,0 +1,194 @@ +import { Pagination } from './pagination.js'; + +export class BaseList { + constructor(config = {}) { + this.config = { + // Default configuration + selectors: { + resultSection: 'section[name="result"]', + resultList: 'section[name="result"] ul', + allSection: 'section[name="all"]', + allList: null, // Must be provided + counter: '.counter' + }, + itemsPerPage: 30, + itemName: 'items', // for pagination text + ...config + }; + + this.cardRenderer = config.cardRenderer; + this.pagination = new Pagination(); + } + + getSection(type = 'result') { + const selector = type === 'result' + ? this.config.selectors.resultSection + : this.config.selectors.allSection; + return document.querySelector(selector); + } + + getList(type = 'result') { + const selector = type === 'result' + ? this.config.selectors.resultList + : this.config.selectors.allList; + return document.querySelector(selector); + } + + setCounter(count = null) { + const counter = document.querySelector(this.config.selectors.counter); + if (counter) { + if (count !== null) { + counter.textContent = count; + } else { + const totalCount = counter.getAttribute('data-all') || '0'; + counter.textContent = totalCount; + } + } + } + + switchToResults() { + const resultSection = this.getSection('result'); + const allSection = this.getSection('all'); + + if (resultSection) resultSection.style.display = 'block'; + if (allSection) allSection.style.display = 'none'; + } + + revertToNormal() { + const resultSection = this.getSection('result'); + const allSection = this.getSection('all'); + + if (resultSection) resultSection.style.display = 'none'; + if (allSection) allSection.style.display = 'block'; + + this.setCounter(); + } + + clear() { + const resultsList = this.getList('result'); + const allList = this.getList('all'); + + if (resultsList) resultsList.innerHTML = ''; + if (allList) allList.innerHTML = ''; + } + + renderAll(items) { + const list = this.getList('all'); + if (!list) return; + + list.innerHTML = ''; + items.forEach(item => { + const li = document.createElement('li'); + li.innerHTML = this.cardRenderer.render(item); + list.appendChild(li); + }); + } + + renderPage(items, page = 1) { + const startIndex = (page - 1) * this.config.itemsPerPage; + const endIndex = startIndex + this.config.itemsPerPage; + const pageItems = items.slice(startIndex, endIndex); + + const list = this.getList('result'); + if (!list) return; + + list.innerHTML = ''; + pageItems.forEach(item => { + const li = document.createElement('li'); + li.innerHTML = this.cardRenderer.render(item); + list.appendChild(li); + }); + + // Add pagination if needed + const totalPages = Math.ceil(items.length / this.config.itemsPerPage); + if (totalPages > 1) { + const paginationHtml = this.pagination.get(page, totalPages, this.config.itemName); + list.insertAdjacentHTML('afterend', paginationHtml); + } + } +} + +// Specialized list classes +export class PackageList extends BaseList { + constructor(cardRenderer) { + super({ + selectors: { + resultSection: 'section[name="result"]', + resultList: 'section[name="result"] ul', + allSection: 'section[name="newest"], section[name="recent"]', + allList: null, // Packages use different approach + counter: 'h1 .counter' + }, + itemsPerPage: 24, + itemName: 'packages', + cardRenderer + }); + } + + // Override switchToResults to handle multiple sections + switchToResults() { + const resultSection = this.getSection('result'); + if (resultSection) resultSection.style.display = 'block'; + + // Hide all homepage sections (newest and recent) + document.querySelectorAll('section[name="newest"], section[name="recent"]').forEach(section => { + section.style.display = 'none'; + }); + } + + // Override revertToNormal to handle multiple sections + revertToNormal() { + const resultSection = this.getSection('result'); + if (resultSection) resultSection.style.display = 'none'; + + // Show all homepage sections (newest and recent) + document.querySelectorAll('section[name="newest"], section[name="recent"]').forEach(section => { + section.style.display = 'block'; + }); + + this.setCounter(); + } + + // Override for packages special behavior + clear() { + const section = this.getSection('result'); + if (section) { + section.querySelectorAll('li, .pagination').forEach(ui => ui.remove()); + } + } + + renderPage(items, page) { + this.clear(); + + const section = this.getSection('result'); + const list = section?.querySelector('ul'); + if (!list) return; + + const pagination = new Pagination(items, page, section); + + pagination.calculate().forEach(item => { + const li = document.createElement('li'); + li.appendChild(this.cardRenderer.render(item)); + list.appendChild(li); + }); + + pagination.render(); + } +} + +export class LibraryList extends BaseList { + constructor(cardRenderer) { + super({ + selectors: { + resultSection: 'section[name="result"]', + resultList: 'section[name="result"] ul.libraries-list', + allSection: 'section[name="all"]', + allList: '#libraries-list', + counter: '.counter' + }, + itemsPerPage: 30, + itemName: 'libraries', + cardRenderer + }); + } +} \ No newline at end of file diff --git a/static/module/base-search.js b/static/module/base-search.js new file mode 100644 index 000000000..16e748c57 --- /dev/null +++ b/static/module/base-search.js @@ -0,0 +1,110 @@ +import minisearch from 'https://cdn.jsdelivr.net/npm/minisearch@7.1.2/+esm' + +export class BaseSearch { + constructor(value, data, config = {}) { + this.value = value; + this.data = data; + this.config = { + // Default configuration + filters: { + author: { enabled: true, property: 'author' }, + ...config.filters + }, + searchFields: ['name', 'description'], + idField: 'name', + ...config + }; + } + + get() { + let base = this.data; + let value = this.value; + + // Apply configured filters + for (const [filterName, filterConfig] of Object.entries(this.config.filters)) { + if (!filterConfig.enabled) continue; + + const regex = new RegExp(`${filterName}:"([^"]+)"`, 'i'); + const match = value.match(regex); + + if (match) { + base = this.applyFilter(base, filterConfig, match[1]); + value = value.replace(match[0], ''); + } + } + + if (!value.trim()) { + return base; + } + + return this.performSearch(base, value.trim()); + } + + applyFilter(data, filterConfig, filterValue) { + const { property, type = 'includes' } = filterConfig; + + return data.filter(item => { + const fieldValue = item[property] || ''; + + switch (type) { + case 'includes': + return fieldValue.toLowerCase().includes(filterValue.toLowerCase()); + case 'array_includes': + return fieldValue.toLowerCase().split(',').includes(filterValue.toLowerCase()); + case 'array_contains': + return fieldValue.toLowerCase().split(',').some(val => + val.trim().includes(filterValue.toLowerCase()) + ); + default: + return fieldValue.toLowerCase().includes(filterValue.toLowerCase()); + } + }); + } + + performSearch(base, searchValue) { + const minisrch = new minisearch({ + idField: this.config.idField, + fields: this.config.searchFields, + searchOptions: { + boost: { [this.config.searchFields[0]]: 2 }, + fuzzy: 0.2, + prefix: true + } + }); + + minisrch.addAll(base); + const results = minisrch.search(searchValue); + const resultIds = results.map(r => r.id); + const scores = results.reduce((acc, r) => { + acc[r.id] = r.score; + return acc; + }, {}); + + return base.filter(item => resultIds.includes(item[this.config.idField])) + .sort((a, b) => scores[b[this.config.idField]] - scores[a[this.config.idField]]); + } +} + +// Specialized search classes +export class PackageSearch extends BaseSearch { + constructor(value, data) { + super(value, data, { + filters: { + author: { enabled: true, property: 'author' }, + label: { enabled: true, property: 'labels', type: 'array_contains' }, + platform: { enabled: true, property: 'platforms', type: 'array_contains' } + } + }); + } +} + +export class LibrarySearch extends BaseSearch { + constructor(value, data) { + super(value, data, { + filters: { + author: { enabled: true, property: 'author' }, + python: { enabled: true, property: 'python_versions', type: 'array_contains' } + } + }); + } +} \ No newline at end of file diff --git a/static/module/card.js b/static/module/card.js deleted file mode 100644 index 0a73d679c..000000000 --- a/static/module/card.js +++ /dev/null @@ -1,69 +0,0 @@ -export class Card { - pkg = {}; - clone; - - constructor (data) { - this.pkg = data; - - const template = document.querySelector("template#package-card"); - this.clone = template.content.cloneNode(true); - } - - render () { - this.clone.querySelector('a').innerHTML = this.pkg.name; - this.clone.querySelector('a').setAttribute('href', this.pkg.permalink); - this.clone.querySelector('p').innerHTML = 'by ' + this.pkg.author; - - const dl = this.clone.querySelector('.stars').closest('dl') - if (this.pkg.stars !== '0') { - dl.setAttribute('title', this.pkg.stars + (this.pkg.stars < 2 ? ' star' : ' stars') + ' on GitHub'); - this.clone.querySelector('.stars').innerText = this.pkg.stars; - } else { - dl.remove(); - } - - const labels = this.clone.querySelector('ul.labels'); - this.platforms(labels); - this.labels(labels); - - return this.clone; - } - - platforms (parent) { - if (this.pkg.platforms.length < 1) { - return - } - - this.pkg.platforms.split(',').forEach(item => { - parent.appendChild(this.button(item)); - }) - } - - labels (parent) { - if (this.pkg.labels.length < 1) { - return - } - - this.pkg.labels.split(',').forEach(item => { - parent.appendChild(this.button(item)); - }) - } - - button (name) { - const li = document.createElement('li'); - const a = document.createElement('a'); - - if (['linux','macos','windows'].indexOf(name) >= 0) { - a.classList.add('button', 'platform', 'platform-' + name); - a.setAttribute('href', '/?q=' + encodeURI('platform::"' + name + '"')); - } else { - a.classList.add('button', 'label'); - a.setAttribute('href', '/?q=' + encodeURI('label:"' + name + '"')); - } - - a.innerText = name; - li.appendChild(a); - - return li; - } -} diff --git a/static/module/data.js b/static/module/data.js deleted file mode 100644 index 151bf8780..000000000 --- a/static/module/data.js +++ /dev/null @@ -1,23 +0,0 @@ -export class Data { - data = null; - - async get() { - if (this.data) { - return this.data; - } - - try { - const response = await fetch('/packages/searchindex.json'); - const contentType = response.headers.get("content-type") ?? ''; - - if (!response.ok || !contentType.includes('application/json')) { - throw new Error('bad response'); - } - - this.data = response.json(); - return this.data; - } catch (error) { - console.error(error.message); - } - } -} diff --git a/static/module/list.js b/static/module/list.js deleted file mode 100644 index a47f7889e..000000000 --- a/static/module/list.js +++ /dev/null @@ -1,66 +0,0 @@ -import { Card } from './card.js'; -import { Pagination } from './pagination.js'; - -export class List { - // the section where we'll render search results - getSection() { - return document.querySelector('section[name="result"]'); - } - - // the list inside that section - getList() { - return this.getSection().querySelector('ul'); - } - - setCounter(count = null) { - const counter = document.querySelector('h1 .counter'); - counter.innerText = count ?? counter.dataset.all; - } - - // reveal search results and hide the static homepage - switchToResults() { - document.querySelectorAll('section').forEach(section => { - if (section.getAttribute('name') === 'result') { - section.style.display = null; - } else { - section.style.display = 'none'; - } - }); - } - - // revert to the static homepage - revertToNormal() { - document.querySelectorAll('section').forEach(section => { - if (section.getAttribute('name') === 'result') { - section.style.display = 'none'; - } else { - section.style.display = null; - } - }); - - this.setCounter(); - } - - // clear previous results and pagination ui - clear() { - this.getSection().querySelectorAll('li, .pagination').forEach(ui => { - ui.remove(); - }); - } - - // render the current page of results and pagination - renderPage(items, page) { - this.clear(); - - const pagination = new Pagination(items, page, this.getSection()); - - // Render items for current page - pagination.calculate().forEach(pkg => { - const li = document.createElement('li'); - li.appendChild((new Card(pkg)).render()); - this.getList().appendChild(li); - }); - - pagination.render(); - } -} diff --git a/static/module/pagination.js b/static/module/pagination.js index 462c8012f..d249dfa2f 100644 --- a/static/module/pagination.js +++ b/static/module/pagination.js @@ -1,10 +1,33 @@ export class Pagination { - constructor(items, page, parent) { + constructor(items, page, parent, itemName = 'items') { this.items = items; this.currentPage = page; this.totalPages = 0; this.itemsPerPage = 24; this.parent = parent; + this.itemName = itemName; + } + + // Static method for creating pagination HTML without needing instance + static createPaginationHtml(currentPage, totalPages, totalItems, itemName = 'items') { + if (totalPages < 2) return ''; + + const pagination = document.createElement('div'); + pagination.className = 'pagination'; + + const startItem = (currentPage - 1) * 24 + 1; + const endItem = Math.min(currentPage * 24, totalItems); + + const info = document.createElement('div'); + info.className = 'pagination-info'; + info.textContent = `Showing ${startItem}-${endItem} of ${totalItems} ${itemName}`; + pagination.appendChild(info); + + return pagination.outerHTML; + } + + get(page, totalPages, itemName = 'items') { + return Pagination.createPaginationHtml(page, totalPages, this.items?.length || 0, itemName); } // calculate pagination and result the items of the current page @@ -29,7 +52,8 @@ export class Pagination { const endItem = Math.min(this.currentPage * this.itemsPerPage, this.items.length); const info = document.createElement('div'); info.className = 'pagination-info'; - info.textContent = `Showing ${startItem}-${endItem} of ${this.items.length} packages`; + const itemName = this.itemName || 'items'; + info.textContent = `Showing ${startItem}-${endItem} of ${this.items.length} ${itemName}`; pagination.appendChild(info); // controls diff --git a/static/module/search.js b/static/module/search.js deleted file mode 100644 index 2c36400a3..000000000 --- a/static/module/search.js +++ /dev/null @@ -1,91 +0,0 @@ -import minisearch from 'https://cdn.jsdelivr.net/npm/minisearch@7.1.2/+esm' - -export class Search { - value = ''; - data = null; - - constructor(value, data) { - this.value = value; - this.data = data; - } - - get() { - let base = this.data; - let value = this.value; - - // handle author filter - const author = value.match(/author:"([^"]+)"/i); - if (author) { - base = base.filter( - pkg => pkg.author.toLowerCase().includes(author[1].toLowerCase()) - ); - - // remove this filter from the search string - value = value.replace(author[0], ''); - } - - // handle label filter - const label = value.match(/label:"([^"]+)"/i); - if (label) { - base = base.filter(pkg => { - return pkg.labels.toLowerCase().split(',').indexOf(label[1].toLowerCase()) > -1; - }); - - // remove this filter from the search string - value = value.replace(label[0], ''); - } - - // handle platform filter - const platform = value.match(/platform:"([^"]+)"/i); - if (platform) { - base = base.filter( - pkg => { - if (!pkg.platforms) { - return true; - } - return pkg.platforms.toLowerCase().split(',').indexOf(platform[1].toLowerCase()) > -1 - } - ); - - // remove this filter from the search string - value = value.replace(platform[0], ''); - } - - if (!value.trim()) { - // if after that filtering no search terms remain, just return what's left - return base; - } - - // use minisearch to find matches with some level of fuzziness - // we sloppily reset the index each search - // which is wasteful of cpu cycles I guess, but it seems fast enough :shrug: - // https://github.com/lucaong/minisearch - const minisrch = new minisearch({ - idField: 'name', - fields: ['name', 'description'], - searchOptions: { - boost: { name: 2 }, - fuzzy: 0.2, - prefix: true - } - }); - - // start with all data post label/author filtering - minisrch.addAll(base); - // search and then map results so we can easily use them for output - const miniresult = minisrch.search(value.trim()) - const minikeys = miniresult.map(mini => mini.id); - const scores = miniresult.reduce((acc, mini) => { - acc[mini.id] = mini.score; - return acc; - }, {}); - - // return matches sorted by their score according to minisearch - // where a high score means a better match - return base.filter( - pkg => minikeys.indexOf(pkg.name) > -1 - ).sort((a,b) => { - return scores[b.name] - scores[a.name]; - }); - } -} diff --git a/static/module/sort.js b/static/module/sort.js index 69e66e4fc..c14e85ba9 100644 --- a/static/module/sort.js +++ b/static/module/sort.js @@ -29,6 +29,8 @@ export class Sort { case 'author-desc': return sortedPackages.sort((a, b) => b.author.toLowerCase().localeCompare(a.author.toLowerCase())); + + case 'relevance': default: return sortedPackages; // Return as-is for relevance or default diff --git a/static/style/colors.css b/static/style/colors.css index 78aa42f75..c2e238a6b 100644 --- a/static/style/colors.css +++ b/static/style/colors.css @@ -9,22 +9,22 @@ --background: #fff; --background-slightly-darker: #fcfcfc; - --background-darker: #f2f2f2; + --background-darker: #f5f5f5; --background-darkest: #cacaca; --foreground: #586e75; --foreground-darker: #38464b; - --foreground-lighter: #839496; - --foreground-bright: #c1cfd4; + --foreground-lighter: #9AAFB5; + --foreground-bright: #C1CFD4; --label-highlight: #38464b; } @media (prefers-color-scheme: dark) { :root { - --background: #2b303b; - --background-slightly-darker: #383e4d; - --background-darker: #3f5157; + --background: #2B303B; + --background-slightly-darker: #383E4D; + --background-darker: #3F5157; --background-darkest: #586e75; --foreground: #839496; diff --git a/static/style/libraries.css b/static/style/libraries.css new file mode 100644 index 000000000..a9c223327 --- /dev/null +++ b/static/style/libraries.css @@ -0,0 +1,109 @@ +/* Libraries-specific styles */ +.libraries-list { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.libraries-list > li { + display: flex; + margin: 0; + padding: 0; + border-bottom: 1px solid var(--background-darker); +} + +.libraries-list > li:last-child { + border-bottom: none; +} + +.library-item { + width: 100%; + padding: 1.2rem 0; +} + +.library-item h3 { + margin: 0 0 0.5rem 0; + padding: 0; + font-size: 1.2rem; +} + +.library-item h3 a { + color: var(--foreground); + text-decoration: none; +} + +.library-item h3 a:hover { + color: var(--primary-accent-darker); + text-decoration: underline; +} + +.library-item .description { + margin: 0 0 0.5rem 0; + color: var(--foreground-lighter); + line-height: 1.4; + font-size: 1rem; + font-style: normal; +} + +.library-item .description::before, +.library-item .description::after { + content: none; +} + +.library-item .meta { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.9rem; + color: var(--foreground-lighter); +} + +.library-item .meta-second-line { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} + +.library-item .author { + font-style: italic; +} + +.library-item .issues-link { + color: var(--primary-accent-darker); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.85rem; +} + +.library-item .issues-link:hover { + text-decoration: underline; +} + +.library-item .issues-link svg { + width: 12px; + height: 12px; +} + +.library-item .python-versions { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-top: 4px; +} + +.library-item .py-version, +.py-version { + background: var(--secondary-accent); + color: var(--background); + padding: 2px 6px; + border-radius: 3px; + font-size: 0.75rem; + font-weight: 500; + display: inline-block; +} \ No newline at end of file diff --git a/static/styles.css b/static/styles.css index 4473d6bed..eaef0dfb6 100644 --- a/static/styles.css +++ b/static/styles.css @@ -6,6 +6,7 @@ @import url('style/grid.css'); @import url('style/pagination.css'); @import url('style/search.css'); +@import url('style/libraries.css'); html { height: 100%;