Files
Virtnet.bond/themes/lucentui/views/components/command-palette.blade.php
Joseph.Rawlings 8baf7d6c7d feat: Enhance UI components and add command palette
- Updated dropdown component styles and transitions for improved UX.
- Refined checkbox, input, radio, select, and textarea components with consistent error messaging and styling.
- Introduced a new card component for better layout options.
- Added a stat card component for displaying metrics with trends.
- Implemented a command palette for global search and navigation, accessible via Cmd/Ctrl+K.
- Enhanced modal and navigation components with new styles and transitions.
- Improved layout styles for better responsiveness and visual appeal.
2026-04-22 10:40:33 -04:00

336 lines
16 KiB
PHP

{{--
Command Palette — Cmd/Ctrl+K global search & navigation.
Included once in layouts/app.blade.php; works on every page (logged in or out).
Data sources:
- Pages: App\Classes\Navigation::getLinks() + getAccountDropdownLinks()
- Services: Auth::user()->services (active only, small set)
- Servers: linked via services->server relation (best-effort; skipped if none)
- Quick actions: hard-coded (toggle theme, logout, new ticket)
--}}
@php
$cp_pages = [];
foreach (\App\Classes\Navigation::getLinks() as $nav) {
if (isset($nav['children']) && count($nav['children']) > 0) {
foreach ($nav['children'] as $child) {
try {
$cp_pages[] = [
'label' => $nav['name'] . ' / ' . $child['name'],
'href' => route($child['route'], $child['params'] ?? null),
'icon' => 'ri-arrow-right-line',
'section' => 'Pages',
];
} catch (\Throwable $e) {}
}
} elseif (isset($nav['route'])) {
try {
$cp_pages[] = [
'label' => $nav['name'],
'href' => route($nav['route'], $nav['params'] ?? null),
'icon' => $nav['icon'] ?? 'ri-arrow-right-line',
'section' => 'Pages',
];
} catch (\Throwable $e) {}
}
}
if (auth()->check()) {
foreach (\App\Classes\Navigation::getAccountDropdownLinks() as $nav) {
try {
$cp_pages[] = [
'label' => $nav['name'],
'href' => route($nav['route'], $nav['params'] ?? null),
'icon' => 'ri-user-line',
'section' => 'Account',
];
} catch (\Throwable $e) {}
}
// Services (current user, active)
try {
$cp_services = auth()->user()->services()
->where('status', 'active')
->limit(50)
->get(['id', 'name', 'product_id']);
foreach ($cp_services as $svc) {
$cp_pages[] = [
'label' => $svc->name ?: ('Service #' . $svc->id),
'href' => route('services.show', ['service' => $svc->id]),
'icon' => 'ri-server-line',
'section' => 'Services',
];
}
} catch (\Throwable $e) {}
}
// Quick actions (always present)
$cp_pages[] = [
'label' => 'Toggle dark mode',
'action' => 'toggleTheme',
'icon' => 'ri-contrast-2-line',
'section' => 'Quick actions',
];
if (auth()->check()) {
try {
$cp_pages[] = [
'label' => 'New ticket',
'href' => route('tickets.create'),
'icon' => 'ri-mail-add-line',
'section' => 'Quick actions',
];
} catch (\Throwable $e) {}
try {
$cp_pages[] = [
'label' => 'Invoices',
'href' => route('invoices'),
'icon' => 'ri-bill-line',
'section' => 'Quick actions',
];
} catch (\Throwable $e) {}
} else {
try {
$cp_pages[] = [
'label' => 'Login',
'href' => route('login'),
'icon' => 'ri-login-box-line',
'section' => 'Quick actions',
];
$cp_pages[] = [
'label' => 'Register',
'href' => route('register'),
'icon' => 'ri-user-add-line',
'section' => 'Quick actions',
];
} catch (\Throwable $e) {}
}
@endphp
<div x-data="commandPalette(@js($cp_pages))"
x-init="init()"
@keydown.window="handleKeydown($event)"
@open-command-palette.window="open()"
x-cloak>
{{-- Backdrop + container --}}
<div x-show="isOpen"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-[100] flex items-start justify-center pt-[10vh] px-4">
<div class="absolute inset-0 bg-background/80 backdrop-blur-md" @click="close()"></div>
<div x-show="isOpen"
x-transition:enter="transition ease-out duration-180"
x-transition:enter-start="opacity-0 scale-[0.97] translate-y-2"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-120"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-[0.97]"
class="relative w-full max-w-xl glass-panel shadow-floating overflow-hidden"
@click.stop>
{{-- Search input --}}
<div class="flex items-center gap-3 px-4 py-3 border-b border-neutral/30">
<x-ri-search-line class="size-5 text-base/60 shrink-0" />
<input x-ref="input"
x-model="query"
@input="activeIndex = 0"
type="text"
placeholder="Search pages, services, actions…"
class="flex-1 bg-transparent text-base outline-none placeholder:text-base/40" />
<kbd class="hidden sm:inline-block text-[10px] font-semibold text-base/50 px-1.5 py-0.5 rounded border border-neutral/50 bg-background/40">ESC</kbd>
</div>
{{-- Results --}}
<div class="max-h-[60vh] overflow-y-auto py-1" x-ref="list">
<template x-for="(group, section) in grouped" :key="section">
<div>
<div class="px-4 pt-3 pb-1 text-[10px] font-bold uppercase tracking-wider text-base/40"
x-text="section"></div>
<template x-for="(item, idx) in group" :key="section + '-' + idx">
<button type="button"
@click="select(item)"
@mousemove="activeIndex = item._globalIndex"
:class="activeIndex === item._globalIndex
? 'bg-primary/15 text-primary'
: 'text-base hover:bg-background-secondary/60'"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors">
<span class="size-5 shrink-0 flex items-center justify-center text-base/60">
<template x-if="item.icon">
<span x-html="iconSvg(item.icon)"></span>
</template>
</span>
<span class="flex-1 truncate" x-text="item.label"></span>
<span x-show="activeIndex === item._globalIndex" class="text-[10px] text-base/60">↵</span>
</button>
</template>
</div>
</template>
<template x-if="visibleItems.length === 0">
<div class="px-4 py-8 text-center text-sm text-base/50">
No results for "<span x-text="query"></span>"
</div>
</template>
</div>
{{-- Footer hint --}}
<div class="flex items-center justify-between gap-3 px-4 py-2 border-t border-neutral/30 text-[11px] text-base/50">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1"><kbd class="px-1.5 py-0.5 rounded border border-neutral/50 bg-background/40 font-semibold">↑↓</kbd> navigate</span>
<span class="flex items-center gap-1"><kbd class="px-1.5 py-0.5 rounded border border-neutral/50 bg-background/40 font-semibold">↵</kbd> select</span>
</div>
<span class="flex items-center gap-1">
<kbd class="px-1.5 py-0.5 rounded border border-neutral/50 bg-background/40 font-semibold">⌘K</kbd>
to toggle
</span>
</div>
</div>
</div>
</div>
@once
<script>
function commandPalette(items) {
return {
isOpen: false,
query: '',
activeIndex: 0,
allItems: items,
init() {
// Annotate global index for active-highlight tracking
this.allItems.forEach((it, i) => it._id = i);
},
get filtered() {
const q = this.query.trim().toLowerCase();
if (!q) return this.allItems;
const score = (label) => {
const l = label.toLowerCase();
if (l === q) return 1000;
if (l.startsWith(q)) return 500;
// word-boundary boost
if (new RegExp('\\b' + q.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).test(l)) return 200;
if (l.includes(q)) return 100;
return 0;
};
return this.allItems
.map(it => ({ it, s: score(it.label) }))
.filter(x => x.s > 0)
.sort((a, b) => b.s - a.s)
.map(x => x.it);
},
get visibleItems() {
return this.filtered;
},
get grouped() {
const out = {};
this.visibleItems.forEach((it, gi) => {
it._globalIndex = gi;
const s = it.section || 'Other';
if (!out[s]) out[s] = [];
out[s].push(it);
});
return out;
},
open() {
this.isOpen = true;
this.query = '';
this.activeIndex = 0;
this.$nextTick(() => this.$refs.input && this.$refs.input.focus());
},
close() {
this.isOpen = false;
},
handleKeydown(e) {
// Cmd/Ctrl+K — toggle
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
this.isOpen ? this.close() : this.open();
return;
}
if (!this.isOpen) return;
if (e.key === 'Escape') { this.close(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
this.activeIndex = Math.min(this.activeIndex + 1, this.visibleItems.length - 1);
this.scrollActiveIntoView();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.activeIndex = Math.max(this.activeIndex - 1, 0);
this.scrollActiveIntoView();
} else if (e.key === 'Enter') {
e.preventDefault();
const item = this.visibleItems[this.activeIndex];
if (item) this.select(item);
}
},
scrollActiveIntoView() {
this.$nextTick(() => {
const list = this.$refs.list;
if (!list) return;
const active = list.querySelectorAll('button')[this.activeIndex];
if (active) active.scrollIntoView({ block: 'nearest' });
});
},
select(item) {
this.close();
if (item.action === 'toggleTheme') {
// Flip Alpine $persist darkMode on body
try {
const body = document.body;
const current = localStorage.getItem('_x_darkMode') === 'true';
localStorage.setItem('_x_darkMode', String(!current));
body.classList.toggle('dark');
// Nudge Alpine to re-read persisted value
window.dispatchEvent(new Event('storage'));
} catch (e) {}
return;
}
if (item.href) {
// Livewire SPA navigation if available, else full nav
if (window.Livewire && typeof window.Livewire.navigate === 'function') {
window.Livewire.navigate(item.href);
} else {
window.location.href = item.href;
}
}
},
iconSvg(name) {
// Minimal inline icon fallback: we rely on CSS mask-image via currentColor-less Remix Icons
// but since Blade's x-{icon} components aren't available in JS, emit a simple placeholder.
// Pages get a right arrow, services get a server glyph, quick actions get a bolt.
const map = {
'ri-arrow-right-line': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M16.0037 9.41421 7.39712 18.0208 5.98291 16.6066 14.5895 8H7.00373V6h11l-.0001 11h-2l.0001-7.58579Z"/></svg>',
'ri-server-line': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M5 15V19H19V15H5ZM4 13H20A1 1 0 0 1 21 14V20A1 1 0 0 1 20 21H4A1 1 0 0 1 3 20V14A1 1 0 0 1 4 13ZM5 5V9H19V5H5ZM4 3H20A1 1 0 0 1 21 4V10A1 1 0 0 1 20 11H4A1 1 0 0 1 3 10V4A1 1 0 0 1 4 3ZM7 6H9V8H7V6ZM7 16H9V18H7V16Z"/></svg>',
'ri-user-line': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M4 22a8 8 0 1 1 16 0h-2a6 6 0 1 0-12 0H4Zm8-9c-3.315 0-6-2.685-6-6s2.685-6 6-6 6 2.685 6 6-2.685 6-6 6Z"/></svg>',
'ri-home-2': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M20 20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9.49a1 1 0 0 1 .386-.79l7-5.444a1 1 0 0 1 1.228 0l7 5.443a1 1 0 0 1 .386.79V20Z"/></svg>',
'ri-shopping-bag': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M12 1a5 5 0 0 1 5 5v1h4v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7h4V6a5 5 0 0 1 5-5Zm0 2a3 3 0 0 0-3 3v1h6V6a3 3 0 0 0-3-3Z"/></svg>',
'ri-bill-line': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M20 22H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1Zm-1-2V4H5v16h14ZM8 7h8v2H8V7Zm0 4h8v2H8v-2Zm0 4h5v2H8v-2Z"/></svg>',
'ri-mail-add-line': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M13 21v-2h7.007L14 14.15V18H4V6h16v6h2V5a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h10Zm7-6v-3h2v3h3v2h-3v3h-2v-3h-3v-2h3Z"/></svg>',
'ri-login-box-line': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M4 15h2v5h12V4H6v5H4V3a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6Zm6-4V8l5 4-5 4v-3H2v-2h8Z"/></svg>',
'ri-user-add-line': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M14 14.252v2.09A6 6 0 0 0 6 22H4a8 8 0 0 1 10-7.748ZM12 13c-3.315 0-6-2.685-6-6s2.685-6 6-6 6 2.685 6 6-2.685 6-6 6Zm6 4v-3h2v3h3v2h-3v3h-2v-3h-3v-2h3Z"/></svg>',
'ri-contrast-2-line': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10Zm0-2V4a8 8 0 1 0 0 16Z"/></svg>',
'ri-search-line': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="size-4"><path d="M18.031 16.617 22.314 20.9l-1.414 1.414-4.283-4.283A8.5 8.5 0 1 1 18.03 16.617Zm-2.006-.742A6.5 6.5 0 1 0 16.617 14.99l-.592.885Z"/></svg>',
};
return map[name] || map['ri-arrow-right-line'];
},
};
}
window.commandPalette = commandPalette;
</script>
@endonce