Skip to content

Instantly share code, notes, and snippets.

@AnandPilania
Last active January 31, 2025 09:18
Show Gist options
  • Save AnandPilania/b7ccdfcdc19a5decc2dc46631eb6f140 to your computer and use it in GitHub Desktop.
Save AnandPilania/b7ccdfcdc19a5decc2dc46631eb6f140 to your computer and use it in GitHub Desktop.
Advanced Streaming Search with Dynamic Filtering in Laravel Blade
.filter-panel {
margin:1rem 0;
border-bottom:1px solid #eee;
padding-bottom:1rem;
}
.filter-controls {
display:flex;
gap:1rem;
margin-bottom:0.5rem;
}
.filter-select,.filter-button {
padding:0.5rem;
border:1px solid #ddd;
border-radius:4px;
}
.active-filters {
display:flex;
flex-wrap:wrap;
gap:0.5rem;
}
.active-filter {
background:#e0e0e0;
padding:0.25rem 0.5rem;
border-radius:4px;
display:flex;
align-items:center;
gap:0.5rem;
}
.active-filter button {
border:none;
background:none;
cursor:pointer;
padding:0;
font-size:1.2em;
}
.filter-modal {
display:none;
position:fixed;
top:0;
left:0;
right:0;
bottom:0;
background:rgba(0,0,0,0.5);
z-index:1000;
}
.filter-modal-content {
background:white;
margin:10% auto;
padding:2rem;
max-width:600px;
border-radius:8px;
max-height:80vh;
overflow-y:auto;
}
.filter-section {
margin-bottom:1.5rem;
}
.filter-options {
display:grid;
grid-template-columns:repeat(auto-fill,minmax(150px,1fr));
gap:0.5rem;
}
.filter-actions {
margin-top:1rem;
display:flex;
gap:1rem;
justify-content:flex-end;
}
.filter-actions button {
padding:0.5rem 1rem;
border:1px solid #ddd;
border-radius:4px;
cursor:pointer;
}
#apply-filters {
background:#007bff;
color:white;
border-color:#0056b3;
}
import './../css/streaming-search.css';
class StreamingSearch {
constructor(searchUrl) {
this.searchUrl = searchUrl;
this.abortController = null;
this.domSearchConfig = {
selectors: ["a", "[data-searchable]"],
searchableAttributes: [
"href",
"textContent",
"title",
"data-search-content",
],
};
this.groups = new Map();
this.allResults = new Map();
this.currentFilters = new Set();
this.resultsContainer = document.querySelector("#search-results");
this.setupFilterPanel();
}
setupFilterPanel() {
const filterPanel = document.createElement("div");
filterPanel.className = "filter-panel";
filterPanel.innerHTML = `
<div class="filter-controls">
<select id="filter-limit" class="filter-select">
<option value="10">10 per group</option>
<option value="25">25 per group</option>
<option value="50">50 per group</option>
<option value="100">100 per group</option>
</select>
<button id="show-filters" class="filter-button">Filters</button>
</div>
<div id="active-filters" class="active-filters"></div>
<div id="filter-modal" class="filter-modal">
<div class="filter-modal-content">
<div class="filter-sections"></div>
<div class="filter-actions">
<button id="apply-filters">Apply</button>
<button id="reset-filters">Reset</button>
</div>
</div>
</div>
`;
this.resultsContainer.parentNode.insertBefore(
filterPanel,
this.resultsContainer,
);
this.bindFilterEvents();
}
bindFilterEvents() {
const modal = document.getElementById("filter-modal");
const showFilters = document.getElementById("show-filters");
const applyFilters = document.getElementById("apply-filters");
const resetFilters = document.getElementById("reset-filters");
const limitSelect = document.getElementById("filter-limit");
showFilters.onclick = () => (modal.style.display = "block");
modal.onclick = (e) => {
if (e.target === modal) modal.style.display = "none";
};
applyFilters.onclick = () => {
this.applyFilters();
modal.style.display = "none";
};
resetFilters.onclick = () => {
this.resetFilters();
this.updateActiveFilters();
};
limitSelect.onchange = () => this.renderResults();
}
updateFilterPanel(results) {
const filterSections = document.querySelector(".filter-sections");
filterSections.innerHTML = "";
results.forEach((items, groupName) => {
const uniqueValues = this.extractFilterValues(items);
if (uniqueValues.size > 0) {
filterSections.appendChild(
this.createFilterSection(groupName, uniqueValues),
);
}
});
}
extractFilterValues(items) {
const values = new Set();
items.forEach((item) => {
Object.entries(item).forEach(([key, value]) => {
if (typeof value === "string" || typeof value === "number") {
values.add(`${key}:${value}`);
}
});
});
return values;
}
createFilterSection(groupName, values) {
const section = document.createElement("div");
section.className = "filter-section";
section.innerHTML = `
<h4>${this.formatGroupName(groupName)}</h4>
<div class="filter-options">
${Array.from(values)
.map(
(value) => `
<label>
<input type="checkbox" value="${value}"
${this.currentFilters.has(value) ? "checked" : ""}>
${value.split(":")[1]}
</label>
`,
)
.join("")}
</div>
`;
return section;
}
updateActiveFilters() {
const container = document.getElementById("active-filters");
container.innerHTML = Array.from(this.currentFilters)
.map(
(filter) => `
<span class="active-filter">
${filter.split(":")[1]}
<button data-filter="${filter}">&times;</button>
</span>
`,
)
.join("");
container.onclick = (e) => {
if (e.target.tagName === "BUTTON") {
this.currentFilters.delete(e.target.dataset.filter);
this.updateActiveFilters();
this.renderResults();
}
};
}
applyFilters() {
const checkboxes = document.querySelectorAll(
".filter-section input:checked",
);
this.currentFilters = new Set(Array.from(checkboxes).map((cb) => cb.value));
this.updateActiveFilters();
this.renderResults();
}
resetFilters() {
this.currentFilters.clear();
document
.querySelectorAll(".filter-section input")
.forEach((cb) => (cb.checked = false));
this.renderResults();
}
filterResults(items) {
if (this.currentFilters.size === 0) return items;
return items.filter((item) => {
return Array.from(this.currentFilters).some((filter) => {
const [key, value] = filter.split(":");
return item[key] === value || item[key]?.toString() === value;
});
});
}
async search(query) {
await Promise.all([this.searchAPI(query), this.searchDOM(query)]);
}
async searchAPI(query) {
if (this.abortController) {
this.abortController.abort();
}
this.clearResults();
this.abortController = new AbortController();
try {
const response = await fetch(
`${this.searchUrl}?query=${encodeURIComponent(query)}`,
{
signal: this.abortController.signal,
},
);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
try {
let data = JSON.parse(buffer);
this.handleResults(data);
buffer = "";
} catch (e) {
if (e instanceof SyntaxError) {
continue;
} else {
throw e;
}
}
}
} catch (error) {
if (error.name !== "AbortError") {
console.error("Search error:", error);
}
}
}
async searchDOM(query) {
let domResultsContainer = document.getElementById("dom-results");
if (!domResultsContainer) {
domResultsContainer = document.createElement("div");
domResultsContainer.id = "dom-results";
domResultsContainer.className = "results-container";
document
.querySelector("#search-results")
.appendChild(domResultsContainer);
}
domResultsContainer.innerHTML = "";
if (!query.trim()) {
return;
}
const elements = this.domSearchConfig.selectors
.map((selector) => Array.from(document.querySelectorAll(selector)))
.flat();
const worker = new Worker(
URL.createObjectURL(
new Blob(
[
`
onmessage = function(e) {
const { elements, query, searchableAttributes } = e.data;
const results = elements.map(element => {
for (const attr of searchableAttributes) {
const value = element[attr] || (element.attributes && element.attributes[attr]);
if (value && value.toLowerCase().includes(query.toLowerCase())) {
return {
text: element.textContent.trim(),
href: element.href || null,
matchedAttribute: attr,
matchedValue: value
};
}
}
return null;
}).filter(Boolean);
postMessage(results);
};
`,
],
{ type: "text/javascript" },
),
),
);
worker.onmessage = (e) => {
const results = e.data;
this.handleResults({ DOM: results });
worker.terminate();
};
worker.postMessage({
elements: elements.map((el) => ({
textContent: el.textContent,
href: el.href,
attributes: Object.fromEntries(
Array.from(el.attributes).map((attr) => [attr.name, attr.value]),
),
})),
query,
searchableAttributes: this.domSearchConfig.searchableAttributes,
});
}
handleResults(data) {
Object.entries(data).forEach(([groupName, items]) => {
if (Number.isInteger(+groupName)) {
groupName = items["type"];
items = items["data"];
}
if (!Array.isArray(items)) return;
if (!this.allResults.has(groupName)) {
this.allResults.set(groupName, []);
}
this.allResults.get(groupName).push(...items);
this.updateFilterPanel(this.allResults);
this.renderResults();
});
}
renderResults() {
this.clearDisplayedResults();
const limit = parseInt(document.getElementById("filter-limit").value);
this.allResults.forEach((items, groupName) => {
const filteredItems = this.filterResults(items);
const limitedItems = filteredItems.slice(0, limit);
if (limitedItems.length > 0) {
const groupElement = this.createGroupElement(
groupName,
filteredItems.length,
);
const ul = groupElement.querySelector("ul");
limitedItems.forEach((item) => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = item.href ?? "#";
a.textContent = this.getDisplayText(item);
li.appendChild(a);
ul.appendChild(li);
});
this.resultsContainer.appendChild(groupElement);
}
});
}
clearResults() {
this.resultsContainer.innerHTML = "";
this.allResults.clear();
this.currentFilters.clear();
this.updateActiveFilters();
}
clearDisplayedResults() {
this.resultsContainer.innerHTML = "";
}
createGroupElement(groupName, totalCount) {
const div = document.createElement("div");
div.className = "search-group";
div.innerHTML = `
<h3>
<span class="group-title">${this.formatGroupName(groupName)}</span>
<span class="group-count">(showing ${Math.min(parseInt(document.getElementById("filter-limit").value), totalCount)} of ${totalCount})</span>
</h3>
<ul></ul>
`;
return div;
}
formatGroupName(name) {
return name.charAt(0).toUpperCase() + name.slice(1);
}
getDisplayText(item) {
return (
item.name ||
item.title ||
item.display_name ||
item.text ||
JSON.stringify(item)
);
}
}
<head>
... @vite(['resources/js/streaming-search.js']) ...
</head>
<body>
...
<input type="text" id="search-input" placeholder="Search...">
<div id="search-results">
</div>
...
<script>
const search = new StreamingSearch("/api/search");
const searchInput = document.querySelector("#search-input");
let timeout = null;
searchInput.addEventListener("input", (e) = >{
clearTimeout(timeout);
timeout = setTimeout(() = >{
if (e.target.value.length >= 2) {
search.search(e.target.value);
}
},
300);
});
</script>
...
</body>
Route::view('/search', 'search');
// Sample search: users and webforms
Route::get('/api/search', function (Request $request) {
$request->validate([
'query' => 'required|min:2',
]);
$searchTerm = $request->input('query');
$streamHandler = static function (\Illuminate\Database\Eloquent\Builder $query, callable $resource, int $flushInterval = 100): Generator {
foreach ($query->cursor() as $index => $item) {
yield $resource($item)->resolve();
if (($index + 1) % $flushInterval === 0) {
flush();
}
}
flush();
};
$yieldUsers = fn ($searchTerm): Generator => $streamHandler(
\App\Models\User::query()
->where('name', 'LIKE', "%{$searchTerm}%")
->orWhere('email', 'LIKE', "%{$searchTerm}%"),
fn ($user) => new class($user) extends \Illuminate\Http\Resources\Json\JsonResource
{
public function toArray($request): array
{
return [
'name' => $this->name,
'email' => $this->email,
];
}
}
);
$yieldWebforms = fn ($searchTerm): Generator => $streamHandler(
\App\Models\Webform::query()
->where('webform_uid', 'LIKE', "%{$searchTerm}%")
->where('api_name', 'LIKE', "%{$searchTerm}%")
->orWhere('display_name', 'LIKE', "%{$searchTerm}%"),
fn ($webform) => new class($webform) extends \Illuminate\Http\Resources\Json\JsonResource
{
public function toArray($request): array
{
return [
'webform_uid' => $this->webform_uid,
'api_name' => $this->api_name,
'display_name' => $this->display_name,
];
}
}
);
$jsonStreamer = response()->streamJson([
'users' => $yieldUsers($searchTerm),
'webforms' => $yieldWebforms($searchTerm),
]);
return $jsonStreamer;
});
// OR use below core streamer instead of json streamer;
/*
$coreStreamer = response()->stream(function () use ($yieldUsers, $yieldWebforms, $searchTerm) {
if (ob_get_level()) {
ob_end_clean();
}
echo '[';
$first = true;
foreach ($yieldUsers($searchTerm) as $user) {
if (! $first) {
echo ',';
}
echo json_encode(['type' => 'user', 'data' => $user]);
flush();
$first = false;
}
foreach ($yieldWebforms($searchTerm) as $webform) {
if (! $first) {
echo ',';
}
echo json_encode(['type' => 'webform', 'data' => $webform]);
flush();
$first = false;
}
echo ']';
flush();
}, 200, ['Content-Type' => 'application/json']);
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment