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:
2026-04-22 10:40:33 -04:00
parent a910d41e6e
commit 8baf7d6c7d
19 changed files with 859 additions and 82 deletions

View File

@@ -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" />

View File

@@ -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" />

View 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>

View 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 }}>

View 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

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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) />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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' }}" />

View File

@@ -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>