aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/SearchBar.vue109
-rw-r--r--src/components/SearchBarItem.vue48
-rw-r--r--src/layouts/base.layout.vue8
-rw-r--r--src/modules/command-palette/command-palette.store.ts68
-rw-r--r--src/modules/command-palette/command-palette.types.ts13
-rw-r--r--src/modules/command-palette/command-palette.vue137
-rw-r--r--src/modules/command-palette/components/command-palette-option.vue36
-rw-r--r--src/pages/About.vue4
-rw-r--r--src/ui/c-input-text/c-input-text.vue42
-rw-r--r--src/ui/c-modal/c-modal.demo.vue15
-rw-r--r--src/ui/c-modal/c-modal.theme.ts11
-rw-r--r--src/ui/c-modal/c-modal.vue74
-rw-r--r--src/ui/theme/themes.ts2
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' }}&nbsp;+&nbsp;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',