feat(CommandPalette): introduce Command Palette component for enhanced navigation and action execution with search functionality

This commit is contained in:
2026-01-02 17:27:21 -06:00
parent eb8d8ce75d
commit ece1414079

View 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>