- 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.
336 lines
16 KiB
PHP
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
|