chore: cleanup project structure and update readme for beta release

This commit is contained in:
2025-12-23 04:59:21 +07:00
parent 1640ced748
commit 10a00bac0e
122 changed files with 8320 additions and 661 deletions

View File

@@ -1,21 +1,48 @@
{{-- Notification Dropdown Component --}}
<div class="relative" x-data="{
dropdownOpen: false,
notifying: true,
notifying: false,
notifications: [],
init() {
this.fetchNotifications();
// Listen for realtime notifications
if (window.Echo) {
window.Echo.private('App.Models.User.{{ auth()->id() }}')
.notification((notification) => {
this.notifications.unshift({
id: notification.id,
data: {
title: notification.data?.title || notification.title || 'Notification',
body: notification.data?.body || notification.body || '',
icon: notification.data?.icon || notification.icon,
action_url: notification.data?.action_url || notification.action_url
},
created_at_human: 'Just now',
read_url: notification.read_url || '#'
});
this.notifying = true;
// Dispatch Global Event for Toast Alerts
window.dispatchEvent(new CustomEvent('reverb-notification', {
detail: notification.data?.title ? notification.data : notification
}));
});
}
},
fetchNotifications() {
fetch('{{ route('notifications.unread') }}')
.then(response => response.json())
.then(data => {
this.notifications = data.notifications;
this.notifying = data.count > 0;
});
},
toggleDropdown() {
this.dropdownOpen = !this.dropdownOpen;
this.notifying = false;
},
closeDropdown() {
this.dropdownOpen = false;
},
handleItemClick() {
console.log('Notification item clicked');
this.closeDropdown();
},
handleViewAllClick() {
console.log('View All Notifications clicked');
this.closeDropdown();
}
}" @click.away="closeDropdown()">
<!-- Notification Button -->
@@ -28,6 +55,7 @@
<span
x-show="notifying"
class="absolute right-0 top-0.5 z-1 h-2 w-2 rounded-full bg-orange-400"
style="display: none;"
>
<span
class="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 -z-1 animate-ping"
@@ -61,7 +89,7 @@
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute -right-[240px] mt-[17px] flex h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px] lg:right-0"
class="absolute -right-[240px] mt-[17px] flex h-auto max-h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px] lg:right-0"
style="display: none;"
>
<!-- Dropdown Header -->
@@ -80,7 +108,7 @@
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51356 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill=""
/>
</svg>
@@ -88,135 +116,55 @@
</div>
<!-- Notification List -->
<ul class="flex flex-col h-auto overflow-y-auto custom-scrollbar">
@php
$notifications = [
[
'id' => 1,
'userName' => 'Terry Franci',
'userImage' => '/images/user/user-02.jpg',
'action' => 'requests permission to change',
'project' => 'Project - Nganter App',
'type' => 'Project',
'time' => '5 min ago',
'status' => 'online',
],
[
'id' => 2,
'userName' => 'Alex Johnson',
'userImage' => '/images/user/user-03.jpg',
'action' => 'requests permission to change',
'project' => 'Project - Nganter App',
'type' => 'Project',
'time' => '10 min ago',
'status' => 'offline',
],
[
'id' => 3,
'userName' => 'Sarah Williams',
'userImage' => '/images/user/user-04.jpg',
'action' => 'requests permission to change',
'project' => 'Project - Dashboard UI',
'type' => 'Project',
'time' => '15 min ago',
'status' => 'online',
],
[
'id' => 4,
'userName' => 'Mike Brown',
'userImage' => '/images/user/user-05.jpg',
'action' => 'requests permission to change',
'project' => 'Project - E-commerce',
'type' => 'Project',
'time' => '20 min ago',
'status' => 'online',
],
[
'id' => 5,
'userName' => 'Emma Davis',
'userImage' => '/images/user/user-06.jpg',
'action' => 'requests permission to change',
'project' => 'Project - Mobile App',
'type' => 'Project',
'time' => '25 min ago',
'status' => 'offline',
],
[
'id' => 6,
'userName' => 'John Smith',
'userImage' => '/images/user/user-07.jpg',
'action' => 'requests permission to change',
'project' => 'Project - Landing Page',
'type' => 'Project',
'time' => '30 min ago',
'status' => 'online',
],
[
'id' => 7,
'userName' => 'Lisa Anderson',
'userImage' => '/images/user/user-08.jpg',
'action' => 'requests permission to change',
'project' => 'Project - Blog System',
'type' => 'Project',
'time' => '35 min ago',
'status' => 'online',
],
[
'id' => 8,
'userName' => 'David Wilson',
'userImage' => '/images/user/user-09.jpg',
'action' => 'requests permission to change',
'project' => 'Project - CRM Dashboard',
'type' => 'Project',
'time' => '40 min ago',
'status' => 'online',
],
];
@endphp
@foreach ($notifications as $notification)
<li @click="handleItemClick()">
<ul class="flex flex-col h-auto overflow-y-auto custom-scrollbar max-h-[300px]">
<template x-for="notification in notifications" :key="notification.id">
<li>
<a
class="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
href="#"
:href="notification.read_url"
>
<span class="relative block w-full h-10 rounded-full z-1 max-w-10">
<img src="{{ $notification['userImage'] }}" alt="User" class="overflow-hidden rounded-full" />
<span
class="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white dark:border-gray-900 {{ $notification['status'] === 'online' ? 'bg-success-500' : 'bg-error-500' }}"
></span>
</span>
<div class="flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-brand-100 text-brand-500 flex items-center justify-center">
<template x-if="notification.data.icon">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
</template>
<template x-if="!notification.data.icon">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</template>
</div>
</div>
<span class="block">
<span class="mb-1.5 block text-theme-sm text-gray-500 dark:text-gray-400">
<span class="font-medium text-gray-800 dark:text-white/90">
{{ $notification['userName'] }}
</span>
{{ $notification['action'] }}
<span class="font-medium text-gray-800 dark:text-white/90">
{{ $notification['project'] }}
</span>
<span class="font-medium text-gray-800 dark:text-white/90" x-text="notification.data.title"></span>
<span class="block text-xs mt-1" x-text="notification.data.body"></span>
</span>
<span class="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
<span>{{ $notification['type'] }}</span>
<span class="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>{{ $notification['time'] }}</span>
<span x-text="notification.created_at_human"></span>
</span>
</span>
</a>
</li>
@endforeach
</template>
<li x-show="notifications.length === 0" class="p-6 text-center text-gray-500 dark:text-gray-400">
<p>No new notifications.</p>
</li>
</ul>
<!-- View All Button -->
<a
href="#"
class="mt-3 flex justify-center rounded-lg border border-gray-300 bg-white p-3 text-theme-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
@click.prevent="handleViewAllClick()"
>
View All Notification
</a>
<div x-show="notifications.length > 0">
<!-- Mark All Read Button -->
<form action="{{ route('notifications.readAll') }}" method="POST" class="mt-3">
@csrf
<button
type="submit"
class="flex w-full justify-center rounded-lg border border-gray-300 bg-white p-3 text-theme-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
>
Mark All as Read
</button>
</form>
</div>
</div>
<!-- Dropdown End -->
</div>

View File

@@ -0,0 +1,12 @@
<footer class="py-12 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="flex flex-wrap justify-center gap-6 mb-6 text-gray-500 dark:text-gray-400 text-sm font-medium">
<a href="{{ route('contact') }}" class="hover:text-brand-500 transition-colors">Contact</a>
<a href="{{ route('legal.show', 'terms-and-conditions') }}" class="hover:text-brand-500 transition-colors">Terms and Conditions</a>
<a href="{{ route('legal.show', 'privacy-policy') }}" class="hover:text-brand-500 transition-colors">Privacy Policy</a>
</div>
<p class="text-gray-500 dark:text-gray-400 text-sm font-medium">
&copy; {{ date('Y') }} {{ config('app.name') }}. Built for security and performance.
</p>
</div>
</footer>

View File

@@ -0,0 +1,127 @@
<nav class="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg border-b border-gray-100 dark:border-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-20">
<!-- Logo -->
<a href="/" class="flex items-center gap-3">
<div class="p-2 bg-brand-500 rounded-xl shadow-lg shadow-brand-500/20">
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<span class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-500 dark:from-white dark:to-gray-400">
{{ config('app.name') }}
</span>
</a>
<!-- Desktop Menu -->
<div class="hidden md:flex items-center gap-8 text-sm font-medium">
<a href="{{ route('home') }}" class="text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors">Home</a>
<a href="{{ route('home') }}#features" @if(Route::is('home')) @click.prevent="window.appSmoothScroll('#features')" @endif class="text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors">Features</a>
<!-- Tools Dropdown -->
<div class="relative" x-data="{ open: false }" @click.away="open = false">
<button @click="open = !open" class="flex items-center gap-1 text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors">
Tools
<svg class="w-4 h-4 transition-transform duration-200" :class="{ 'rotate-180': open }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-1"
style="display: none;"
class="absolute left-0 mt-3 w-64 rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-100 dark:border-gray-700 py-2 z-50 overflow-hidden">
<a href="{{ route('tools.chat-id-finder') }}" class="flex items-center gap-3 px-4 py-3 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:text-brand-500 transition-all">
<div class="w-8 h-8 bg-brand-50 dark:bg-brand-500/10 rounded-lg flex items-center justify-center text-brand-500">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
</div>
<div>
<div class="font-bold">Chat ID Finder</div>
<div class="text-[10px] text-gray-400">Find your Telegram ID</div>
</div>
</a>
<a href="{{ route('tools.app-key-generator') }}" class="flex items-center gap-3 px-4 py-3 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:text-brand-500 transition-all">
<div class="w-8 h-8 bg-blue-50 dark:bg-blue-500/10 rounded-lg flex items-center justify-center text-blue-500">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
</div>
<div>
<div class="font-bold">App Key Generator</div>
<div class="text-[10px] text-gray-400">Secure Laravel keys</div>
</div>
</a>
</div>
</div>
<a href="{{ route('contact') }}" class="text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors">Contact</a>
<a href="{{ route('signin') }}" class="text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors">Sign In</a>
<!-- Theme Toggle -->
<button @click.prevent="$store.theme.toggle()" class="p-2 ml-2 rounded-xl bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg x-show="$store.theme.theme === 'light'" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<svg x-show="$store.theme.theme === 'dark'" style="display: none;" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 9H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</button>
<a href="{{ route('signup') }}" class="px-5 py-2.5 bg-brand-500 hover:bg-brand-600 text-white rounded-xl font-semibold shadow-lg shadow-brand-500/25 transition-all hover:scale-105">
Get Started
</a>
</div>
<!-- Mobile Header Actions -->
<div class="md:hidden flex items-center gap-2">
<button @click.prevent="$store.theme.toggle()" class="p-2 rounded-xl bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
<svg x-show="$store.theme.theme === 'light'" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<svg x-show="$store.theme.theme === 'dark'" style="display: none;" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 9H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</button>
<button @click.prevent="$store.sidebar.toggleMobileOpen()" class="p-2 text-gray-600 dark:text-gray-400">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
</svg>
</button>
</div>
</div>
</div>
</nav>
<!-- Mobile Navigation Overlay -->
<div x-show="$store.sidebar.isMobileOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 -translate-y-full"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-full"
style="display: none;"
class="fixed inset-0 z-40 md:hidden bg-white dark:bg-gray-900 pt-24 px-6">
<div class="flex flex-col gap-6">
<a href="{{ route('home') }}" @click="$store.sidebar.setMobileOpen(false)" class="text-lg font-bold text-gray-900 dark:text-white border-b border-gray-100 dark:border-gray-800 pb-4">Home</a>
<a href="{{ route('home') }}#features" @click="$store.sidebar.setMobileOpen(false); if(Route::is('home')) window.appSmoothScroll('#features')" class="text-lg font-bold text-gray-900 dark:text-white border-b border-gray-100 dark:border-gray-800 pb-4">Features</a>
<div x-data="{ expanded: false }">
<button @click="expanded = !expanded" class="w-full flex justify-between items-center text-lg font-bold text-gray-900 dark:text-white border-b border-gray-100 dark:border-gray-800 pb-4">
Tools
<svg class="w-5 h-5 transition-transform" :class="{ 'rotate-180': expanded }" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path d="M19 9l-7 7-7-7" /></svg>
</button>
<div x-show="expanded" class="pl-4 pt-4 space-y-4" style="display: none;">
<a href="{{ route('tools.chat-id-finder') }}" class="block text-gray-600 dark:text-gray-400 font-medium">Chat ID Finder</a>
<a href="{{ route('tools.app-key-generator') }}" class="block text-gray-600 dark:text-gray-400 font-medium">App Key Generator</a>
</div>
</div>
<a href="{{ route('contact') }}" class="text-lg font-bold text-gray-900 dark:text-white border-b border-gray-100 dark:border-gray-800 pb-4">Contact</a>
<a href="{{ route('signin') }}" class="text-lg font-bold text-gray-900 dark:text-white border-b border-gray-100 dark:border-gray-800 pb-4">Sign In</a>
<a href="{{ route('signup') }}" class="mt-6 w-full py-4 bg-brand-500 text-white rounded-2xl font-bold text-center shadow-xl shadow-brand-500/20">Get Started</a>
</div>
</div>

View File

@@ -0,0 +1,226 @@
<div x-data="{
isOpen: false,
search: '',
results: {},
selectedIndex: -1,
isLoading: false,
init() {
// Global Hotkey
window.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
this.toggle();
}
if (e.key === 'Escape' && this.isOpen) {
this.toggle();
}
});
// Listen for internal trigger
window.addEventListener('open-global-search', () => this.open());
},
open() {
this.isOpen = true;
this.search = '';
this.results = {};
this.selectedIndex = -1;
this.$nextTick(() => {
$refs.searchInput.focus();
this.fetchResults(); // Fetch initial navigation
});
document.body.classList.add('overflow-hidden');
},
close() {
this.isOpen = false;
document.body.classList.remove('overflow-hidden');
},
toggle() {
this.isOpen ? this.close() : this.open();
},
async fetchResults() {
// We still fetch if search.length < 2 to get the default navigation
this.isLoading = true;
try {
const query = this.search.length >= 2 ? encodeURIComponent(this.search) : '';
const response = await fetch(`/search/global?q=${query}`);
this.results = await response.json();
this.selectedIndex = -1;
} catch (error) {
console.error('Search failed:', error);
} finally {
this.isLoading = false;
}
},
get flatResults() {
const flat = [];
Object.keys(this.results).forEach(group => {
this.results[group].forEach(item => {
flat.push({ ...item, group });
});
});
return flat;
},
navigate(direction) {
const total = this.flatResults.length;
if (total === 0) return;
if (direction === 'down') {
this.selectedIndex = (this.selectedIndex + 1) % total;
} else {
this.selectedIndex = (this.selectedIndex - 1 + total) % total;
}
// Scroll select into view if needed
this.$nextTick(() => {
const el = document.getElementById(`search-result-${this.selectedIndex}`);
if (el) el.scrollIntoView({ block: 'nearest' });
});
},
select() {
const item = this.flatResults[this.selectedIndex];
if (item) {
window.location.href = item.url;
}
}
}" @keydown.window.escape="close()" x-show="isOpen"
class="fixed inset-0 z-[99999] flex items-start justify-center pt-20 sm:pt-32"
style="display: none;" x-cloak>
<!-- Backdrop (Click to close) -->
<div x-show="isOpen"
@click="close()"
x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm shadow-inner"></div>
<!-- Modal Content -->
<div x-show="isOpen"
@click.away="close()"
x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="relative w-full max-w-2xl px-4 mx-auto">
<div class="overflow-hidden bg-white rounded-2xl shadow-2xl dark:bg-gray-900 border border-gray-200 dark:border-gray-800 ring-1 ring-black/5">
<!-- Search Input -->
<div class="relative flex items-center px-6 py-5 border-b border-gray-100 dark:border-gray-800">
<svg class="w-6 h-6 text-gray-400 dark:text-gray-500 mr-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input x-ref="searchInput"
x-model="search"
@input.debounce.300ms="fetchResults()"
@keydown.down.prevent="navigate('down')"
@keydown.up.prevent="navigate('up')"
@keydown.enter.prevent="select()"
type="text"
class="w-full py-2 text-xl text-gray-800 bg-transparent border-none focus:ring-0 dark:text-gray-200 placeholder:text-gray-400"
placeholder="Search certificates, tickets, or try 'Settings'...">
<!-- Loading Indicator -->
<div x-show="isLoading" class="absolute right-20 top-1/2 -translate-y-1/2">
<svg class="w-5 h-5 animate-spin text-brand-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- ESC Button UI -->
<div @click="close()"
class="cursor-pointer ml-4 hidden sm:flex items-center gap-1 px-2.5 py-1.5 text-xs font-bold text-gray-400 bg-gray-100 hover:bg-gray-200 rounded-lg dark:bg-white/5 dark:text-gray-500 dark:hover:bg-white/10 border border-gray-200 dark:border-gray-800 transition-all active:scale-95">
<span class="text-[10px]">ESC</span>
</div>
</div>
<!-- Results Area -->
<div class="max-h-[60vh] overflow-y-auto p-2 scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-800">
<template x-if="Object.keys(results).length === 0 && !isLoading">
<div class="px-4 py-12 text-center text-gray-500">
<template x-if="search.length < 2">
<p class="text-sm">Type to search for specific items...</p>
</template>
<template x-if="search.length >= 2">
<p class="text-sm">No results found for "<span class="font-medium text-gray-800 dark:text-gray-200" x-text="search"></span>"</p>
</template>
<div class="mt-6 flex flex-wrap justify-center gap-2 opacity-50">
<span class="px-2 py-1 text-[11px] bg-gray-50 dark:bg-white/5 rounded border border-gray-100 dark:border-gray-800"> K to toggle</span>
<span class="px-2 py-1 text-[11px] bg-gray-50 dark:bg-white/5 rounded border border-gray-100 dark:border-gray-800">↑↓ to navigate</span>
<span class="px-2 py-1 text-[11px] bg-gray-50 dark:bg-white/5 rounded border border-gray-100 dark:border-gray-800"> to select</span>
</div>
</div>
</template>
<div class="space-y-4">
<template x-for="(items, group) in results" :key="group">
<div>
<h3 class="px-3 py-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500" x-text="group"></h3>
<div class="space-y-1">
<template x-for="(item, index) in items" :key="item.url">
@php $flatIndex = 'flatResults.findIndex(f => f.url === item.url)'; @endphp
<a :id="'search-result-' + flatResults.findIndex(f => f.url === item.url)"
:href="item.url"
@mouseenter="selectedIndex = flatResults.findIndex(f => f.url === item.url)"
class="flex items-center gap-3 px-3 py-3 text-sm transition-colors rounded-xl group"
:class="selectedIndex === flatResults.findIndex(f => f.url === item.url) ? 'bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/5'">
<!-- Icon Wrapper -->
<div class="flex items-center justify-center w-9 h-9 min-w-9 rounded-lg bg-gray-100 group-hover:bg-white dark:bg-gray-800 dark:group-hover:bg-gray-700 transition-colors"
:class="selectedIndex === flatResults.findIndex(f => f.url === item.url) ? 'bg-white shadow-sm dark:bg-gray-700' : ''">
<template x-if="item.icon === 'certificate'">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A11.955 11.955 0 0121.056 12a11.955 11.955 0 01-2.944 5.96z" /></svg>
</template>
<template x-if="item.icon === 'user'">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
</template>
<template x-if="item.icon === 'ticket'">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 012-2h10a2 2 0 012 2v14a2 2 0 01-2 2H7a2 2 0 01-2-2V5z" /></svg>
</template>
<template x-if="item.icon === 'home'">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>
</template>
<template x-if="item.icon === 'key'">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 11-7.743-5.743L11 7.001M11 7H9v2H7v2H4v3l2 2h3.5" /></svg>
</template>
<template x-if="item.icon === 'shield'">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A11.955 11.955 0 0121.056 12a11.955 11.955 0 01-2.944 5.96z" /></svg>
</template>
<template x-if="item.icon === 'users'">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
</template>
<template x-if="!['certificate', 'user', 'ticket', 'home', 'shield', 'users', 'key'].includes(item.icon)">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</template>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="font-medium truncate" x-text="item.label"></p>
<span x-show="selectedIndex === flatResults.findIndex(f => f.url === item.url)" class="text-[10px] text-brand-500 font-bold">ENTER </span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate" x-text="item.sublabel || item.url"></p>
</div>
</a>
</template>
</div>
</div>
</template>
</div>
</div>
<!-- Footer -->
<div class="px-4 py-3 bg-gray-50 dark:bg-white/[0.02] border-t border-gray-100 dark:border-gray-800 flex items-center justify-between text-[11px] text-gray-400">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1"><kbd class="px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-sm font-sans">↑↓</kbd> Navigate</span>
<span class="flex items-center gap-1"><kbd class="px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-sm font-sans"></kbd> Select</span>
<span class="flex items-center gap-1"><kbd class="px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-sm font-sans">ESC</kbd> Close</span>
</div>
<div class="font-medium">Command Palette</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,103 @@
<div
x-data="{
toasts: [],
add(data) {
const id = Date.now();
this.toasts.push({
id: id,
title: data.title || 'Notification',
body: data.body || '',
type: data.type || 'info',
icon: data.icon || 'notification',
progress: 100
});
// Handle progress bar animation
const duration = 6000;
const interval = 50;
const step = (interval / duration) * 100;
const timer = setInterval(() => {
const toast = this.toasts.find(t => t.id === id);
if (toast) {
toast.progress -= step;
} else {
clearInterval(timer);
}
}, interval);
setTimeout(() => {
this.remove(id);
clearInterval(timer);
}, duration);
},
remove(id) {
this.toasts = this.toasts.filter(t => t.id !== id);
}
}"
@reverb-notification.window="add($event.detail)"
class="fixed top-24 right-5 sm:right-10 z-[1000] flex flex-col gap-4 w-auto pointer-events-none"
>
<template x-for="toast in toasts" :key="toast.id">
<div
x-show="true"
x-transition:enter="transition ease-out duration-500"
x-transition:enter-start="opacity-0 translate-y-[-20px] translate-x-12 scale-90"
x-transition:enter-end="opacity-100 translate-y-0 translate-x-0 scale-100"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-20"
class="pointer-events-auto relative overflow-hidden rounded-2xl border border-white/20 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur-3xl shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5)] p-5 flex items-start gap-4 transition-all hover:scale-[1.02] w-80 sm:w-96"
>
<!-- Background Accent Glow -->
<div class="absolute -left-10 -top-10 w-24 h-24 blur-3xl opacity-20 pointer-none"
:class="{
'bg-blue-500': toast.type === 'info',
'text-green-500': toast.type === 'success',
'text-yellow-500': toast.type === 'warning',
'text-red-500': toast.type === 'error'
}"></div>
<!-- Icon Ring -->
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center shadow-inner"
:class="{
'bg-blue-500/10 text-blue-600 dark:text-blue-400': toast.type === 'info',
'bg-green-500/10 text-green-600 dark:text-green-400': toast.type === 'success',
'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400': toast.type === 'warning',
'bg-red-500/10 text-red-600 dark:text-red-400': toast.type === 'error'
}">
<template x-if="toast.icon === 'ticket'">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4v-3a2 2 0 002-2V7a2 2 0 00-2-2H5z"></path></svg>
</template>
<template x-if="toast.icon === 'chat'">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path></svg>
</template>
<template x-if="toast.icon !== 'ticket' && toast.icon !== 'chat'">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</template>
</div>
<!-- Content -->
<div class="flex-1 min-w-0 pr-4">
<h4 class="text-base font-bold text-gray-900 dark:text-white leading-tight" x-text="toast.title"></h4>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 lines-2 font-medium" x-text="toast.body"></p>
</div>
<!-- Close Button -->
<button @click="remove(toast.id)" class="absolute top-4 right-4 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<svg class="w-4 h-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>
<!-- Progress Bar -->
<div class="absolute bottom-0 left-0 h-1 transition-all ease-linear"
:style="`width: ${toast.progress}%`"
:class="{
'bg-blue-500': toast.type === 'info',
'bg-green-500': toast.type === 'success',
'bg-yellow-500': toast.type === 'warning',
'bg-red-500': toast.type === 'error'
}"></div>
</div>
</template>
</div>