mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-04-24 08:43:36 +00:00
- Torrent Table gets more consistent background styling - StorageSectionBrowser w/ different chevron icons for open/close - Vim keyboard navigation for command palette - Update main.scss with animations which don't stack & interfere - Command pallet vim vertical navigation
846 lines
23 KiB
Vue
846 lines
23 KiB
Vue
<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"
|
|
@keydown.ctrl.j.prevent="selectNext"
|
|
@keydown.ctrl.k.prevent="selectPrevious"
|
|
/>
|
|
<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 IconBinoculars from "@/icons/IconBinoculars.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" },
|
|
discover: {
|
|
icon: IconBinoculars,
|
|
description: "Discover movies by category"
|
|
},
|
|
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>
|