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.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<button
|
||||
{{ $attributes->merge(['class' => 'flex items-center gap-2 justify-center bg-red-600 text-white text-sm font-semibold hover:bg-red-700 active:scale-95 py-2.5 lg:py-2 px-4.5 rounded-md w-full duration-300 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:ring-offset-2 focus:ring-offset-background transition-transform']) }}>
|
||||
{{ $attributes->merge(['class' => 'btn-ripple flex items-center gap-2 justify-center bg-error text-white text-sm font-semibold hover:bg-error/90 hover:shadow-[0_0_16px_-2px_hsl(var(--color-error)/0.5)] active:scale-95 py-2.5 lg:py-2 px-4.5 rounded-md w-full duration-300 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-error/50 focus:ring-offset-2 focus:ring-offset-background transition-all']) }}>
|
||||
@if (isset($type) && $type === 'submit')
|
||||
<div role="status" wire:loading>
|
||||
<x-ri-loader-5-fill aria-hidden="true" class="size-6 me-2 fill-white animate-spin" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<button
|
||||
{{ $attributes->merge(['class' => 'flex items-center gap-2 justify-center bg-primary text-white text-sm font-semibold hover:bg-primary/90 hover:glow-primary-sm active:scale-95 py-2.5 lg:py-2 px-4.5 rounded-md w-full duration-300 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-primary/60 focus:ring-offset-2 focus:ring-offset-background transition-all']) }}>
|
||||
{{ $attributes->merge(['class' => 'btn-ripple flex items-center gap-2 justify-center bg-primary text-white text-sm font-semibold hover:bg-primary/90 hover:glow-primary-sm active:scale-95 py-2.5 lg:py-2 px-4.5 rounded-md w-full duration-300 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-primary/60 focus:ring-offset-2 focus:ring-offset-background transition-all']) }}>
|
||||
@if (isset($type) && $type === 'submit')
|
||||
<div role="status" wire:loading>
|
||||
<x-ri-loader-5-fill aria-hidden="true" class="size-6 me-2 fill-background animate-spin" />
|
||||
|
||||
27
themes/lucentui/views/components/card/index.blade.php
Normal file
27
themes/lucentui/views/components/card/index.blade.php
Normal file
@@ -0,0 +1,27 @@
|
||||
@props([
|
||||
'header' => null,
|
||||
'footer' => null,
|
||||
'actions' => null,
|
||||
'padded' => true,
|
||||
'hover' => true,
|
||||
])
|
||||
<div {{ $attributes->merge(['class' => 'card-glass' . ($hover ? ' card-glass-hover' : '')]) }}>
|
||||
@if($header ?? false)
|
||||
<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-neutral/30">
|
||||
<div class="min-w-0 flex-1">{{ $header }}</div>
|
||||
@if($actions ?? false)
|
||||
<div class="flex items-center gap-2 shrink-0">{{ $actions }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="{{ $padded ? 'p-5' : '' }}">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@if($footer ?? false)
|
||||
<div class="px-5 py-3 border-t border-neutral/30 text-sm text-base/80">
|
||||
{{ $footer }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
51
themes/lucentui/views/components/card/stat.blade.php
Normal file
51
themes/lucentui/views/components/card/stat.blade.php
Normal file
@@ -0,0 +1,51 @@
|
||||
@props([
|
||||
'label',
|
||||
'value',
|
||||
'delta' => null,
|
||||
'deltaLabel' => null,
|
||||
'trend' => null,
|
||||
'icon' => null,
|
||||
'href' => null,
|
||||
])
|
||||
@php
|
||||
$trendClass = match($trend) {
|
||||
'up' => 'text-success',
|
||||
'down' => 'text-error',
|
||||
'flat' => 'text-base/60',
|
||||
default => 'text-base/60',
|
||||
};
|
||||
$trendIcon = match($trend) {
|
||||
'up' => 'ri-arrow-up-line',
|
||||
'down' => 'ri-arrow-down-line',
|
||||
default => null,
|
||||
};
|
||||
$tag = $href ? 'a' : 'div';
|
||||
@endphp
|
||||
<{{ $tag }} @if($href) href="{{ $href }}" wire:navigate @endif
|
||||
{{ $attributes->merge(['class' => 'card-glass card-glass-hover block p-5 group']) }}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-semibold tracking-wider uppercase text-base/60">{{ $label }}</p>
|
||||
<p class="mt-2 text-3xl font-bold tnum text-base">{{ $value }}</p>
|
||||
@if($delta !== null || $deltaLabel)
|
||||
<div class="mt-1 flex items-center gap-1.5 text-xs {{ $trendClass }}">
|
||||
@if($trendIcon)
|
||||
<x-dynamic-component :component="$trendIcon" class="size-3.5" />
|
||||
@endif
|
||||
@if($delta !== null)<span class="font-semibold tnum">{{ $delta }}</span>@endif
|
||||
@if($deltaLabel)<span class="text-base/50">{{ $deltaLabel }}</span>@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@if($icon)
|
||||
<div class="size-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0 group-hover:bg-primary/20 transition-colors">
|
||||
<x-dynamic-component :component="$icon" class="size-5" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@isset($slot)
|
||||
@if(trim($slot) !== '')
|
||||
<div class="mt-4">{{ $slot }}</div>
|
||||
@endif
|
||||
@endisset
|
||||
</{{ $tag }}>
|
||||
335
themes/lucentui/views/components/command-palette.blade.php
Normal file
335
themes/lucentui/views/components/command-palette.blade.php
Normal file
@@ -0,0 +1,335 @@
|
||||
{{--
|
||||
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
|
||||
@@ -21,11 +21,11 @@
|
||||
</button>
|
||||
|
||||
<div x-ref="dropdown"
|
||||
class="absolute mt-2 w-48 bg-background-secondary rounded-xl shadow-xl shadow-black/5 z-10 border border-neutral/50 overflow-hidden"
|
||||
class="absolute mt-3 w-48 glass-panel shadow-floating z-10 overflow-hidden"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 scale-95 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 scale-100 translate-y-0" x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
x-transition:enter="transition ease-out duration-180" x-transition:enter-start="opacity-0 scale-[0.96] -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.96]"
|
||||
x-on:click.outside="open = false" x-cloak>
|
||||
<div class="py-1 [&>a]:block [&>a]:px-4 [&>a]:py-2.5 [&>a]:text-sm [&>a]:text-color-base [&>a]:transition-colors [&>a]:duration-150 [&>a:hover]:bg-primary/10 [&>a:hover]:text-primary [&>a:focus]:bg-primary/10 [&>a:focus]:text-primary [&>a:focus]:outline-none">
|
||||
{{ $content }}
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<div class="relative">
|
||||
<input type="checkbox" name="{{ $name }}" id="{{ $id ?? $name }}"
|
||||
{{ $attributes->except(['label', 'name', 'id', 'class', 'divClass', 'required']) }}
|
||||
class="peer form-checkbox size-5 text-primary rounded border-2 border-neutral bg-background-secondary/50
|
||||
focus:ring-2 focus:ring-primary/30 focus:ring-offset-0 focus:outline-none
|
||||
checked:bg-primary checked:border-primary
|
||||
hover:border-primary/50 transition-all duration-200 cursor-pointer
|
||||
class="peer form-checkbox size-5 text-primary rounded border-2 border-neutral/70 bg-background-secondary/50
|
||||
focus:ring-2 focus:ring-primary/40 focus:ring-offset-0 focus:outline-none
|
||||
checked:bg-primary checked:border-primary checked:shadow-[0_0_12px_-2px_hsl(var(--color-primary)/0.55)]
|
||||
hover:border-primary/60 transition-all duration-200 cursor-pointer
|
||||
appearance-none" />
|
||||
<svg class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-3 text-white pointer-events-none opacity-0 scale-50 peer-checked:opacity-100 peer-checked:scale-100 transition-all duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
@@ -26,6 +26,6 @@
|
||||
</label>
|
||||
|
||||
@error($name)
|
||||
<p class="text-red-500 text-xs ml-2">{{ $message }}</p>
|
||||
<p class="text-error text-xs ml-2">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
class="text-sm font-bold text-base">
|
||||
{{ $label }}
|
||||
@if ($required && !$hideRequiredIndicator)
|
||||
<span class="text-red-500">*</span>
|
||||
<span class="text-error">*</span>
|
||||
@endif
|
||||
</label>
|
||||
</legend>
|
||||
@endif
|
||||
<input type="{{ $type ?? 'text' }}" id="{{ $id ?? $name }}" name="{{ $name }}"
|
||||
class="block w-full text-sm text-base bg-background-secondary/40 border border-neutral/60 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary hover:border-neutral transition-all duration-300 ease-in-out disabled:bg-background-secondary/30 disabled:cursor-not-allowed {{ $class ?? '' }} @if ($type !== 'color') px-2.5 py-2.5 @endif"
|
||||
class="block w-full text-sm text-base bg-background-secondary/40 border border-neutral/60 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary hover:border-neutral/90 transition-all duration-300 ease-in-out disabled:bg-background-secondary/30 disabled:cursor-not-allowed placeholder:text-base/40 {{ $class ?? '' }} @if ($type !== 'color') px-3 py-2.5 @endif"
|
||||
placeholder="{{ $placeholder ?? ($label ?? '') }}"
|
||||
@if ($dirty && isset($attributes['wire:model'])) wire:dirty.class="!border-yellow-600" @endif
|
||||
{{ $attributes->except(['placeholder', 'label', 'id', 'name', 'type', 'class', 'divClass', 'required', 'hideRequiredIndicator', 'dirty']) }} @required($required) />
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<label for="{{ $name }}" class="text-sm font-bold text-color-base">
|
||||
{{ $label }}
|
||||
@if ($required && !$hideRequiredIndicator)
|
||||
<span class="text-red-500">*</span>
|
||||
<span class="text-error">*</span>
|
||||
@endif
|
||||
</label>
|
||||
</legend>
|
||||
@@ -30,10 +30,10 @@
|
||||
<input type="radio" id="{{ $name }}_{{ $key }}" name="{{ $name }}"
|
||||
value="{{ gettype($options) == 'array' ? $option : $key }}"
|
||||
{{ ($multiple && $selected ? in_array($key, $selected) : $selected==$option) ? 'checked' : '' }}
|
||||
class="peer form-radio size-5 text-primary border-2 border-neutral/60 bg-background-secondary/40
|
||||
class="peer form-radio size-5 text-primary border-2 border-neutral/70 bg-background-secondary/40
|
||||
focus:ring-2 focus:ring-primary/40 focus:ring-offset-0 focus:outline-none
|
||||
checked:bg-primary checked:border-primary
|
||||
hover:border-primary/50 transition-all duration-200 cursor-pointer
|
||||
checked:bg-primary checked:border-primary checked:shadow-[0_0_12px_-2px_hsl(var(--color-primary)/0.55)]
|
||||
hover:border-primary/60 transition-all duration-200 cursor-pointer
|
||||
appearance-none rounded-full" />
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-2 bg-white rounded-full pointer-events-none opacity-0 scale-0 peer-checked:opacity-100 peer-checked:scale-100 transition-all duration-200"></div>
|
||||
</div>
|
||||
@@ -46,6 +46,6 @@
|
||||
</div>
|
||||
|
||||
@error($name)
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
<p class="text-error text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@@ -15,7 +15,7 @@
|
||||
class="text-sm font-bold text-base">
|
||||
{{ $label }}
|
||||
@if ($required && !$hideRequiredIndicator)
|
||||
<span class="text-red-500">*</span>
|
||||
<span class="text-error">*</span>
|
||||
@endif
|
||||
</label>
|
||||
</legend>
|
||||
@@ -41,6 +41,6 @@
|
||||
@endif
|
||||
|
||||
@error($name)
|
||||
<p class="text-red-500 text-xs">{{ $message }}</p>
|
||||
<p class="text-error text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@@ -17,7 +17,7 @@
|
||||
class="text-sm text-primary-100 absolute -translate-y-1/2 start-1 ml-1 bg-primary-800 px-2 rounded-md">
|
||||
{{ $label }}
|
||||
@if ($required && !$hideRequiredIndicator)
|
||||
<span class="text-red-500">*</span>
|
||||
<span class="text-error">*</span>
|
||||
@endif
|
||||
</label>
|
||||
</legend>
|
||||
@@ -28,6 +28,6 @@
|
||||
wire:dirty.class="!border-yellow-600" @endif {{ $attributes->except(['placeholder', 'label', 'id', 'name', 'type', 'class', 'divClass', 'required', 'hideRequiredIndicator', 'dirty']) }}
|
||||
@required($required)>{{ $slot }}</textarea>
|
||||
@error($name)
|
||||
<p class="text-red-500 text-xs">{{ $message }}</p>
|
||||
<p class="text-error text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@@ -17,18 +17,18 @@
|
||||
x-transition:leave-end="opacity-0">
|
||||
|
||||
{{-- Backdrop overlay --}}
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
<div class="absolute inset-0 bg-background/80 backdrop-blur-md"
|
||||
@click="{{ $closable ? 'open = false' : '' }}"></div>
|
||||
|
||||
{{-- Modal content --}}
|
||||
<div class="relative px-6 py-4 w-full mx-2 md:mx-auto text-left bg-background-secondary rounded-xl shadow-2xl border border-neutral/50 {{ $width }}"
|
||||
<div class="relative px-6 py-4 w-full mx-2 md:mx-auto text-left glass-panel shadow-floating {{ $width }}"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-[0.96] 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-95"
|
||||
x-transition:leave-end="opacity-0 scale-[0.96]"
|
||||
@click.stop>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-semibold text-color-base">{{ $title }}</h2>
|
||||
|
||||
@@ -85,10 +85,10 @@
|
||||
$watch('slideOverOpen', value => { document.documentElement.style.overflow = value ? 'hidden' : '' });
|
||||
"
|
||||
:class="{
|
||||
'shadow-lg shadow-black/5 border-neutral/80': scrolled,
|
||||
'border-neutral': !scrolled
|
||||
'shadow-elevated border-primary/20 backdrop-blur-xl bg-background-secondary/70': scrolled,
|
||||
'border-neutral bg-background-secondary': !scrolled
|
||||
}"
|
||||
class="w-full px-4 lg:px-8 bg-background-secondary border-b flex flex-col lg:flex-row justify-between fixed top-0 z-20 h-auto transition-all duration-300">
|
||||
class="w-full px-4 lg:px-8 border-b flex flex-col lg:flex-row justify-between fixed top-0 z-20 h-auto transition-all duration-300">
|
||||
<div class="relative z-50 w-full h-auto">
|
||||
|
||||
<div class="flex flex-row items-center justify-between w-full h-16">
|
||||
@@ -135,6 +135,16 @@
|
||||
|
||||
<livewire:components.cart />
|
||||
|
||||
<!-- Command palette trigger (Cmd/Ctrl+K) -->
|
||||
<button type="button"
|
||||
@click="$dispatch('open-command-palette')"
|
||||
class="hidden md:inline-flex items-center gap-2 h-10 px-3 rounded-lg text-sm text-base/70 hover:text-base hover:bg-neutral/40 border border-neutral/50 hover:border-primary/40 transition-all duration-200"
|
||||
aria-label="Open command palette">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24" fill="currentColor"><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 7 9.5a6.5 6.5 0 0 0 9.025 6.375l.885.885Z"/></svg>
|
||||
<span class="hidden lg:inline text-xs">Search</span>
|
||||
<kbd class="hidden lg:inline-block text-[10px] font-semibold px-1.5 py-0.5 rounded border border-neutral/50 bg-background/50">⌘K</kbd>
|
||||
</button>
|
||||
|
||||
<!-- Dark/Light mode toggle (left of user icon / login & register) -->
|
||||
<button @click="darkMode = !darkMode" type="button"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-neutral/50 transition-colors duration-200"
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
@else
|
||||
<x-navigation.link :href="route($nav['route'], $nav['params'] ?? [])"
|
||||
:spa="$nav['spa'] ?? true"
|
||||
class="flex items-center gap-3 p-3 rounded-lg transition-colors duration-200 w-full
|
||||
{{ $nav['active'] ? 'bg-primary/10 text-primary font-semibold' : 'hover:bg-primary/5 text-color-base' }}">
|
||||
class="relative flex items-center gap-3 p-3 rounded-lg transition-all duration-200 w-full
|
||||
{{ $nav['active'] ? 'bg-primary/10 text-primary font-semibold before:absolute before:left-0 before:top-2 before:bottom-2 before:w-[3px] before:rounded-full before:bg-gradient-to-b before:from-primary before:to-secondary' : 'hover:bg-primary/5 text-color-base' }}">
|
||||
@isset($nav['icon'])
|
||||
<x-dynamic-component :component="$nav['icon']"
|
||||
class="size-5 {{ $nav['active'] ? 'text-primary' : 'text-color-muted' }}" />
|
||||
@@ -98,8 +98,8 @@
|
||||
@else
|
||||
<x-navigation.link :href="route($nav['route'], $nav['params'] ?? [])"
|
||||
:spa="$nav['spa'] ?? true"
|
||||
class="flex items-center gap-3 p-3 rounded-lg transition-colors duration-200 w-full
|
||||
{{ $nav['active'] ? 'bg-primary/10 text-primary font-semibold' : 'hover:bg-primary/5 text-color-base' }}">
|
||||
class="relative flex items-center gap-3 p-3 rounded-lg transition-all duration-200 w-full
|
||||
{{ $nav['active'] ? 'bg-primary/10 text-primary font-semibold before:absolute before:left-0 before:top-2 before:bottom-2 before:w-[3px] before:rounded-full before:bg-gradient-to-b before:from-primary before:to-secondary' : 'hover:bg-primary/5 text-color-base' }}">
|
||||
@isset($nav['icon'])
|
||||
<x-dynamic-component :component="$nav['icon']"
|
||||
class="size-5 {{ $nav['active'] ? 'text-primary' : 'text-color-muted' }}" />
|
||||
|
||||
@@ -1,41 +1,130 @@
|
||||
<div x-data class="fixed bottom-4 right-4 z-50 flex flex-col-reverse gap-3 max-w-sm w-full pointer-events-none">
|
||||
<template x-for="(notification, index) in $store.notifications.notifications" :key="notification.id">
|
||||
<div x-show="notification.show"
|
||||
x-transition:enter="transition ease-out duration-300 transform"
|
||||
x-transition:enter-start="opacity-0 translate-x-full scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-x-0 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200 transform"
|
||||
x-transition:leave-start="opacity-100 translate-x-0 scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-x-full scale-95"
|
||||
:class="notification.type === 'success' ? 'bg-success border-success/50' : 'bg-error border-error/50'"
|
||||
class="relative flex flex-col text-white rounded-xl shadow-lg border backdrop-blur-sm pointer-events-auto overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<template x-if="notification.type === 'success'">
|
||||
<svg class="size-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
</template>
|
||||
<template x-if="notification.type !== 'success'">
|
||||
<svg class="size-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</template>
|
||||
<p x-text="notification.message" class="text-sm font-medium"></p>
|
||||
{{--
|
||||
Toast v2 — supports: success | error | info | warning types,
|
||||
positions (top|bottom)-(left|right|center), action buttons,
|
||||
swipe-to-dismiss, pause-on-hover. Backward compatible with
|
||||
existing `$this->dispatch('notify', [...])` calls.
|
||||
--}}
|
||||
<div x-data>
|
||||
@foreach (['top-right','bottom-right','top-left','bottom-left','top-center','bottom-center'] as $pos)
|
||||
@php
|
||||
[$vy, $hx] = explode('-', $pos);
|
||||
$posClasses = [
|
||||
'top-right' => 'top-4 right-4 items-end',
|
||||
'bottom-right' => 'bottom-4 right-4 items-end flex-col-reverse',
|
||||
'top-left' => 'top-4 left-4 items-start',
|
||||
'bottom-left' => 'bottom-4 left-4 items-start flex-col-reverse',
|
||||
'top-center' => 'top-4 left-1/2 -translate-x-1/2 items-center',
|
||||
'bottom-center'=> 'bottom-4 left-1/2 -translate-x-1/2 items-center flex-col-reverse',
|
||||
][$pos];
|
||||
$enterFromClass = match($hx) {
|
||||
'left' => 'opacity-0 -translate-x-full scale-95',
|
||||
'center' => 'opacity-0 scale-95 ' . ($vy === 'top' ? '-translate-y-4' : 'translate-y-4'),
|
||||
default => 'opacity-0 translate-x-full scale-95',
|
||||
};
|
||||
$leaveToClass = $enterFromClass;
|
||||
@endphp
|
||||
<div class="fixed z-50 flex flex-col gap-3 max-w-sm w-full pointer-events-none {{ $posClasses }}">
|
||||
<template x-for="notification in $store.notifications.notifications.filter(n => (n.position || 'bottom-right') === '{{ $pos }}')" :key="notification.id">
|
||||
<div x-show="notification.show"
|
||||
x-data="{
|
||||
dragX: 0,
|
||||
dragging: false,
|
||||
startX: 0,
|
||||
reduced: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
onDown(e) {
|
||||
if (this.reduced) return;
|
||||
this.dragging = true;
|
||||
this.startX = e.clientX;
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
},
|
||||
onMove(e) {
|
||||
if (!this.dragging) return;
|
||||
this.dragX = e.clientX - this.startX;
|
||||
},
|
||||
onUp(e) {
|
||||
if (!this.dragging) return;
|
||||
this.dragging = false;
|
||||
if (Math.abs(this.dragX) > 80) {
|
||||
$store.notifications.removeNotification(notification.id);
|
||||
} else {
|
||||
this.dragX = 0;
|
||||
}
|
||||
}
|
||||
}"
|
||||
@mouseenter="$store.notifications.pauseNotification(notification.id)"
|
||||
@mouseleave="$store.notifications.resumeNotification(notification.id)"
|
||||
@pointerdown="onDown($event)"
|
||||
@pointermove="onMove($event)"
|
||||
@pointerup="onUp($event)"
|
||||
@pointercancel="onUp($event)"
|
||||
x-transition:enter="transition ease-out duration-300 transform"
|
||||
x-transition:enter-start="{{ $enterFromClass }}"
|
||||
x-transition:enter-end="opacity-100 translate-x-0 translate-y-0 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200 transform"
|
||||
x-transition:leave-start="opacity-100 translate-x-0 translate-y-0 scale-100"
|
||||
x-transition:leave-end="{{ $leaveToClass }}"
|
||||
:style="`transform: translateX(${dragX}px); opacity: ${1 - Math.min(Math.abs(dragX)/160, 0.5)}; transition: ${dragging ? 'none' : 'transform 240ms var(--ease-out-soft), opacity 240ms var(--ease-out-soft)'}; touch-action: pan-y;`"
|
||||
:class="{
|
||||
'bg-success border-success/50': notification.type === 'success',
|
||||
'bg-error border-error/50': notification.type === 'error',
|
||||
'bg-info border-info/50': notification.type === 'info',
|
||||
'bg-warning border-warning/60': notification.type === 'warning',
|
||||
}"
|
||||
class="relative flex flex-col text-white rounded-xl shadow-floating border backdrop-blur-sm pointer-events-auto overflow-hidden select-none cursor-grab active:cursor-grabbing">
|
||||
<div class="flex items-start justify-between gap-3 px-4 py-3">
|
||||
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||
<template x-if="notification.type === 'success'">
|
||||
<svg class="size-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.type === 'error'">
|
||||
<svg class="size-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.type === 'info'">
|
||||
<svg class="size-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.type === 'warning'">
|
||||
<svg class="size-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v2m0 4h.01M4.93 19h14.14a2 2 0 001.74-3l-7.07-12a2 2 0 00-3.48 0L3.19 16a2 2 0 001.74 3z"/></svg>
|
||||
</template>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p x-text="notification.title || ''" x-show="notification.title" class="text-sm font-bold mb-0.5"></p>
|
||||
<p x-text="notification.message" class="text-sm font-medium leading-snug break-words"></p>
|
||||
{{-- Action buttons --}}
|
||||
<template x-if="notification.actions && notification.actions.length > 0">
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<template x-for="(action, aIdx) in notification.actions" :key="aIdx">
|
||||
<button type="button"
|
||||
@click.stop="$store.notifications.invokeAction(notification.id, aIdx)"
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1 rounded-md bg-white/15 hover:bg-white/25 active:scale-95 transition-all duration-150"
|
||||
x-text="action.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<button @click.stop="$store.notifications.removeNotification(notification.id)"
|
||||
aria-label="Dismiss"
|
||||
class="text-white/70 hover:text-white transition-colors p-1 rounded-full hover:bg-white/10 shrink-0">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{{-- Auto-dismiss progress bar (pauses on hover) --}}
|
||||
<div class="h-1 bg-black/20">
|
||||
<div class="h-full bg-white/70 notification-progress"
|
||||
:style="`animation-duration: ${notification.timeout || 5000}ms; animation-play-state: ${notification.paused ? 'paused' : 'running'};`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="$store.notifications.removeNotification(notification.id)" class="text-white/70 hover:text-white transition-colors p-1 rounded-full hover:bg-white/10">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
{{-- Auto-dismiss countdown progress bar --}}
|
||||
<div class="h-1 bg-white/20">
|
||||
<div class="h-full bg-white/50 notification-progress"
|
||||
:class="notification.type === 'success' ? 'bg-white/60' : 'bg-white/60'"
|
||||
style="animation: notification-countdown 5s linear forwards;"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes notification-countdown {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
.notification-progress {
|
||||
animation: notification-countdown 5000ms linear forwards;
|
||||
transform-origin: left center;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user