mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-09 19:09:07 +00:00
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:
@@ -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();
|
||||
|
||||
838
src/components/ui/CommandPalette.vue
Normal file
838
src/components/ui/CommandPalette.vue
Normal 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>
|
||||
101
src/utils/commandTracking.ts
Normal file
101
src/utils/commandTracking.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user