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
Binary file modified config/Migrations/schema-dump-default.lock
Binary file not shown.
2 changes: 2 additions & 0 deletions config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@
$routes->setRouteClass(DashedRoute::class);

$routes->scope('/', function (RouteBuilder $builder): void {
$builder->setExtensions(['json']);
/*
* Here, we are connecting '/' (base path) to a controller called 'Pages',
* its action called 'display', and we pass a param to select the view file
* to use (in this case, templates/Pages/home.php)...
*/
$builder->connect('/', ['controller' => 'Packages', 'action' => 'index']);
$builder->connect('/autocomplete', ['controller' => 'Packages', 'action' => 'autocomplete']);
$builder->connect('/requirements', ['controller' => 'Pages', 'action' => 'display', 'requirements']);

/*
Expand Down
27 changes: 26 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"build": "vite build"
},
"dependencies": {
"alpinejs": "^3.15.11",
"htmx.org": "^2.0.8",
"slim-select": "^3.4.3",
"swiper": "^12.1.3"
Expand Down
4 changes: 4 additions & 0 deletions resources/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
--color-cake-blue: #2F85AE;
}

[x-cloak] {
display: none !important;
}

/* Styling for default app homepage */

.bullet:before {
Expand Down
129 changes: 129 additions & 0 deletions resources/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,136 @@ import Swiper from 'swiper'
import { Autoplay, Navigation, Pagination } from 'swiper/modules'
import htmx from 'htmx.org'
import SlimSelect from 'slim-select'
import Alpine from 'alpinejs'

window.htmx = htmx

Alpine.data('packageSearch', () => ({
query: '',
results: [],
open: false,
loading: false,
selectedIndex: -1,
abortController: null,
debounceTimer: null,

init() {
// Sync initial value from the input
this.query = this.$refs.input?.value || ''

this.$watch('query', (value) => {
this.debouncedFetch(value)
})
},

debouncedFetch(value) {
clearTimeout(this.debounceTimer)

if (this.abortController) {
this.abortController.abort()
this.abortController = null
}

if (value.trim().length < 2) {
this.results = []
this.open = false
this.loading = false
return
}

this.loading = true

this.debounceTimer = setTimeout(() => {
this.fetchResults(value.trim())
}, 250)
},

async fetchResults(q) {
if (this.abortController) {
this.abortController.abort()
}

this.abortController = new AbortController()

try {
const response = await fetch(`/autocomplete?q=${encodeURIComponent(q)}`, {
signal: this.abortController.signal,
headers: { 'Accept': 'application/json' },
})

const data = await response.json()
this.results = data
this.open = data.length > 0
this.selectedIndex = -1
} catch (e) {
if (e.name !== 'AbortError') {
this.results = []
this.open = false
}
} finally {
this.loading = false
}
},

close() {
this.open = false
this.selectedIndex = -1
},

onKeydown(e) {
if (!this.open) return

switch (e.key) {
case 'ArrowDown':
e.preventDefault()
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1)
this.scrollToSelected()
break
case 'ArrowUp':
e.preventDefault()
this.selectedIndex = Math.max(this.selectedIndex - 1, -1)
this.scrollToSelected()
break
case 'Enter':
if (this.selectedIndex >= 0) {
e.preventDefault()
this.selectResult(this.results[this.selectedIndex])
}
break
case 'Escape':
this.close()
break
}
},

scrollToSelected() {
this.$nextTick(() => {
const el = this.$refs.listbox?.querySelector('[aria-selected="true"]')
el?.scrollIntoView({ block: 'nearest' })
})
},

selectResult(result) {
try {
const url = new URL(result.repo_url)
if (url.protocol === 'https:') {
window.open(url.toString(), '_blank', 'noopener,noreferrer')
}
} catch {
// Invalid URL, ignore
}
this.close()
},



formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'
if (num >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
return String(num)
},
}))

const initializeSelects = (root = document) => {
const selects = document.querySelectorAll('select')

Expand Down Expand Up @@ -78,6 +205,8 @@ document.addEventListener('DOMContentLoaded', () => {
initializeSelects()
})

Alpine.start()

if (typeof window.htmx !== 'undefined') {
const reinitializeDynamicUi = () => {
initializeFeaturedPackagesSlider(document)
Expand Down
50 changes: 49 additions & 1 deletion src/Controller/PackagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace App\Controller;

use Cake\Core\Configure;
use Cake\Http\Response;
use Cake\ORM\Query\SelectQuery;

/**
Expand All @@ -20,7 +21,7 @@ public function initialize(): void
{
parent::initialize();

$this->Authentication->allowUnauthenticated(['index']);
$this->Authentication->allowUnauthenticated(['index', 'autocomplete']);
}

/**
Expand Down Expand Up @@ -98,6 +99,53 @@ public function index()
$this->set(compact('featuredPackages', 'packages', 'cakephpTags', 'phpTags'));
}

/**
* Autocomplete endpoint for package search.
*
* @return \Cake\Http\Response
*/
public function autocomplete(): Response
{
$q = trim((string)$this->request->getQuery('q'));
if (mb_strlen($q) < 2) {
return $this->response
->withType('application/json')
->withStringBody(json_encode([], JSON_THROW_ON_ERROR));
}

$packages = $this->Packages
->find('autocomplete', search: $q)
->all();

$results = [];
foreach ($packages as $package) {
$cakeVersions = [];
foreach ($package->cake_php_tag_groups as $major => $tags) {
$cakeVersions[] = $major . '.x';
}

$phpVersions = [];
foreach ($package->php_tag_groups as $major => $tags) {
$phpVersions[] = $major . '.x';
}

$results[] = [
'package' => $package->package,
'description' => $package->description,
'repo_url' => $package->repo_url,
'downloads' => $package->downloads,
'stars' => $package->stars,
'latest_version' => $package->latest_stable_version,
'cakephp_versions' => $cakeVersions,
'php_versions' => $phpVersions,
];
}

return $this->response
->withType('application/json')
->withStringBody(json_encode($results, JSON_THROW_ON_ERROR));
}

/**
* @param mixed $value
* @return bool
Expand Down
30 changes: 30 additions & 0 deletions src/Model/Table/PackagesTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace App\Model\Table;

use App\Model\Filter\PackagesCollection;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\Table;
use Cake\Validation\Validator;

Expand Down Expand Up @@ -93,4 +94,33 @@ public function validationDefault(Validator $validator): Validator

return $validator;
}

/**
* Finder for autocomplete search results.
*
* @param \Cake\ORM\Query\SelectQuery $query Query instance.
* @param string $search Search term.
* @param int $maxResults Maximum number of results.
* @return \Cake\ORM\Query\SelectQuery
*/
public function findAutocomplete(SelectQuery $query, string $search, int $maxResults = 8): SelectQuery
{
$escapedSearch = str_replace(['%', '_'], ['\%', '\_'], $search);

return $query
->find('search', search: ['search' => $search])
->contain(['Tags' => function (SelectQuery $q) {
return $q->orderByDesc('Tags.label');
}])
->selectAlso([
'name_match' => $query->expr()
->case()
->when(['Packages.package LIKE' => '%' . $escapedSearch . '%'])
->then(1, 'integer')
->else(0, 'integer'),
])
->orderByDesc('name_match')
->orderByDesc('Packages.downloads')
->limit($maxResults);
}
}
1 change: 1 addition & 0 deletions src/View/AppView.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,6 @@ public function initialize(): void
$this->loadHelper('Form', ['templates' => 'form-templates']);
$this->loadHelper('Html', ['templates' => 'html-templates']);
$this->loadHelper('Authentication.Identity');
$this->loadHelper('User');
}
}
Loading
Loading