feat(CommandPalette): introduce Command Palette component for enhanced navigation and action execution with search functionality
This commit is contained in:
362
meshchatx/src/frontend/components/CommandPalette.vue
Normal file
362
meshchatx/src/frontend/components/CommandPalette.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<transition name="slide-down">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-x-0 top-0 z-[200] flex items-start justify-center p-4 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
v-click-outside="close"
|
||||
class="w-full max-w-2xl bg-white/95 dark:bg-zinc-900/95 backdrop-blur-md rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden flex flex-col max-h-[70vh] pointer-events-auto mt-2 sm:mt-8"
|
||||
>
|
||||
<!-- search input -->
|
||||
<div class="relative flex items-center p-4 border-b border-gray-100 dark:border-zinc-800">
|
||||
<MaterialDesignIcon icon-name="magnify" class="size-6 text-gray-400 mr-3" />
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
type="text"
|
||||
class="w-full bg-transparent border-none focus:ring-0 text-gray-900 dark:text-white placeholder-gray-400 text-lg"
|
||||
:placeholder="$t('command_palette.search_placeholder')"
|
||||
@keydown.down.prevent="moveHighlight(1)"
|
||||
@keydown.up.prevent="moveHighlight(-1)"
|
||||
@keydown.enter="executeAction"
|
||||
@keydown.esc="close"
|
||||
/>
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<kbd
|
||||
class="px-2 py-1 text-xs font-semibold text-gray-500 bg-gray-100 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg shadow-sm"
|
||||
>ESC</kbd
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- results -->
|
||||
<div class="flex-1 overflow-y-auto p-2 min-h-0">
|
||||
<div v-if="filteredResults.length === 0" class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ $t("command_palette.no_results", { query: query }) }}
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div v-for="(group, groupName) in groupedResults" :key="groupName">
|
||||
<div class="px-3 py-2 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
{{ $t(`command_palette.${groupName}`) }}
|
||||
</div>
|
||||
<button
|
||||
v-for="result in group"
|
||||
:key="result.id"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 p-3 rounded-xl transition-all text-left group"
|
||||
:class="[
|
||||
highlightedId === result.id
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
|
||||
]"
|
||||
@click="executeResult(result)"
|
||||
@mousemove="highlightedId = result.id"
|
||||
>
|
||||
<div
|
||||
class="size-10 rounded-xl flex items-center justify-center shrink-0 border transition-colors"
|
||||
:class="[
|
||||
highlightedId === result.id
|
||||
? 'bg-blue-100 dark:bg-blue-900/40 border-blue-200 dark:border-blue-800'
|
||||
: 'bg-gray-100 dark:bg-zinc-800 border-gray-200 dark:border-zinc-700',
|
||||
]"
|
||||
>
|
||||
<LxmfUserIcon
|
||||
v-if="result.type === 'contact' || result.type === 'peer'"
|
||||
:icon-name="result.icon"
|
||||
:icon-foreground-colour="result.iconForeground"
|
||||
:icon-background-colour="result.iconBackground"
|
||||
icon-class="size-5"
|
||||
/>
|
||||
<MaterialDesignIcon v-else :icon-name="result.icon" class="size-5" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-bold truncate">{{ result.title }}</div>
|
||||
<div class="text-xs opacity-60 truncate">{{ result.description }}</div>
|
||||
</div>
|
||||
<MaterialDesignIcon
|
||||
v-if="highlightedId === result.id"
|
||||
icon-name="arrow-right"
|
||||
class="size-4 animate-in slide-in-from-left-2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- footer -->
|
||||
<div
|
||||
class="p-3 bg-gray-50/50 dark:bg-zinc-900/50 border-t border-gray-100 dark:border-zinc-800 flex justify-center gap-6 text-[10px] font-bold text-gray-400 uppercase tracking-widest"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd
|
||||
class="px-1.5 py-0.5 bg-white dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded shadow-sm"
|
||||
>↑↓</kbd
|
||||
>
|
||||
<span>{{ $t("command_palette.footer_navigate") }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd
|
||||
class="px-1.5 py-0.5 bg-white dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded shadow-sm"
|
||||
>Enter</kbd
|
||||
>
|
||||
<span>{{ $t("command_palette.footer_select") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
|
||||
import LxmfUserIcon from "./LxmfUserIcon.vue";
|
||||
|
||||
import GlobalEmitter from "../js/GlobalEmitter";
|
||||
|
||||
export default {
|
||||
name: "CommandPalette",
|
||||
components: { MaterialDesignIcon, LxmfUserIcon },
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
query: "",
|
||||
highlightedId: null,
|
||||
peers: [],
|
||||
contacts: [],
|
||||
actions: [
|
||||
{
|
||||
id: "nav-messages",
|
||||
title: "nav_messages",
|
||||
description: "nav_messages_desc",
|
||||
icon: "message-text",
|
||||
type: "navigation",
|
||||
route: { name: "messages" },
|
||||
},
|
||||
{
|
||||
id: "nav-nomad",
|
||||
title: "nav_nomad",
|
||||
description: "nav_nomad_desc",
|
||||
icon: "earth",
|
||||
type: "navigation",
|
||||
route: { name: "nomadnetwork" },
|
||||
},
|
||||
{
|
||||
id: "nav-map",
|
||||
title: "nav_map",
|
||||
description: "nav_map_desc",
|
||||
icon: "map",
|
||||
type: "navigation",
|
||||
route: { name: "map" },
|
||||
},
|
||||
{
|
||||
id: "nav-paper",
|
||||
title: "nav_paper",
|
||||
description: "nav_paper_desc",
|
||||
icon: "qrcode",
|
||||
type: "navigation",
|
||||
route: { name: "paper-message" },
|
||||
},
|
||||
{
|
||||
id: "nav-call",
|
||||
title: "nav_call",
|
||||
description: "nav_call_desc",
|
||||
icon: "phone",
|
||||
type: "navigation",
|
||||
route: { name: "call" },
|
||||
},
|
||||
{
|
||||
id: "nav-settings",
|
||||
title: "nav_settings",
|
||||
description: "nav_settings_desc",
|
||||
icon: "cog",
|
||||
type: "navigation",
|
||||
route: { name: "settings" },
|
||||
},
|
||||
{
|
||||
id: "action-sync",
|
||||
title: "action_sync",
|
||||
description: "action_sync_desc",
|
||||
icon: "refresh",
|
||||
type: "action",
|
||||
action: "sync",
|
||||
},
|
||||
{
|
||||
id: "action-compose",
|
||||
title: "action_compose",
|
||||
description: "action_compose_desc",
|
||||
icon: "email-plus",
|
||||
type: "action",
|
||||
action: "compose",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
allResults() {
|
||||
const results = this.actions.map((action) => ({
|
||||
...action,
|
||||
title: this.$t(`command_palette.${action.title}`),
|
||||
description: this.$t(`command_palette.${action.description}`),
|
||||
}));
|
||||
|
||||
// add peers
|
||||
for (const peer of this.peers) {
|
||||
results.push({
|
||||
id: `peer-${peer.destination_hash}`,
|
||||
title: peer.custom_display_name ?? peer.display_name,
|
||||
description: peer.destination_hash,
|
||||
icon: peer.lxmf_user_icon?.icon_name ?? "account",
|
||||
iconForeground: peer.lxmf_user_icon?.foreground_colour,
|
||||
iconBackground: peer.lxmf_user_icon?.background_colour,
|
||||
type: "peer",
|
||||
peer: peer,
|
||||
});
|
||||
}
|
||||
|
||||
// add contacts
|
||||
for (const contact of this.contacts) {
|
||||
results.push({
|
||||
id: `contact-${contact.id}`,
|
||||
title: contact.name,
|
||||
description: this.$t("app.call") + ` ${contact.remote_identity_hash}`,
|
||||
icon: "phone",
|
||||
type: "contact",
|
||||
contact: contact,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
filteredResults() {
|
||||
if (!this.query) return this.allResults.filter((r) => r.type === "navigation" || r.type === "action");
|
||||
const q = this.query.toLowerCase();
|
||||
return this.allResults.filter(
|
||||
(r) => r.title.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)
|
||||
);
|
||||
},
|
||||
groupedResults() {
|
||||
const groups = {};
|
||||
for (const result of this.filteredResults) {
|
||||
const groupName =
|
||||
result.type === "peer"
|
||||
? "group_recent"
|
||||
: result.type === "contact"
|
||||
? "group_contacts"
|
||||
: "group_actions";
|
||||
if (!groups[groupName]) groups[groupName] = [];
|
||||
groups[groupName].push(result);
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filteredResults: {
|
||||
handler(newResults) {
|
||||
if (
|
||||
newResults.length > 0 &&
|
||||
(!this.highlightedId || !newResults.find((r) => r.id === this.highlightedId))
|
||||
) {
|
||||
this.highlightedId = newResults[0].id;
|
||||
}
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener("keydown", this.handleGlobalKeydown);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener("keydown", this.handleGlobalKeydown);
|
||||
},
|
||||
methods: {
|
||||
handleGlobalKeydown(e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
},
|
||||
async toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
await this.open();
|
||||
}
|
||||
},
|
||||
async open() {
|
||||
this.query = "";
|
||||
this.isOpen = true;
|
||||
this.loadPeersAndContacts();
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input?.focus();
|
||||
});
|
||||
},
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
},
|
||||
async loadPeersAndContacts() {
|
||||
try {
|
||||
// fetch announces for "lxmf.delivery" aspect to get peers
|
||||
const peerResponse = await window.axios.get(`/api/v1/announces`, {
|
||||
params: { aspect: "lxmf.delivery", limit: 20 },
|
||||
});
|
||||
this.peers = peerResponse.data.announces;
|
||||
|
||||
// fetch telephone contacts
|
||||
const contactResponse = await window.axios.get("/api/v1/telephone/contacts");
|
||||
this.contacts = contactResponse.data;
|
||||
} catch (e) {
|
||||
console.error("Failed to load command palette data:", e);
|
||||
}
|
||||
},
|
||||
moveHighlight(step) {
|
||||
const index = this.filteredResults.findIndex((r) => r.id === this.highlightedId);
|
||||
let nextIndex = index + step;
|
||||
if (nextIndex < 0) nextIndex = this.filteredResults.length - 1;
|
||||
if (nextIndex >= this.filteredResults.length) nextIndex = 0;
|
||||
this.highlightedId = this.filteredResults[nextIndex].id;
|
||||
},
|
||||
executeAction() {
|
||||
const result = this.filteredResults.find((r) => r.id === this.highlightedId);
|
||||
if (result) this.executeResult(result);
|
||||
},
|
||||
executeResult(result) {
|
||||
this.close();
|
||||
if (result.type === "navigation") {
|
||||
this.$router.push(result.route);
|
||||
} else if (result.type === "peer") {
|
||||
this.$router.push({ name: "messages", params: { destinationHash: result.peer.destination_hash } });
|
||||
} else if (result.type === "contact") {
|
||||
this.$router.push({ name: "call", query: { destination_hash: result.contact.remote_identity_hash } });
|
||||
} else if (result.type === "action") {
|
||||
if (result.action === "sync") {
|
||||
GlobalEmitter.emit("sync-propagation-node");
|
||||
} else if (result.action === "compose") {
|
||||
this.$router.push({ name: "messages" });
|
||||
this.$nextTick(() => {
|
||||
const input = document.getElementById("compose-input");
|
||||
input?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.98);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user