diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index 56a69d9..d306130 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -5,68 +5,88 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + --foreground: 0 0% 3.9%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + --popover-foreground: 0 0% 3.9%; --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card-foreground: 0 0% 3.9%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; + --destructive-foreground: 0 0% 98%; - --ring: 222.2 84% 4.9%; + --ring: 0 0% 3.9%; --radius: 0.5rem; + + --chart-1: 12 76% 61%; + + --chart-2: 173 58% 39%; + + --chart-3: 197 37% 24%; + + --chart-4: 43 74% 66%; + + --chart-5: 27 87% 67%; } .dark { - --background: 224 71% 4%; - --foreground: 213 31% 91%; + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; - --muted: 223 47% 11%; - --muted-foreground: 215.4 16.3% 70%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; - --popover: 224 71% 4%; - --popover-foreground: 215 20.2% 91%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; - --card: 224 71% 4%; - --card-foreground: 213 31% 91%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; - --border: 216 34% 17%; - --input: 216 34% 17%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 1.2%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; - --secondary: 222 47% 11%; - --secondary-foreground: 210 40% 98%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; - --accent: 216 34% 17%; - --accent-foreground: 210 40% 98%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; - --destructive: 0 63% 31%; - --destructive-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; - --ring: 216 34% 17%; + --ring: 0 0% 83.1%; --radius: 0.5rem; + + --chart-1: 220 70% 50%; + + --chart-2: 160 60% 45%; + + --chart-3: 30 80% 55%; + + --chart-4: 280 65% 60%; + + --chart-5: 340 75% 55%; } } diff --git a/components.json b/components.json index 2ffe2f3..487cb42 100644 --- a/components.json +++ b/components.json @@ -2,17 +2,19 @@ "$schema": "https://shadcn-vue.com/schema.json", "style": "new-york", "typescript": true, - "tsConfigPath": ".nuxt/tsconfig.json", "tailwind": { "config": "tailwind.config.ts", "css": "assets/css/tailwind.css", - "baseColor": "slate", + "baseColor": "neutral", "cssVariables": true, "prefix": "" }, - "framework": "nuxt", "aliases": { "components": "@/components", - "utils": "@/lib/utils" - } + "composables": "@/composables", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + }, + "iconLibrary": "lucide" } \ No newline at end of file diff --git a/components/ThemeToggle.vue b/components/ThemeToggle.vue index c6103a7..8f0e554 100644 --- a/components/ThemeToggle.vue +++ b/components/ThemeToggle.vue @@ -3,36 +3,85 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Button } from '#components' const colorMode = useColorMode() + +// Función para cambiar el tema de manera inmediata +const changeTheme = (theme: 'light' | 'dark' | 'system') => { + colorMode.preference = theme + // Si es system, usar la preferencia del sistema + if (theme === 'system') { + colorMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } else { + colorMode.value = theme + } + + nextTick(() => { + document.documentElement.setAttribute('data-theme', colorMode.value) + }) +} + + diff --git a/components/icons/IconSearch.vue b/components/icons/IconSearch.vue new file mode 100644 index 0000000..ec68169 --- /dev/null +++ b/components/icons/IconSearch.vue @@ -0,0 +1,14 @@ + diff --git a/components/search/SearchInput.vue b/components/search/SearchInput.vue new file mode 100644 index 0000000..cff4a8d --- /dev/null +++ b/components/search/SearchInput.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/components/search/SearchInputField.vue b/components/search/SearchInputField.vue new file mode 100644 index 0000000..3f85402 --- /dev/null +++ b/components/search/SearchInputField.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/components/search/SearchSuggestions.vue b/components/search/SearchSuggestions.vue new file mode 100644 index 0000000..6add6f9 --- /dev/null +++ b/components/search/SearchSuggestions.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/components/search/composables/useQualifiers.ts b/components/search/composables/useQualifiers.ts new file mode 100644 index 0000000..7fb2af2 --- /dev/null +++ b/components/search/composables/useQualifiers.ts @@ -0,0 +1,22 @@ +import type { QualifierData } from "~/components/search/search" + +export const QUALIFIERS: QualifierData = { + 'repo:': { label: 'Repository (user/repo)', icon: 'octicon:repo-16' }, + 'user:': { label: 'User', icon: 'octicon:person-16' }, + 'org:': { label: 'Organization', icon: 'octicon:organization-16' }, + 'in:': { label: 'Search in', icon: 'octicon:search-16' }, + 'size:': { label: 'Size', icon: 'octicon:file-16' }, + 'stars:': { label: 'Stars', icon: 'octicon:star-16' }, + 'language:': { label: 'Language', icon: 'octicon:code-16' }, + 'created:': { label: 'Created date', icon: 'octicon:calendar-16' }, + 'pushed:': { label: 'Push date', icon: 'octicon:git-commit-16' }, + 'topic:': { label: 'Topic', icon: 'octicon:hash-16' }, + 'is:': { label: 'State', icon: 'octicon:circle-16' }, + 'fork:': { label: 'Fork', icon: 'octicon:repo-forked-16' }, +} + +export function useQualifiers() { + return { + QUALIFIERS + } +} diff --git a/components/search/composables/useScrollLock.ts b/components/search/composables/useScrollLock.ts new file mode 100644 index 0000000..a71acd1 --- /dev/null +++ b/components/search/composables/useScrollLock.ts @@ -0,0 +1,24 @@ +import { ref } from 'vue' + +export function useScrollLock() { + const originalPosition = ref(0) + + const lockScroll = () => { + originalPosition.value = window.scrollY + document.body.style.position = 'fixed' + document.body.style.top = `-${originalPosition.value}px` + document.body.style.width = '100%' + } + + const unlockScroll = () => { + document.body.style.position = '' + document.body.style.top = '' + document.body.style.width = '' + window.scrollTo(0, originalPosition.value) + } + + return { + lockScroll, + unlockScroll + } +} diff --git a/components/search/composables/useSearchHighlighter.ts b/components/search/composables/useSearchHighlighter.ts new file mode 100644 index 0000000..8f5e979 --- /dev/null +++ b/components/search/composables/useSearchHighlighter.ts @@ -0,0 +1,27 @@ +import { useSearchParser } from './useSearchParser' + +export function useSearchHighlighter() { + const { parseSearchTokens } = useSearchParser() + + const highlightText = (text: string): string => { + if (!text) return '' + + const tokens = parseSearchTokens(text) + return tokens.map(token => { + switch (token.type) { + case 'qualifier': + // Ahora el qualifier se muestra como texto normal + return `${token.qualifier}:` + + (token.value ? `${token.value}` : '') + case 'text': + return `${token.value}` + case 'space': + return ' ' + default: + return token.value + } + }).join('') + } + + return { highlightText } +} diff --git a/components/search/composables/useSearchInput.ts b/components/search/composables/useSearchInput.ts new file mode 100644 index 0000000..1fc6aa5 --- /dev/null +++ b/components/search/composables/useSearchInput.ts @@ -0,0 +1,119 @@ +import { ref, nextTick, onMounted, onUnmounted } from 'vue' + +export function useSearchInput(onHandleFocus?: () => void) { + const input = ref(null) + const cursorPosition = ref(0) + const searchContainer = ref(null) + const containerWidth = ref(0) + const dropdownPosition = ref({ left: 0, top: 0 }) + const isInputFocused = ref(false) + + const updateCursorPosition = () => { + nextTick(() => { + if (input.value) { + const inputElement = input.value + cursorPosition.value = inputElement.selectionStart || 0 + const container = inputElement.parentElement + + if (container) { + const measureElement = document.createElement('span') + measureElement.style.font = window.getComputedStyle(inputElement).font + measureElement.style.visibility = 'hidden' + measureElement.style.position = 'absolute' + measureElement.style.whiteSpace = 'pre' + measureElement.textContent = inputElement.value.substring(0, inputElement.selectionStart || 0) + document.body.appendChild(measureElement) + + const cursorOffset = measureElement.offsetWidth + const containerWidth = container.offsetWidth + const scrollLeft = container.scrollLeft + + document.body.removeChild(measureElement) + + if (cursorOffset > scrollLeft + containerWidth - 80) { + container.scrollLeft = cursorOffset - containerWidth + 80 + } + else if (cursorOffset < scrollLeft + 80) { + container.scrollLeft = Math.max(0, cursorOffset - 80) + } + } + } + }) + } + + const handleInputScroll = (event: Event) => { + const input = event.target as HTMLElement + const mirror = input.previousElementSibling as HTMLElement + if (mirror) { + mirror.scrollLeft = input.scrollLeft + } + } + + const updateContainerWidth = () => { + if (searchContainer.value) { + containerWidth.value = searchContainer.value.offsetWidth + } + } + + const updateDropdownPosition = () => { + if (searchContainer.value) { + containerWidth.value = searchContainer.value.offsetWidth + } + } + + const forceFocus = () => { + if (input.value) { + // Inmediatamente intentar el foco + input.value.focus() + + // Usar múltiples técnicas para asegurar el foco + requestAnimationFrame(() => { + if (input.value) { + input.value.focus() + input.value.click() // Simular click para forzar foco + + // Último intento después de un pequeño delay + setTimeout(() => { + input.value?.focus() + }, 1) + } + }) + } + } + + const handleGlobalShortcut = (event: KeyboardEvent) => { + if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName)) { + event.preventDefault() + event.stopPropagation() + isInputFocused.value = true + onHandleFocus?.() + forceFocus() + } + } + + onMounted(() => { + updateContainerWidth() + window.addEventListener('resize', updateContainerWidth) + window.addEventListener('keydown', handleGlobalShortcut) + }) + + onUnmounted(() => { + window.removeEventListener('resize', updateContainerWidth) + window.removeEventListener('keydown', handleGlobalShortcut) + }) + + return { + input, + cursorPosition, + searchContainer, + containerWidth, + dropdownPosition, + isInputFocused, + updateCursorPosition, + handleInputScroll, + updateContainerWidth, + updateDropdownPosition, + handleGlobalShortcut, + forceFocus, // Exportar para uso externo + } +} diff --git a/components/search/composables/useSearchInteractions.ts b/components/search/composables/useSearchInteractions.ts new file mode 100644 index 0000000..a0d5aec --- /dev/null +++ b/components/search/composables/useSearchInteractions.ts @@ -0,0 +1,211 @@ +import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue' +import type { SearchToken } from '../search' +import { useSearchParser } from './useSearchParser' + +export function useSearchInteractions( + props: { modelValue: string, mode?: 'inline' | 'modal' }, + emit: { (e: 'update:modelValue', value: string): void, (e: 'search'): void }, + input: any, + updateCursorPosition: () => void, + lockScroll: () => void, + unlockScroll: () => void +) { + const { parseSearchTokens } = useSearchParser() + const searchInput = ref(props.modelValue) + const showSuggestions = ref(false) + const isInputFocused = ref(false) + const isExpanded = ref(false) + + const isInlineMode = computed(() => props.mode === 'inline') + const searchTokens = computed(() => parseSearchTokens(searchInput.value)) + + const handleInput = (event: Event) => { + const value = (event.target as HTMLInputElement).value + searchInput.value = value + emit('update:modelValue', value) + updateCursorPosition() + showSuggestions.value = true // Mantener sugerencias visibles al escribir + } + + const handleFocus = () => { + if (!isInlineMode.value && !isExpanded.value) { + isExpanded.value = true + isInputFocused.value = true + showSuggestions.value = true + lockScroll() + } else { + isInputFocused.value = true + showSuggestions.value = true + } + + // Forzar foco inmediatamente + if (input.value) { + input.value.focus() + input.value.click() // Simular click + + requestAnimationFrame(() => { + if (input.value) { + input.value.focus() + + setTimeout(() => { + input.value?.focus() + }, 1) + } + }) + } + } + + const closeSearch = () => { + if (!isInlineMode.value) { + isExpanded.value = false + isInputFocused.value = false + showSuggestions.value = false + unlockScroll() + } else { + isInputFocused.value = false + showSuggestions.value = false + } + } + + const handleBlur = (event: FocusEvent) => { + const dialogContent = document.querySelector('.search-dialog-content') + if (dialogContent?.contains(event.relatedTarget as Node)) { + return + } + + if (!event.relatedTarget || !dialogContent?.contains(event.relatedTarget as Node)) { + closeSearch() + } + } + + const handleClose = () => { + closeSearch() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + emit('search') + closeSearch() + } else if (event.key === 'Escape' && isExpanded.value) { + closeSearch() + } + updateCursorPosition() + } + + const handleSuggestionClick = (suggestion: string) => { + const currentValue = searchInput.value + const space = currentValue && !currentValue.endsWith(' ') ? ' ' : '' + searchInput.value = currentValue + space + suggestion + emit('update:modelValue', searchInput.value) + input.value?.focus() + updateCursorPosition() + showSuggestions.value = true + } + + const removeQualifier = (qualifierToRemove: string, valueToRemove: string) => { + const tokens = searchTokens.value + let newSearchText = '' + let skipNext = false + let needsSpace = false + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + + // Si este token debe ser saltado + if (skipNext) { + skipNext = false + continue + } + + // Si es el qualifier que queremos remover + if (token.type === 'qualifier' && + token.qualifier + ':' === qualifierToRemove && + token.value === valueToRemove) { + // Si hay un espacio después, marcarlo para saltar + if (i + 1 < tokens.length && tokens[i + 1].type === 'space') { + skipNext = true + } + // Si hay un espacio antes, no añadir espacio extra + if (i > 0 && tokens[i - 1].type === 'space') { + needsSpace = false + } + continue + } + + // Añadir espacio si es necesario + if (needsSpace && token.type !== 'space') { + newSearchText += ' ' + } + + // Añadir el token actual + if (token.type === 'qualifier') { + newSearchText += token.qualifier + ':' + token.value + needsSpace = true + } else if (token.type === 'text') { + newSearchText += token.value + needsSpace = true + } else if (token.type === 'space') { + needsSpace = false + } + } + + searchInput.value = newSearchText.trim() + emit('update:modelValue', searchInput.value) + nextTick(() => { + input.value?.focus() + updateCursorPosition() + }) + } + + // Escuchar la tecla '/' directamente aquí para abrir/modal y forzar foco + const handleGlobalShortcut = (event: KeyboardEvent) => { + if ( + event.key === '/' && + !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName) + ) { + event.preventDefault() + isInputFocused.value = true + + // Si está en modo modal y cerrado, abrirlo + if (!isInlineMode.value && !isExpanded.value) { + isExpanded.value = true + lockScroll() + } + // Forzar foco en el input + if (input.value) { + input.value.focus() + requestAnimationFrame(() => { + input.value?.focus() + setTimeout(() => { + input.value?.focus() + }, 1) + }) + } + } + } + + onMounted(() => { + window.addEventListener('keydown', handleGlobalShortcut) + }) + + onUnmounted(() => { + window.removeEventListener('keydown', handleGlobalShortcut) + }) + + return { + searchInput, + showSuggestions, + isInputFocused, + isExpanded, + isInlineMode, + searchTokens, // Exportar searchTokens + handleInput, + handleFocus, + closeSearch, + handleBlur, + handleClose, + handleKeyDown, + handleSuggestionClick, + removeQualifier + } +} diff --git a/components/search/composables/useSearchParser.ts b/components/search/composables/useSearchParser.ts new file mode 100644 index 0000000..ddb58c5 --- /dev/null +++ b/components/search/composables/useSearchParser.ts @@ -0,0 +1,123 @@ +import type { ColoredPart, SearchToken } from '~/components/search/search' +import { QUALIFIERS } from './useQualifiers' + +export function useSearchParser() { + const parseSearchTokens = (value: string): SearchToken[] => { + const tokens: SearchToken[] = [] + let i = 0 + + while (i < value.length) { + // Check for qualifiers + const qualifierMatch = Object.keys(QUALIFIERS).find(q => + value.slice(i).toLowerCase().startsWith(q.toLowerCase()) + ) + + if (qualifierMatch) { + // Move past the qualifier + i += qualifierMatch.length + + // Collect the value after the qualifier + let qualifierValue = '' + while (i < value.length && value[i] !== ' ') { + qualifierValue += value[i] + i++ + } + + tokens.push({ + type: 'qualifier', + qualifier: qualifierMatch.slice(0, -1), // Remove trailing colon + value: qualifierValue + }) + + // Add space if there is one + if (i < value.length && value[i] === ' ') { + tokens.push({ type: 'space', value: ' ' }) + i++ + } + } else { + // Handle regular text + if (value[i] === ' ') { + tokens.push({ type: 'space', value: ' ' }) + i++ + } else { + let text = '' + while (i < value.length && + !Object.keys(QUALIFIERS).some(q => value.slice(i).toLowerCase().startsWith(q.toLowerCase())) && + value[i] !== ' ') { + text += value[i] + i++ + } + if (text) { + tokens.push({ type: 'text', value: text }) + } + } + } + } + + return tokens + } + + const colorText = (text: string): ColoredPart[] => { + const parts: ColoredPart[] = [] + let currentPosition = 0 + + while (currentPosition < text.length) { + let matched = false + + for (const qualifier of Object.keys(QUALIFIERS)) { + if (text.slice(currentPosition).toLowerCase().startsWith(qualifier.toLowerCase())) { + parts.push({ + text: qualifier, + type: 'qualifier' + }) + + currentPosition += qualifier.length + + let valueStart = currentPosition + while (currentPosition < text.length && text[currentPosition] !== ' ') { + currentPosition++ + } + + if (currentPosition > valueStart) { + parts.push({ + text: text.slice(valueStart, currentPosition), + type: 'value' + }) + } + + matched = true + break + } + } + + if (!matched) { + let normalText = '' + while (currentPosition < text.length) { + let isQualifier = false + for (const qualifier of Object.keys(QUALIFIERS)) { + if (text.slice(currentPosition).toLowerCase().startsWith(qualifier.toLowerCase())) { + isQualifier = true + break + } + } + if (isQualifier) break + normalText += text[currentPosition] + currentPosition++ + } + if (normalText) { + parts.push({ + text: normalText, + type: 'normal' + }) + } + } + } + + return parts + } + + return { + parseSearchTokens, + colorText + } +} diff --git a/components/search/search.ts b/components/search/search.ts new file mode 100644 index 0000000..ccc4f7d --- /dev/null +++ b/components/search/search.ts @@ -0,0 +1,25 @@ +export interface SearchToken { + type: 'qualifier' | 'text' | 'space'; + value: string; + qualifier?: string; +} + +export interface QualifierInfo { + label: string; + icon: string; +} + +export interface QualifierData { + [key: string]: QualifierInfo; +} + +export interface SuggestionItem { + qualifier: string; + isUsed: boolean; + value?: string; +} + +export interface ColoredPart { + text: string; + type: 'qualifier' | 'value' | 'normal'; +} diff --git a/components/search/types.ts b/components/search/types.ts new file mode 100644 index 0000000..7ad42b7 --- /dev/null +++ b/components/search/types.ts @@ -0,0 +1,17 @@ +import type { ComputedRef, Ref } from 'vue' + +export interface SearchInputFieldProps { + searchValue: string + highlightedHTML: string | ComputedRef + handleInput: (e: Event) => void + handleFocus: () => void + handleBlur: (e: FocusEvent) => void + handleKeyDown: (e: KeyboardEvent) => void + handleInputScroll: (e: Event) => void + input: any +} + +export interface SearchInputProps { + modelValue: string + mode?: 'inline' | 'modal' +} diff --git a/components/ui/command/Command.vue b/components/ui/command/Command.vue new file mode 100644 index 0000000..9597010 --- /dev/null +++ b/components/ui/command/Command.vue @@ -0,0 +1,92 @@ + + + diff --git a/components/ui/command/CommandDialog.vue b/components/ui/command/CommandDialog.vue new file mode 100644 index 0000000..b7b0a91 --- /dev/null +++ b/components/ui/command/CommandDialog.vue @@ -0,0 +1,21 @@ + + + diff --git a/components/ui/command/CommandEmpty.vue b/components/ui/command/CommandEmpty.vue new file mode 100644 index 0000000..58795f2 --- /dev/null +++ b/components/ui/command/CommandEmpty.vue @@ -0,0 +1,25 @@ + + + diff --git a/components/ui/command/CommandGroup.vue b/components/ui/command/CommandGroup.vue new file mode 100644 index 0000000..fd4f5b3 --- /dev/null +++ b/components/ui/command/CommandGroup.vue @@ -0,0 +1,46 @@ + + + diff --git a/components/ui/command/CommandInput.vue b/components/ui/command/CommandInput.vue new file mode 100644 index 0000000..4c3b963 --- /dev/null +++ b/components/ui/command/CommandInput.vue @@ -0,0 +1,37 @@ + + + diff --git a/components/ui/command/CommandItem.vue b/components/ui/command/CommandItem.vue new file mode 100644 index 0000000..97365e1 --- /dev/null +++ b/components/ui/command/CommandItem.vue @@ -0,0 +1,78 @@ + + + diff --git a/components/ui/command/CommandList.vue b/components/ui/command/CommandList.vue new file mode 100644 index 0000000..83c7d95 --- /dev/null +++ b/components/ui/command/CommandList.vue @@ -0,0 +1,24 @@ + + + diff --git a/components/ui/command/CommandSeparator.vue b/components/ui/command/CommandSeparator.vue new file mode 100644 index 0000000..4122020 --- /dev/null +++ b/components/ui/command/CommandSeparator.vue @@ -0,0 +1,23 @@ + + + diff --git a/components/ui/command/CommandShortcut.vue b/components/ui/command/CommandShortcut.vue new file mode 100644 index 0000000..0d4da92 --- /dev/null +++ b/components/ui/command/CommandShortcut.vue @@ -0,0 +1,14 @@ + + + diff --git a/components/ui/command/index.ts b/components/ui/command/index.ts new file mode 100644 index 0000000..cb48e1e --- /dev/null +++ b/components/ui/command/index.ts @@ -0,0 +1,25 @@ +import type { Ref } from 'vue' +import { createContext } from 'reka-ui' + +export { default as Command } from './Command.vue' +export { default as CommandDialog } from './CommandDialog.vue' +export { default as CommandEmpty } from './CommandEmpty.vue' +export { default as CommandGroup } from './CommandGroup.vue' +export { default as CommandInput } from './CommandInput.vue' +export { default as CommandItem } from './CommandItem.vue' +export { default as CommandList } from './CommandList.vue' +export { default as CommandSeparator } from './CommandSeparator.vue' +export { default as CommandShortcut } from './CommandShortcut.vue' + +export const [useCommand, provideCommandContext] = createContext<{ + allItems: Ref> + allGroups: Ref>> + filterState: { + search: string + filtered: { count: number, items: Map, groups: Set } + } +}>('Command') + +export const [useCommandGroup, provideCommandGroupContext] = createContext<{ + id?: string +}>('CommandGroup') diff --git a/components/ui/dialog/Dialog.vue b/components/ui/dialog/Dialog.vue index a04c026..9fc9c7d 100644 --- a/components/ui/dialog/Dialog.vue +++ b/components/ui/dialog/Dialog.vue @@ -1,5 +1,5 @@ diff --git a/components/ui/dialog/DialogContent.vue b/components/ui/dialog/DialogContent.vue index c9fe803..be00c5e 100644 --- a/components/ui/dialog/DialogContent.vue +++ b/components/ui/dialog/DialogContent.vue @@ -1,6 +1,6 @@ diff --git a/components/ui/search/FilterInput.vue b/components/ui/search/FilterInput.vue new file mode 100644 index 0000000..f715e26 --- /dev/null +++ b/components/ui/search/FilterInput.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/components/ui/search/FilterSuggestions.vue b/components/ui/search/FilterSuggestions.vue new file mode 100644 index 0000000..cdef2aa --- /dev/null +++ b/components/ui/search/FilterSuggestions.vue @@ -0,0 +1,110 @@ + + + diff --git a/components/ui/search/KeyboardShortcut.vue b/components/ui/search/KeyboardShortcut.vue new file mode 100644 index 0000000..bb02cb0 --- /dev/null +++ b/components/ui/search/KeyboardShortcut.vue @@ -0,0 +1,11 @@ + + + diff --git a/components/ui/search/composables/useFilterSuggestions.ts b/components/ui/search/composables/useFilterSuggestions.ts new file mode 100644 index 0000000..c1e9753 --- /dev/null +++ b/components/ui/search/composables/useFilterSuggestions.ts @@ -0,0 +1,54 @@ +import { ref } from 'vue' + +export interface FilterSuggestion { + prefix: string + label: string + icon: string +} + +export const useFilterSuggestions = () => { + const suggestions = ref([ + { prefix: 'repo:', label: 'Repository (user/repo)', icon: 'octicon:repo-16' }, + { prefix: 'user:', label: 'User', icon: 'octicon:person-16' }, + { prefix: 'org:', label: 'Organization', icon: 'octicon:organization-16' }, + { prefix: 'in:', label: 'Search in', icon: 'octicon:search-16' }, + { prefix: 'size:', label: 'Size', icon: 'octicon:file-16' }, + { prefix: 'stars:', label: 'Stars', icon: 'octicon:star-16' }, + { prefix: 'language:', label: 'Language', icon: 'octicon:code-16' }, + { prefix: 'created:', label: 'Created date', icon: 'octicon:calendar-16' }, + { prefix: 'pushed:', label: 'Push date', icon: 'octicon:git-commit-16' }, + { prefix: 'topic:', label: 'Topic', icon: 'octicon:hash-16' }, + { prefix: 'is:', label: 'State', icon: 'octicon:circle-16' }, + { prefix: 'fork:', label: 'Fork', icon: 'octicon:repo-forked-16' } + ]) + + const usedFilters = ref([]) + + const addUsedFilter = (filter: string) => { + if (!usedFilters.value.includes(filter)) { + usedFilters.value.unshift(filter) + if (usedFilters.value.length > 5) usedFilters.value.pop() + } + } + + const getMatchingSuggestions = (query: string): FilterSuggestion[] => { + const lastWord = query.split(' ').pop()?.toLowerCase() || '' + if (!lastWord) return suggestions.value + return suggestions.value.filter(s => + s.prefix.toLowerCase().includes(lastWord) || + s.label.toLowerCase().includes(lastWord) + ) + } + + const isValidFilter = (text: string): boolean => { + return suggestions.value.some(s => text.startsWith(s.prefix)) + } + + return { + suggestions, + usedFilters, + addUsedFilter, + getMatchingSuggestions, + isValidFilter + } as const +} diff --git a/components/ui/search/index.ts b/components/ui/search/index.ts new file mode 100644 index 0000000..60f2b14 --- /dev/null +++ b/components/ui/search/index.ts @@ -0,0 +1 @@ +export { default as FilterInput } from '~/components/ui/search/FilterInput.vue'; \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index d32b0fe..4c51992 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,15 @@ +import type { Updater } from '@tanstack/vue-table' +import type { Ref } from 'vue' import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function valueUpdater>(updaterOrValue: T, ref: Ref) { + ref.value + = typeof updaterOrValue === 'function' + ? updaterOrValue(ref.value) + : updaterOrValue +} diff --git a/nuxt.config.ts b/nuxt.config.ts index c1af008..db2a048 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -14,7 +14,8 @@ export default defineNuxtConfig({ '@nuxtjs/robots', 'shadcn-nuxt', '@nuxt/icon', - '@nuxtjs/seo' + '@nuxtjs/seo', + 'nuxt-schema-org' ], tailwindcss: { cssPath: '~/assets/css/tailwind.css', @@ -67,10 +68,11 @@ export default defineNuxtConfig({ identity: { type: 'Organization', name: 'GitHub Open Source Explorer', + url: 'https://github-explorer.nuxt.dev', logo: 'https://github-explorer.nuxt.dev/logo.png' }, - host: process.env.NUXT_PUBLIC_SITE_URL || 'https://github-explorer.nuxt.dev', - includeHomeInBreadcrumb: false + host: 'https://github-explorer.nuxt.dev', + canonicalHost: 'https://github-explorer.nuxt.dev' }, routeRules: { // Página principal diff --git a/package.json b/package.json index 1d04aed..85d4e75 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,16 @@ "@nuxthub/core": "0.8.17", "@octokit/rest": "^21.1.0", "@radix-icons/vue": "^1.0.0", + "@tanstack/vue-table": "^8.21.2", "@vueuse/core": "^12.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "lucide-vue-next": "^0.475.0", "nuxt": "^3.15.4", + "nuxt-schema-org": "4.1.3", "pinia": "^3.0.0", "radix-vue": "^1.9.13", + "reka-ui": "^2.0.0", "tailwind-merge": "^3.0.1", "tailwindcss-animate": "^1.0.7", "vite": ">=6.0", diff --git a/pages/index.vue b/pages/index.vue index 85d1272..aef62fc 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,8 +1,8 @@