Feat: Command palette (#105)

* Add command palette with smart navigation and usage tracking

- Add CommandPalette.vue component with keyboard shortcut (Cmd+K/Ctrl+K)
- Implement smart route navigation with parameter input support
- Add content search integration via Elasticsearch
- Create commandTracking.ts utility for usage analytics
- Track command frequency and recency with scoring algorithm
- Support for all application routes with metadata and icons
- Includes badge system for auth requirements and shortcuts

* Integrate CommandPalette into main application

- Add CommandPalette component to App.vue
- Enable global keyboard shortcut (Cmd+K/Ctrl+K)
- Command palette is now accessible from anywhere in the app
This commit is contained in:
2026-03-08 21:29:07 +01:00
committed by GitHub
parent c309016299
commit 0cd2a73a8b
3 changed files with 943 additions and 0 deletions

View File

@@ -13,6 +13,9 @@
<!-- Popup that will show above existing rendered content -->
<popup />
<!-- Command Palette for quick navigation -->
<command-palette />
<darkmode-toggle />
</div>
</template>
@@ -22,6 +25,7 @@
import NavigationHeader from "@/components/header/NavigationHeader.vue";
import NavigationIcons from "@/components/header/NavigationIcons.vue";
import Popup from "@/components/Popup.vue";
import CommandPalette from "@/components/ui/CommandPalette.vue";
import DarkmodeToggle from "@/components/ui/DarkmodeToggle.vue";
const router = useRouter();

View File

@@ -0,0 +1,838 @@
<template>
<transition name="fade">
<div v-if="isOpen" class="command-palette-overlay" @click="close">
<div class="command-palette" @click.stop>
<div class="command-palette__search">
<input
v-if="!parameterMode"
ref="searchInput"
v-model="searchQuery"
type="text"
placeholder="Search routes..."
class="command-palette__input"
@keydown.down.prevent="selectNext"
@keydown.up.prevent="selectPrevious"
@keydown.enter.prevent="navigateToSelected"
@keydown.esc.prevent="close"
@keydown="handleInputKeydown"
/>
<input
v-else
ref="parameterInput"
v-model="parameterValue"
type="text"
:placeholder="`Enter ${parameterName}...`"
class="command-palette__input command-palette__input--parameter"
@keydown.enter.prevent="confirmParameter"
@keydown.esc.prevent="cancelParameter"
/>
</div>
<div v-if="!parameterMode" class="command-palette__results">
<div
v-for="(route, index) in filteredRoutes"
:key="route.path"
:class="[
'command-palette__item',
{ 'command-palette__item--selected': index === selectedIndex }
]"
@click="navigateTo(route)"
@mouseenter="selectedIndex = index"
>
<div class="command-palette__item-left">
<div class="command-palette__item-icon">
<component :is="getRouteIcon(route.name)" />
</div>
<div class="command-palette__item-content">
<div class="command-palette__item-title">
<span class="command-palette__item-name">{{
formatRouteName(route.name)
}}</span>
<span class="command-palette__item-path">
{{ route.path }}
<span
v-if="routeRequiresInput(route)"
class="command-palette__item-param-hint"
>
(requires {{ getInputParameterName(route) }})
</span>
</span>
</div>
<span class="command-palette__item-description">{{
getRouteDescription(route.name)
}}</span>
</div>
</div>
<div class="command-palette__item-right">
<span
v-if="route.meta?.requiresAuth"
class="command-palette__item-badge command-palette__item-badge--auth"
>
🔒 Auth
</span>
<span
v-if="route.meta?.requiresPlexAccount"
class="command-palette__item-badge command-palette__item-badge--plex"
>
Plex
</span>
<span v-if="index < 9" class="command-palette__item-shortcut">
{{ index + 1 }}
</span>
</div>
</div>
<div
v-if="filteredRoutes.length === 0 && contentResults.length === 0"
class="command-palette__empty"
>
<span v-if="isSearchingContent">Searching content...</span>
<span v-else-if="searchDisabled"
>Search temporarily disabled due to errors</span
>
<span v-else>No routes or content found</span>
</div>
<div
v-if="filteredRoutes.length === 0 && contentResults.length > 0"
class="command-palette__content-results"
>
<div class="command-palette__content-header">Movies & Shows</div>
<div
v-for="(result, index) in contentResults"
:key="result.id"
:class="[
'command-palette__item',
{ 'command-palette__item--selected': index === selectedIndex }
]"
@click="openContent(result)"
@mouseenter="selectedIndex = index"
>
<div class="command-palette__item-left">
<div class="command-palette__item-icon">
<component
:is="result.type === 'movie' ? IconMovie : IconShow"
/>
</div>
<div class="command-palette__item-content">
<div class="command-palette__item-title">
<span class="command-palette__item-name">{{
result.title
}}</span>
</div>
<span class="command-palette__item-description">
{{ result.type === "movie" ? "Movie" : "TV Show" }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import type { RouteRecordNormalized } from "vue-router";
import type { Component } from "vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconActivity from "@/icons/IconActivity.vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconInbox from "@/icons/IconInbox.vue";
import IconSearch from "@/icons/IconSearch.vue";
import IconEdit from "@/icons/IconEdit.vue";
import IconSettings from "@/icons/IconSettings.vue";
import IconKey from "@/icons/IconKey.vue";
import IconMagnet from "@/icons/IconMagnet.vue";
import IconProfileLock from "@/icons/IconProfileLock.vue";
import IconShow from "@/icons/IconShow.vue";
import { elasticSearchMoviesAndShows } from "@/api";
import type { IAutocompleteResult } from "@/interfaces/IAutocompleteSearch";
import { trackCommand, getCommandScore } from "@/utils/commandTracking";
const router = useRouter();
const store = useStore();
const isOpen = ref(false);
const searchQuery = ref("");
const selectedIndex = ref(0);
const searchInput = ref<HTMLInputElement | null>(null);
const parameterInput = ref<HTMLInputElement | null>(null);
const parameterMode = ref(false);
const parameterName = ref("");
const parameterValue = ref("");
const pendingRoute = ref<RouteRecordNormalized | null>(null);
const contentResults = ref<IAutocompleteResult[]>([]);
const isSearchingContent = ref(false);
const searchDisabled = ref(false);
const searchErrorCount = ref(0);
const lastSearchTime = ref(0);
const SEARCH_COOLDOWN = 500; // ms between searches
const MAX_ERRORS = 3; // Disable after 3 errors
const routeMetadata: Record<
string,
{
icon: Component;
description: string;
requiresInput?: boolean;
inputParamName?: string;
}
> = {
home: { icon: IconMovie, description: "Browse movies and TV shows" },
activity: { icon: IconActivity, description: "View Plex server activity" },
profile: { icon: IconProfile, description: "Manage your profile" },
"requests-list": null,
list: { icon: IconInbox, description: "Browse custom lists" },
search: {
icon: IconSearch,
description: "Search for content",
requiresInput: true,
inputParamName: "query"
},
register: { icon: IconEdit, description: "Create a new account" },
settings: { icon: IconSettings, description: "Configure your preferences" },
signin: { icon: IconKey, description: "Sign in to your account" },
torrents: { icon: IconMagnet, description: "Manage torrents" },
"password-gen": {
icon: IconKey,
description: "Generate secure passwords"
},
admin: { icon: IconProfileLock, description: "Admin dashboard" }
};
const routes = computed(() => {
return router.getRoutes().filter(route => {
return (
routeMetadata[route?.name?.toString() ?? ""] &&
route.name &&
route.name !== "NotFound"
);
});
});
const filteredRoutes = computed(() => {
let filtered: RouteRecordNormalized[];
if (!searchQuery.value) {
filtered = routes.value;
} else {
const query = searchQuery.value.toLowerCase();
filtered = routes.value.filter(route => {
const name = String(route.name).toLowerCase();
const path = route.path.toLowerCase();
return name.includes(query) || path.includes(query);
});
}
// Sort by command score (most used + recent first)
return filtered.sort((a, b) => {
const scoreA = getCommandScore(String(a.name));
const scoreB = getCommandScore(String(b.name));
return scoreB - scoreA;
});
});
function formatRouteName(name: string | symbol | undefined): string {
if (!name) return "";
const str = String(name);
return str
.split("-")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function getRouteIcon(name: string | symbol | undefined): Component {
if (!name) return IconMovie;
const routeName = String(name);
return routeMetadata[routeName]?.icon || IconMovie;
}
function getRouteDescription(name: string | symbol | undefined): string {
if (!name) return "";
const routeName = String(name);
return routeMetadata[routeName]?.description || "";
}
function open() {
isOpen.value = true;
searchQuery.value = "";
selectedIndex.value = 0;
// Reset search state when opening
searchErrorCount.value = 0;
searchDisabled.value = false;
}
function close() {
isOpen.value = false;
searchQuery.value = "";
selectedIndex.value = 0;
parameterMode.value = false;
parameterName.value = "";
parameterValue.value = "";
pendingRoute.value = null;
}
function scrollSelectedIntoView() {
nextTick(() => {
const selectedElement = document.querySelector(
".command-palette__item--selected"
);
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest"
});
}
});
}
function selectNext() {
const maxIndex = Math.max(
filteredRoutes.value.length - 1,
contentResults.value.length - 1
);
if (selectedIndex.value < maxIndex) {
selectedIndex.value++;
scrollSelectedIntoView();
}
}
function selectPrevious() {
if (selectedIndex.value > 0) {
selectedIndex.value--;
scrollSelectedIntoView();
}
}
function navigateToSelected() {
// Check if we have route results
if (filteredRoutes.value.length > 0) {
const route = filteredRoutes.value[selectedIndex.value];
if (route) {
navigateTo(route);
return;
}
}
// Check if we have content results
if (contentResults.value.length > 0) {
const result = contentResults.value[selectedIndex.value];
if (result) {
openContent(result);
return;
}
}
}
function hasParameter(path: string): boolean {
return path.includes(":");
}
function extractParameterName(path: string): string {
const match = path.match(/:([^/]+)/);
return match ? match[1] : "";
}
function routeRequiresInput(route: RouteRecordNormalized): boolean {
const routeName = String(route.name);
return routeMetadata[routeName]?.requiresInput || hasParameter(route.path);
}
function getInputParameterName(route: RouteRecordNormalized): string {
const routeName = String(route.name);
if (routeMetadata[routeName]?.inputParamName) {
return routeMetadata[routeName].inputParamName!;
}
return extractParameterName(route.path);
}
function navigateTo(route: RouteRecordNormalized) {
if (routeRequiresInput(route)) {
// Enter parameter mode
parameterMode.value = true;
parameterName.value = getInputParameterName(route);
parameterValue.value = "";
pendingRoute.value = route;
setTimeout(() => {
parameterInput.value?.focus();
}, 50);
} else {
// Track the command usage
trackCommand(String(route.name), "route", { routePath: route.path });
router.push(route.path);
close();
}
}
function confirmParameter() {
if (!pendingRoute.value || !parameterValue.value.trim()) return;
const routeName = String(pendingRoute.value.name);
const metadata = routeMetadata[routeName];
// Track the command usage
trackCommand(routeName, "route", { routePath: pendingRoute.value.path });
// Check if this route uses query parameters instead of path parameters
if (metadata?.inputParamName) {
router.push({
path: pendingRoute.value.path,
query: { [metadata.inputParamName]: parameterValue.value.trim() }
});
} else {
// Traditional path parameter replacement
const path = pendingRoute.value.path.replace(
/:([^/]+)/,
parameterValue.value.trim()
);
router.push(path);
}
close();
}
function cancelParameter() {
parameterMode.value = false;
parameterName.value = "";
parameterValue.value = "";
pendingRoute.value = null;
setTimeout(() => {
searchInput.value?.focus();
}, 50);
}
async function searchContent() {
// Prevent searching if already searching, disabled, or on cooldown
if (isSearchingContent.value || searchDisabled.value) return;
const now = Date.now();
if (now - lastSearchTime.value < SEARCH_COOLDOWN) return;
lastSearchTime.value = now;
const ELASTIC_URL = import.meta.env.VITE_ELASTIC_URL;
const ELASTIC_API_KEY = import.meta.env.VITE_ELASTIC_API_KEY;
// Don't search if elastic is not configured
if (!ELASTIC_URL || !ELASTIC_API_KEY) {
contentResults.value = [];
return;
}
isSearchingContent.value = true;
try {
const response = await elasticSearchMoviesAndShows(searchQuery.value, 10);
const results: IAutocompleteResult[] = response.hits.hits.map(
(item: any) => ({
title: item._source?.original_name || item._source.original_title,
id: item._source.id,
type: item._source.type === "movie" ? "movie" : "show"
})
);
// Sort content results by command score (most used + recent first)
const sortedResults = results.sort((a, b) => {
const scoreA = getCommandScore(`${a.type}:${a.id}`);
const scoreB = getCommandScore(`${b.type}:${b.id}`);
return scoreB - scoreA;
});
contentResults.value = sortedResults;
// Reset error count on success
searchErrorCount.value = 0;
} catch (error) {
console.error("Search failed:", error);
contentResults.value = [];
// Increment error count and disable if threshold reached
searchErrorCount.value++;
if (searchErrorCount.value >= MAX_ERRORS) {
searchDisabled.value = true;
console.warn(
`Content search disabled after ${MAX_ERRORS} consecutive errors`
);
}
} finally {
isSearchingContent.value = false;
}
}
function openContent(result: IAutocompleteResult) {
// Track content opening with unique ID
const contentId = `${result.type}:${result.id}`;
trackCommand(contentId, "content");
store.dispatch("popup/open", {
id: result.id,
type: result.type
});
close();
}
function handleInputKeydown(event: KeyboardEvent) {
// Check for number keys 1-9 to select routes or content
const num = parseInt(event.key);
if (!isNaN(num) && num >= 1 && num <= 9) {
const index = num - 1;
// Try routes first
if (index < filteredRoutes.value.length) {
event.preventDefault();
navigateTo(filteredRoutes.value[index]);
return;
}
// Try content results
if (index < contentResults.value.length) {
event.preventDefault();
openContent(contentResults.value[index]);
return;
}
}
}
function handleKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault();
if (isOpen.value) {
close();
} else {
open();
}
}
if (event.key === "Escape" && isOpen.value) {
event.preventDefault();
close();
}
}
watch(isOpen, newValue => {
if (newValue) {
document.body.style.overflow = "hidden";
setTimeout(() => {
searchInput.value?.focus();
}, 50);
} else {
document.body.style.overflow = "";
}
});
let searchTimeout: NodeJS.Timeout | null = null;
watch(searchQuery, () => {
selectedIndex.value = 0;
// Don't clear content results immediately - let debounce handle it
});
// Trigger content search when no routes match (with debouncing)
watch(filteredRoutes, newRoutes => {
// Clear existing timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (newRoutes.length === 0 && searchQuery.value.length > 0) {
// Debounce search to avoid clearing results while typing fast
searchTimeout = setTimeout(() => {
searchContent();
}, 300);
} else if (newRoutes.length > 0) {
// Clear content results when routes are found
contentResults.value = [];
}
});
onMounted(() => {
window.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeydown);
});
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
@import "scss/variables.scss";
@import "scss/media-queries.scss";
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.command-palette-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 9999;
padding-top: 15vh;
@include mobile {
padding-top: 10vh;
}
}
.command-palette {
background: var(--background-color-secondary);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 640px;
max-height: 60vh;
display: flex;
flex-direction: column;
overflow: hidden;
@include mobile {
width: 95%;
max-height: 70vh;
}
}
.command-palette__search {
padding: 1rem;
border-bottom: 1px solid var(--text-color-10);
}
.command-palette__input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1.1rem;
border: none;
background: var(--background-ui);
color: var(--text-color);
border-radius: 8px;
outline: none;
font-family: inherit;
&::placeholder {
color: var(--text-color-50);
}
&--parameter {
background: var(--color-success);
color: var(--color-success-text);
font-weight: 500;
&::placeholder {
color: var(--color-success-text);
opacity: 0.8;
}
}
@include mobile {
font-size: 1rem;
padding: 0.625rem 0.875rem;
}
}
.command-palette__results {
overflow-y: auto;
max-height: 50vh;
padding: 0.5rem;
@include mobile {
max-height: 60vh;
}
}
.command-palette__item {
padding: 1rem;
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.15s ease;
gap: 1rem;
&:hover,
&--selected {
background: var(--background-ui);
}
@include mobile {
padding: 0.875rem;
gap: 0.75rem;
}
}
.command-palette__item-left {
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex: 1;
min-width: 0;
@include mobile {
gap: 0.625rem;
}
}
.command-palette__item-icon {
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-color-70);
svg {
width: 100%;
height: 100%;
fill: currentColor;
}
@include mobile {
width: 1.25rem;
height: 1.25rem;
}
}
.command-palette__item-content {
display: flex;
flex-direction: column;
gap: 0.375rem;
flex: 1;
min-width: 0;
}
.command-palette__item-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.command-palette__item-name {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
@include mobile {
font-size: 0.95rem;
}
}
.command-palette__item-path {
font-size: 0.8rem;
color: var(--text-color-50);
font-weight: 400;
@include mobile {
font-size: 0.75rem;
}
}
.command-palette__item-param-hint {
font-size: 0.75rem;
color: var(--text-color-70);
font-style: italic;
margin-left: 0.25rem;
@include mobile {
font-size: 0.7rem;
}
}
.command-palette__item-description {
font-size: 0.875rem;
color: var(--text-color-70);
line-height: 1.4;
@include mobile {
font-size: 0.8rem;
}
}
.command-palette__item-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
@include mobile {
gap: 0.375rem;
}
}
.command-palette__item-badge {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
white-space: nowrap;
font-weight: 500;
&--auth {
background: var(--color-warning);
color: var(--text-color);
}
&--plex {
background: var(--color-success);
color: var(--color-success-text);
}
@include mobile {
font-size: 0.65rem;
padding: 0.2rem 0.4rem;
}
}
.command-palette__item-shortcut {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: var(--background-ui);
color: var(--text-color-70);
border: 1px solid var(--text-color-10);
border-radius: 4px;
font-weight: 600;
min-width: 1.5rem;
text-align: center;
@include mobile {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
min-width: 1.25rem;
}
}
.command-palette__empty {
padding: 2rem;
text-align: center;
color: var(--text-color-50);
font-size: 0.95rem;
}
.command-palette__content-results {
padding: 0.5rem;
}
.command-palette__content-header {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-color-50);
padding: 0.5rem 1rem;
margin-bottom: 0.25rem;
}
</style>

View File

@@ -0,0 +1,101 @@
interface CommandData {
count: number;
lastUsed: string; // ISO timestamp
routePath?: string;
type: "route" | "content";
}
interface CommandStats {
commands: Record<string, CommandData>;
version: number;
}
const STORAGE_KEY = "commandPalette_stats";
const CURRENT_VERSION = 1;
function getStats(): CommandStats {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
return { commands: {}, version: CURRENT_VERSION };
}
const parsed = JSON.parse(stored) as CommandStats;
return parsed;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Failed to parse command stats:", error);
return { commands: {}, version: CURRENT_VERSION };
}
}
function saveStats(stats: CommandStats): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(stats));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Failed to save command stats:", error);
}
}
export function trackCommand(
id: string,
type: "route" | "content",
metadata?: { routePath?: string }
): void {
const stats = getStats();
if (!stats.commands[id]) {
stats.commands[id] = {
count: 0,
lastUsed: new Date().toISOString(),
type,
routePath: metadata?.routePath
};
}
stats.commands[id].count += 1;
stats.commands[id].lastUsed = new Date().toISOString();
if (metadata?.routePath) {
stats.commands[id].routePath = metadata.routePath;
}
saveStats(stats);
}
export function getCommandScore(commandId: string): number {
const stats = getStats();
const command = stats.commands[commandId];
if (!command) return 0;
const now = new Date().getTime();
const lastUsed = new Date(command.lastUsed).getTime();
const daysSinceLastUse = (now - lastUsed) / (1000 * 60 * 60 * 24);
// Recency bonus: 10 points for today, decreasing to 0 after 10 days
const recencyBonus = Math.max(0, 10 - daysSinceLastUse);
// Combined score: 70% frequency, 30% recency
return command.count * 0.7 + recencyBonus * 0.3;
}
export function getTopCommands(limit = 10): { id: string; score: number }[] {
const stats = getStats();
const scored = Object.keys(stats.commands).map(id => ({
id,
score: getCommandScore(id)
}));
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
}
export function clearCommandHistory(): void {
localStorage.removeItem(STORAGE_KEY);
}
export function getCommandStats(commandId: string): CommandData | null {
const stats = getStats();
return stats.commands[commandId] || null;
}