mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-08 05:17:49 +02:00
Added CTRL+p for quick navigation
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { menu, topMenu } from '$lib/consts/navigation';
|
||||
import { shouldHideMenuItem } from '$lib/utils/common';
|
||||
// external
|
||||
export let visible = false;
|
||||
export let toggleChangeCompanyModal = () => {};
|
||||
|
||||
// local state
|
||||
let searchQuery = '';
|
||||
let filteredItems = [];
|
||||
let selectedIndex = 0;
|
||||
let searchInput;
|
||||
|
||||
let allItems = [];
|
||||
|
||||
// build searchable items from navigation
|
||||
const buildSearchableItems = () => {
|
||||
const items = [];
|
||||
|
||||
// add all routes from menu structure
|
||||
menu.forEach((section) => {
|
||||
section.items.forEach((item) => {
|
||||
if (!shouldHideMenuItem(item)) {
|
||||
items.push({
|
||||
label: item.label,
|
||||
route: item.route,
|
||||
category: section.label
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// add top menu items
|
||||
topMenu.forEach((item) => {
|
||||
if (!shouldHideMenuItem(item)) {
|
||||
if (item.external) {
|
||||
items.push({
|
||||
label: item.label,
|
||||
url: item.route,
|
||||
category: 'Account',
|
||||
external: true
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: item.label,
|
||||
route: item.route,
|
||||
category: 'Account'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// add special pages not in main navigation
|
||||
items.push({
|
||||
label: 'Orphaned Recipients',
|
||||
route: '/recipient/orphaned/',
|
||||
category: 'Recipients'
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: 'Recipient Groups',
|
||||
route: '/recipient/group/',
|
||||
category: 'Recipients'
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: 'System Update',
|
||||
route: '/settings/update/',
|
||||
category: 'Settings'
|
||||
});
|
||||
|
||||
// add actions (not navigation)
|
||||
items.push({
|
||||
label: 'Switch Company',
|
||||
action: 'changeCompany',
|
||||
category: 'Account'
|
||||
});
|
||||
|
||||
// add development mode links (open in new window)
|
||||
if (import.meta.env.DEV) {
|
||||
items.push({
|
||||
label: 'Database',
|
||||
url: 'http://localhost:8101',
|
||||
category: 'Development',
|
||||
external: true
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: 'Mailbox',
|
||||
url: 'http://localhost:8102',
|
||||
category: 'Development',
|
||||
external: true
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: 'Logs',
|
||||
url: 'http://localhost:8103',
|
||||
category: 'Development',
|
||||
external: true
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: 'Stats',
|
||||
url: 'http://localhost:8104',
|
||||
category: 'Development',
|
||||
external: true
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// filter items based on search query
|
||||
const filterItems = (query) => {
|
||||
if (!query.trim()) {
|
||||
return allItems.slice(0, 10); // show first 10 items when no query
|
||||
}
|
||||
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
const filtered = allItems.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(lowercaseQuery) ||
|
||||
item.category.toLowerCase().includes(lowercaseQuery)
|
||||
);
|
||||
|
||||
// sort by relevance - exact matches first, then starts with, then contains
|
||||
return filtered
|
||||
.sort((a, b) => {
|
||||
const aLabel = a.label.toLowerCase();
|
||||
const bLabel = b.label.toLowerCase();
|
||||
|
||||
if (aLabel === lowercaseQuery) return -1;
|
||||
if (bLabel === lowercaseQuery) return 1;
|
||||
if (aLabel.startsWith(lowercaseQuery)) return -1;
|
||||
if (bLabel.startsWith(lowercaseQuery)) return 1;
|
||||
return 0;
|
||||
})
|
||||
.slice(0, 10); // limit to 10 results
|
||||
};
|
||||
|
||||
// handle keyboard events
|
||||
const handleKeydown = (e) => {
|
||||
if (!visible) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
close();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (filteredItems[selectedIndex]) {
|
||||
navigateToItem(filteredItems[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// global keyboard shortcut handler
|
||||
const handleGlobalKeydown = (e) => {
|
||||
if (e.ctrlKey && e.key === 'p') {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
// navigate to selected item or execute action
|
||||
const navigateToItem = (item) => {
|
||||
if (item.route) {
|
||||
goto(item.route);
|
||||
close();
|
||||
} else if (item.action === 'changeCompany') {
|
||||
toggleChangeCompanyModal();
|
||||
close();
|
||||
} else if (item.external && item.url) {
|
||||
window.open(item.url, '_blank');
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
// open command palette
|
||||
const open = () => {
|
||||
visible = true;
|
||||
allItems = buildSearchableItems();
|
||||
searchQuery = '';
|
||||
filteredItems = filterItems('');
|
||||
selectedIndex = 0;
|
||||
|
||||
// focus search input after DOM update
|
||||
setTimeout(() => {
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
// close command palette
|
||||
const close = () => {
|
||||
visible = false;
|
||||
searchQuery = '';
|
||||
selectedIndex = 0;
|
||||
};
|
||||
|
||||
// handle search input changes
|
||||
$: {
|
||||
filteredItems = filterItems(searchQuery);
|
||||
selectedIndex = Math.min(selectedIndex, Math.max(0, filteredItems.length - 1));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// add global keyboard listener
|
||||
document.addEventListener('keydown', handleGlobalKeydown);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('keydown', handleGlobalKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm z-50 flex items-start justify-center pt-20"
|
||||
on:click|self={close}
|
||||
on:keydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-lg mx-4 overflow-hidden transition-colors duration-200"
|
||||
role="presentation"
|
||||
>
|
||||
<!-- search input -->
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700 transition-colors duration-200">
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
bind:value={searchQuery}
|
||||
type="text"
|
||||
placeholder="search pages..."
|
||||
class="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- results list -->
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
{#if filteredItems.length === 0}
|
||||
<div
|
||||
class="p-4 text-center text-gray-500 dark:text-gray-400 transition-colors duration-200"
|
||||
>
|
||||
no pages found
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredItems as item, index}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center justify-between transition-colors duration-200 {selectedIndex ===
|
||||
index
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 border-r-2 border-blue-500'
|
||||
: ''}"
|
||||
on:click={() => navigateToItem(item)}
|
||||
on:mouseenter={() => (selectedIndex = index)}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-gray-900 dark:text-gray-100 font-medium transition-colors duration-200"
|
||||
>
|
||||
{item.label}
|
||||
{#if item.external}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">↗</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="text-sm text-gray-500 dark:text-gray-400 transition-colors duration-200"
|
||||
>
|
||||
{item.category}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -14,6 +14,7 @@
|
||||
import MobileMenu from '$lib/components/header/MobileMenu.svelte';
|
||||
import RootLoader from '$lib/components/RootLoader.svelte';
|
||||
import ChangeCompanyModal from '$lib/components/modal/ChangeCompanyModal.svelte';
|
||||
import CommandPalette from '$lib/components/modal/CommandPalette.svelte';
|
||||
import DesktopMenu from '$lib/components/header/DesktopMenu.svelte';
|
||||
import { hideIsLoading, showIsLoading } from '$lib/store/loading';
|
||||
import Header from '$lib/components/header/Header.svelte';
|
||||
@@ -38,6 +39,7 @@
|
||||
let isProfileMenuVisible = false;
|
||||
let isMobileMenuVisible = false;
|
||||
let isChangeCompanyModalVisible = false;
|
||||
let isCommandPaletteVisible = false;
|
||||
let isReady = false;
|
||||
|
||||
beforeNavigate((beforeNavigate) => {
|
||||
@@ -209,6 +211,7 @@
|
||||
<Loader />
|
||||
<Toast />
|
||||
<ChangeCompanyModal bind:visible={isChangeCompanyModalVisible} />
|
||||
<CommandPalette bind:visible={isCommandPaletteVisible} {toggleChangeCompanyModal} />
|
||||
<!-- VIEW -->
|
||||
{#if !isReady}
|
||||
<RootLoader />
|
||||
|
||||
Reference in New Issue
Block a user