diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/SearchBar.vue | 109 | ||||
-rw-r--r-- | src/components/SearchBarItem.vue | 48 | ||||
-rw-r--r-- | src/layouts/base.layout.vue | 8 | ||||
-rw-r--r-- | src/modules/command-palette/command-palette.store.ts | 68 | ||||
-rw-r--r-- | src/modules/command-palette/command-palette.types.ts | 13 | ||||
-rw-r--r-- | src/modules/command-palette/command-palette.vue | 137 | ||||
-rw-r--r-- | src/modules/command-palette/components/command-palette-option.vue | 36 | ||||
-rw-r--r-- | src/pages/About.vue | 4 | ||||
-rw-r--r-- | src/ui/c-input-text/c-input-text.vue | 42 | ||||
-rw-r--r-- | src/ui/c-modal/c-modal.demo.vue | 15 | ||||
-rw-r--r-- | src/ui/c-modal/c-modal.theme.ts | 11 | ||||
-rw-r--r-- | src/ui/c-modal/c-modal.vue | 74 | ||||
-rw-r--r-- | src/ui/theme/themes.ts | 2 |
13 files changed, 397 insertions, 170 deletions
diff --git a/src/components/SearchBar.vue b/src/components/SearchBar.vue deleted file mode 100644 index 6da0f36..0000000 --- a/src/components/SearchBar.vue +++ /dev/null @@ -1,109 +0,0 @@ -<script lang="ts" setup> -import { SearchRound } from '@vicons/material'; -import { useMagicKeys, whenever } from '@vueuse/core'; -import { NInput } from 'naive-ui'; -import { useRouter } from 'vue-router'; -import SearchBarItem from './SearchBarItem.vue'; -import type { Tool } from '@/tools/tools.types'; -import { tools } from '@/tools'; -import { useTracker } from '@/modules/tracker/tracker.services'; -import { useFuzzySearch } from '@/composable/fuzzySearch'; - -const toolToOption = (tool: Tool) => ({ label: tool.name, value: tool.path, tool }); - -const router = useRouter(); -const { tracker } = useTracker(); - -const queryString = ref(''); -const inputEl = ref<HTMLElement>(); -const displayDropDown = ref(true); -const isMac = computed(() => window.navigator.userAgent.toLowerCase().includes('mac')); - -const { searchResult } = useFuzzySearch({ - search: queryString, - data: tools, - options: { keys: [{ name: 'name', weight: 2 }, 'description', 'keywords'] }, -}); - -const options = computed(() => { - if (queryString.value === '') { - return tools.map(toolToOption); - } - - return searchResult.value.map(toolToOption); -}); - -const keys = useMagicKeys({ - passive: false, - onEventFired(e) { - if (e.ctrlKey && e.key === 'k' && e.type === 'keydown') { - e.preventDefault(); - } - - if (e.metaKey && e.key === 'k' && e.type === 'keydown') { - e.preventDefault(); - } - }, -}); - -whenever(keys.ctrl_k, claimFocus); -whenever(keys.meta_k, claimFocus); -whenever(keys.escape, releaseFocus); - -function renderOption({ tool }: { tool: Tool }) { - return h(SearchBarItem, { tool }); -} - -function onSelect(path: string) { - router.push(path); - queryString.value = ''; -} - -function claimFocus() { - displayDropDown.value = true; - - inputEl.value?.focus(); -} - -function releaseFocus() { - displayDropDown.value = false; -} - -function onFocus() { - tracker.trackEvent({ eventName: 'Search-bar focused' }); - displayDropDown.value = true; -} -</script> - -<template> - <div class="search-bar"> - <n-auto-complete - v-model:value="queryString" - :options="options" - :on-select="(value: string | number) => onSelect(String(value))" - :render-label="renderOption" - default-value="aa" - :get-show="() => displayDropDown" - :on-focus="onFocus" - @update:value="() => (displayDropDown = true)" - > - <template #default="{ handleInput, handleBlur, handleFocus, value: slotValue }"> - <NInput - ref="inputEl" - round - clearable - :placeholder="`Search a tool (use ${isMac ? 'Cmd' : 'Ctrl'} + K to focus)`" - :value="slotValue" - :input-props="{ autocomplete: 'disabled' }" - @input="handleInput" - @focus="handleFocus" - @blur="handleBlur" - > - <template #prefix> - <n-icon :component="SearchRound" /> - </template> - </NInput> - </template> - </n-auto-complete> - </div> -</template> diff --git a/src/components/SearchBarItem.vue b/src/components/SearchBarItem.vue deleted file mode 100644 index 541695e..0000000 --- a/src/components/SearchBarItem.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script lang="ts" setup> -import type { Tool } from '@/tools/tools.types'; - -const props = defineProps<{ tool: Tool }>(); -const { tool } = toRefs(props); -</script> - -<template> - <div class="search-bar-item"> - <n-icon class="icon" :component="tool.icon" /> - - <div> - <div class="name"> - {{ tool.name }} - </div> - <div class="description"> - {{ tool.description }} - </div> - </div> - </div> -</template> - -<style lang="less" scoped> -.search-bar-item { - padding: 10px; - display: flex; - flex-direction: row; - align-items: center; - - .icon { - font-size: 30px; - margin-right: 10px; - opacity: 0.7; - } - - .name { - font-weight: bold; - font-size: 15px; - line-height: 1; - margin-bottom: 5px; - } - - .description { - opacity: 0.7; - line-height: 1; - } -} -</style> diff --git a/src/layouts/base.layout.vue b/src/layouts/base.layout.vue index ec1d5fe..36edd77 100644 --- a/src/layouts/base.layout.vue +++ b/src/layouts/base.layout.vue @@ -3,7 +3,7 @@ import { NIcon, useThemeVars } from 'naive-ui'; import { RouterLink } from 'vue-router'; import { Heart, Home2, Menu2 } from '@vicons/tabler'; -import SearchBar from '../components/SearchBar.vue'; + import HeroGradient from '../assets/hero-gradient.svg?component'; import MenuLayout from '../components/MenuLayout.vue'; import NavbarButtons from '../components/NavbarButtons.vue'; @@ -104,7 +104,7 @@ const tools = computed<ToolCategory[]>(() => [ Home </n-tooltip> - <SearchBar /> + <command-palette mx-2 /> <NavbarButtons v-if="!styleStore.isSmallScreen" /> @@ -218,10 +218,6 @@ const tools = computed<ToolCategory[]>(() => [ justify-content: center; flex-direction: row; - & > *:not(:last-child) { - margin-right: 5px; - } - .search-bar { // width: 100%; flex-grow: 1; diff --git a/src/modules/command-palette/command-palette.store.ts b/src/modules/command-palette/command-palette.store.ts new file mode 100644 index 0000000..f215388 --- /dev/null +++ b/src/modules/command-palette/command-palette.store.ts @@ -0,0 +1,68 @@ +import { defineStore } from 'pinia'; +import _ from 'lodash'; +import type { PaletteOption } from './command-palette.types'; +import { useToolStore } from '@/tools/tools.store'; +import { useFuzzySearch } from '@/composable/fuzzySearch'; +import { useStyleStore } from '@/stores/style.store'; + +import SunIcon from '~icons/mdi/white-balance-sunny'; +import GithubIcon from '~icons/mdi/github'; +import BugIcon from '~icons/mdi/bug-outline'; + +export const useCommandPaletteStore = defineStore('command-palette', () => { + const toolStore = useToolStore(); + const styleStore = useStyleStore(); + const searchPrompt = ref(''); + + const toolsOptions = toolStore.tools.map(tool => ({ + ...tool, + to: tool.path, + toolCategory: tool.category, + category: 'Tools', + })); + + const searchOptions: PaletteOption[] = [ + ...toolsOptions, + { + name: 'Toggle dark mode', + description: 'Toggle dark mode on or off.', + action: () => styleStore.toggleDark(), + icon: SunIcon, + category: 'Actions', + keywords: ['dark', 'theme', 'toggle', 'mode', 'light', 'system'], + }, + { + name: 'Github repository', + href: 'https://github.com/CorentinTh/it-tools', + category: 'External', + description: 'View the source code of it-tools on Github.', + keywords: ['github', 'repo', 'repository', 'source', 'code'], + icon: GithubIcon, + }, + { + name: 'Report a bug or an issue', + description: 'Report a bug or an issue to help improve it-tools.', + href: 'https://github.com/CorentinTh/it-tools/issues/new/choose', + category: 'Actions', + keywords: ['report', 'issue', 'bug', 'problem', 'error'], + icon: BugIcon, + }, + ]; + + const { searchResult } = useFuzzySearch({ + search: searchPrompt, + data: searchOptions, + options: { + keys: [{ name: 'name', weight: 2 }, 'description', 'keywords', 'category'], + threshold: 0.3, + }, + }); + + const filteredSearchResult = computed(() => + _.chain(searchResult.value).groupBy('category').mapValues(categoryOptions => _.take(categoryOptions, 5)).value()); + + return { + filteredSearchResult, + searchPrompt, + }; +}); diff --git a/src/modules/command-palette/command-palette.types.ts b/src/modules/command-palette/command-palette.types.ts new file mode 100644 index 0000000..0b072a7 --- /dev/null +++ b/src/modules/command-palette/command-palette.types.ts @@ -0,0 +1,13 @@ +import type { Component } from 'vue'; +import type { RouteLocationRaw } from 'vue-router'; + +export interface PaletteOption { + name: string + description?: string + icon?: Component + action?: () => void + to?: RouteLocationRaw + category: string + keywords?: string[] + href?: string +} diff --git a/src/modules/command-palette/command-palette.vue b/src/modules/command-palette/command-palette.vue new file mode 100644 index 0000000..bd431a0 --- /dev/null +++ b/src/modules/command-palette/command-palette.vue @@ -0,0 +1,137 @@ +<script setup lang="ts"> +import { storeToRefs } from 'pinia'; +import _ from 'lodash'; +import { useCommandPaletteStore } from './command-palette.store'; +import type { PaletteOption } from './command-palette.types'; + +const isModalOpen = ref(false); +const inputRef = ref(); +const router = useRouter(); +const isMac = computed(() => window.navigator.userAgent.toLowerCase().includes('mac')); + +const commandPaletteStore = useCommandPaletteStore(); +const { searchPrompt, filteredSearchResult } = storeToRefs(commandPaletteStore); + +const keys = useMagicKeys({ + passive: false, + onEventFired(e) { + if (e.ctrlKey && e.key === 'k' && e.type === 'keydown') { + e.preventDefault(); + } + + if (e.metaKey && e.key === 'k' && e.type === 'keydown') { + e.preventDefault(); + } + }, +}); + +whenever(isModalOpen, () => inputRef.value?.focus()); + +whenever(keys.ctrl_k, open); +whenever(keys.meta_k, open); +whenever(keys.escape, close); + +function open() { + return isModalOpen.value = true; +} + +function close() { + isModalOpen.value = false; +} + +const selectedOptionIndex = ref(0); + +function handleKeydown(event: KeyboardEvent) { + const { key } = event; + const isEnterPressed = key === 'Enter'; + const isArrowUpOrDown = ['ArrowUp', 'ArrowDown'].includes(key); + const isArrowDown = key === 'ArrowDown'; + + if (isArrowUpOrDown) { + const increment = isArrowDown ? 1 : -1; + const maxIndex = Math.max(_.chain(filteredSearchResult.value).values().flatten().size().value() - 1, 0); + + selectedOptionIndex.value = Math.min(Math.max(selectedOptionIndex.value + increment, 0), maxIndex); + + return; + } + + if (isEnterPressed) { + const option = _.chain(filteredSearchResult.value) + .values() + .flatten() + .nth(selectedOptionIndex.value) + .value(); + + activateOption(option); + } +} + +function getOptionIndex(option: PaletteOption) { + return _.chain(filteredSearchResult.value) + .values() + .flatten() + .findIndex(o => o === option) + .value(); +} + +function activateOption(option: PaletteOption) { + if (option.action) { + option.action(); + return; + } + + if (option.to) { + router.push(option.to); + close(); + } + + if (option.href) { + window.open(option.href, '_blank'); + close(); + } +} +</script> + +<template> + <div flex-1> + <c-button w-full important:justify-start @click="isModalOpen = true"> + <span flex items-center gap-3 op-40> + + <icon-mdi-search /> + Search... + + <span hidden flex-1 border border-current border-op-40 rounded border-solid px-5px py-3px sm:inline> + {{ isMac ? 'Cmd' : 'Ctrl' }} + K + </span> + </span> + </c-button> + + <c-modal v-model:open="isModalOpen" class="palette-modal" shadow-xl important:max-w-650px important:pa-12px @keydown="handleKeydown"> + <c-input-text ref="inputRef" v-model:value="searchPrompt" raw-text placeholder="Type to search a tool or a command..." autofocus clearable /> + + <div v-for="(options, category) in filteredSearchResult" :key="category"> + <div ml-3 mt-3 text-sm font-bold text-primary op-60> + {{ category }} + </div> + <command-palette-option v-for="option in options" :key="option.name" :option="option" :selected="selectedOptionIndex === getOptionIndex(option)" @activated="activateOption" /> + </div> + </c-modal> + </div> +</template> + +<style scoped lang="less"> +.c-input-text { + font-size: 18px; + + ::v-deep(.input-wrapper) { + padding: 4px; + padding-left: 18px; + } +} + +.c-modal--overlay { + align-items: flex-start !important; + padding-top: 80px; +} +</style> diff --git a/src/modules/command-palette/components/command-palette-option.vue b/src/modules/command-palette/components/command-palette-option.vue new file mode 100644 index 0000000..9192bc0 --- /dev/null +++ b/src/modules/command-palette/components/command-palette-option.vue @@ -0,0 +1,36 @@ +<script setup lang="ts"> +import type { PaletteOption } from '../command-palette.types'; + +const props = withDefaults(defineProps<{ option: PaletteOption; selected?: boolean }>(), { + selected: false, +}); +const emit = defineEmits(['activated']); +const { option } = toRefs(props); + +const { selected } = toRefs(props); +</script> + +<template> + <div + role="option" + :aria-selected="selected" + :class="{ + 'text-white': selected, + 'bg-primary': selected, + }" + w-full flex cursor-pointer items-center overflow-hidden rounded pa-3 transition hover:bg-primary hover:text-white + @click="() => emit('activated', option)" + > + <component :is="option.icon" v-if="option.icon" mr-3 h-30px w-30px shrink-0 op-50 /> + + <div flex-1 overflow-hidden> + <div truncate font-bold lh-tight op-90> + {{ option.name }} + </div> + + <div v-if="option.description" truncate lh-tight op-60> + {{ option.description }} + </div> + </div> + </div> +</template> diff --git a/src/pages/About.vue b/src/pages/About.vue index cbf8964..0a3dd3b 100644 --- a/src/pages/About.vue +++ b/src/pages/About.vue @@ -46,7 +46,7 @@ const { tracker } = useTracker(); If you need a tool that is currently not present here, and you think can be relevant, you are welcome to submit a feature request in the <c-link - href="https://github.com/CorentinTh/it-tools/issues/new?assignees=CorentinTh&labels=enhancement&template=feature_request.md&title=%5BFEAT%5D%20My%20feature" + href="https://github.com/CorentinTh/it-tools/issues/new/choose" rel="noopener" target="_blank" > @@ -57,7 +57,7 @@ const { tracker } = useTracker(); <n-p> And if you found a bug, or something broken that doesn't work as expected, please fill a bug report in the <c-link - href="https://github.com/CorentinTh/it-tools/issues/new?assignees=CorentinTh&labels=bug&template=bug_report.md&title=%5BBUG%5D%20My%20bug" + href="https://github.com/CorentinTh/it-tools/issues/new/choose" rel="noopener" target="_blank" > diff --git a/src/ui/c-input-text/c-input-text.vue b/src/ui/c-input-text/c-input-text.vue index 513834d..b89d1e4 100644 --- a/src/ui/c-input-text/c-input-text.vue +++ b/src/ui/c-input-text/c-input-text.vue @@ -29,6 +29,7 @@ const props = withDefaults( multiline?: boolean rows?: number | string autosize?: boolean + autofocus?: boolean }>(), { value: '', @@ -54,13 +55,14 @@ const props = withDefaults( multiline: false, rows: 3, autosize: false, + autofocus: false, }, ); const emit = defineEmits(['update:value']); const value = useVModel(props, 'value', emit); const showPassword = ref(false); -const { id, placeholder, label, validationRules, labelPosition, labelWidth, labelAlign, autosize, readonly, disabled, clearable, type, multiline, rows, rawText } = toRefs(props); +const { id, placeholder, label, validationRules, labelPosition, labelWidth, labelAlign, autosize, readonly, disabled, clearable, type, multiline, rows, rawText, autofocus } = toRefs(props); const validation = props.validation @@ -74,12 +76,9 @@ const theme = useTheme(); const appTheme = useAppTheme(); const textareaRef = ref<HTMLTextAreaElement>(); +const inputRef = ref<HTMLInputElement>(); const inputWrapperRef = ref<HTMLElement>(); -defineExpose({ - inputWrapperRef, -}); - watch( value, () => { @@ -107,6 +106,38 @@ const htmlInputType = computed(() => { return 'text'; }); + +function focus() { + if (textareaRef.value) { + textareaRef.value.focus(); + } + + if (inputRef.value) { + inputRef.value.focus(); + } +} + +function blur() { + if (textareaRef.value) { + textareaRef.value.blur?.(); + } + + if (inputRef.value) { + inputRef.value.blur?.(); + } +} + +onMounted(() => { + if (autofocus.value) { + focus(); + } +}); + +defineExpose({ + inputWrapperRef, + focus, + blur, +}); </script> <template> @@ -140,6 +171,7 @@ const htmlInputType = computed(() => { <input v-else :id="id" + ref="inputRef" v-model="value" :type="htmlInputType" class="input" diff --git a/src/ui/c-modal/c-modal.demo.vue b/src/ui/c-modal/c-modal.demo.vue new file mode 100644 index 0000000..c4349f6 --- /dev/null +++ b/src/ui/c-modal/c-modal.demo.vue @@ -0,0 +1,15 @@ +<script lang="ts" setup> +const modal1 = ref(); +</script> + +<template> + <div> + <c-button @click="() => modal1?.open()"> + Open Modal + </c-button> + + <c-modal ref="modal1"> + Content + </c-modal> + </div> +</template> diff --git a/src/ui/c-modal/c-modal.theme.ts b/src/ui/c-modal/c-modal.theme.ts new file mode 100644 index 0000000..0681417 --- /dev/null +++ b/src/ui/c-modal/c-modal.theme.ts @@ -0,0 +1,11 @@ +import { defineThemes } from '../theme/theme.models'; +import { appThemes } from '../theme/themes'; + +export const { useTheme } = defineThemes({ + dark: { + background: appThemes.dark.background, + }, + light: { + background: appThemes.light.background, + }, +}); diff --git a/src/ui/c-modal/c-modal.vue b/src/ui/c-modal/c-modal.vue new file mode 100644 index 0000000..af92f01 --- /dev/null +++ b/src/ui/c-modal/c-modal.vue @@ -0,0 +1,74 @@ +<script setup lang="ts"> +import { useTheme } from './c-modal.theme'; + +const props = withDefaults(defineProps<{ open?: boolean; centered?: boolean }>(), { + open: false, + centered: true, +}); +const emit = defineEmits(['update:open']); +const isOpen = useVModel(props, 'open', emit, { passive: true }); + +const { centered } = toRefs(props); + +function close() { + isOpen.value = false; +} + +function open() { + isOpen.value = true; +} + +function toggle() { + isOpen.value = !isOpen.value; +} + +defineExpose({ + close, + open, + toggle, + isOpen, +}); + +defineOptions({ + inheritAttrs: false, +}); + +const theme = useTheme(); +const modal = ref(); + +onClickOutside(modal, () => { + if (isOpen.value) { + close(); + } +}); +</script> + +<template> + <transition> + <div v-if="isOpen" class="c-modal--overlay" fixed left-0 top-0 z-10 h-full w-full flex justify-center px-2 :class="{ 'items-center': centered }"> + <div ref="modal" class="c-modal--container" v-bind="$attrs" max-w-xl w-full flex-grow rounded-md pa-24px> + <slot /> + </div> + </div> + </transition> +</template> + +<style scoped lang="less"> +.c-modal--overlay { + background-color: rgba(0, 0, 0, 0.5); +} + +.c-modal--container { + background-color: v-bind('theme.background'); +} + +.v-enter-active, +.v-leave-active { + transition: opacity 0.3s ease-in-out; +} + +.v-enter-from, +.v-leave-to { + opacity: 0; +} +</style> diff --git a/src/ui/theme/themes.ts b/src/ui/theme/themes.ts index 81d7ddf..2c330b0 100644 --- a/src/ui/theme/themes.ts +++ b/src/ui/theme/themes.ts @@ -2,6 +2,7 @@ import { defineThemes } from './theme.models'; export const { themes: appThemes, useTheme: useAppTheme } = defineThemes({ light: { + background: '#ffffff', text: { baseColor: '#333639', mutedColor: '#767c82', @@ -37,6 +38,7 @@ export const { themes: appThemes, useTheme: useAppTheme } = defineThemes({ }, }, dark: { + background: '#1e1e1e', text: { baseColor: '#ffffffd1', mutedColor: '#ffffff80', |