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">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { clickOutside } from '$lib/utils/mouseEvents';
|
import { clickOutside } from '$lib/utils/mouseEvents';
|
||||||
|
|
||||||
export let title: string;
|
interface Props {
|
||||||
export let description: string | null = null;
|
title: string;
|
||||||
|
description: string | null;
|
||||||
const dispatch = createEventDispatcher();
|
close(): void
|
||||||
const close = () => dispatch('close');
|
}
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
close
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
@@ -40,7 +45,7 @@
|
|||||||
class="dialog"
|
class="dialog"
|
||||||
>
|
>
|
||||||
<div tabindex="-1" id="dialog-title" class="title">
|
<div tabindex="-1" id="dialog-title" class="title">
|
||||||
{#if title.length || description.length}
|
{#if title.length || description?.length}
|
||||||
<header>
|
<header>
|
||||||
<button on:click={close} aria-disabled="false" aria-label="Close" type="button" tabindex="0"
|
<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%"
|
><svg viewBox="0 0 24 24" aria-hidden="true" tabindex="-1" height="100%" width="100%"
|
||||||
|
|||||||
@@ -1,19 +1,135 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto, pushState } from '$app/navigation';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { onMount } from 'svelte';
|
import { goto } from '$app/navigation';
|
||||||
import Dialog from './Dialog.svelte';
|
import Dialog from './Dialog.svelte';
|
||||||
import Input from './Input.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';
|
const className = 'search-container';
|
||||||
|
|
||||||
/* search & filter */
|
// search & filter
|
||||||
let filterString = $state('');
|
let filterString = $state('');
|
||||||
let focusIndex = $state(0);
|
let focusIndex = $state(0);
|
||||||
let children = $state([]);
|
|
||||||
|
|
||||||
let filteredchildren = $derived.by(() => {
|
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) => {
|
const updateFocus = (index: number) => {
|
||||||
@@ -25,12 +141,10 @@
|
|||||||
focusIndex = index;
|
focusIndex = index;
|
||||||
};
|
};
|
||||||
|
|
||||||
/* setup & register */
|
// setup & register
|
||||||
const toggleSearchDialog = () => (open = !open);
|
|
||||||
const focusSearchInput = () => {
|
const focusSearchInput = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const input = document.getElementsByClassName(className)[0]?.getElementsByTagName('input')[0];
|
const input = document.getElementsByClassName(className)[0]?.getElementsByTagName('input')[0];
|
||||||
|
|
||||||
input.focus();
|
input.focus();
|
||||||
}, 50);
|
}, 50);
|
||||||
};
|
};
|
||||||
@@ -43,64 +157,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerShortcutKey() {
|
onMount(() => {
|
||||||
document.addEventListener('keydown', function (event) {
|
manager = new KeyboardShortcutManager();
|
||||||
// listen for open/close command + k
|
manager.register({
|
||||||
if ((event.metaKey && event.key === 'k') || (event.ctrlKey && event.key === 'k')) {
|
keys: ['Meta', 'K'],
|
||||||
event.preventDefault();
|
handler: () => {
|
||||||
console.log('Command + K / Ctrl + K was pressed');
|
if (!window || !('elements' in window)) return;
|
||||||
|
const elements = window?.elements;
|
||||||
toggleSearchDialog();
|
showOverlay('elements', elements);
|
||||||
|
|
||||||
// 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.register({
|
||||||
registerShortcutKey();
|
keys: ['Meta', 'J'],
|
||||||
|
handler: () => showOverlay('pages', pages)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => manager?.unregisterAll());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if open}
|
{#if overlayStore.type}
|
||||||
<Dialog on:close={() => (open = false)} title="" description="">
|
<Dialog close={hideOverlay} title="" description="">
|
||||||
<div class={className}>
|
<div class={className}>
|
||||||
<Input label="" bind:value={filterString} placeholder="Search anything..." />
|
<Input label="" bind:value={filterString} placeholder="Search anything..." />
|
||||||
|
|
||||||
@@ -108,7 +186,7 @@
|
|||||||
{#each filteredchildren as element, index (element)}
|
{#each filteredchildren as element, index (element)}
|
||||||
<li
|
<li
|
||||||
class={index === focusIndex ? 'focus' : ''}
|
class={index === focusIndex ? 'focus' : ''}
|
||||||
on:click={() => openElement(element?.link) ?? '/'}
|
on:click={() => 'link' in element && openElement(element.link ?? '/')}
|
||||||
>
|
>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h3>{element?.name}</h3>
|
<h3>{element?.name}</h3>
|
||||||
|
|||||||
@@ -78,12 +78,12 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div class="liveimage">
|
||||||
{#if !fullscreen}
|
{#if !fullscreen}
|
||||||
<img on:click={() => (fullscreen = !fullscreen)} src={String(imageSource)} id="live-image" />
|
<img on:click={() => (fullscreen = !fullscreen)} src={String(imageSource)} id="live-image" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="fullscreen-container">
|
<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" />
|
<img style="width: 100%;" src={String(imageSource)} id="live-image" />
|
||||||
<span>Last update {timestamp}s ago</span>
|
<span>Last update {timestamp}s ago</span>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -95,8 +95,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
img {
|
.liveimage img {
|
||||||
width: 400px;
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
columns={['Domain', 'SSL', 'Status', 'Code']}
|
columns={['Domain', 'SSL', 'Status', 'Code']}
|
||||||
>
|
>
|
||||||
<tbody slot="tbody">
|
<tbody slot="tbody">
|
||||||
{#each httpHealth as row, i (row)}
|
{#each httpHealth as row (row)}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{row.domain}</td>
|
<td>{row.domain}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{#if selectedSSL !== null}
|
{#if selectedSSL !== null}
|
||||||
<Dialog on:close={() => (selectedSSL = null)} title="SSL Certificate info">
|
<Dialog close={() => (selectedSSL = null)} title="SSL Certificate info">
|
||||||
<JsonViewer json={selectedSSL} />
|
<JsonViewer json={selectedSSL} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -247,7 +247,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<tbody slot="tbody">
|
<tbody slot="tbody">
|
||||||
{#each filament as row, i (row)}
|
{#each filament as row (row)}
|
||||||
<tr class="link" on:click={() => goto(filamentLink(row))}>
|
<tr class="link" on:click={() => goto(filamentLink(row))}>
|
||||||
<td><span class="color" style={`background: ${row.hex}`} /></td>
|
<td><span class="color" style={`background: ${row.hex}`} /></td>
|
||||||
<td class="info">
|
<td class="info">
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<Dialog
|
<Dialog
|
||||||
on:close={() => (open = false)}
|
close={() => (open = false)}
|
||||||
title="Add new filament"
|
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."
|
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 {
|
&.info {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
padding-left: 25px;
|
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<Dialog
|
<Dialog
|
||||||
on:close={() => (open = false)}
|
close={() => (open = false)}
|
||||||
title="Add new site"
|
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."
|
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