mirror of
https://github.com/KevinMidboe/infra-map.git
synced 2025-10-29 17:40:28 +00:00
Globally search between pages with meta + J
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { clickOutside } from '$lib/utils/mouseEvents';
|
||||
|
||||
export let title: string;
|
||||
export let description: string | null = null;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const close = () => dispatch('close');
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string | null;
|
||||
close(): void
|
||||
}
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
close
|
||||
}: Props = $props()
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
@@ -40,7 +45,7 @@
|
||||
class="dialog"
|
||||
>
|
||||
<div tabindex="-1" id="dialog-title" class="title">
|
||||
{#if title.length || description.length}
|
||||
{#if title.length || description?.length}
|
||||
<header>
|
||||
<button on:click={close} aria-disabled="false" aria-label="Close" type="button" tabindex="0"
|
||||
><svg viewBox="0 0 24 24" aria-hidden="true" tabindex="-1" height="100%" width="100%"
|
||||
|
||||
@@ -1,19 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { goto, pushState } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Dialog from './Dialog.svelte';
|
||||
import Input from './Input.svelte';
|
||||
import { allRoutes } from '$lib/remote/filesystem.remote.ts';
|
||||
import type { PageRoute } from '$lib/remote/filesystem.remote.ts';
|
||||
|
||||
let open = $state(false);
|
||||
type ShortcutHandler = (event: KeyboardEvent) => void;
|
||||
interface Shortcut {
|
||||
keys: string[];
|
||||
handler: ShortcutHandler;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MinimalElement {
|
||||
name: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface OverlayData {
|
||||
type: 'elements' | 'pages' | null;
|
||||
content: MinimalElement | unknown;
|
||||
}
|
||||
|
||||
class KeyboardShortcutManager {
|
||||
private shortcuts: Shortcut[] = [];
|
||||
|
||||
constructor() {
|
||||
window.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
register(shortcut: Shortcut) {
|
||||
this.shortcuts.push(shortcut);
|
||||
}
|
||||
|
||||
unregisterAll() {
|
||||
this.shortcuts = [];
|
||||
window.removeEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
private handleKeydown = (event: KeyboardEvent) => {
|
||||
const pressedKeys = [
|
||||
event.metaKey ? 'Meta' : '',
|
||||
event.ctrlKey ? 'Control' : '',
|
||||
event.shiftKey ? 'Shift' : '',
|
||||
event.altKey ? 'Alt' : '',
|
||||
event.key.toUpperCase()
|
||||
].filter(Boolean);
|
||||
|
||||
for (const shortcut of this.shortcuts) {
|
||||
if (this.isMatch(shortcut.keys, pressedKeys)) {
|
||||
event.preventDefault();
|
||||
shortcut.handler(event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// some other key, but not overlay is not open. Nothing to do
|
||||
if (!overlayStore.type) return;
|
||||
|
||||
// listen for text, any letter should reset focusIndex
|
||||
const singleLetter = (event.key.length == 1 && event.key.match(/\D/)) || 0 > 0;
|
||||
if (singleLetter) {
|
||||
updateFocus(0);
|
||||
}
|
||||
|
||||
// listen for number as shortcut actions
|
||||
const digit = event.key.match(/\d/)?.[0];
|
||||
if (digit?.length && digit?.length > 0) {
|
||||
setTimeout(() => {
|
||||
filterString = String(filterString)?.replaceAll(digit, '');
|
||||
}, 1);
|
||||
|
||||
updateFocus(Number(digit) - 1);
|
||||
}
|
||||
|
||||
// listen for arrow keys
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
updateFocus(focusIndex + 1 * direction);
|
||||
}
|
||||
|
||||
// listen for enter key
|
||||
if (event.key === 'Enter' && filteredchildren.length > 0) {
|
||||
const { link, path } = filteredchildren[focusIndex];
|
||||
hideOverlay();
|
||||
openElement(link || path);
|
||||
}
|
||||
};
|
||||
|
||||
private isMatch(shortcutKeys: string[], pressedKeys: string[]) {
|
||||
return (
|
||||
shortcutKeys.length === pressedKeys.length &&
|
||||
shortcutKeys.every((key) => pressedKeys.includes(key))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let overlayStore: OverlayData = $state({ type: null, content: null });
|
||||
let pages: Array<PageRoute> = $state([]);
|
||||
|
||||
function showOverlay(type: 'elements' | 'pages', content: unknown) {
|
||||
if (type === overlayStore.type) return hideOverlay();
|
||||
|
||||
overlayStore = { type, content };
|
||||
focusSearchInput();
|
||||
filterString = '';
|
||||
updateFocus(0);
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
overlayStore = { type: null, content: null };
|
||||
}
|
||||
|
||||
async function resolvePages() {
|
||||
pages = await allRoutes();
|
||||
}
|
||||
|
||||
// call as soon as possible, even if blocking
|
||||
resolvePages();
|
||||
|
||||
// setup managers
|
||||
let manager: KeyboardShortcutManager;
|
||||
const className = 'search-container';
|
||||
|
||||
/* search & filter */
|
||||
// search & filter
|
||||
let filterString = $state('');
|
||||
let focusIndex = $state(0);
|
||||
let children = $state([]);
|
||||
|
||||
let filteredchildren = $derived.by(() => {
|
||||
return children.filter((a) => a?.name.toLowerCase().includes(filterString));
|
||||
return overlayStore.content?.filter((a) => a?.name.toLowerCase().includes(filterString));
|
||||
});
|
||||
|
||||
const updateFocus = (index: number) => {
|
||||
@@ -25,12 +141,10 @@
|
||||
focusIndex = index;
|
||||
};
|
||||
|
||||
/* setup & register */
|
||||
const toggleSearchDialog = () => (open = !open);
|
||||
// setup & register
|
||||
const focusSearchInput = () => {
|
||||
setTimeout(() => {
|
||||
const input = document.getElementsByClassName(className)[0]?.getElementsByTagName('input')[0];
|
||||
|
||||
input.focus();
|
||||
}, 50);
|
||||
};
|
||||
@@ -43,64 +157,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
function registerShortcutKey() {
|
||||
document.addEventListener('keydown', function (event) {
|
||||
// listen for open/close command + k
|
||||
if ((event.metaKey && event.key === 'k') || (event.ctrlKey && event.key === 'k')) {
|
||||
event.preventDefault();
|
||||
console.log('Command + K / Ctrl + K was pressed');
|
||||
|
||||
toggleSearchDialog();
|
||||
|
||||
// initial state
|
||||
if (open) {
|
||||
focusSearchInput();
|
||||
filterString = '';
|
||||
updateFocus(0);
|
||||
children = window?.elements || [{ name: 'empty' }];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// listen for text, any letter should reset focusIndex
|
||||
const singleLetter = (event.key.length == 1 && event.key.match(/\D/)) || 0 > 0;
|
||||
if (open && singleLetter) {
|
||||
updateFocus(0);
|
||||
}
|
||||
|
||||
// listen for number as shortcut actions
|
||||
const digit = event.key.match(/\d/)?.[0];
|
||||
if (open && digit?.length && digit?.length > 0) {
|
||||
setTimeout(() => {
|
||||
filterString = String(filterString)?.replaceAll(digit, '');
|
||||
}, 1);
|
||||
|
||||
updateFocus(Number(digit) - 1);
|
||||
}
|
||||
|
||||
// listen for arrow keys
|
||||
if (open && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
updateFocus(focusIndex + 1 * direction);
|
||||
}
|
||||
|
||||
// listen for enter key
|
||||
if (open && event.key === 'Enter' && filteredchildren.length > 0) {
|
||||
const { link } = filteredchildren[focusIndex];
|
||||
toggleSearchDialog();
|
||||
|
||||
openElement(link);
|
||||
onMount(() => {
|
||||
manager = new KeyboardShortcutManager();
|
||||
manager.register({
|
||||
keys: ['Meta', 'K'],
|
||||
handler: () => {
|
||||
if (!window || !('elements' in window)) return;
|
||||
const elements = window?.elements;
|
||||
showOverlay('elements', elements);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
registerShortcutKey();
|
||||
manager.register({
|
||||
keys: ['Meta', 'J'],
|
||||
handler: () => showOverlay('pages', pages)
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => manager?.unregisterAll());
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<Dialog on:close={() => (open = false)} title="" description="">
|
||||
{#if overlayStore.type}
|
||||
<Dialog close={hideOverlay} title="" description="">
|
||||
<div class={className}>
|
||||
<Input label="" bind:value={filterString} placeholder="Search anything..." />
|
||||
|
||||
@@ -108,7 +186,7 @@
|
||||
{#each filteredchildren as element, index (element)}
|
||||
<li
|
||||
class={index === focusIndex ? 'focus' : ''}
|
||||
on:click={() => openElement(element?.link) ?? '/'}
|
||||
on:click={() => 'link' in element && openElement(element.link ?? '/')}
|
||||
>
|
||||
<div class="header">
|
||||
<h3>{element?.name}</h3>
|
||||
|
||||
@@ -78,12 +78,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="liveimage">
|
||||
{#if !fullscreen}
|
||||
<img on:click={() => (fullscreen = !fullscreen)} src={String(imageSource)} id="live-image" />
|
||||
{:else}
|
||||
<div class="fullscreen-container">
|
||||
<Dialog title="Live stream of printer" on:close={() => (fullscreen = false)}>
|
||||
<Dialog title="Live stream of printer" close={() => (fullscreen = false)}>
|
||||
<img style="width: 100%;" src={String(imageSource)} id="live-image" />
|
||||
<span>Last update {timestamp}s ago</span>
|
||||
</Dialog>
|
||||
@@ -95,8 +95,9 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
img {
|
||||
width: 400px;
|
||||
.liveimage img {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
columns={['Domain', 'SSL', 'Status', 'Code']}
|
||||
>
|
||||
<tbody slot="tbody">
|
||||
{#each httpHealth as row, i (row)}
|
||||
{#each httpHealth as row (row)}
|
||||
<tr>
|
||||
<td>{row.domain}</td>
|
||||
<td>
|
||||
@@ -48,7 +48,7 @@
|
||||
</Table>
|
||||
|
||||
{#if selectedSSL !== null}
|
||||
<Dialog on:close={() => (selectedSSL = null)} title="SSL Certificate info">
|
||||
<Dialog close={() => (selectedSSL = null)} title="SSL Certificate info">
|
||||
<JsonViewer json={selectedSSL} />
|
||||
</Dialog>
|
||||
{/if}
|
||||
|
||||
@@ -247,7 +247,7 @@
|
||||
</div>
|
||||
|
||||
<tbody slot="tbody">
|
||||
{#each filament as row, i (row)}
|
||||
{#each filament as row (row)}
|
||||
<tr class="link" on:click={() => goto(filamentLink(row))}>
|
||||
<td><span class="color" style={`background: ${row.hex}`} /></td>
|
||||
<td class="info">
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
{#if open}
|
||||
<Dialog
|
||||
on:close={() => (open = false)}
|
||||
close={() => (open = false)}
|
||||
title="Add new filament"
|
||||
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
|
||||
>
|
||||
@@ -335,7 +335,6 @@
|
||||
&.info {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
padding-left: 25px;
|
||||
|
||||
h2 {
|
||||
width: 100%;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
{#if open}
|
||||
<Dialog
|
||||
on:close={() => (open = false)}
|
||||
close={() => (open = false)}
|
||||
title="Add new site"
|
||||
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user